Files
new-api-key-usage/apps/server/src/upstream/fixture.ts
T
skycurtain c26012a040 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
2026-09-15 00:37:22 +08:00

108 lines
4.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 上游客户端 · 样例文件实现(实施计划 §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
},
}
}