From 6980d09750efdd063f52a87b12cb577ab4256749 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Tue, 6 Oct 2026 01:30:51 +0800 Subject: [PATCH] =?UTF-8?q?feat(core,db):=20=E4=B8=9A=E5=8A=A1=E6=A0=B8?= =?UTF-8?q?=E5=BF=83=E4=B8=8E=E6=95=B0=E6=8D=AE=E8=AE=BF=E9=97=AE=E5=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。 db:八表 Drizzle schema + 仓储 + drizzle 配置。 --- packages/core/package.json | 13 + packages/core/src/crypto.ts | 178 +++++++++++ packages/core/src/dedupe.ts | 65 ++++ packages/core/src/errors.ts | 46 +++ packages/core/src/index.ts | 10 + packages/core/src/log.ts | 67 ++++ packages/core/src/money.ts | 62 ++++ packages/core/src/schemas.ts | 240 ++++++++++++++ packages/core/src/time.ts | 34 ++ packages/core/src/types.ts | 130 ++++++++ packages/core/src/upstream.ts | 107 +++++++ packages/core/src/usage.ts | 64 ++++ packages/db/drizzle.config.ts | 12 + packages/db/package.json | 22 ++ packages/db/src/index.ts | 13 + packages/db/src/repos.ts | 575 ++++++++++++++++++++++++++++++++++ packages/db/src/schema.ts | 201 ++++++++++++ 17 files changed, 1839 insertions(+) create mode 100644 packages/core/package.json create mode 100644 packages/core/src/crypto.ts create mode 100644 packages/core/src/dedupe.ts create mode 100644 packages/core/src/errors.ts create mode 100644 packages/core/src/index.ts create mode 100644 packages/core/src/log.ts create mode 100644 packages/core/src/money.ts create mode 100644 packages/core/src/schemas.ts create mode 100644 packages/core/src/time.ts create mode 100644 packages/core/src/types.ts create mode 100644 packages/core/src/upstream.ts create mode 100644 packages/core/src/usage.ts create mode 100644 packages/db/drizzle.config.ts create mode 100644 packages/db/package.json create mode 100644 packages/db/src/index.ts create mode 100644 packages/db/src/repos.ts create mode 100644 packages/db/src/schema.ts diff --git a/packages/core/package.json b/packages/core/package.json new file mode 100644 index 0000000..89c0d81 --- /dev/null +++ b/packages/core/package.json @@ -0,0 +1,13 @@ +{ + "name": "@new-api-key-usage/core", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "dependencies": { + "@logtape/logtape": "^2.3.11", + "zod": "^4.6.5" + } +} diff --git a/packages/core/src/crypto.ts b/packages/core/src/crypto.ts new file mode 100644 index 0000000..f8d37e2 --- /dev/null +++ b/packages/core/src/crypto.ts @@ -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 { + 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, + info: string, + length: number, +): Promise { + 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): Promise { + 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 { + 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 { + 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 { + 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 { + 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$$$ */ +export const PBKDF2_ITERATIONS = 210_000; + +export async function hashPassword(password: string): Promise { + 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 { + 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 { + 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))); +} diff --git a/packages/core/src/dedupe.ts b/packages/core/src/dedupe.ts new file mode 100644 index 0000000..9588e11 --- /dev/null +++ b/packages/core/src/dedupe.ts @@ -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 { + 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 { + 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 { + 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}`; +} diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts new file mode 100644 index 0000000..bfeeec3 --- /dev/null +++ b/packages/core/src/errors.ts @@ -0,0 +1,46 @@ +export interface ErrorBody { + error: { + code: string; + message: string; + fields?: Record; + key_id?: number; + }; +} + +export function errorBody( + code: string, + message: string, + extra?: { fields?: Record; 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) => + errorBody("validation_failed", "请检查表单填写", { fields }), + internal: () => + errorBody("internal", "服务暂时不可用,请稍后重试"), + too_many_requests: () => + errorBody("too_many_requests", "操作过于频繁,请稍后再试"), +} as const; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..0cec1d1 --- /dev/null +++ b/packages/core/src/index.ts @@ -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"; diff --git a/packages/core/src/log.ts b/packages/core/src/log.ts new file mode 100644 index 0000000..b612afd --- /dev/null +++ b/packages/core/src/log.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; + +/** 防止密钥类串进日志正文(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]); +} diff --git a/packages/core/src/money.ts b/packages/core/src/money.ts new file mode 100644 index 0000000..7acc7af --- /dev/null +++ b/packages/core/src/money.ts @@ -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; +} diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts new file mode 100644 index 0000000..5dd5897 --- /dev/null +++ b/packages/core/src/schemas.ts @@ -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; + +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, +}; diff --git a/packages/core/src/time.ts b/packages/core/src/time.ts new file mode 100644 index 0000000..f4b6458 --- /dev/null +++ b/packages/core/src/time.ts @@ -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(), + }; +} diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts new file mode 100644 index 0000000..6abcfd1 --- /dev/null +++ b/packages/core/src/types.ts @@ -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 { + 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; diff --git a/packages/core/src/upstream.ts b/packages/core/src/upstream.ts new file mode 100644 index 0000000..ccf949d --- /dev/null +++ b/packages/core/src/upstream.ts @@ -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 | 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 = { 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( + url: string, + skKey: string, + timeoutMs: number, +): Promise> { + 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> { + const r = await requestJson( + joinUrl(opts.baseUrl, "/api/usage/token"), + skKey, + timeoutMs, + ); + return r; + }, + + /** 换算率权威来源:GET /api/status(取 quota_per_unit) */ + async fetchQuotaPerUnit(): Promise> { + return requestJson<{ quota_per_unit: number }>( + joinUrl(opts.baseUrl, "/api/status"), + "sk-unused", + timeoutMs, + ); + }, + }; +} + +export type UpstreamClient = ReturnType; + +/** expires_at:上游 0 = 永不过期 → 我们存 null */ +export function normalizeExpiresAt(unixSeconds: number): Date | null { + if (!unixSeconds) return null; + return new Date(unixSeconds * 1000); +} diff --git a/packages/core/src/usage.ts b/packages/core/src/usage.ts new file mode 100644 index 0000000..6039d6c --- /dev/null +++ b/packages/core/src/usage.ts @@ -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, + }; +} diff --git a/packages/db/drizzle.config.ts b/packages/db/drizzle.config.ts new file mode 100644 index 0000000..91d200b --- /dev/null +++ b/packages/db/drizzle.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + dialect: "postgresql", + schema: "./src/schema.ts", + out: "./drizzle", + dbCredentials: { + url: + process.env.DATABASE_URL ?? + "postgres://postgres:postgres@127.0.0.1:5432/new_api_key_usage", + }, +}); diff --git a/packages/db/package.json b/packages/db/package.json new file mode 100644 index 0000000..cd62139 --- /dev/null +++ b/packages/db/package.json @@ -0,0 +1,22 @@ +{ + "name": "@new-api-key-usage/db", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "db:generate": "drizzle-kit generate", + "db:push": "drizzle-kit push", + "db:studio": "drizzle-kit studio" + }, + "dependencies": { + "drizzle-orm": "^0.45.3", + "postgres": "^3.4.9" + }, + "devDependencies": { + "@types/pg": "^8.23.1", + "drizzle-kit": "^0.31.11" + } +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts new file mode 100644 index 0000000..8e004b6 --- /dev/null +++ b/packages/db/src/index.ts @@ -0,0 +1,13 @@ +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; +import * as schema from "./schema.ts"; + +export function createDb(connectionString: string) { + const client = postgres(connectionString, { max: 10 }); + return drizzle(client, { schema }); +} + +export type Db = ReturnType; + +export * from "./schema.ts"; +export * from "./repos.ts"; diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts new file mode 100644 index 0000000..0cb60db --- /dev/null +++ b/packages/db/src/repos.ts @@ -0,0 +1,575 @@ +import type { Db } from "./index.ts"; +import { + apiKeys, + auditLogs, + members, + payments, + qpuHistory, + sessions, + usageLogs, + users, + type ApiKeyRow, + type AuditLogRow, + type MemberRow, + type PaymentRow, + type SessionRow, + type UsageLogRow, + type UserRow, +} from "./schema.ts"; +import { and, asc, count, desc, eq, gt, gte, lt, or, sql } from "drizzle-orm"; + +export interface SessionUser { + user_id: number; + username: string; + expires_at: Date; +} + +export interface KeyListRow extends ApiKeyRow { + member_name: string | null; +} + +/** + * 仓储层(R14):数据访问只出现在 packages/db。 + * apps/server 不 import drizzle-orm / postgres 驱动。 + */ +export function createRepos(db: Db) { + return { + users: { + async countAll(): Promise { + const rows = await db.select({ id: users.id }).from(users).limit(1); + return rows[0] ? 1 : 0; + }, + async findByUsername(username: string): Promise { + const rows = await db + .select() + .from(users) + .where(eq(users.username, username)) + .limit(1); + return rows[0] ?? null; + }, + async findById(id: number): Promise { + const rows = await db.select().from(users).where(eq(users.id, id)).limit(1); + return rows[0] ?? null; + }, + async insert(username: string, password_hash: string): Promise { + const [row] = await db + .insert(users) + .values({ username, password_hash }) + .returning(); + return row!; + }, + async updatePassword(id: number, password_hash: string): Promise { + await db.update(users).set({ password_hash }).where(eq(users.id, id)); + }, + }, + + sessions: { + async findValid( + token_hash: string, + now: Date, + ): Promise { + const rows = await db + .select({ + user_id: sessions.user_id, + username: users.username, + expires_at: sessions.expires_at, + }) + .from(sessions) + .innerJoin(users, eq(sessions.user_id, users.id)) + .where( + and(eq(sessions.token_hash, token_hash), gt(sessions.expires_at, now)), + ) + .limit(1); + return rows[0] ?? null; + }, + async insert( + user_id: number, + token_hash: string, + expires_at: Date, + ): Promise { + const [row] = await db + .insert(sessions) + .values({ user_id, token_hash, expires_at }) + .returning(); + return row!; + }, + async deleteByTokenHash(token_hash: string): Promise { + await db.delete(sessions).where(eq(sessions.token_hash, token_hash)); + }, + async listByUser(user_id: number) { + return db + .select({ id: sessions.id, token_hash: sessions.token_hash }) + .from(sessions) + .where(eq(sessions.user_id, user_id)); + }, + async deleteById(id: number): Promise { + await db.delete(sessions).where(eq(sessions.id, id)); + }, + }, + + keys: { + async listAll(filter?: { + member_id?: number | "unassigned"; + status?: string; + }): Promise { + const conds = []; + if (filter?.member_id === "unassigned") { + conds.push(sql`${apiKeys.member_id} is null`); + } else if (typeof filter?.member_id === "number") { + conds.push(eq(apiKeys.member_id, filter.member_id)); + } + if (filter?.status) { + conds.push(eq(apiKeys.status, filter.status)); + } + const base = db + .select({ + id: apiKeys.id, + key_hash: apiKeys.key_hash, + key_ciphertext: apiKeys.key_ciphertext, + status: apiKeys.status, + member_id: apiKeys.member_id, + registered_at: apiKeys.registered_at, + log_synced_at: apiKeys.log_synced_at, + usage_synced_at: apiKeys.usage_synced_at, + token_name: apiKeys.token_name, + total_granted: apiKeys.total_granted, + total_used: apiKeys.total_used, + total_available: apiKeys.total_available, + unlimited_quota: apiKeys.unlimited_quota, + model_limits: apiKeys.model_limits, + model_limits_enabled: apiKeys.model_limits_enabled, + expires_at: apiKeys.expires_at, + member_name: members.name, + }) + .from(apiKeys) + .leftJoin(members, eq(apiKeys.member_id, members.id)); + if (conds.length > 0) { + return base.where(and(...conds)); + } + return base; + }, + async findById(id: number): Promise { + const rows = await db + .select() + .from(apiKeys) + .where(eq(apiKeys.id, id)) + .limit(1); + return rows[0] ?? null; + }, + async findByHash(key_hash: string): Promise<{ id: number } | null> { + const rows = await db + .select({ id: apiKeys.id }) + .from(apiKeys) + .where(eq(apiKeys.key_hash, key_hash)) + .limit(1); + return rows[0] ?? null; + }, + async insert(values: { + key_hash: string; + key_ciphertext: Uint8Array; + member_id: number | null; + token_name?: string; + }): Promise { + const [row] = await db + .insert(apiKeys) + .values({ + key_hash: values.key_hash, + key_ciphertext: values.key_ciphertext, + member_id: values.member_id, + token_name: values.token_name ?? "", + }) + .returning(); + return row!; + }, + async update( + id: number, + patch: Partial, + ): Promise { + const [row] = await db + .update(apiKeys) + .set(patch) + .where(eq(apiKeys.id, id)) + .returning(); + return row!; + }, + }, + + members: { + async listAll(): Promise { + return db.select().from(members); + }, + async findById(id: number): Promise { + const rows = await db + .select() + .from(members) + .where(eq(members.id, id)) + .limit(1); + return rows[0] ?? null; + }, + async findByName(name: string): Promise<{ id: number } | null> { + const rows = await db + .select({ id: members.id }) + .from(members) + .where(eq(members.name, name)) + .limit(1); + return rows[0] ?? null; + }, + async findByTokenHash(token_hash: string): Promise<{ + id: number; + status: string; + } | null> { + const rows = await db + .select({ id: members.id, status: members.status }) + .from(members) + .where(eq(members.token_hash, token_hash)) + .limit(1); + return rows[0] ?? null; + }, + async insert(values: { + name: string; + remark: string; + token_hash: string; + token_issued_at: Date; + }): Promise { + const [row] = await db.insert(members).values(values).returning(); + return row!; + }, + async updateStatus(id: number, status: string): Promise { + const [row] = await db + .update(members) + .set({ status }) + .where(eq(members.id, id)) + .returning(); + return row!; + }, + async reissueToken( + id: number, + token_hash: string, + token_issued_at: Date, + ): Promise { + await db + .update(members) + .set({ token_hash, token_issued_at }) + .where(eq(members.id, id)); + }, + async countKeys(member_id: number): Promise { + const rows = await db + .select({ n: count() }) + .from(apiKeys) + .where(eq(apiKeys.member_id, member_id)); + return rows[0]?.n ?? 0; + }, + }, + + audit: { + async insert(values: { + operator: string | null; + domain: string; + action: string; + summary: string; + }): Promise { + const [row] = await db.insert(auditLogs).values(values).returning(); + return row!; + }, + async listPage(params: { + domain?: string; + page: number; + page_size: number; + }): Promise<{ rows: AuditLogRow[]; total: number }> { + const conds = params.domain ? [eq(auditLogs.domain, params.domain)] : []; + const totalRows = await db + .select({ n: count() }) + .from(auditLogs) + .where(conds.length ? and(...conds) : undefined); + const total = totalRows[0]?.n ?? 0; + const rows = await db + .select() + .from(auditLogs) + .where(conds.length ? and(...conds) : undefined) + .orderBy(desc(auditLogs.created_at), desc(auditLogs.id)) + .limit(params.page_size) + .offset((params.page - 1) * params.page_size); + return { rows, total }; + }, + }, + + payments: { + async listAll(): Promise { + return db.select().from(payments).orderBy(desc(payments.paid_at)); + }, + async findById(id: number): Promise { + const rows = await db + .select() + .from(payments) + .where(eq(payments.id, id)) + .limit(1); + return rows[0] ?? null; + }, + async insert(values: { + kind: string; + paid_at: Date; + paid_amount: number; + remark: string; + period_start: Date | null; + period_end: Date | null; + subscription_id: number | null; + quota_granted: number | null; + }): Promise { + const [row] = await db.insert(payments).values(values).returning(); + return row!; + }, + async update(id: number, patch: Partial) { + const [row] = await db + .update(payments) + .set(patch) + .where(eq(payments.id, id)) + .returning(); + return row!; + }, + async remove(id: number): Promise { + await db.delete(payments).where(eq(payments.id, id)); + }, + }, + + qpu: { + async listTimeline(): Promise< + Array<{ qpu: number; effective_from: Date }> + > { + return db + .select({ + qpu: qpuHistory.qpu, + effective_from: qpuHistory.effective_from, + }) + .from(qpuHistory) + .orderBy(asc(qpuHistory.effective_from)); + }, + async latest(): Promise<{ qpu: number; effective_from: Date } | null> { + const rows = await db + .select({ + qpu: qpuHistory.qpu, + effective_from: qpuHistory.effective_from, + }) + .from(qpuHistory) + .orderBy(desc(qpuHistory.effective_from)) + .limit(1); + return rows[0] ?? null; + }, + }, + + logs: { + /** 明细分页(时间范围左闭右开) */ + async listPage(params: { + from: Date; + to: Date; + api_key_id?: number; + member_id?: number; + billing_source?: string; + subscription_id?: number; + type?: number; + page: number; + page_size: number; + }): Promise<{ rows: Array; total: number }> { + const conds = [ + gte(usageLogs.created_at, params.from), + lt(usageLogs.created_at, params.to), + ]; + if (params.api_key_id != null) { + conds.push(eq(usageLogs.api_key_id, params.api_key_id)); + } + if (params.billing_source) { + conds.push(eq(usageLogs.billing_source, params.billing_source)); + } + if (params.subscription_id != null) { + conds.push(eq(usageLogs.subscription_id, params.subscription_id)); + } + if (params.type != null) { + conds.push(eq(usageLogs.type, params.type)); + } + if (params.member_id != null) { + conds.push(eq(apiKeys.member_id, params.member_id)); + } + + const totalRows = await db + .select({ n: count() }) + .from(usageLogs) + .leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...conds)); + const total = totalRows[0]?.n ?? 0; + + const offset = (params.page - 1) * params.page_size; + const rows = await db + .select({ + id: usageLogs.id, + api_key_id: usageLogs.api_key_id, + dedupe_key: usageLogs.dedupe_key, + billing_source: usageLogs.billing_source, + subscription_id: usageLogs.subscription_id, + user_id: usageLogs.user_id, + created_at: usageLogs.created_at, + type: usageLogs.type, + content: usageLogs.content, + username: usageLogs.username, + token_name: apiKeys.token_name, + model_name: usageLogs.model_name, + quota: usageLogs.quota, + prompt_tokens: usageLogs.prompt_tokens, + completion_tokens: usageLogs.completion_tokens, + use_time: usageLogs.use_time, + is_stream: usageLogs.is_stream, + channel: usageLogs.channel, + channel_name: usageLogs.channel_name, + token_id: usageLogs.token_id, + group_name: usageLogs.group_name, + ip: usageLogs.ip, + request_id: usageLogs.request_id, + upstream_request_id: usageLogs.upstream_request_id, + other: usageLogs.other, + }) + .from(usageLogs) + .leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...conds)) + .orderBy(desc(usageLogs.created_at), desc(usageLogs.id)) + .limit(params.page_size) + .offset(offset); + + return { + rows: rows.map((r) => ({ + ...r, + token_name: r.token_name ?? "", + })) as Array, + total, + }; + }, + + /** 总览聚合用:type=2、按维字段 */ + async listForAggregate(from: Date, to: Date, type = 2) { + return db + .select({ + api_key_id: usageLogs.api_key_id, + member_id: apiKeys.member_id, + billing_source: usageLogs.billing_source, + subscription_id: usageLogs.subscription_id, + quota: usageLogs.quota, + created_at: usageLogs.created_at, + token_name: apiKeys.token_name, + status: apiKeys.status, + log_synced_at: apiKeys.log_synced_at, + member_name: members.name, + }) + .from(usageLogs) + .leftJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .leftJoin(members, eq(apiKeys.member_id, members.id)) + .where( + and( + gte(usageLogs.created_at, from), + lt(usageLogs.created_at, to), + eq(usageLogs.type, type), + ), + ); + }, + + /** 成员视图聚合(名下 key、type=2) */ + async listMemberForAggregate( + member_id: number, + from: Date, + to: Date, + type = 2, + ) { + return db + .select({ + api_key_id: usageLogs.api_key_id, + billing_source: usageLogs.billing_source, + subscription_id: usageLogs.subscription_id, + quota: usageLogs.quota, + created_at: usageLogs.created_at, + token_name: apiKeys.token_name, + log_synced_at: apiKeys.log_synced_at, + }) + .from(usageLogs) + .innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where( + and( + eq(apiKeys.member_id, member_id), + gte(usageLogs.created_at, from), + lt(usageLogs.created_at, to), + eq(usageLogs.type, type), + ), + ); + }, + + /** 成员视图明细分页(恒 type=2) */ + async listMemberPage(params: { + member_id: number; + from: Date; + to: Date; + page: number; + page_size: number; + }) { + const conds = [ + eq(apiKeys.member_id, params.member_id), + gte(usageLogs.created_at, params.from), + lt(usageLogs.created_at, params.to), + eq(usageLogs.type, 2), + ]; + const totalRows = await db + .select({ n: count() }) + .from(usageLogs) + .innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...conds)); + const total = totalRows[0]?.n ?? 0; + const rows = await db + .select({ + id: usageLogs.id, + api_key_id: usageLogs.api_key_id, + created_at: usageLogs.created_at, + token_name: apiKeys.token_name, + model_name: usageLogs.model_name, + type: usageLogs.type, + content: usageLogs.content, + quota: usageLogs.quota, + prompt_tokens: usageLogs.prompt_tokens, + completion_tokens: usageLogs.completion_tokens, + use_time: usageLogs.use_time, + is_stream: usageLogs.is_stream, + billing_source: usageLogs.billing_source, + subscription_id: usageLogs.subscription_id, + log_synced_at: apiKeys.log_synced_at, + }) + .from(usageLogs) + .innerJoin(apiKeys, eq(usageLogs.api_key_id, apiKeys.id)) + .where(and(...conds)) + .orderBy(desc(usageLogs.created_at), desc(usageLogs.id)) + .limit(params.page_size) + .offset((params.page - 1) * params.page_size); + return { rows, total }; + }, + }, + + keysExtra: { + /** 全池数据截止 = 最慢 log_synced_at */ + async slowestLogSyncedAt(): Promise { + const rows = await db + .select({ t: sql`min(${apiKeys.log_synced_at})::text` }) + .from(apiKeys) + .where(sql`${apiKeys.log_synced_at} is not null`); + const raw = rows[0]?.t; + if (!raw) return null; + const d = new Date(raw); + return Number.isNaN(d.getTime()) ? null : d; + }, + async listIdsWithCutoff() { + return db + .select({ + id: apiKeys.id, + member_id: apiKeys.member_id, + token_name: apiKeys.token_name, + status: apiKeys.status, + log_synced_at: apiKeys.log_synced_at, + }) + .from(apiKeys); + }, + }, + }; +} + +export type Repos = ReturnType; diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts new file mode 100644 index 0000000..9e4c2d8 --- /dev/null +++ b/packages/db/src/schema.ts @@ -0,0 +1,201 @@ +import { + bigint, + boolean, + customType, + index, + integer, + jsonb, + numeric, + pgTable, + smallint, + text, + timestamp, +} from "drizzle-orm/pg-core"; + +/** bytea:AES-GCM 打包密文(nonce||ct||tag) */ +const bytea = customType<{ data: Uint8Array; driverData: Buffer }>({ + dataType: () => "bytea", + toDriver: (v) => Buffer.from(v), + fromDriver: (v) => new Uint8Array(v), +}); + +export const members = pgTable("members", { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + name: text("name").notNull().unique(), + remark: text("remark").notNull().default(""), + status: text("status").notNull().default("active"), + token_hash: text("token_hash").notNull().unique(), + token_issued_at: timestamp("token_issued_at", { + withTimezone: true, + mode: "date", + }), + created_at: timestamp("created_at", { withTimezone: true, mode: "date" }) + .notNull() + .defaultNow(), +}); + +export const apiKeys = pgTable( + "api_keys", + { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + key_hash: text("key_hash").notNull().unique(), + key_ciphertext: bytea("key_ciphertext").notNull(), + status: text("status").notNull().default("active"), + member_id: bigint("member_id", { mode: "number" }).references( + () => members.id, + ), + registered_at: timestamp("registered_at", { + withTimezone: true, + mode: "date", + }) + .notNull() + .defaultNow(), + log_synced_at: timestamp("log_synced_at", { + withTimezone: true, + mode: "date", + }), + usage_synced_at: timestamp("usage_synced_at", { + withTimezone: true, + mode: "date", + }), + token_name: text("token_name").notNull().default(""), + total_granted: bigint("total_granted", { mode: "number" }) + .notNull() + .default(0), + total_used: bigint("total_used", { mode: "number" }).notNull().default(0), + total_available: bigint("total_available", { mode: "number" }) + .notNull() + .default(0), + unlimited_quota: boolean("unlimited_quota").notNull().default(false), + model_limits: jsonb("model_limits"), + model_limits_enabled: boolean("model_limits_enabled") + .notNull() + .default(false), + expires_at: timestamp("expires_at", { withTimezone: true, mode: "date" }), + }, + (t) => [index("api_keys_member_id_idx").on(t.member_id)], +); + +export const usageLogs = pgTable( + "usage_logs", + { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + api_key_id: bigint("api_key_id", { mode: "number" }) + .notNull() + .references(() => apiKeys.id), + dedupe_key: text("dedupe_key").notNull().unique(), + billing_source: text("billing_source"), + subscription_id: bigint("subscription_id", { mode: "number" }), + user_id: bigint("user_id", { mode: "number" }).notNull().default(0), + created_at: timestamp("created_at", { + withTimezone: true, + mode: "date", + }).notNull(), + type: smallint("type").notNull().default(2), + content: text("content").notNull().default(""), + username: text("username").notNull().default(""), + token_name: text("token_name").notNull().default(""), + model_name: text("model_name").notNull().default(""), + quota: bigint("quota", { mode: "number" }).notNull().default(0), + prompt_tokens: integer("prompt_tokens").notNull().default(0), + completion_tokens: integer("completion_tokens").notNull().default(0), + use_time: integer("use_time").notNull().default(0), + is_stream: boolean("is_stream").notNull().default(false), + channel: integer("channel").notNull().default(0), + channel_name: text("channel_name").notNull().default(""), + token_id: bigint("token_id", { mode: "number" }).notNull().default(0), + group_name: text("group_name").notNull().default(""), + ip: text("ip").notNull().default(""), + request_id: text("request_id").notNull().default(""), + upstream_request_id: text("upstream_request_id"), + other: jsonb("other"), + }, + (t) => [ + index("usage_logs_created_at_idx").on(t.created_at), + index("usage_logs_api_key_created_idx").on(t.api_key_id, t.created_at), + index("usage_logs_subscription_idx").on(t.subscription_id), + ], +); + +export const payments = pgTable("payments", { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + kind: text("kind").notNull(), + paid_at: timestamp("paid_at", { withTimezone: true, mode: "date" }).notNull(), + paid_amount: numeric("paid_amount", { + precision: 18, + scale: 2, + mode: "number", + }).notNull(), + remark: text("remark").notNull().default(""), + period_start: timestamp("period_start", { withTimezone: true, mode: "date" }), + period_end: timestamp("period_end", { withTimezone: true, mode: "date" }), + subscription_id: bigint("subscription_id", { mode: "number" }), + quota_granted: bigint("quota_granted", { mode: "number" }), +}); + +export const qpuHistory = pgTable("qpu_history", { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + qpu: numeric("qpu", { precision: 18, scale: 6, mode: "number" }).notNull(), + effective_from: timestamp("effective_from", { + withTimezone: true, + mode: "date", + }) + .notNull() + .defaultNow(), +}); + +export const users = pgTable("users", { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + username: text("username").notNull().unique(), + password_hash: text("password_hash").notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: "date" }) + .notNull() + .defaultNow(), +}); + +export const sessions = pgTable( + "sessions", + { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + user_id: bigint("user_id", { mode: "number" }) + .notNull() + .references(() => users.id), + token_hash: text("token_hash").notNull().unique(), + expires_at: timestamp("expires_at", { + withTimezone: true, + mode: "date", + }).notNull(), + created_at: timestamp("created_at", { withTimezone: true, mode: "date" }) + .notNull() + .defaultNow(), + }, + (t) => [index("sessions_user_id_idx").on(t.user_id)], +); + +/** 审计日志(管理面写操作留痕)——结构对齐 API契约-管理面 §9 */ +export const auditLogs = pgTable( + "audit_logs", + { + id: bigint("id", { mode: "number" }).primaryKey().generatedAlwaysAsIdentity(), + created_at: timestamp("created_at", { + withTimezone: true, + mode: "date", + }) + .notNull() + .defaultNow(), + operator: text("operator"), + domain: text("domain").notNull(), + action: text("action").notNull(), + summary: text("summary").notNull().default(""), + }, + (t) => [index("audit_logs_created_at_idx").on(t.created_at)], +); + +export type MemberRow = typeof members.$inferSelect; +export type ApiKeyRow = typeof apiKeys.$inferSelect; +export type UsageLogRow = typeof usageLogs.$inferSelect; +export type PaymentRow = typeof payments.$inferSelect; +export type QpuHistoryRow = typeof qpuHistory.$inferSelect; +export type UserRow = typeof users.$inferSelect; +export type SessionRow = typeof sessions.$inferSelect; +export type AuditLogRow = typeof auditLogs.$inferSelect;