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

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