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