feat(core,db): 业务核心与数据访问层
core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。 db:八表 Drizzle schema + 仓储 + drizzle 配置。
This commit is contained in:
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<typeof createDb>;
|
||||||
|
|
||||||
|
export * from "./schema.ts";
|
||||||
|
export * from "./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<number> {
|
||||||
|
const rows = await db.select({ id: users.id }).from(users).limit(1);
|
||||||
|
return rows[0] ? 1 : 0;
|
||||||
|
},
|
||||||
|
async findByUsername(username: string): Promise<UserRow | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select()
|
||||||
|
.from(users)
|
||||||
|
.where(eq(users.username, username))
|
||||||
|
.limit(1);
|
||||||
|
return rows[0] ?? null;
|
||||||
|
},
|
||||||
|
async findById(id: number): Promise<UserRow | null> {
|
||||||
|
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<UserRow> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(users)
|
||||||
|
.values({ username, password_hash })
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
},
|
||||||
|
async updatePassword(id: number, password_hash: string): Promise<void> {
|
||||||
|
await db.update(users).set({ password_hash }).where(eq(users.id, id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
sessions: {
|
||||||
|
async findValid(
|
||||||
|
token_hash: string,
|
||||||
|
now: Date,
|
||||||
|
): Promise<SessionUser | null> {
|
||||||
|
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<SessionRow> {
|
||||||
|
const [row] = await db
|
||||||
|
.insert(sessions)
|
||||||
|
.values({ user_id, token_hash, expires_at })
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
},
|
||||||
|
async deleteByTokenHash(token_hash: string): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
await db.delete(sessions).where(eq(sessions.id, id));
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
keys: {
|
||||||
|
async listAll(filter?: {
|
||||||
|
member_id?: number | "unassigned";
|
||||||
|
status?: string;
|
||||||
|
}): Promise<KeyListRow[]> {
|
||||||
|
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<ApiKeyRow | null> {
|
||||||
|
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<ApiKeyRow> {
|
||||||
|
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<typeof apiKeys.$inferInsert>,
|
||||||
|
): Promise<ApiKeyRow> {
|
||||||
|
const [row] = await db
|
||||||
|
.update(apiKeys)
|
||||||
|
.set(patch)
|
||||||
|
.where(eq(apiKeys.id, id))
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
members: {
|
||||||
|
async listAll(): Promise<MemberRow[]> {
|
||||||
|
return db.select().from(members);
|
||||||
|
},
|
||||||
|
async findById(id: number): Promise<MemberRow | null> {
|
||||||
|
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<MemberRow> {
|
||||||
|
const [row] = await db.insert(members).values(values).returning();
|
||||||
|
return row!;
|
||||||
|
},
|
||||||
|
async updateStatus(id: number, status: string): Promise<MemberRow> {
|
||||||
|
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<void> {
|
||||||
|
await db
|
||||||
|
.update(members)
|
||||||
|
.set({ token_hash, token_issued_at })
|
||||||
|
.where(eq(members.id, id));
|
||||||
|
},
|
||||||
|
async countKeys(member_id: number): Promise<number> {
|
||||||
|
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<AuditLogRow> {
|
||||||
|
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<PaymentRow[]> {
|
||||||
|
return db.select().from(payments).orderBy(desc(payments.paid_at));
|
||||||
|
},
|
||||||
|
async findById(id: number): Promise<PaymentRow | null> {
|
||||||
|
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<PaymentRow> {
|
||||||
|
const [row] = await db.insert(payments).values(values).returning();
|
||||||
|
return row!;
|
||||||
|
},
|
||||||
|
async update(id: number, patch: Partial<typeof payments.$inferInsert>) {
|
||||||
|
const [row] = await db
|
||||||
|
.update(payments)
|
||||||
|
.set(patch)
|
||||||
|
.where(eq(payments.id, id))
|
||||||
|
.returning();
|
||||||
|
return row!;
|
||||||
|
},
|
||||||
|
async remove(id: number): Promise<void> {
|
||||||
|
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<UsageLogRow & { token_name: string }>; 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<UsageLogRow & { token_name: string }>,
|
||||||
|
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<Date | null> {
|
||||||
|
const rows = await db
|
||||||
|
.select({ t: sql<string | null>`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<typeof createRepos>;
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user