feat(engine): 实现同步引擎与 engine_status 快照

apps/engine 独立进程(syncRound + 防重叠调度);上游 log/token;QPU 采样;
server 读 engine_status;时刻出参 toUnixSec;中文日志;zod-openapi 扩展顺序修复。
This commit is contained in:
2026-10-07 12:54:13 +08:00
parent 3e12db1706
commit d47cb5543c
28 changed files with 676 additions and 102 deletions
+15 -1
View File
@@ -23,6 +23,20 @@ export function redactSecrets(message: string): string {
);
}
/** 日志时间戳:本地时区、可读(终端运维友好) */
export function formatLogTime(d: Date = new Date()): string {
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
const offsetMin = -d.getTimezoneOffset();
const sign = offsetMin >= 0 ? "+" : "-";
const abs = Math.abs(offsetMin);
const off = `${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`;
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
` ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}` +
`.${pad(d.getMilliseconds(), 3)}${off}`
);
}
let configured = false;
/**
@@ -37,7 +51,7 @@ export function setupLogging(level: AppLogLevel = "info"): void {
sinks: {
console: getConsoleSink({
formatter: (record) => {
const ts = new Date(record.timestamp).toISOString();
const ts = formatLogTime(new Date(record.timestamp));
const cat = record.category.join(".");
const msg = redactSecrets(String(record.message));
return `${ts} ${record.level.toUpperCase()} [${cat}] ${msg}`;
+14 -13
View File
@@ -52,7 +52,7 @@ export const usageTotals = z.object({
export const logItem = z.object({
id: z.number().int(),
api_key_id: z.number().int(),
created_at: z.string(),
created_at: z.number().int(),
token_name: z.string(),
model_name: z.string(),
type: z.number().int(),
@@ -78,14 +78,14 @@ export const keyItem = z.object({
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(),
registered_at: z.number().int(),
log_synced_at: z.number().int().nullable(),
usage_synced_at: z.number().int().nullable(),
total_used: z.number(),
total_available: z.number(),
total_granted: z.number(),
unlimited_quota: z.boolean(),
expires_at: z.string().nullable(),
expires_at: z.number().int().nullable(),
});
export const registerKeyBody = z.object({
@@ -103,8 +103,8 @@ export const memberItem = z.object({
name: z.string(),
remark: z.string(),
status: z.enum(["active", "disabled"]),
token_issued_at: z.string().nullable(),
created_at: z.string(),
token_issued_at: z.number().int().nullable(),
created_at: z.number().int(),
key_count: z.number().int(),
});
@@ -132,11 +132,11 @@ export const memberMe = z.object({
export const paymentItem = z.object({
id: z.number().int(),
kind: z.enum(["subscription", "topup"]),
paid_at: z.string(),
paid_at: z.number().int(),
paid_amount: z.number(),
remark: z.string(),
period_start: z.string().nullable(),
period_end: z.string().nullable(),
period_start: z.number().int().nullable(),
period_end: z.number().int().nullable(),
subscription_id: z.number().int().nullable(),
quota_granted: z.number().int().nullable(),
quota_usd: z.string(),
@@ -144,9 +144,10 @@ export const paymentItem = z.object({
export const engineStatus = z.object({
running: z.boolean(),
sync_interval_ms: z.number().int().optional(),
last_round: z
.object({
started_at: z.string(),
started_at: z.number().int(),
keys_total: z.number().int(),
succeeded: z.number().int(),
failed: z.number().int(),
@@ -157,7 +158,7 @@ export const engineStatus = z.object({
export const auditLogItem = z.object({
id: z.number().int(),
created_at: z.string(),
created_at: z.number().int(),
operator: z.string().nullable(),
domain: z.enum(["key", "payment", "member", "account"]),
action: z.string(),
@@ -182,7 +183,7 @@ export const memberOverview = z.object({
z.object({
key_id: z.number().int(),
token_name: z.string(),
log_synced_at: z.string().nullable(),
log_synced_at: z.number().int().nullable(),
usage: usageTotals,
}),
),
+23 -6
View File
@@ -1,18 +1,35 @@
/** 存储层 UTC Unix 秒 / Date / ISO 字符串 ↔ DTO 层 ISO 8601 UTC */
export function unixToIso(
value: number | string | Date | null | undefined,
): string | null {
/**
* 时间口径(API 响应统一 Unix 秒整数;请求 from/to 仍收 ISO 8601 便于试调)
* 库内 timestamptz ↔ JS Date ↔ Unix 秒
*/
function toDate(value: number | string | Date | null | undefined): Date | 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 Number.isNaN(d.getTime()) ? null : d;
}
/** → Unix 秒(UTC);无效则 null */
export function toUnixSec(
value: number | string | Date | null | undefined,
): number | null {
const d = toDate(value);
if (!d) return null;
return Math.floor(d.getTime() / 1000);
}
/** 兼容旧名:ISO 串(仅本地/文档用;API 响应改用 toUnixSec) */
export function unixToIso(
value: number | string | Date | null | undefined,
): string | null {
const d = toDate(value);
if (!d) return null;
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
}
+15 -15
View File
@@ -1,5 +1,5 @@
/** ISO 8601 UTC 字符串,如 2026-10-02T14:22:05Z */
export type Iso8601Utc = string;
/** Unix 秒(UTC 时刻)——API 响应时刻字段统一此类型 */
export type UnixSec = number;
/** 支付金额(币种无关数值) */
export type PaidAmount = number;
@@ -24,7 +24,7 @@ export interface UsageTotals {
export interface LogItem {
id: number;
api_key_id: number;
created_at: Iso8601Utc;
created_at: UnixSec;
token_name: string;
model_name: string;
type: number;
@@ -45,24 +45,24 @@ export interface KeyItem {
status: KeyStatus;
member_id: number | null;
member_name: string | null;
registered_at: Iso8601Utc;
log_synced_at: Iso8601Utc | null;
usage_synced_at: Iso8601Utc | null;
registered_at: UnixSec;
log_synced_at: UnixSec | null;
usage_synced_at: UnixSec | null;
total_used: number;
total_available: number;
total_granted: number;
unlimited_quota: boolean;
expires_at: Iso8601Utc | null;
expires_at: UnixSec | null;
}
export interface PaymentItem {
id: number;
kind: PaymentKind;
paid_at: Iso8601Utc;
paid_at: UnixSec;
paid_amount: PaidAmount;
remark: string;
period_start: Iso8601Utc | null;
period_end: Iso8601Utc | null;
period_start: UnixSec | null;
period_end: UnixSec | null;
subscription_id: number | null;
quota_granted: number | null;
quota_usd: QuotaUsd;
@@ -73,14 +73,14 @@ export interface MemberItem {
name: string;
remark: string;
status: MemberStatus;
token_issued_at: Iso8601Utc | null;
created_at: Iso8601Utc;
token_issued_at: UnixSec | null;
created_at: UnixSec;
key_count: number;
}
export interface AuditLogItem {
id: number;
created_at: Iso8601Utc;
created_at: UnixSec;
operator: string | null;
domain: "key" | "payment" | "member" | "account";
action: string;
@@ -93,7 +93,7 @@ export interface Paginated<T> {
}
export interface EngineLastRound {
started_at: Iso8601Utc;
started_at: UnixSec;
keys_total: number;
succeeded: number;
failed: number;
@@ -107,7 +107,7 @@ export interface EngineStatus {
export interface QpuLatest {
qpu: number;
effective_from: Iso8601Utc;
effective_from: UnixSec;
}
export type AuditAction =
+33
View File
@@ -73,6 +73,30 @@ async function requestJson<T>(
}
}
export interface TokenLogRow {
id?: number;
user_id?: number;
created_at: number;
type: number;
content?: string;
username?: string;
token_name?: string;
model_name?: string;
quota?: number;
prompt_tokens?: number;
completion_tokens?: number;
use_time?: number;
is_stream?: boolean;
channel?: number;
channel_name?: string;
token_id?: number;
group?: string;
ip?: string;
request_id?: string;
upstream_request_id?: string;
other?: string;
}
export function createUpstreamClient(opts: UpstreamClientOptions) {
const timeoutMs = opts.timeoutMs ?? 8_000;
@@ -87,6 +111,15 @@ export function createUpstreamClient(opts: UpstreamClientOptions) {
return r;
},
/** 轮询日志:GET /api/log/token(最近最多 1000 条) */
async fetchTokenLogs(skKey: string): Promise<UpstreamResult<TokenLogRow[]>> {
return requestJson<TokenLogRow[]>(
joinUrl(opts.baseUrl, "/api/log/token"),
skKey,
timeoutMs,
);
},
/** 换算率权威来源:GET /api/status(取 quota_per_unit) */
async fetchQuotaPerUnit(): Promise<UpstreamResult<{ quota_per_unit: number }>> {
return requestJson<{ quota_per_unit: number }>(
+20
View File
@@ -62,3 +62,23 @@ export function addUsage(a: UsageSum, b: UsageSum): UsageSum {
quota_usd: a.quota_usd,
};
}
/** 解析日志行 other JSON(billing_source / subscription_id) */
export function parseOtherJson(other: string | undefined | null): {
billing_source: string | null;
subscription_id: number | null;
raw: Record<string, unknown> | null;
} {
if (!other) return { billing_source: null, subscription_id: null, raw: null };
try {
const o = JSON.parse(other) as Record<string, unknown>;
return {
billing_source: typeof o.billing_source === "string" ? o.billing_source : null,
subscription_id:
typeof o.subscription_id === "number" ? o.subscription_id : null,
raw: o,
};
} catch {
return { billing_source: null, subscription_id: null, raw: null };
}
}
+90
View File
@@ -2,6 +2,7 @@ import type { Db } from "./index.ts";
import {
apiKeys,
auditLogs,
engineStatus,
members,
payments,
qpuHistory,
@@ -10,6 +11,7 @@ import {
users,
type ApiKeyRow,
type AuditLogRow,
type EngineStatusRow,
type MemberRow,
type PaymentRow,
type SessionRow,
@@ -148,6 +150,54 @@ export function createRepos(db: Db) {
}
return base;
},
async listActive() {
return db
.select({
id: apiKeys.id,
key_hash: apiKeys.key_hash,
key_ciphertext: apiKeys.key_ciphertext,
status: apiKeys.status,
member_id: apiKeys.member_id,
token_name: apiKeys.token_name,
})
.from(apiKeys)
.where(eq(apiKeys.status, "active"))
.orderBy(asc(apiKeys.id));
},
async insertLog(values: {
api_key_id: number;
dedupe_key: string;
billing_source: string | null;
subscription_id: number | null;
user_id: number;
created_at: Date;
type: number;
content: string;
username: string;
token_name: string;
model_name: string;
quota: number;
prompt_tokens: number;
completion_tokens: number;
use_time: number;
is_stream: boolean;
channel: number;
channel_name: string;
token_id: number;
group_name: string;
ip: string;
request_id: string;
upstream_request_id: string | null;
other: unknown;
}): Promise<boolean> {
const before = await db
.select({ n: count() })
.from(usageLogs)
.where(eq(usageLogs.dedupe_key, values.dedupe_key));
if ((before[0]?.n ?? 0) > 0) return false;
await db.insert(usageLogs).values(values).onConflictDoNothing();
return true;
},
async findById(id: number): Promise<ApiKeyRow | null> {
const rows = await db
.select()
@@ -293,6 +343,43 @@ export function createRepos(db: Db) {
},
},
engine: {
async upsertSnapshot(values: {
running: boolean;
sync_interval_ms: number;
last_round: unknown;
}): Promise<EngineStatusRow> {
const [row] = await db
.insert(engineStatus)
.values({
id: 1,
running: values.running,
sync_interval_ms: values.sync_interval_ms,
last_round: values.last_round,
updated_at: new Date(),
})
.onConflictDoUpdate({
target: engineStatus.id,
set: {
running: values.running,
sync_interval_ms: values.sync_interval_ms,
last_round: values.last_round,
updated_at: new Date(),
},
})
.returning();
return row!;
},
async getSnapshot(): Promise<EngineStatusRow | null> {
const rows = await db
.select()
.from(engineStatus)
.where(eq(engineStatus.id, 1))
.limit(1);
return rows[0] ?? null;
},
},
payments: {
async listAll(): Promise<PaymentRow[]> {
return db.select().from(payments).orderBy(desc(payments.paid_at));
@@ -354,6 +441,9 @@ export function createRepos(db: Db) {
.limit(1);
return rows[0] ?? null;
},
async insertChange(qpu: number, effective_from: Date): Promise<void> {
await db.insert(qpuHistory).values({ qpu, effective_from });
},
},
logs: {
+14
View File
@@ -191,6 +191,19 @@ export const auditLogs = pgTable(
(t) => [index("audit_logs_created_at_idx").on(t.created_at)],
);
/** 同步引擎状态单行快照(《同步引擎设计》§5) */
export const engineStatus = pgTable("engine_status", {
id: integer("id").primaryKey().default(1),
running: boolean("running").notNull().default(false),
sync_interval_ms: bigint("sync_interval_ms", { mode: "number" })
.notNull()
.default(300_000),
last_round: jsonb("last_round"),
updated_at: timestamp("updated_at", { withTimezone: true, mode: "date" })
.notNull()
.defaultNow(),
});
export type MemberRow = typeof members.$inferSelect;
export type ApiKeyRow = typeof apiKeys.$inferSelect;
export type UsageLogRow = typeof usageLogs.$inferSelect;
@@ -199,3 +212,4 @@ export type QpuHistoryRow = typeof qpuHistory.$inferSelect;
export type UserRow = typeof users.$inferSelect;
export type SessionRow = typeof sessions.$inferSelect;
export type AuditLogRow = typeof auditLogs.$inferSelect;
export type EngineStatusRow = typeof engineStatus.$inferSelect;