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:
@@ -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 }
|
||||
@@ -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(),
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
})
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user