chore: 初始化 monorepo 脚手架与代码骨架

- Bun workspaces:apps/web(Vue 3.5 + Vite 8 + naive-ui)· apps/server(Hono 4.13 + Drizzle)· packages/shared(契约类型)
- 代码骨架:四表 Drizzle schema · 11 个端点占位(501)· 管理面口令中间件 · 同步引擎占位(5 分钟 tick + 防重入)
- 命令链:dev / build / test / lint / type-check / db:*(见 AGENTS.md)
This commit is contained in:
2026-09-14 23:54:58 +08:00
parent 45b312b357
commit 138a1b7685
50 changed files with 2739 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# deps
node_modules/
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'drizzle-kit'
/** drizzle-kit 配置(迁移产物在 ./drizzle;命令见根 package.json 的 db:* 脚本) */
export default defineConfig({
schema: './src/db/schema.ts',
out: './drizzle',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL ?? '',
},
})
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@naku/server",
"private": true,
"type": "module",
"scripts": {
"dev": "bun run --hot src/index.ts",
"start": "bun run src/index.ts",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@naku/shared": "workspace:*",
"drizzle-orm": "^0.45.2",
"hono": "^4.13.7",
"zod": "^4.6.5"
},
"devDependencies": {
"@types/bun": "latest",
"drizzle-kit": "^0.31.10"
}
}
+21
View File
@@ -0,0 +1,21 @@
/**
* 数据库连接(Bun 原生 SQL + Drizzle)。
* 懒初始化:首次调用 `getDb()` 时才建连接——骨架阶段缺 DATABASE_URL 也能起服务。
*/
import { drizzle, type BunSQLDatabase } from 'drizzle-orm/bun-sql'
import { env } from '../lib/env'
import * as schema from './schema'
let db: BunSQLDatabase<typeof schema> | null = null
export function getDb(): BunSQLDatabase<typeof schema> {
if (!db) {
if (!env.databaseUrl) {
throw new Error('缺少环境变量 DATABASE_URL(见 .env.example)')
}
db = drizzle(env.databaseUrl, { schema })
}
return db
}
export { schema }
+120
View File
@@ -0,0 +1,120 @@
/**
* 数据库 schema(Drizzle ORM)—— 逐列对应 `docs/design/` 的四张表字段设计:
* `api_keys`(17 列)· `usage_logs`(23 列)· `purchases`(9 列)· `qpu_history`(3 列)。
* TS 侧字段用 camelCase,列名保持 snake_case(与 API 字段同名同形,契约 约定 3)。
*/
import {
bigint,
bigserial,
boolean,
customType,
index,
integer,
jsonb,
numeric,
pgTable,
smallint,
text,
timestamp,
} from 'drizzle-orm/pg-core'
/** PostgreSQL bytea(Drizzle 无内置;用于 `key_ciphertext`) */
const bytea = customType<{ data: Uint8Array; driverData: Uint8Array }>({
dataType() {
return 'bytea'
},
})
/** 第一张表:API key 登记表(api_keys 表字段设计,17 列) */
export const apiKeys = pgTable('api_keys', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** 登记时 HMAC-SHA256(命中判定,不可逆) */
keyHash: text('key_hash').notNull().unique(),
/** 登记时 AES-256-GCM(nonce + 密文 + tag 打包;唯一用途是调上游) */
keyCiphertext: bytea('key_ciphertext').notNull(),
/** active | disabled */
status: text('status').notNull().default('active'),
/** manual | auto_401 | null(区分「已失效」与「已停用」) */
disabledSource: text('disabled_source'),
/** 连续 401 计数:401 时 +1、成功清零、其他失败不动 */
consecutive401s: integer('consecutive_401s').notNull().default(0),
registeredAt: timestamp('registered_at', { withTimezone: true }).notNull().defaultNow(),
/** 「数据截止」的唯一来源(取请求发起时刻) */
logSyncedAt: timestamp('log_synced_at', { withTimezone: true }),
usageSyncedAt: timestamp('usage_synced_at', { withTimezone: true }),
// —— 上游字段(GET /api/usage/token,整组一起写) ——
tokenName: text('token_name').notNull(),
totalGranted: bigint('total_granted', { mode: 'number' }).notNull().default(0),
totalUsed: bigint('total_used', { mode: 'number' }).notNull().default(0),
totalAvailable: bigint('total_available', { mode: 'number' }).notNull().default(0),
unlimitedQuota: boolean('unlimited_quota').notNull().default(false),
modelLimits: jsonb('model_limits').notNull().default({}),
modelLimitsEnabled: boolean('model_limits_enabled').notNull().default(false),
/** 上游 0 = 永不过期 → NULL */
expiresAt: timestamp('expires_at', { withTimezone: true }),
})
/** 第二张表:调用日志明细(usage_logs 表字段设计,23 列) */
export const usageLogs = pgTable(
'usage_logs',
{
id: bigserial('id', { mode: 'number' }).primaryKey(),
keyId: bigint('key_id', { mode: 'number' })
.notNull()
.references(() => apiKeys.id),
/** 去重唯一键:`${key_id}:${request_id}`;request_id 空时退化为内容指纹 */
dedupeKey: text('dedupe_key').notNull().unique(),
userId: bigint('user_id', { mode: 'number' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).notNull(),
type: smallint('type').notNull(),
content: text('content').notNull().default(''),
username: text('username').notNull(),
/** 日志快照名(展示取当前名的口径见 API 契约 §2.1) */
tokenName: text('token_name').notNull(),
modelName: text('model_name').notNull(),
quota: bigint('quota', { mode: 'number' }).notNull(),
promptTokens: integer('prompt_tokens').notNull(),
completionTokens: integer('completion_tokens').notNull(),
useTime: integer('use_time').notNull(),
isStream: boolean('is_stream').notNull(),
channel: integer('channel').notNull(),
channelName: text('channel_name').notNull().default(''),
tokenId: bigint('token_id', { mode: 'number' }).notNull(),
groupName: text('group_name').notNull().default('default'),
ip: text('ip').notNull().default(''),
requestId: text('request_id').notNull(),
upstreamRequestId: text('upstream_request_id'),
other: jsonb('other').notNull().default({}),
},
(t) => [
index('usage_logs_key_created_idx').on(t.keyId, t.createdAt.desc()),
index('usage_logs_created_idx').on(t.createdAt.desc()),
],
)
/** 第三张表:购买记录(purchases 表字段设计,9 列;全人工登记) */
export const purchases = pgTable('purchases', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** subscription | topup */
kind: text('kind').notNull(),
paidAt: timestamp('paid_at', { withTimezone: true }).notNull(),
/** 实付人民币(唯一「只有作者知道」的信息) */
paidAmount: numeric('paid_amount', { mode: 'number' }).notNull(),
note: text('note'),
/** 期间左闭右开 [period_start, period_end);未结束可空 */
periodStart: timestamp('period_start', { withTimezone: true }),
periodEnd: timestamp('period_end', { withTimezone: true }),
/** 上游订阅实例 ID(与日志 other.subscription_id 匹配) */
subscriptionId: bigint('subscription_id', { mode: 'number' }),
/** 本笔额度(折算链的分母) */
quotaGranted: bigint('quota_granted', { mode: 'number' }),
})
/** 第四张表:换算参数时间线(qpu_history 表字段设计,3 列;纯只读,唯一写入者是同步引擎) */
export const qpuHistory = pgTable('qpu_history', {
id: bigserial('id', { mode: 'number' }).primaryKey(),
/** 当时的 quota_per_unit 值 */
qpu: numeric('qpu', { mode: 'number' }).notNull(),
/** 生效时间;正常全表仅 1 行(出厂默认值从未变过) */
effectiveFrom: timestamp('effective_from', { withTimezone: true }).notNull(),
})
+37
View File
@@ -0,0 +1,37 @@
/**
* 服务入口:Hono 应用 + 同步引擎定时器(后端单进程、单实例——见同步引擎设计 §3.1)。
* 路由骨架按 `docs/design/API契约.md` 的 11 个端点预留,业务逻辑逐步实现。
*/
import { Hono } from 'hono'
import { env } from './lib/env'
import { apiKeysRoutes } from './routes/manage/api-keys'
import { purchasesRoutes } from './routes/manage/purchases'
import { qpuRoutes } from './routes/manage/qpu'
import { usageRoutes } from './routes/usage'
import { startSyncEngine } from './sync'
const app = new Hono()
/** 健康检查(非契约端点,运维用) */
app.get('/api/health', (c) =>
c.json({ ok: true, served_at: new Date().toISOString() }),
)
// 查看面(无会话、无状态;key 集合随请求体,见契约 约定 5 / 19)
app.route('/api/usage', usageRoutes)
// 管理面(X-Manage-Token 口令门帘;见契约 约定 6)
app.route('/api/manage/api-keys', apiKeysRoutes)
app.route('/api/manage/purchases', purchasesRoutes)
app.route('/api/manage/qpu', qpuRoutes)
// 同步引擎:随进程启动,启动即跑一轮(同步引擎设计 §3.2)
startSyncEngine()
console.log(`[server] Hono 已就绪,监听 :${env.port}`)
export default {
port: env.port,
fetch: app.fetch,
}
+26
View File
@@ -0,0 +1,26 @@
/**
* 环境变量(部署时经 env 注入;全量清单见仓库根 `.env.example`)。
* 缺关键变量时在「首次使用处」报错,不在进程启动时硬崩(便于本地起骨架)。
*/
function required(name: string): string {
const v = process.env[name]
if (!v) {
throw new Error(`缺少环境变量 ${name}(见 .env.example)`)
}
return v
}
export const env = {
/** 监听端口(Vite dev 代理与生产反代都指向它) */
port: Number(process.env.PORT ?? 3000),
/** PostgreSQL 连接串 */
databaseUrl: process.env.DATABASE_URL ?? '',
/** 管理面口令(X-Manage-Token;见 API 契约 约定 6) */
manageToken: process.env.MANAGE_TOKEN ?? '',
/** 凭证主密钥(HKDF 派生 hmac / aes 子密钥;丢失 = 全部 key 需重新登记) */
keysMasterKey: process.env.KEYS_MASTER_KEY ?? '',
/** 上游 NewAPI 站点地址(同步引擎调用的 BaseURL) */
newapiBase: process.env.NEWAPI_BASE ?? '',
}
export { required }
+18
View File
@@ -0,0 +1,18 @@
import { createMiddleware } from 'hono/factory'
import { env } from '../lib/env'
/**
* 管理面口令门帘(契约 约定 6):每次请求校验 `X-Manage-Token`。
* 无登录端点、无令牌签发;常量时间比较等加固归技术稿(API 契约 §3)。
*/
export const manageAuth = createMiddleware(async (c, next) => {
const token = c.req.header('x-manage-token')
if (!env.manageToken || token !== env.manageToken) {
return c.json(
{ error: { code: 'manage_unauthorized', message: '口令无效' } },
401,
)
}
await next()
})
+50
View File
@@ -0,0 +1,50 @@
import { Hono } from 'hono'
import { manageAuth } from '../../middleware/manage-auth'
/**
* 管理面 · key 管理(契约 §2.2):清单 / 登记 / 启停。
* 清单与 `api_keys` 表 1:1——`key_hash` / `key_ciphertext` 两个敏感列永不出接口。
*/
export const apiKeysRoutes = new Hono()
apiKeysRoutes.use(manageAuth)
/** 清单(按 id 升序,不分页) */
apiKeysRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:key 清单(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
/** 登记:body `{ key }`;上游验证通过 → 入库(不阻塞首轮同步) */
apiKeysRoutes.post('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:登记 key(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
/** 启停:body `{ status: 'active' | 'disabled' }`;启用需上游验证、成功清零 consecutive_401s */
apiKeysRoutes.patch('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:启停(见 docs/design/API契约.md §2.2)',
},
},
501,
),
)
@@ -0,0 +1,76 @@
import { Hono } from 'hono'
import { manageAuth } from '../../middleware/manage-auth'
/**
* 管理面 · 购买记录(契约 §2.3):清单 / 登记 / 编辑 / 删除 / 观测(核对提示)。
* 登记必填:`kind` / `paid_at` / `paid_amount` / `quota_granted`。
*/
export const purchasesRoutes = new Hono()
purchasesRoutes.use(manageAuth)
/** 清单(按 paid_at DESC,同时间按 id DESC;不分页) */
purchasesRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:purchases 清单(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 观测(核对提示的数据面):按日志 other.subscription_id 聚合 */
purchasesRoutes.get('/observations', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:观测(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 登记 */
purchasesRoutes.post('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:登记购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 编辑(部分更新) */
purchasesRoutes.patch('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:编辑购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
/** 删除(物理) */
purchasesRoutes.delete('/:id', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:删除购买记录(见 docs/design/API契约.md §2.3)',
},
},
501,
),
)
+23
View File
@@ -0,0 +1,23 @@
import { Hono } from 'hono'
import { manageAuth } from '../../middleware/manage-auth'
/**
* 管理面 · 换算参数时间线(契约 §2.4):纯只读——`qpu_history` 唯一写入者是同步引擎。
*/
export const qpuRoutes = new Hono()
qpuRoutes.use(manageAuth)
/** 时间线清单(按 effective_from 升序;不分页) */
qpuRoutes.get('/', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:QPU 时间线(见 docs/design/API契约.md §2.4)',
},
},
501,
),
)
+31
View File
@@ -0,0 +1,31 @@
import { Hono } from 'hono'
/**
* 查看面(契约 §2.1):无会话、无服务端状态——key 集合与过滤参数全在请求体。
* 两个端点:`/logs/query`(明细,分页)· `/summary`(卡片汇总);首次并行请求,翻页只调明细。
*/
export const usageRoutes = new Hono()
usageRoutes.post('/logs/query', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:明细查询(见 docs/design/API契约.md §2.1)',
},
},
501,
),
)
usageRoutes.post('/summary', (c) =>
c.json(
{
error: {
code: 'internal',
message: '未实现:卡片汇总(见 docs/design/API契约.md §2.1)',
},
},
501,
),
)
+45
View File
@@ -0,0 +1,45 @@
/**
* 同步引擎(见 `docs/design/同步引擎设计.md`)——骨架占位。
*
* 已定骨架(§3.2 / §3.3 / §3.4):
* - 后端进程内定时器(不引 worker、不拆进程)
* - 周期 5 分钟,固定 tick + 防重入
* - 启动立即跑一轮
* - 单轮串行遍历全部 active key
* - 每轮:日志(整批单事务入库 → log_synced_at 取请求发起时刻)· 额度(上游字段全组 + usage_synced_at)· QPU 采样(每轮一次、值变化才追加)
* - 失败:401 计数(N=3 摘除)· 403/500/网络不计数 · 不单轮重试
*/
const TICK_MS = 5 * 60 * 1000
let running = false
/** 一轮:遍历全部 active key(串行)→ 日志 / 额度;每轮一次 QPU 采样 */
async function runOnce(): Promise<void> {
// TODO(#5): 日志同步 —— GET /api/log/token → 整批单事务入库(ON CONFLICT (dedupe_key) DO NOTHING)→ 写 log_synced_at
// TODO(#6): 额度采集 —— GET /api/usage/token → 上游字段全组 + usage_synced_at(与日志各自独立)
// TODO(#7): QPU 采样 —— GET /api/status → 值变化才追加 qpu_history(每轮一次,独立于 key 循环)
}
async function tick(): Promise<void> {
if (running) {
console.log('[sync] 上一轮未结束,跳过本轮(防重入)')
return
}
running = true
try {
await runOnce()
console.log('[sync] 轮次完成(骨架占位:暂无实际采集动作)')
} catch (err) {
console.error('[sync] 轮次异常', err)
} finally {
running = false
}
}
/** 启动引擎:立即跑一轮,随后每 5 分钟一次 */
export function startSyncEngine(): void {
console.log('[sync] 引擎启动:立即跑一轮,随后每 5 分钟一次')
void tick()
setInterval(() => void tick(), TICK_MS)
}
+13
View File
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "esnext",
"module": "preserve",
"moduleResolution": "bundler",
"strict": true,
"noEmit": true,
"skipLibCheck": true,
"types": ["bun"],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
}
}