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 @@
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;