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