feat(core,db): 业务核心与数据访问层
core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。 db:八表 Drizzle schema + 仓储 + drizzle 配置。
This commit is contained in:
@@ -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)));
|
||||
}
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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";
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user