feat(core,db): 业务核心与数据访问层

core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。
db:八表 Drizzle schema + 仓储 + drizzle 配置。
This commit is contained in:
2026-10-06 01:30:51 +08:00
parent 4c55c3f15f
commit 6980d09750
17 changed files with 1839 additions and 0 deletions
+178
View File
@@ -0,0 +1,178 @@
/**
* 加密体系(WebCrypto / Web 标准 API——Bun 纪律红线)
* SK_KEY_ENCRYPTION_SECRET 经 HKDF 派生 hmac / aes 子密钥(api_keys表字段设计 3.1)
* 密码哈希 = PBKDF2-SHA256(两形态统一)
*/
const enc = new TextEncoder();
const dec = new TextDecoder();
function b64url(bytes: Uint8Array): string {
let s = "";
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
function b64urlDecode(s: string): Uint8Array<ArrayBuffer> {
const pad = s.replace(/-/g, "+").replace(/_/g, "/");
const bin = atob(pad + "===".slice((pad.length + 3) % 4));
const out = new Uint8Array(new ArrayBuffer(bin.length));
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
async function hkdfDerive(
master: Uint8Array<ArrayBuffer>,
info: string,
length: number,
): Promise<CryptoKey> {
const base = await crypto.subtle.importKey("raw", master, "HKDF", false, [
"deriveKey",
"deriveBits",
]);
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: enc.encode("new-api-key-usage"),
info: enc.encode(info),
},
base,
{ name: "HMAC", hash: "SHA-256", length: length * 8 },
false,
["sign", "verify"],
);
}
async function hkdfAesKey(master: Uint8Array<ArrayBuffer>): Promise<CryptoKey> {
const base = await crypto.subtle.importKey("raw", master, "HKDF", false, [
"deriveKey",
]);
return crypto.subtle.deriveKey(
{
name: "HKDF",
hash: "SHA-256",
salt: enc.encode("new-api-key-usage"),
info: enc.encode("aes-gcm"),
},
base,
{ name: "AES-GCM", length: 256 },
false,
["encrypt", "decrypt"],
);
}
export class KeyCrypto {
private constructor(
private readonly hmacKey: CryptoKey,
private readonly aesKey: CryptoKey,
) {}
static async fromEncryptionSecret(secretB64: string): Promise<KeyCrypto> {
const master = b64urlDecode(secretB64);
if (master.length < 32) {
throw new Error("SK_KEY_ENCRYPTION_SECRET too short");
}
const hmacKey = await hkdfDerive(master, "hmac-sha256", 32);
const aesKey = await hkdfAesKey(master);
return new KeyCrypto(hmacKey, aesKey);
}
/** 登记时:HMAC-SHA256(sk-key) → 不可逆指纹(查库优先命中) */
async hashKey(skKey: string): Promise<string> {
const sig = await crypto.subtle.sign("HMAC", this.hmacKey, enc.encode(skKey));
return b64url(new Uint8Array(sig as ArrayBuffer));
}
/** 登记时:AES-256-GCM,打包 nonce||ciphertext||tag */
async sealKey(skKey: string): Promise<Uint8Array> {
const nonce = crypto.getRandomValues(new Uint8Array(12));
const ct = await crypto.subtle.encrypt(
{ name: "AES-GCM", iv: nonce },
this.aesKey,
enc.encode(skKey),
);
const packed = new Uint8Array(new ArrayBuffer(nonce.length + ct.byteLength));
packed.set(nonce, 0);
packed.set(new Uint8Array(ct), nonce.length);
return packed;
}
/** 调上游时解密原文(唯一用途) */
async openKey(packed: Uint8Array): Promise<string> {
const nonce = packed.slice(0, 12);
const ct = packed.slice(12);
const pt = await crypto.subtle.decrypt(
{ name: "AES-GCM", iv: nonce },
this.aesKey,
ct,
);
return dec.decode(pt);
}
}
/** PBKDF2-SHA256,格式:pbkdf2$<iters>$<b64salt>$<b64hash> */
export const PBKDF2_ITERATIONS = 210_000;
export async function hashPassword(password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(16));
const key = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: PBKDF2_ITERATIONS },
key,
256,
);
return `pbkdf2$${PBKDF2_ITERATIONS}$${b64url(salt)}$${b64url(new Uint8Array(bits))}`;
}
export async function verifyPassword(
password: string,
stored: string,
): Promise<boolean> {
const parts = stored.split("$");
if (parts.length !== 4 || parts[0] !== "pbkdf2") return false;
const iters = Number(parts[1]);
const salt = b64urlDecode(parts[2]!);
const expect = b64urlDecode(parts[3]!);
const key = await crypto.subtle.importKey(
"raw",
enc.encode(password),
"PBKDF2",
false,
["deriveBits"],
);
const bits = new Uint8Array(
await crypto.subtle.deriveBits(
{ name: "PBKDF2", hash: "SHA-256", salt, iterations: iters },
key,
expect.length * 8,
),
);
if (bits.length !== expect.length) return false;
let diff = 0;
for (let i = 0; i < bits.length; i++) diff |= bits[i]! ^ expect[i]!;
return diff === 0;
}
/** 256bit 随机令牌 → b64url 明文;库存其 SHA-256 哈希 */
export async function generateToken(): Promise<{ plaintext: string; hash: string }> {
const raw = crypto.getRandomValues(new Uint8Array(32));
const plaintext = b64url(raw);
const digest = await crypto.subtle.digest("SHA-256", enc.encode(plaintext));
return { plaintext, hash: b64url(new Uint8Array(digest)) };
}
export async function hashToken(plaintext: string): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", enc.encode(plaintext));
return b64url(new Uint8Array(digest));
}
export function randomSkKeyEncryptionSecret(): string {
return b64url(crypto.getRandomValues(new Uint8Array(32)));
}
+65
View File
@@ -0,0 +1,65 @@
/**
* usage_logs.dedupe_key 生成(usage_logs表字段设计 §3.1)
* 消费行 type=2: `${api_key_id}:${request_id}`
* 非消费行: `${api_key_id}:${request_id}:${sha256指纹}`
* request_id 空:退化为纯内容指纹
*/
export interface DedupeInput {
api_key_id: number;
request_id: string | null | undefined;
type: number;
created_at: string | number | Date;
model_name: string;
quota: number;
prompt_tokens: number;
completion_tokens: number;
use_time: number;
content: string;
}
const LOG_TYPE_CONSUME = 2;
function toUnix(created_at: string | number | Date): number {
if (typeof created_at === "number") return created_at;
if (created_at instanceof Date)
return Math.floor(created_at.getTime() / 1000);
return Math.floor(new Date(created_at).getTime() / 1000);
}
async function sha256Hex(input: string): Promise<string> {
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(input),
);
return [...new Uint8Array(digest)]
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/** 内容指纹(错误行含 content;空 request_id 的消费行不含 content) */
export async function contentFingerprint(row: DedupeInput): Promise<string> {
const parts = [
String(row.api_key_id),
String(toUnix(row.created_at)),
String(row.type),
row.model_name,
String(row.quota),
String(row.prompt_tokens),
String(row.completion_tokens),
String(row.use_time),
];
if (row.type !== LOG_TYPE_CONSUME || !row.request_id) {
parts.push(row.content);
}
return sha256Hex(parts.join("\n"));
}
export async function buildDedupeKey(row: DedupeInput): Promise<string> {
const rid = row.request_id?.trim() ?? "";
if (row.type === LOG_TYPE_CONSUME && rid) {
return `${row.api_key_id}:${rid}`;
}
const fp = await contentFingerprint(row);
return rid ? `${row.api_key_id}:${rid}:${fp}` : `${row.api_key_id}:${fp}`;
}
+46
View File
@@ -0,0 +1,46 @@
export interface ErrorBody {
error: {
code: string;
message: string;
fields?: Record<string, string>;
key_id?: number;
};
}
export function errorBody(
code: string,
message: string,
extra?: { fields?: Record<string, string>; key_id?: number },
): ErrorBody {
return {
error: {
code,
message,
...(extra?.fields ? { fields: extra.fields } : {}),
...(extra?.key_id !== undefined ? { key_id: extra.key_id } : {}),
},
};
}
export const ERR = {
invalid_credentials: () =>
errorBody("invalid_credentials", "用户名或密码错误"),
session_expired: () =>
errorBody("session_expired", "会话已过期,请重新登录"),
already_initialized: () =>
errorBody("already_initialized", "系统已初始化,无法重复引导"),
key_already_registered: (key_id: number) =>
errorBody("key_already_registered", "这枚 key 已经登记过", { key_id }),
member_name_taken: () =>
errorBody("member_name_taken", "成员名称已被占用"),
upstream_unreachable: () =>
errorBody("upstream_unreachable", "上游站点暂时不可达,请稍后重试"),
not_found: () => errorBody("not_found", "请求的资源不存在"),
bad_request: () => errorBody("bad_request", "请求格式错误"),
validation_failed: (fields: Record<string, string>) =>
errorBody("validation_failed", "请检查表单填写", { fields }),
internal: () =>
errorBody("internal", "服务暂时不可用,请稍后重试"),
too_many_requests: () =>
errorBody("too_many_requests", "操作过于频繁,请稍后再试"),
} as const;
+10
View File
@@ -0,0 +1,10 @@
export * from "./types.ts";
export * from "./errors.ts";
export * from "./time.ts";
export * from "./money.ts";
export * from "./crypto.ts";
export * from "./dedupe.ts";
export * from "./schemas.ts";
export * from "./log.ts";
export * from "./usage.ts";
export * from "./upstream.ts";
+67
View File
@@ -0,0 +1,67 @@
/**
* 业务日志(@logtape/logtape)——零依赖、Bun/edge 可跑。
* 访问日志另用 Hono `logger` 中间件;本模块只记启动、错误、引擎等业务事件。
*/
import {
configureSync,
getConsoleSink,
getLogger,
type Logger,
type LogLevel,
} from "@logtape/logtape";
export type AppLogLevel = Exclude<LogLevel, "trace">;
/** 防止密钥类串进日志正文(sk-key / Bearer / 机密 env) */
export function redactSecrets(message: string): string {
return message
.replace(/sk-[A-Za-z0-9_\-]{8,}/g, "sk-***")
.replace(/Bearer\s+\S+/gi, "Bearer ***")
.replace(
/SK_KEY_ENCRYPTION_SECRET[=:]\s*\S+/gi,
"SK_KEY_ENCRYPTION_SECRET=***",
);
}
let configured = false;
/**
* 进程内只允许配置一次(热更新时模块可能被重复求值)。
* 使用 configureSync,避免 configure() 异步窗口内的重入锁。
*/
export function setupLogging(level: AppLogLevel = "info"): void {
if (configured) return;
try {
configureSync({
reset: true,
sinks: {
console: getConsoleSink({
formatter: (record) => {
const ts = new Date(record.timestamp).toISOString();
const cat = record.category.join(".");
const msg = redactSecrets(String(record.message));
return `${ts} ${record.level.toUpperCase()} [${cat}] ${msg}`;
},
}),
},
// app 日志;meta 单独压到 warning,避免每次启动的配置提示
loggers: [
{ category: ["naku"], lowestLevel: level, sinks: ["console"] },
{
category: ["logtape", "meta"],
lowestLevel: "warning",
sinks: ["console"],
},
],
});
configured = true;
} catch {
// 并发/热更新竞态:已有配置在跑则忽略,绝不打崩业务进程
configured = true;
}
}
export function appLogger(...category: string[]): Logger {
if (!configured) setupLogging("info");
return getLogger(["naku", ...category]);
}
+62
View File
@@ -0,0 +1,62 @@
/**
* quota → 额度美元换算(公共约定 §5)
* 内部以微分(1/100 美分 = 1e-6 美元)整数运算,仅输出时四舍五入到分。
*/
/** quota → 微分(1e-6 USD) */
export function quotaToMicroUsd(quota: number, qpu: number): number {
if (qpu <= 0) throw new Error("qpu must be positive");
// quota / qpu = USD;再 * 1_000_000 = micro-USD
return Math.round((quota * 1_000_000) / qpu);
}
export function microUsdToQuotaUsdStr(micro: number): string {
// 四舍五入到分(10000 micro = 1 cent)
const cents = Math.round(micro / 10_000);
const dollars = Math.floor(cents / 100);
const rem = cents % 100;
return `${dollars}.${String(rem).padStart(2, "0")}`;
}
export function quotaToUsdStr(quota: number, qpu: number): string {
return microUsdToQuotaUsdStr(quotaToMicroUsd(quota, qpu));
}
/** 聚合:先微分求和再一次取整(小行贡献不丢失) */
export function sumQuotaToUsdStr(quotas: readonly number[], qpu: number): string {
let micro = 0;
for (const q of quotas) micro += quotaToMicroUsd(q, qpu);
return microUsdToQuotaUsdStr(micro);
}
/** 由微分总和直接出字符串(仓储层先累加再调用) */
export function microSumToUsdStr(microSum: number): string {
return microUsdToQuotaUsdStr(microSum);
}
/**
* qpuAt(t):按时刻取 QPU 时间线
* timeline 按 effective_from 升序。
*/
export function qpuAt(
t: Date | number,
timeline: ReadonlyArray<{ qpu: number; effective_from: Date | number }>,
): number {
const tMs = t instanceof Date ? t.getTime() : t * 1000;
let picked: number | null = null;
for (const row of timeline) {
const fromMs =
row.effective_from instanceof Date
? row.effective_from.getTime()
: row.effective_from * 1000;
if (fromMs <= tMs) picked = row.qpu;
else break;
}
if (picked === null) {
// 时刻早于全部时间线:取最早一条
const first = timeline[0];
if (!first) throw new Error("qpu timeline is empty");
return first.qpu;
}
return picked;
}
+240
View File
@@ -0,0 +1,240 @@
import { z } from "zod";
/**
* 契约 zod schema(字段权威 = API契约三篇;DTO 类型见 types.ts)。
* OpenAPI 装饰(.openapi 组件名等)留在 apps/server,core 不依赖 hono。
*/
export const errorShape = z.object({
code: z.string(),
message: z.string(),
fields: z.record(z.string(), z.string()).optional(),
key_id: z.number().int().optional(),
});
export const errorEnvelope = z.object({ error: errorShape });
export type ErrorEnvelope = z.infer<typeof errorEnvelope>;
export const setupState = z.object({ initialized: z.boolean() });
export const usernameBody = z.object({ username: z.string() });
export const setupBody = z.object({
username: z.string().min(1).max(64),
password: z.string().min(8).max(128),
});
export const loginBody = z.object({
username: z.string().min(1),
password: z.string().min(1),
});
export const changePasswordBody = z.object({
old_password: z.string().min(1),
new_password: z.string().min(8).max(128),
});
export const timeRangeQuery = z.object({
from: z.string().min(1),
to: z.string().min(1),
});
export const pageQuery = z.object({
page: z.coerce.number().int().min(1).default(1),
page_size: z.coerce.number().int().min(1).max(200).default(50),
});
export const usageTotals = z.object({
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
});
export const logItem = z.object({
id: z.number().int(),
api_key_id: z.number().int(),
created_at: z.string(),
token_name: z.string(),
model_name: z.string(),
type: z.number().int(),
content: z.string(),
quota: z.number(),
quota_usd: z.string(),
prompt_tokens: z.number().int(),
completion_tokens: z.number().int(),
use_time: z.number().int(),
is_stream: z.boolean(),
billing_source: z.string().nullable(),
subscription_id: z.number().int().nullable(),
});
export const logPage = z.object({
items: z.array(logItem),
total: z.number().int(),
});
export const keyItem = z.object({
id: z.number().int(),
token_name: z.string(),
status: z.enum(["active", "disabled"]),
member_id: z.number().int().nullable(),
member_name: z.string().nullable(),
registered_at: z.string(),
log_synced_at: z.string().nullable(),
usage_synced_at: z.string().nullable(),
total_used: z.number(),
total_available: z.number(),
total_granted: z.number(),
unlimited_quota: z.boolean(),
expires_at: z.string().nullable(),
});
export const registerKeyBody = z.object({
sk_key: z.string().min(8),
member_id: z.number().int().positive().nullable().optional(),
});
export const patchKeyBody = z.object({
status: z.enum(["active", "disabled"]).optional(),
member_id: z.number().int().positive().nullable().optional(),
});
export const memberItem = z.object({
id: z.number().int(),
name: z.string(),
remark: z.string(),
status: z.enum(["active", "disabled"]),
token_issued_at: z.string().nullable(),
created_at: z.string(),
key_count: z.number().int(),
});
export const createMemberBody = z.object({
name: z.string().min(1).max(64),
remark: z.string().optional().default(""),
});
export const patchMemberBody = z.object({
status: z.enum(["active", "disabled"]),
});
export const memberWithToken = z.object({
member: memberItem,
token: z.string(),
});
export const tokenOnly = z.object({ token: z.string() });
export const memberMe = z.object({
id: z.number().int(),
name: z.string(),
});
export const paymentItem = z.object({
id: z.number().int(),
kind: z.enum(["subscription", "topup"]),
paid_at: z.string(),
paid_amount: z.number(),
remark: z.string(),
period_start: z.string().nullable(),
period_end: z.string().nullable(),
subscription_id: z.number().int().nullable(),
quota_granted: z.number().int().nullable(),
quota_usd: z.string(),
});
export const engineStatus = z.object({
running: z.boolean(),
last_round: z
.object({
started_at: z.string(),
keys_total: z.number().int(),
succeeded: z.number().int(),
failed: z.number().int(),
failed_api_key_ids: z.array(z.number().int()),
})
.nullable(),
});
export const auditLogItem = z.object({
id: z.number().int(),
created_at: z.string(),
operator: z.string().nullable(),
domain: z.enum(["key", "payment", "member", "account"]),
action: z.string(),
summary: z.string(),
});
export const memberOverview = z.object({
member: z.object({
id: z.number().int().nullable(),
name: z.string().nullable(),
}),
usage: usageTotals,
by_source: z.array(
z.object({
billing_source: z.string(),
count: z.number().int(),
quota: z.number(),
quota_usd: z.string(),
}),
),
keys: z.array(
z.object({
key_id: z.number().int(),
token_name: z.string(),
log_synced_at: z.string().nullable(),
usage: usageTotals,
}),
),
payments: z.array(z.looseObject({ id: z.number().int() })),
});
export const createPaymentBody = z
.object({
kind: z.enum(["subscription", "topup"]),
paid_at: z.string().min(1),
paid_amount: z.number(),
remark: z.string().default(""),
period_start: z.string().min(1).nullable().optional(),
period_end: z.string().min(1).nullable().optional(),
subscription_id: z.number().int().positive().nullable().optional(),
quota_granted: z.number().int().nullable().optional(),
})
.superRefine((val, ctx) => {
if (val.kind === "subscription") {
if (!val.period_start) {
ctx.addIssue({
code: "custom",
message: "订阅必须填写期间起点",
path: ["period_start"],
});
}
if (val.subscription_id == null) {
ctx.addIssue({
code: "custom",
message: "订阅必须填写订阅编号",
path: ["subscription_id"],
});
}
}
if (val.quota_granted == null) {
ctx.addIssue({
code: "custom",
message: "必须填写本笔额度",
path: ["quota_granted"],
});
}
});
export {
setupBody as setupBodySchema,
loginBody as loginBodySchema,
changePasswordBody as changePasswordBodySchema,
registerKeyBody as registerKeyBodySchema,
patchKeyBody as patchKeyBodySchema,
createMemberBody as createMemberBodySchema,
patchMemberBody as patchMemberBodySchema,
createPaymentBody as createPaymentBodySchema,
timeRangeQuery as timeRangeQuerySchema,
pageQuery as pageQuerySchema,
};
+34
View File
@@ -0,0 +1,34 @@
/** 存储层 UTC Unix 秒 / Date / ISO 字符串 ↔ DTO 层 ISO 8601 UTC */
export function unixToIso(
value: number | string | Date | null | undefined,
): string | null {
if (value === null || value === undefined) return null;
let d: Date;
if (value instanceof Date) {
d = value;
} else if (typeof value === "number") {
// Unix 秒(或毫秒:量级区分)
d = new Date(value < 1e12 ? value * 1000 : value);
} else {
d = new Date(value);
}
if (Number.isNaN(d.getTime())) return null;
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
export function isoToUnix(iso: string): number {
return Math.floor(new Date(iso).getTime() / 1000);
}
export function requireIso(value: string | null | undefined, field: string): string {
if (!value) throw new Error(`missing time field: ${field}`);
return value;
}
/** 左闭右开 [from, to) 的毫秒边界(前端传 ISO,查询用) */
export function rangeToMs(fromIso: string, toIso: string): { fromMs: number; toMs: number } {
return {
fromMs: new Date(fromIso).getTime(),
toMs: new Date(toIso).getTime(),
};
}
+130
View File
@@ -0,0 +1,130 @@
/** ISO 8601 UTC 字符串,如 2026-10-02T14:22:05Z */
export type Iso8601Utc = string;
/** 支付金额(币种无关数值) */
export type PaidAmount = number;
/** quota 原值整数 */
export type Quota = number;
/** 两位小数美元字符串,如 "25.00" */
export type QuotaUsd = string;
export type BillingSource = "subscription" | "wallet";
export type PaymentKind = "subscription" | "topup";
export type KeyStatus = "active" | "disabled";
export type MemberStatus = "active" | "disabled";
export interface UsageTotals {
count: number;
quota: Quota;
quota_usd: QuotaUsd;
}
export interface LogItem {
id: number;
api_key_id: number;
created_at: Iso8601Utc;
token_name: string;
model_name: string;
type: number;
content: string;
quota: Quota;
quota_usd: QuotaUsd;
prompt_tokens: number;
completion_tokens: number;
use_time: number;
is_stream: boolean;
billing_source: BillingSource | null;
subscription_id: number | null;
}
export interface KeyItem {
id: number;
token_name: string;
status: KeyStatus;
member_id: number | null;
member_name: string | null;
registered_at: Iso8601Utc;
log_synced_at: Iso8601Utc | null;
usage_synced_at: Iso8601Utc | null;
total_used: number;
total_available: number;
total_granted: number;
unlimited_quota: boolean;
expires_at: Iso8601Utc | null;
}
export interface PaymentItem {
id: number;
kind: PaymentKind;
paid_at: Iso8601Utc;
paid_amount: PaidAmount;
remark: string;
period_start: Iso8601Utc | null;
period_end: Iso8601Utc | null;
subscription_id: number | null;
quota_granted: number | null;
quota_usd: QuotaUsd;
}
export interface MemberItem {
id: number;
name: string;
remark: string;
status: MemberStatus;
token_issued_at: Iso8601Utc | null;
created_at: Iso8601Utc;
key_count: number;
}
export interface AuditLogItem {
id: number;
created_at: Iso8601Utc;
operator: string | null;
domain: "key" | "payment" | "member" | "account";
action: string;
summary: string;
}
export interface Paginated<T> {
items: T[];
total: number;
}
export interface EngineLastRound {
started_at: Iso8601Utc;
keys_total: number;
succeeded: number;
failed: number;
failed_api_key_ids: number[];
}
export interface EngineStatus {
running: boolean;
last_round: EngineLastRound | null;
}
export interface QpuLatest {
qpu: number;
effective_from: Iso8601Utc;
}
export type AuditAction =
| "key.register"
| "key.enable"
| "key.disable"
| "key.reassign"
| "payment.create"
| "payment.update"
| "payment.delete"
| "member.create"
| "member.enable"
| "member.disable"
| "member.token_issue"
| "member.token_reissue"
| "account.password_change"
| "account.init";
/** 消费行(聚合口径仅此类型) */
export const LOG_TYPE_CONSUME = 2;
+107
View File
@@ -0,0 +1,107 @@
/**
* 上游 NewAPI 客户端(仅 Web 标准 API——Bun 纪律)
* 接口实证:docs/references/NewAPI-接口范围.md
*/
export interface TokenUsage {
name: string;
total_granted: number;
total_used: number;
total_available: number;
unlimited_quota: boolean;
model_limits: Record<string, unknown> | null;
model_limits_enabled: boolean;
/** Unix 秒,0 = 永不过期 */
expires_at: number;
}
export type UpstreamFailure =
/** 401:key 不存在或被显式禁用 */
| { ok: false; kind: "rejected" }
/** 网络失败 / 超时 */
| { ok: false; kind: "unreachable" }
/** 其他非预期 HTTP / 403 封禁 / 5xx */
| { ok: false; kind: "http"; status: number };
export type UpstreamResult<T> = { ok: true; data: T } | UpstreamFailure;
export interface UpstreamClientOptions {
baseUrl: string;
timeoutMs?: number;
}
function joinUrl(base: string, path: string): string {
return `${base.replace(/\/+$/, "")}${path}`;
}
async function requestJson<T>(
url: string,
skKey: string,
timeoutMs: number,
): Promise<UpstreamResult<T>> {
const ac = new AbortController();
const timer = setTimeout(() => ac.abort(), timeoutMs);
try {
const res = await fetch(url, {
method: "GET",
headers: {
Authorization: `Bearer ${skKey}`,
Accept: "application/json",
},
signal: ac.signal,
});
if (res.status === 401) return { ok: false, kind: "rejected" };
if (!res.ok) return { ok: false, kind: "http", status: res.status };
const body = (await res.json()) as {
code?: boolean | number;
success?: boolean;
message?: string;
data?: T;
};
// NewAPI 包装:code:true / success:true + data
if (body.code === false || body.success === false) {
return { ok: false, kind: "rejected" };
}
if (body.data === undefined) {
return { ok: false, kind: "http", status: res.status };
}
return { ok: true, data: body.data };
} catch {
return { ok: false, kind: "unreachable" };
} finally {
clearTimeout(timer);
}
}
export function createUpstreamClient(opts: UpstreamClientOptions) {
const timeoutMs = opts.timeoutMs ?? 8_000;
return {
/** 登记验证 / 轮询额度:GET /api/usage/token */
async fetchTokenUsage(skKey: string): Promise<UpstreamResult<TokenUsage>> {
const r = await requestJson<TokenUsage>(
joinUrl(opts.baseUrl, "/api/usage/token"),
skKey,
timeoutMs,
);
return r;
},
/** 换算率权威来源:GET /api/status(取 quota_per_unit) */
async fetchQuotaPerUnit(): Promise<UpstreamResult<{ quota_per_unit: number }>> {
return requestJson<{ quota_per_unit: number }>(
joinUrl(opts.baseUrl, "/api/status"),
"sk-unused",
timeoutMs,
);
},
};
}
export type UpstreamClient = ReturnType<typeof createUpstreamClient>;
/** expires_at:上游 0 = 永不过期 → 我们存 null */
export function normalizeExpiresAt(unixSeconds: number): Date | null {
if (!unixSeconds) return null;
return new Date(unixSeconds * 1000);
}
+64
View File
@@ -0,0 +1,64 @@
/**
* 用量聚合辅助(公共约定 §5:后端换算、微分取整、聚合先求和再一次取整)
*/
import {
microSumToUsdStr,
qpuAt,
quotaToMicroUsd,
quotaToUsdStr,
} from "./money.ts";
/** 上游事实:QuotaPerUnit 默认 500000(qpu_history 未采样时的回退) */
export const DEFAULT_QPU = 500_000;
export type QpuPoint = { qpu: number; effective_from: Date | number };
export function resolveQpuTimeline(
rows: readonly QpuPoint[],
): QpuPoint[] {
if (rows.length > 0) return [...rows];
return [{ qpu: DEFAULT_QPU, effective_from: 0 }];
}
export interface UsageSum {
count: number;
quota: number;
quota_usd: string;
}
export function emptyUsage(): UsageSum {
return { count: 0, quota: 0, quota_usd: "0.00" };
}
/** 逐行微分累加(历史正确性:按日志时刻取 QPU) */
export function sumUsage(
rows: ReadonlyArray<{ quota: number; created_at: Date | number }>,
timeline: readonly QpuPoint[],
): UsageSum {
let quota = 0;
let micro = 0;
for (const r of rows) {
quota += r.quota;
const qpu = qpuAt(r.created_at, timeline);
micro += quotaToMicroUsd(r.quota, qpu);
}
return { count: rows.length, quota, quota_usd: microSumToUsdStr(micro) };
}
export function usageAt(
quota: number,
created_at: Date | number,
timeline: readonly QpuPoint[],
): UsageSum {
const qpu = qpuAt(created_at, timeline);
return { count: 1, quota, quota_usd: quotaToUsdStr(quota, qpu) };
}
export function addUsage(a: UsageSum, b: UsageSum): UsageSum {
// 合并已成形的两段时,quota_usd 需重算——调用方应尽量走 sumUsage
return {
count: a.count + b.count,
quota: a.quota + b.quota,
quota_usd: a.quota_usd,
};
}