Archived
feat(data): 接口静态数据、网关 fetch wrapper、虚拟滚动文本加工(纯 TS + 单测)
json-text-processor 直接以 segments 方案实现完整行重建:token 与 plain 交替,join 等于原文,括号/缩进/逗号逐字不丢;高亮输出结构化 span 无 v-html;窗口计算含 scrollTop 超界钳制。
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { endpointList, findEndpointByPath } from './endpoints';
|
||||
|
||||
describe('endpointList 数据完整性', () => {
|
||||
it('恰好 5 个接口(设计文档 §1 定案的对接清单)', () => {
|
||||
expect(endpointList).toHaveLength(5);
|
||||
});
|
||||
|
||||
it('路径唯一(path 是 tab / queryKey 的唯一标识)', () => {
|
||||
const pathList = endpointList.map((endpoint) => endpoint.path);
|
||||
expect(new Set(pathList).size).toBe(5);
|
||||
});
|
||||
|
||||
it('顺序与用户指定清单一致', () => {
|
||||
expect(endpointList.map((endpoint) => endpoint.path)).toEqual([
|
||||
'/v1/dashboard/billing/subscription',
|
||||
'/v1/dashboard/billing/usage',
|
||||
'/v1/models',
|
||||
'/api/usage/token',
|
||||
'/api/log/token',
|
||||
]);
|
||||
});
|
||||
|
||||
it('全部为 GET 且带鉴权元数据', () => {
|
||||
for (const endpoint of endpointList) {
|
||||
expect(endpoint.method).toBe('GET');
|
||||
expect(['strict', 'lenient']).toContain(endpoint.authMode);
|
||||
expect(endpoint.authMiddlewareName).toBeTruthy();
|
||||
}
|
||||
});
|
||||
|
||||
it('严格/宽松鉴权分布正确(前 3 严格,后 2 宽松)', () => {
|
||||
expect(endpointList.map((endpoint) => endpoint.authMode)).toEqual([
|
||||
'strict',
|
||||
'strict',
|
||||
'strict',
|
||||
'lenient',
|
||||
'lenient',
|
||||
]);
|
||||
});
|
||||
|
||||
it('每个接口都有注意事项与示例响应(tab 内文档的数据源)', () => {
|
||||
for (const endpoint of endpointList) {
|
||||
expect(endpoint.notes.length).toBeGreaterThan(0);
|
||||
expect(endpoint.sampleResponse).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('findEndpointByPath', () => {
|
||||
it('命中已知路径', () => {
|
||||
expect(findEndpointByPath('/v1/models')?.responseSummary).toContain('模型列表');
|
||||
});
|
||||
|
||||
it('未知路径返回 undefined', () => {
|
||||
expect(findEndpointByPath('/v1/nonexistent')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 5 个对接接口的静态元数据
|
||||
* 数据源:docs/NewAPI-sk-key接口文档.md(源码实证调研)
|
||||
* 顺序即 sider / tab 展示顺序(设计文档 §1 定案,不分组不编号)
|
||||
*/
|
||||
|
||||
export type AuthMode = 'strict' | 'lenient';
|
||||
|
||||
export interface EndpointDefinition {
|
||||
/** 接口路径(唯一标识,同时用作 tab 标签与 queryKey 组成部分) */
|
||||
readonly path: string;
|
||||
/** HTTP 方法(当前全部为 GET,保留扩展位) */
|
||||
readonly method: 'GET';
|
||||
/** 鉴权模式:strict = TokenAuth(key 无效/过期/耗尽直接被拒);lenient = TokenAuthReadOnly(宽松,废 key 也能查) */
|
||||
readonly authMode: AuthMode;
|
||||
/** 鉴权中间件名(展示用) */
|
||||
readonly authMiddlewareName: string;
|
||||
/** 响应特征一句话(展示用) */
|
||||
readonly responseSummary: string;
|
||||
/** 注意事项(tab 内折叠文档,文案来自接口文档的实证调研) */
|
||||
readonly notes: readonly string[];
|
||||
/** 示例响应(展示用,结构对齐真实接口) */
|
||||
readonly sampleResponse: unknown;
|
||||
}
|
||||
|
||||
export const endpointList: readonly EndpointDefinition[] = [
|
||||
{
|
||||
path: '/v1/dashboard/billing/subscription',
|
||||
method: 'GET',
|
||||
authMode: 'strict',
|
||||
authMiddlewareName: 'TokenAuth',
|
||||
responseSummary: '总额度上限(美元)',
|
||||
notes: [
|
||||
'受 DisplayTokenStatEnabled 开关影响:开启返回 token 级额度,关闭回退到账号级额度(会暴露整个账号余额)',
|
||||
'无限额度 key 返回 100000000(一亿)占位值,无参考意义',
|
||||
'受 quota_display_type 影响:USD 模式才是美元;CNY 模式会做货币转换;Tokens 模式是原始 token 数',
|
||||
'hard_limit_usd(本接口)− total_usage ÷ 100(usage 接口)= 剩余额度(仅有限额 key 有意义)',
|
||||
],
|
||||
sampleResponse: {
|
||||
object: 'billing_subscription',
|
||||
has_payment_method: true,
|
||||
soft_limit_usd: 100000000,
|
||||
hard_limit_usd: 100000000,
|
||||
system_hard_limit_usd: 100000000,
|
||||
access_until: 0,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/v1/dashboard/billing/usage',
|
||||
method: 'GET',
|
||||
authMode: 'strict',
|
||||
authMiddlewareName: 'TokenAuth',
|
||||
responseSummary: '累计已用(美分)',
|
||||
notes: [
|
||||
'total_usage 单位是美分,换算美元 ÷ 100',
|
||||
'累计值(key 创建至今),非当月用量',
|
||||
'与 /api/usage/token 的 total_used ÷ 500000 完全吻合(QuotaPerUnit = 500000 的双重验证)',
|
||||
'受 DisplayTokenStatEnabled 与 quota_display_type 影响(同 subscription 接口)',
|
||||
],
|
||||
sampleResponse: { object: 'list', total_usage: 134683.8914 },
|
||||
},
|
||||
{
|
||||
path: '/v1/models',
|
||||
method: 'GET',
|
||||
authMode: 'strict',
|
||||
authMiddlewareName: 'TokenAuth',
|
||||
responseSummary: '可用模型列表(OpenAI 格式)',
|
||||
notes: [
|
||||
'数据源是数据库 abilities 表,反映渠道实际配置的模型',
|
||||
'与 /v1/models/:model 单查接口数据源不同(后者查代码内硬编码静态表)',
|
||||
'渠道新配的模型可能出现「列表里有、单查 model_not_found」,不影响实际调用',
|
||||
],
|
||||
sampleResponse: {
|
||||
object: 'list',
|
||||
data: [{ id: 'gpt-4o', object: 'model', created: 1686935000, owned_by: 'openai' }],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/usage/token',
|
||||
method: 'GET',
|
||||
authMode: 'lenient',
|
||||
authMiddlewareName: 'TokenAuthReadOnly',
|
||||
responseSummary: 'token 级用量汇总(原始 quota)',
|
||||
notes: [
|
||||
'total_used 是累计值(key 创建至今),非当月用量',
|
||||
'额度为原始 quota 整数,换算美元 ÷ 500000(QuotaPerUnit)',
|
||||
'unlimited_quota = true 时 total_granted / total_available 为占位值,无实际意义',
|
||||
'禁用再解禁不会重置 total_used(源码验证:Update() 的 Select 列表不含 used_quota)',
|
||||
'不受 DisplayTokenStatEnabled 开关影响,恒返回 token 级数据',
|
||||
],
|
||||
sampleResponse: {
|
||||
code: true,
|
||||
message: 'ok',
|
||||
data: {
|
||||
object: 'token_usage',
|
||||
name: '令牌名称',
|
||||
total_granted: 6012,
|
||||
total_used: 673419457,
|
||||
total_available: -673413445,
|
||||
unlimited_quota: true,
|
||||
model_limits: {},
|
||||
model_limits_enabled: false,
|
||||
expires_at: 0,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/api/log/token',
|
||||
method: 'GET',
|
||||
authMode: 'lenient',
|
||||
authMiddlewareName: 'TokenAuthReadOnly',
|
||||
responseSummary: '最近 1000 条调用日志',
|
||||
notes: [
|
||||
'硬上限 1000 条(common.MaxRecentItems = 1000)',
|
||||
'不支持任何请求参数:分页 / limit / 时间过滤全被忽略',
|
||||
'固定按 id desc 排序,即最新 1000 条',
|
||||
'月调用量超 1000 次时,此接口无法覆盖完整当月',
|
||||
'大响应:pretty-print 后 2~4MB,约两万行——虚拟滚动的核心场景',
|
||||
],
|
||||
sampleResponse: {
|
||||
success: true,
|
||||
message: '',
|
||||
data: [
|
||||
{
|
||||
id: 123456,
|
||||
user_id: 12,
|
||||
created_at: 1751328000,
|
||||
type: 2,
|
||||
content: '模型倍率 1.0,分组倍率 1.0',
|
||||
username: '用户名',
|
||||
token_name: '令牌名',
|
||||
model_name: 'gpt-4o',
|
||||
quota: 2500,
|
||||
prompt_tokens: 1500,
|
||||
completion_tokens: 500,
|
||||
use_time: 2,
|
||||
is_stream: true,
|
||||
channel: 0,
|
||||
channel_name: '',
|
||||
token_id: 100,
|
||||
group: 'default',
|
||||
ip: '',
|
||||
request_id: 'req_xxx',
|
||||
upstream_request_id: '',
|
||||
other: '',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/** 按路径查接口定义 */
|
||||
export function findEndpointByPath(path: string): EndpointDefinition | undefined {
|
||||
return endpointList.find((endpoint) => endpoint.path === path);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildAuthorizationHeader } from './gateway-client';
|
||||
|
||||
describe('buildAuthorizationHeader', () => {
|
||||
it('有效 key 拼出 Bearer 头', () => {
|
||||
expect(buildAuthorizationHeader('sk-abc123')).toEqual({ Authorization: 'Bearer sk-abc123' });
|
||||
});
|
||||
|
||||
it('key 两端空白被裁剪', () => {
|
||||
expect(buildAuthorizationHeader(' sk-abc123 ')).toEqual({
|
||||
Authorization: 'Bearer sk-abc123',
|
||||
});
|
||||
});
|
||||
|
||||
it('空 key 不带鉴权头(交给网关拒绝,调试工具要看到真实的 401)', () => {
|
||||
expect(buildAuthorizationHeader('')).toEqual({});
|
||||
});
|
||||
|
||||
it('纯空白 key 同样视为空', () => {
|
||||
expect(buildAuthorizationHeader(' ')).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* NewAPI 网关的薄 fetch wrapper
|
||||
* 职责:拼 Authorization 头、计时、量字节数、分离 JSON / 纯文本
|
||||
* 刻意不做的事(设计文档 §4.3):无拦截器、无重试——调试工具必须看到真实的失败
|
||||
*/
|
||||
|
||||
export interface GatewayResponse {
|
||||
/** HTTP 状态码;网络层失败(fetch reject)时为 null */
|
||||
readonly status: number | null;
|
||||
/** 请求耗时(毫秒,performance.now 量测) */
|
||||
readonly durationMs: number;
|
||||
/** 响应体字节数(按字符数近似,仅展示用) */
|
||||
readonly byteLength: number;
|
||||
/** 响应体原文 */
|
||||
readonly text: string;
|
||||
/** JSON.parse 成功的结果;解析失败为 null(非 JSON 响应原样展示) */
|
||||
readonly json: unknown;
|
||||
/** 网络层错误信息(fetch reject 时);成功为 null */
|
||||
readonly networkError: string | null;
|
||||
}
|
||||
|
||||
export function buildAuthorizationHeader(skKey: string): Record<string, string> {
|
||||
const trimmedKey = skKey.trim();
|
||||
if (!trimmedKey) return {};
|
||||
return { Authorization: `Bearer ${trimmedKey}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求网关接口(相对路径,经 Vite dev server 代理转发到网关,见 vite.config.ts)
|
||||
*/
|
||||
export async function requestGatewayEndpoint(path: string, skKey: string): Promise<GatewayResponse> {
|
||||
const requestStartedAt = performance.now();
|
||||
|
||||
try {
|
||||
const response = await fetch(path, { headers: buildAuthorizationHeader(skKey) });
|
||||
const text = await response.text();
|
||||
const durationMs = Math.round(performance.now() - requestStartedAt);
|
||||
|
||||
let json: unknown = null;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch {
|
||||
// 非 JSON 响应(如网关 502 的 HTML 错误页):保持 json = null,UI 走纯文本展示
|
||||
}
|
||||
|
||||
return {
|
||||
status: response.status,
|
||||
durationMs,
|
||||
byteLength: text.length,
|
||||
text,
|
||||
json,
|
||||
networkError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
const durationMs = Math.round(performance.now() - requestStartedAt);
|
||||
return {
|
||||
status: null,
|
||||
durationMs,
|
||||
byteLength: 0,
|
||||
text: '',
|
||||
json: null,
|
||||
networkError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user