- 上游客户端接口化: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
108 lines
4.3 KiB
TypeScript
108 lines
4.3 KiB
TypeScript
/**
|
||
* 上游客户端 · 样例文件实现(实施计划 §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
|
||
},
|
||
}
|
||
}
|