feat(core,db): 业务核心与数据访问层
core:DTO/错误信封/时间口径/微分折算/dedupe_key/加密与 PBKDF2/契约 zod/logtape/上游客户端。 db:八表 Drizzle schema + 仓储 + drizzle 配置。
This commit is contained in:
@@ -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>;
|
||||
Reference in New Issue
Block a user