feat(server): 上游客户端与 key 管理(阶段 2)

- 上游客户端接口化:UpstreamClient + HTTP / fixture 两实现(env UPSTREAM_SOURCE 切换;fixture 读仓库根样例快照,错误分支由 key 内容约定触发)
- 失败分类:401 / 403 → key_rejected;其余非 2xx / 网络故障 / 超时 / 响应异常 → upstream_unavailable
- /api/manage/api-keys 三端点:清单(按 id 升序,敏感列不出接口)· 登记(上游验证 → 上游字段全组入库)· 启停(幂等、启用需验证并清 consecutive_401s)
- 登记成功触发「立即异步补一轮」hook(引擎骨架占位,阶段 4 填充)
- 统一错误信封:onError → 500 internal · notFound → 404
- 修正:load-env 未被服务端接线(此前只有 drizzle-kit 接了),env.ts 顶部补副作用导入
- 实施计划:进度表阶段 2 ✅ · 实现假设 #11–#14
This commit is contained in:
2026-09-15 00:37:22 +08:00
parent 575643eb13
commit c26012a040
15 changed files with 738 additions and 36 deletions
+107
View File
@@ -0,0 +1,107 @@
/**
* 上游客户端 · 样例文件实现(实施计划 §6:仓库根实测响应快照 —— `api.log.token.json`
* / `api.status.json`,未入库、绝不提交)。开发期以 `UPSTREAM_SOURCE=fixture` 启用。
*
* 快照缺失的接口(`/api/usage/token` 无快照)就地按实测结构合成:总量类字段取自
* `api.log.token.json` 首条日志的 `other.subscription_*`(真实形状),保证 fixture
* 下的登记结果与 seed 数据(阶段 4)同一形状。
*
* 错误分支的仿真约定(key 内容大小写不敏感;仅 fixture 模式生效,见实施计划「实现假设」):
* 含 `reject` → 401 key_rejected · 含 `forbidden` → 403 key_rejected · 含 `unavailable` → 网络故障
*/
import { readFileSync } from 'node:fs'
import { DEFAULT_QUOTA_PER_UNIT } from '@naku/shared'
import { env } from '../lib/env'
import { errorMessage, UpstreamError } from './errors'
import type { UpstreamClient } from './client'
import type { UpstreamLogRecord, UpstreamStatus, UpstreamTokenUsage } from './types'
/** 仓库根(本文件位于 apps/server/src/upstream/)——样例快照的默认目录 */
const REPO_ROOT = new URL('../../../../', import.meta.url)
export interface FixtureUpstreamOptions {
/** 样例文件目录;缺省取 env.newapiFixtureDir,再退到仓库根 */
dir?: string | URL
}
function readSnapshot<T>(dir: string | URL, name: string): T {
try {
return JSON.parse(readFileSync(new URL(name, dir), 'utf8')) as T
} catch (err) {
throw new UpstreamError(
'upstream_unavailable',
`样例文件不可用(${name}):${errorMessage(err)}`,
)
}
}
function asNumber(v: unknown, fallback: number): number {
return typeof v === 'number' && Number.isFinite(v) ? v : fallback
}
export function createFixtureClient(options: FixtureUpstreamOptions = {}): UpstreamClient {
// env.newapiFixtureDir 为「空 = 仓库根」的语义,故用 ||(空字符串要退到 REPO_ROOT)
const dir = options.dir || env.newapiFixtureDir || REPO_ROOT
/** 仿真约定:错误分支由 key 内容触发 */
function simulate(key: string): void {
const k = key.toLowerCase()
if (k.includes('reject')) throw new UpstreamError('key_rejected', 'fixture:模拟上游拒绝该 key(401)', 401)
if (k.includes('forbidden')) throw new UpstreamError('key_rejected', 'fixture:模拟账号被封禁(403)', 403)
if (k.includes('unavailable')) {
throw new UpstreamError('upstream_unavailable', 'fixture:模拟上游不可达(网络故障)')
}
}
function loadLogs(): UpstreamLogRecord[] {
const snap = readSnapshot<{ data?: UpstreamLogRecord[] }>(dir, 'api.log.token.json')
if (!Array.isArray(snap.data)) {
throw new UpstreamError('upstream_unavailable', '样例文件结构异常(api.log.token.json 缺 data[])')
}
return snap.data
}
return {
async fetchTokenUsage(plainKey: string): Promise<UpstreamTokenUsage> {
simulate(plainKey)
const first = loadLogs()[0]
let other: Record<string, unknown> = {}
if (first?.other) {
try {
other = JSON.parse(first.other) as Record<string, unknown>
} catch {
other = {} // 样例 other 解析失败不致命:退到兜底值
}
}
const totalGranted = asNumber(other.subscription_total, 800_000_000)
const totalUsed = asNumber(other.subscription_used, 85_579_458)
return {
name: first?.token_name ?? 'fixture-token',
total_granted: totalGranted,
total_used: totalUsed,
total_available: totalGranted - totalUsed,
unlimited_quota: false,
model_limits: {},
model_limits_enabled: false,
expires_at: 0, // 永不过期
}
},
async fetchLogs(plainKey: string): Promise<UpstreamLogRecord[]> {
simulate(plainKey)
return loadLogs()
},
async fetchStatus(): Promise<UpstreamStatus> {
const snap = readSnapshot<{ data?: Record<string, unknown> }>(dir, 'api.status.json')
const data = snap.data
if (!data || typeof data !== 'object') {
throw new UpstreamError('upstream_unavailable', '样例文件结构异常(api.status.json 缺 data)')
}
return {
...data,
quota_per_unit: asNumber(data.quota_per_unit, DEFAULT_QUOTA_PER_UNIT),
} as UpstreamStatus
},
}
}