feat(engine): 实现同步引擎与 engine_status 快照
apps/engine 独立进程(syncRound + 防重叠调度);上游 log/token;QPU 采样; server 读 engine_status;时刻出参 toUnixSec;中文日志;zod-openapi 扩展顺序修复。
This commit is contained in:
@@ -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}`;
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -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 }>(
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user