feat(engine): 实现同步引擎与 engine_status 快照
apps/engine 独立进程(syncRound + 防重叠调度);上游 log/token;QPU 采样; server 读 engine_status;时刻出参 toUnixSec;中文日志;zod-openapi 扩展顺序修复。
This commit is contained in:
@@ -29,6 +29,7 @@ Bun 工具链(选型见 `docs/design/技术选型.md`)。**Windows 上 `bun`
|
|||||||
| `bun --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
|
| `bun --env-file=.env scripts/seed-from-samples.ts` | 从根目录 `api*.json` 实测快照灌开发库(幂等);验收接口口径用 |
|
||||||
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
|
| `bun run typecheck` | 根 tsconfig 类型检查(不含 web `.vue`) |
|
||||||
| `bun run dev:server` | 开发跑母体后端(hot reload) |
|
| `bun run dev:server` | 开发跑母体后端(hot reload) |
|
||||||
|
| `bun run dev:engine` | 开发跑同步引擎(独立进程,读 `SYNC_*` env) |
|
||||||
| `bun run dev:web` | 开发跑前端 Vite |
|
| `bun run dev:web` | 开发跑前端 Vite |
|
||||||
| `docker compose up -d` | 起开发用 PostgreSQL |
|
| `docker compose up -d` | 起开发用 PostgreSQL |
|
||||||
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
| `bun run --filter @new-api-key-usage/db db:generate` | 生成 SQL 迁移(`packages/db/drizzle/`) |
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"name": "@new-api-key-usage/engine",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "bun run src/index.ts"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@new-api-key-usage/core": "workspace:*",
|
||||||
|
"@new-api-key-usage/db": "workspace:*"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
export interface EngineEnv {
|
||||||
|
DATABASE_URL: string;
|
||||||
|
SK_KEY_ENCRYPTION_SECRET: string;
|
||||||
|
NEWAPI_BASE: string;
|
||||||
|
SYNC_INTERVAL_MS: number;
|
||||||
|
LOG_LEVEL: "debug" | "info" | "warning" | "error" | "fatal";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLevel(
|
||||||
|
value: string | undefined,
|
||||||
|
): "debug" | "info" | "warning" | "error" | "fatal" {
|
||||||
|
const v = (value ?? "info").trim().toLowerCase();
|
||||||
|
if (
|
||||||
|
v === "debug" ||
|
||||||
|
v === "info" ||
|
||||||
|
v === "warning" ||
|
||||||
|
v === "error" ||
|
||||||
|
v === "fatal"
|
||||||
|
) {
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
throw new Error("LOG_LEVEL must be debug|info|warning|error|fatal");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEnv(source: Record<string, string | undefined>): EngineEnv {
|
||||||
|
const databaseUrl = source.DATABASE_URL;
|
||||||
|
const secret = source.SK_KEY_ENCRYPTION_SECRET;
|
||||||
|
if (!databaseUrl) throw new Error("DATABASE_URL is required");
|
||||||
|
if (!secret) throw new Error("SK_KEY_ENCRYPTION_SECRET is required");
|
||||||
|
|
||||||
|
const interval = Number(source.SYNC_INTERVAL_MS ?? 300_000);
|
||||||
|
if (!Number.isInteger(interval) || interval < 1000) {
|
||||||
|
throw new Error("SYNC_INTERVAL_MS must be an integer >= 1000");
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
DATABASE_URL: databaseUrl,
|
||||||
|
SK_KEY_ENCRYPTION_SECRET: secret,
|
||||||
|
NEWAPI_BASE: source.NEWAPI_BASE ?? "http://127.0.0.1:8080",
|
||||||
|
SYNC_INTERVAL_MS: interval,
|
||||||
|
LOG_LEVEL: parseLogLevel(source.LOG_LEVEL),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { appLogger, setupLogging } from "@new-api-key-usage/core";
|
||||||
|
import { createDb, createRepos } from "@new-api-key-usage/db";
|
||||||
|
import { loadEnv } from "./env.ts";
|
||||||
|
import { createSyncLoop } from "./loop.ts";
|
||||||
|
|
||||||
|
const env = loadEnv(process.env);
|
||||||
|
setupLogging(env.LOG_LEVEL);
|
||||||
|
const log = appLogger("engine");
|
||||||
|
|
||||||
|
const db = createDb(env.DATABASE_URL);
|
||||||
|
const repos = createRepos(db);
|
||||||
|
|
||||||
|
const loop = createSyncLoop({
|
||||||
|
repos,
|
||||||
|
newapiBase: env.NEWAPI_BASE,
|
||||||
|
skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET,
|
||||||
|
intervalMs: env.SYNC_INTERVAL_MS,
|
||||||
|
});
|
||||||
|
|
||||||
|
loop.start();
|
||||||
|
|
||||||
|
log.info`apps/engine 已就绪`;
|
||||||
|
|
||||||
|
process.on("SIGINT", () => {
|
||||||
|
void loop.stop().then(() => process.exit(0));
|
||||||
|
});
|
||||||
|
process.on("SIGTERM", () => {
|
||||||
|
void loop.stop().then(() => process.exit(0));
|
||||||
|
});
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
/**
|
||||||
|
* 调度循环(方案 A):一轮结束 → setTimeout 下一枪;inFlight 防重叠。
|
||||||
|
* 启停不得抹掉已有 last_round。
|
||||||
|
*/
|
||||||
|
import { appLogger } from "@new-api-key-usage/core";
|
||||||
|
import { syncRound, type SyncRoundDeps } from "./sync-round.ts";
|
||||||
|
|
||||||
|
const log = appLogger("engine");
|
||||||
|
|
||||||
|
export interface LoopOptions extends SyncRoundDeps {
|
||||||
|
intervalMs: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createSyncLoop(opts: LoopOptions) {
|
||||||
|
let inFlight = false;
|
||||||
|
let stopped = false;
|
||||||
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
let last_round: unknown = null;
|
||||||
|
|
||||||
|
async function persist(running: boolean) {
|
||||||
|
await opts.repos.engine.upsertSnapshot({
|
||||||
|
running,
|
||||||
|
sync_interval_ms: opts.intervalMs,
|
||||||
|
last_round,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runOnce(): Promise<void> {
|
||||||
|
if (stopped) return;
|
||||||
|
if (inFlight) {
|
||||||
|
log.warn("上一轮尚未结束,跳过本枪");
|
||||||
|
schedule();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inFlight = true;
|
||||||
|
try {
|
||||||
|
last_round = await syncRound(opts);
|
||||||
|
await persist(!stopped);
|
||||||
|
} catch (e) {
|
||||||
|
log.error(`同步轮次异常:${e}`);
|
||||||
|
await persist(!stopped).catch(() => {});
|
||||||
|
} finally {
|
||||||
|
inFlight = false;
|
||||||
|
schedule();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function schedule() {
|
||||||
|
if (stopped) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
void runOnce();
|
||||||
|
}, opts.intervalMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start() {
|
||||||
|
log.info(`同步引擎启动:周期=${opts.intervalMs}ms`);
|
||||||
|
void (async () => {
|
||||||
|
const snap = await opts.repos.engine.getSnapshot();
|
||||||
|
last_round = snap?.last_round ?? null;
|
||||||
|
await persist(true).catch(() => {});
|
||||||
|
void runOnce();
|
||||||
|
})();
|
||||||
|
},
|
||||||
|
async stop() {
|
||||||
|
stopped = true;
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
// 等在跑的一轮结束,避免 stop 与 upsert 交错
|
||||||
|
for (let i = 0; i < 50 && inFlight; i++) {
|
||||||
|
await new Promise((r) => setTimeout(r, 100));
|
||||||
|
}
|
||||||
|
await persist(false).catch(() => {});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
/**
|
||||||
|
* 同步轮次:按《同步引擎设计》§3 编排。
|
||||||
|
* 只经 packages/core(上游 + 去重)与 packages/db(仓储)写库。
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
appLogger,
|
||||||
|
buildDedupeKey,
|
||||||
|
createUpstreamClient,
|
||||||
|
KeyCrypto,
|
||||||
|
parseOtherJson,
|
||||||
|
toUnixSec,
|
||||||
|
} from "@new-api-key-usage/core";
|
||||||
|
import type { Repos } from "@new-api-key-usage/db";
|
||||||
|
|
||||||
|
const log = appLogger("engine");
|
||||||
|
|
||||||
|
export interface SyncRoundDeps {
|
||||||
|
repos: Repos;
|
||||||
|
newapiBase: string;
|
||||||
|
skKeyEncryptionSecret: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LastRound {
|
||||||
|
started_at: number;
|
||||||
|
keys_total: number;
|
||||||
|
succeeded: number;
|
||||||
|
failed: number;
|
||||||
|
failed_api_key_ids: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseOther(other: string | undefined): {
|
||||||
|
billing_source: string | null;
|
||||||
|
subscription_id: number | null;
|
||||||
|
raw: Record<string, unknown> | null;
|
||||||
|
} {
|
||||||
|
return parseOtherJson(other);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function syncRound(deps: SyncRoundDeps): Promise<LastRound> {
|
||||||
|
const started = Date.now();
|
||||||
|
const started_at = toUnixSec(new Date())!;
|
||||||
|
const client = createUpstreamClient({ baseUrl: deps.newapiBase });
|
||||||
|
const crypto = await KeyCrypto.fromEncryptionSecret(
|
||||||
|
deps.skKeyEncryptionSecret,
|
||||||
|
);
|
||||||
|
|
||||||
|
const keys = await deps.repos.keys.listActive();
|
||||||
|
const failed_api_key_ids: number[] = [];
|
||||||
|
let succeeded = 0;
|
||||||
|
let pulled = 0;
|
||||||
|
let inserted = 0;
|
||||||
|
let skipped = 0;
|
||||||
|
|
||||||
|
for (const key of keys) {
|
||||||
|
try {
|
||||||
|
const sk = await crypto.openKey(key.key_ciphertext);
|
||||||
|
|
||||||
|
const usage = await client.fetchTokenUsage(sk);
|
||||||
|
if (!usage.ok) {
|
||||||
|
failed_api_key_ids.push(key.id);
|
||||||
|
log.warn(`同步失败 key_id=${key.id} 原因=usage/${usage.kind}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
await deps.repos.keys.update(key.id, {
|
||||||
|
token_name: usage.data.name ?? key.token_name,
|
||||||
|
total_granted: usage.data.total_granted,
|
||||||
|
total_used: usage.data.total_used,
|
||||||
|
total_available: usage.data.total_available,
|
||||||
|
unlimited_quota: usage.data.unlimited_quota,
|
||||||
|
model_limits: usage.data.model_limits,
|
||||||
|
model_limits_enabled: usage.data.model_limits_enabled,
|
||||||
|
expires_at: normalizeExpires(usage.data.expires_at),
|
||||||
|
usage_synced_at: new Date(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const logs = await client.fetchTokenLogs(sk);
|
||||||
|
if (!logs.ok) {
|
||||||
|
failed_api_key_ids.push(key.id);
|
||||||
|
log.warn(`同步失败 key_id=${key.id} 原因=logs/${logs.kind}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const rows = [...(logs.data ?? [])].sort((a, b) => {
|
||||||
|
const ta = a.created_at ?? 0;
|
||||||
|
const tb = b.created_at ?? 0;
|
||||||
|
if (ta !== tb) return ta - tb;
|
||||||
|
return String(a.request_id ?? "").localeCompare(String(b.request_id ?? ""));
|
||||||
|
});
|
||||||
|
pulled += rows.length;
|
||||||
|
|
||||||
|
for (const row of rows) {
|
||||||
|
const other = parseOther(row.other);
|
||||||
|
const created_at = new Date(row.created_at * 1000);
|
||||||
|
const dedupe_key = await buildDedupeKey({
|
||||||
|
api_key_id: key.id,
|
||||||
|
request_id: row.request_id,
|
||||||
|
type: row.type ?? 2,
|
||||||
|
created_at: row.created_at,
|
||||||
|
model_name: row.model_name ?? "",
|
||||||
|
quota: row.quota ?? 0,
|
||||||
|
prompt_tokens: row.prompt_tokens ?? 0,
|
||||||
|
completion_tokens: row.completion_tokens ?? 0,
|
||||||
|
use_time: row.use_time ?? 0,
|
||||||
|
content: row.content ?? "",
|
||||||
|
});
|
||||||
|
const ok = await deps.repos.keys.insertLog({
|
||||||
|
api_key_id: key.id,
|
||||||
|
dedupe_key,
|
||||||
|
billing_source: other.billing_source,
|
||||||
|
subscription_id: other.subscription_id,
|
||||||
|
user_id: row.user_id ?? 0,
|
||||||
|
created_at,
|
||||||
|
type: row.type ?? 2,
|
||||||
|
content: row.content ?? "",
|
||||||
|
username: row.username ?? "",
|
||||||
|
token_name: row.token_name ?? "",
|
||||||
|
model_name: row.model_name ?? "",
|
||||||
|
quota: row.quota ?? 0,
|
||||||
|
prompt_tokens: row.prompt_tokens ?? 0,
|
||||||
|
completion_tokens: row.completion_tokens ?? 0,
|
||||||
|
use_time: row.use_time ?? 0,
|
||||||
|
is_stream: !!row.is_stream,
|
||||||
|
channel: row.channel ?? 0,
|
||||||
|
channel_name: row.channel_name ?? "",
|
||||||
|
token_id: row.token_id ?? 0,
|
||||||
|
group_name: row.group ?? "",
|
||||||
|
ip: row.ip ?? "",
|
||||||
|
request_id: row.request_id ?? "",
|
||||||
|
upstream_request_id: row.upstream_request_id ?? null,
|
||||||
|
other: other.raw,
|
||||||
|
});
|
||||||
|
if (ok) inserted += 1;
|
||||||
|
else skipped += 1;
|
||||||
|
}
|
||||||
|
await deps.repos.keys.update(key.id, { log_synced_at: new Date() });
|
||||||
|
succeeded += 1;
|
||||||
|
} catch (e) {
|
||||||
|
failed_api_key_ids.push(key.id);
|
||||||
|
log.error(`同步异常 key_id=${key.id} ${e}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// QPU 采样:值变化才追加
|
||||||
|
const qpuRes = await client.fetchQuotaPerUnit();
|
||||||
|
if (qpuRes.ok) {
|
||||||
|
const latest = await deps.repos.qpu.latest();
|
||||||
|
const next = qpuRes.data.quota_per_unit;
|
||||||
|
if (!latest || latest.qpu !== next) {
|
||||||
|
await deps.repos.qpu.insertChange(next, new Date());
|
||||||
|
log.info(`QPU 变更 ${latest?.qpu ?? "-"} -> ${next}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.warn(`QPU 采样失败 ${qpuRes.kind}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const last_round: LastRound = {
|
||||||
|
started_at,
|
||||||
|
keys_total: keys.length,
|
||||||
|
succeeded,
|
||||||
|
failed: failed_api_key_ids.length,
|
||||||
|
failed_api_key_ids,
|
||||||
|
};
|
||||||
|
|
||||||
|
log.info(
|
||||||
|
`同步轮次完成 key=${keys.length} 成功=${succeeded} 失败=${failed_api_key_ids.length} 拉取=${pulled} 新写=${inserted} 去重跳过=${skipped} 耗时=${Date.now() - started}ms`,
|
||||||
|
);
|
||||||
|
|
||||||
|
return last_round;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeExpires(unixSeconds: number | undefined | null): Date | null {
|
||||||
|
if (!unixSeconds) return null;
|
||||||
|
return new Date(unixSeconds * 1000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"types": ["bun"],
|
||||||
|
"allowImportingTsExtensions": true
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts"]
|
||||||
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { z } from "zod";
|
/** 必须先扩展 zod,再加载 core schema(见 zod-openapi-setup) */
|
||||||
import { extendZodWithOpenApi } from "@hono/zod-openapi";
|
import "./zod-openapi-setup.ts";
|
||||||
import {
|
import {
|
||||||
changePasswordBody,
|
changePasswordBody,
|
||||||
createMemberBody,
|
createMemberBody,
|
||||||
@@ -24,21 +24,34 @@ import {
|
|||||||
usernameBody,
|
usernameBody,
|
||||||
} from "@new-api-key-usage/core";
|
} from "@new-api-key-usage/core";
|
||||||
|
|
||||||
// OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立
|
/** OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立 */
|
||||||
extendZodWithOpenApi(z);
|
function nameSchema<T extends { openapi?: (n: string) => unknown }>(
|
||||||
|
schema: T,
|
||||||
|
name: string,
|
||||||
|
): T {
|
||||||
|
return typeof schema.openapi === "function"
|
||||||
|
? (schema.openapi(name) as T)
|
||||||
|
: schema;
|
||||||
|
}
|
||||||
|
|
||||||
export const errorSchema = errorEnvelope.openapi("ErrorEnvelope");
|
export const errorSchema = nameSchema(errorEnvelope, "ErrorEnvelope");
|
||||||
export const setupStateSchema = setupState.openapi("SetupState");
|
export const setupStateSchema = nameSchema(setupState, "SetupState");
|
||||||
export const engineStatusSchema = engineStatus.openapi("EngineStatus");
|
export const engineStatusSchema = nameSchema(engineStatus, "EngineStatus");
|
||||||
export const keyItemSchema = keyItem.openapi("KeyItem");
|
export const keyItemSchema = nameSchema(keyItem, "KeyItem");
|
||||||
export const memberItemSchema = memberItem.openapi("MemberItem");
|
export const memberItemSchema = nameSchema(memberItem, "MemberItem");
|
||||||
export const memberWithTokenSchema = memberWithToken.openapi("MemberWithToken");
|
export const memberWithTokenSchema = nameSchema(
|
||||||
export const memberMeSchema = memberMe.openapi("MemberMe");
|
memberWithToken,
|
||||||
export const memberOverviewSchema = memberOverview.openapi("MemberOverview");
|
"MemberWithToken",
|
||||||
export const logItemSchema = logItem.openapi("LogItem");
|
);
|
||||||
export const logPageSchema = logPage.openapi("LogPage");
|
export const memberMeSchema = nameSchema(memberMe, "MemberMe");
|
||||||
export const paymentItemSchema = paymentItem.openapi("PaymentItem");
|
export const memberOverviewSchema = nameSchema(
|
||||||
export const auditLogItemSchema = auditLogItem.openapi("AuditLogItem");
|
memberOverview,
|
||||||
|
"MemberOverview",
|
||||||
|
);
|
||||||
|
export const logItemSchema = nameSchema(logItem, "LogItem");
|
||||||
|
export const logPageSchema = nameSchema(logPage, "LogPage");
|
||||||
|
export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem");
|
||||||
|
export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
|
||||||
|
|
||||||
export {
|
export {
|
||||||
changePasswordBody as changePasswordBodySchema,
|
changePasswordBody as changePasswordBodySchema,
|
||||||
|
|||||||
@@ -43,7 +43,9 @@ export function createApp(opts: CreateAppOptions) {
|
|||||||
try {
|
try {
|
||||||
await next();
|
await next();
|
||||||
} finally {
|
} finally {
|
||||||
appLogger("http").info`${c.req.method} ${c.req.path} → ${c.res.status} · ${Date.now() - start}ms`;
|
appLogger("http").info(
|
||||||
|
`${c.req.method} ${c.req.path} -> ${c.res.status} | ${Date.now() - start}ms`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
app.use("*", async (c, next) => {
|
app.use("*", async (c, next) => {
|
||||||
@@ -67,7 +69,7 @@ export function createApp(opts: CreateAppOptions) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.onError((err, c) => {
|
app.onError((err, c) => {
|
||||||
appLogger("server").error`未捕获错误 ${c.req.method} ${c.req.path}:${err}`;
|
appLogger("server").error(`未捕获错误 ${c.req.method} ${c.req.path}:${err}`);
|
||||||
return jsonError(c, 500, ERR.internal());
|
return jsonError(c, 500, ERR.internal());
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,9 @@ const app = createApp({
|
|||||||
adminInitPassword: env.ADMIN_INIT_PASSWORD,
|
adminInitPassword: env.ADMIN_INIT_PASSWORD,
|
||||||
});
|
});
|
||||||
|
|
||||||
appLogger("server").info`服务已启动,端口 ${env.PORT}(cookie_secure=${env.COOKIE_SECURE},日志级别=${env.LOG_LEVEL}`;
|
appLogger("server").info(
|
||||||
|
`服务已启动,端口 ${env.PORT}(cookie_secure=${env.COOKIE_SECURE},日志级别=${env.LOG_LEVEL})`,
|
||||||
|
);
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
port: env.PORT,
|
port: env.PORT,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||||
import { unixToIso, type AuditLogItem } from "@new-api-key-usage/core";
|
import { toUnixSec, type AuditLogItem } from "@new-api-key-usage/core";
|
||||||
import {
|
import {
|
||||||
requireAdminSession,
|
requireAdminSession,
|
||||||
type AppVars,
|
type AppVars,
|
||||||
@@ -56,7 +56,7 @@ export function adminAuditRoutes() {
|
|||||||
});
|
});
|
||||||
const items: AuditLogItem[] = rows.map((row) => ({
|
const items: AuditLogItem[] = rows.map((row) => ({
|
||||||
id: row.id,
|
id: row.id,
|
||||||
created_at: unixToIso(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
operator: row.operator,
|
operator: row.operator,
|
||||||
domain: row.domain as AuditLogItem["domain"],
|
domain: row.domain as AuditLogItem["domain"],
|
||||||
action: row.action,
|
action: row.action,
|
||||||
|
|||||||
@@ -3,11 +3,22 @@ import type { EngineStatus } from "@new-api-key-usage/core";
|
|||||||
import { requireAdminSession, type AppVars } from "../middleware.ts";
|
import { requireAdminSession, type AppVars } from "../middleware.ts";
|
||||||
import { engineStatusSchema, err401 } from "../api-schemas.ts";
|
import { engineStatusSchema, err401 } from "../api-schemas.ts";
|
||||||
|
|
||||||
/** 状态存储归同步引擎设计;此处只暴露契约字段 */
|
export type EngineStatusView = EngineStatus & { sync_interval_ms: number };
|
||||||
let engineStatus: EngineStatus = {
|
|
||||||
running: false,
|
export function toEngineStatusView(row: {
|
||||||
last_round: null,
|
running: boolean;
|
||||||
|
sync_interval_ms: number;
|
||||||
|
last_round: unknown;
|
||||||
|
} | null): EngineStatusView {
|
||||||
|
if (!row) {
|
||||||
|
return { running: false, sync_interval_ms: 300_000, last_round: null };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
running: row.running,
|
||||||
|
sync_interval_ms: row.sync_interval_ms,
|
||||||
|
last_round: (row.last_round as EngineStatus["last_round"]) ?? null,
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function adminEngineRoutes() {
|
export function adminEngineRoutes() {
|
||||||
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
const r = new OpenAPIHono<{ Variables: AppVars }>();
|
||||||
@@ -19,7 +30,7 @@ export function adminEngineRoutes() {
|
|||||||
path: "/engine",
|
path: "/engine",
|
||||||
summary: "引擎状态",
|
summary: "引擎状态",
|
||||||
description:
|
description:
|
||||||
"最近轮次摘要;last_round 为 null 表示引擎尚未跑过。状态存储细节归同步引擎设计。",
|
"同步引擎单行快照(engine_status);last_round 为 null 表示尚未跑过。sync_interval_ms 只读。",
|
||||||
tags: ["引擎"],
|
tags: ["引擎"],
|
||||||
security: [{ adminSession: [] }],
|
security: [{ adminSession: [] }],
|
||||||
responses: {
|
responses: {
|
||||||
@@ -31,17 +42,10 @@ export function adminEngineRoutes() {
|
|||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
async (c) => {
|
async (c) => {
|
||||||
return c.json(engineStatus, 200);
|
const row = await c.get("repos").engine.getSnapshot();
|
||||||
|
return c.json(toEngineStatusView(row), 200);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
return r;
|
return r;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setEngineStatus(next: EngineStatus) {
|
|
||||||
engineStatus = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getEngineStatus(): EngineStatus {
|
|
||||||
return engineStatus;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ERR,
|
ERR,
|
||||||
KeyCrypto,
|
KeyCrypto,
|
||||||
normalizeExpiresAt,
|
normalizeExpiresAt,
|
||||||
unixToIso,
|
toUnixSec,
|
||||||
type KeyItem,
|
type KeyItem,
|
||||||
} from "@new-api-key-usage/core";
|
} from "@new-api-key-usage/core";
|
||||||
import type { ApiKeyRow } from "@new-api-key-usage/db";
|
import type { ApiKeyRow } from "@new-api-key-usage/db";
|
||||||
@@ -30,14 +30,14 @@ function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
|
|||||||
status: row.status as "active" | "disabled",
|
status: row.status as "active" | "disabled",
|
||||||
member_id: row.member_id,
|
member_id: row.member_id,
|
||||||
member_name: row.member_name ?? null,
|
member_name: row.member_name ?? null,
|
||||||
registered_at: unixToIso(row.registered_at)!,
|
registered_at: toUnixSec(row.registered_at)!,
|
||||||
log_synced_at: unixToIso(row.log_synced_at),
|
log_synced_at: toUnixSec(row.log_synced_at),
|
||||||
usage_synced_at: unixToIso(row.usage_synced_at),
|
usage_synced_at: toUnixSec(row.usage_synced_at),
|
||||||
total_used: row.total_used,
|
total_used: row.total_used,
|
||||||
total_available: row.total_available,
|
total_available: row.total_available,
|
||||||
total_granted: row.total_granted,
|
total_granted: row.total_granted,
|
||||||
unlimited_quota: row.unlimited_quota,
|
unlimited_quota: row.unlimited_quota,
|
||||||
expires_at: unixToIso(row.expires_at),
|
expires_at: toUnixSec(row.expires_at),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,8 +85,11 @@ export function adminKeyRoutes() {
|
|||||||
}
|
}
|
||||||
let rows = await c.get("repos").keys.listAll(filter);
|
let rows = await c.get("repos").keys.listAll(filter);
|
||||||
if (q.recent_failed === "true") {
|
if (q.recent_failed === "true") {
|
||||||
const { getEngineStatus } = await import("./admin-engine.ts");
|
const snap = await c.get("repos").engine.getSnapshot();
|
||||||
const failed = new Set(getEngineStatus().last_round?.failed_api_key_ids ?? []);
|
const failed = new Set(
|
||||||
|
((snap?.last_round as { failed_api_key_ids?: number[] } | null)
|
||||||
|
?.failed_api_key_ids) ?? [],
|
||||||
|
);
|
||||||
rows = rows.filter((r) => failed.has(r.id));
|
rows = rows.filter((r) => failed.has(r.id));
|
||||||
}
|
}
|
||||||
return c.json(rows.map(toKeyItem), 200);
|
return c.json(rows.map(toKeyItem), 200);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
|
||||||
import { ERR, generateToken, unixToIso } from "@new-api-key-usage/core";
|
import { ERR, generateToken, toUnixSec } from "@new-api-key-usage/core";
|
||||||
import type { MemberRow } from "@new-api-key-usage/db";
|
import type { MemberRow } from "@new-api-key-usage/db";
|
||||||
import {
|
import {
|
||||||
jsonError,
|
jsonError,
|
||||||
@@ -25,8 +25,8 @@ function toMemberItem(row: MemberRow, key_count: number) {
|
|||||||
name: row.name,
|
name: row.name,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
status: row.status as "active" | "disabled",
|
status: row.status as "active" | "disabled",
|
||||||
token_issued_at: unixToIso(row.token_issued_at),
|
token_issued_at: toUnixSec(row.token_issued_at),
|
||||||
created_at: unixToIso(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
key_count,
|
key_count,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import {
|
|||||||
qpuAt,
|
qpuAt,
|
||||||
quotaToUsdStr,
|
quotaToUsdStr,
|
||||||
resolveQpuTimeline,
|
resolveQpuTimeline,
|
||||||
unixToIso,
|
toUnixSec,
|
||||||
type PaymentItem,
|
type PaymentItem,
|
||||||
} from "@new-api-key-usage/core";
|
} from "@new-api-key-usage/core";
|
||||||
import type { PaymentRow } from "@new-api-key-usage/db";
|
import type { PaymentRow } from "@new-api-key-usage/db";
|
||||||
@@ -31,11 +31,11 @@ function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
kind: row.kind as PaymentItem["kind"],
|
kind: row.kind as PaymentItem["kind"],
|
||||||
paid_at: unixToIso(row.paid_at)!,
|
paid_at: toUnixSec(row.paid_at)!,
|
||||||
paid_amount: row.paid_amount,
|
paid_amount: row.paid_amount,
|
||||||
remark: row.remark,
|
remark: row.remark,
|
||||||
period_start: unixToIso(row.period_start),
|
period_start: toUnixSec(row.period_start),
|
||||||
period_end: unixToIso(row.period_end),
|
period_end: toUnixSec(row.period_end),
|
||||||
subscription_id: row.subscription_id,
|
subscription_id: row.subscription_id,
|
||||||
quota_granted: row.quota_granted,
|
quota_granted: row.quota_granted,
|
||||||
quota_usd,
|
quota_usd,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ERR,
|
ERR,
|
||||||
resolveQpuTimeline,
|
resolveQpuTimeline,
|
||||||
sumUsage,
|
sumUsage,
|
||||||
unixToIso,
|
toUnixSec,
|
||||||
type LogItem,
|
type LogItem,
|
||||||
} from "@new-api-key-usage/core";
|
} from "@new-api-key-usage/core";
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,7 @@ const usageTotals = z.object({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const overviewSchema = z.object({
|
const overviewSchema = z.object({
|
||||||
data_cutoff: z.string().nullable(),
|
data_cutoff: z.number().int().nullable(),
|
||||||
unassigned_subscription: usageTotals,
|
unassigned_subscription: usageTotals,
|
||||||
by_source: z.array(
|
by_source: z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -40,7 +40,7 @@ const overviewSchema = z.object({
|
|||||||
member_id: z.number().int().nullable(),
|
member_id: z.number().int().nullable(),
|
||||||
member_name: z.string().nullable(),
|
member_name: z.string().nullable(),
|
||||||
key_count: z.number().int(),
|
key_count: z.number().int(),
|
||||||
data_cutoff: z.string().nullable(),
|
data_cutoff: z.number().int().nullable(),
|
||||||
usage: usageTotals,
|
usage: usageTotals,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -50,7 +50,7 @@ const overviewSchema = z.object({
|
|||||||
token_name: z.string(),
|
token_name: z.string(),
|
||||||
member_id: z.number().int().nullable(),
|
member_id: z.number().int().nullable(),
|
||||||
status: z.string(),
|
status: z.string(),
|
||||||
data_cutoff: z.string().nullable(),
|
data_cutoff: z.number().int().nullable(),
|
||||||
usage: usageTotals,
|
usage: usageTotals,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
@@ -58,7 +58,7 @@ const overviewSchema = z.object({
|
|||||||
|
|
||||||
const qpuLatestSchema = z.object({
|
const qpuLatestSchema = z.object({
|
||||||
qpu: z.number(),
|
qpu: z.number(),
|
||||||
effective_from: z.string(),
|
effective_from: z.number().int(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const timeRange = z.object({
|
const timeRange = z.object({
|
||||||
@@ -106,14 +106,14 @@ export function adminUsageRoutes() {
|
|||||||
const latest = await c.get("repos").qpu.latest();
|
const latest = await c.get("repos").qpu.latest();
|
||||||
if (!latest) {
|
if (!latest) {
|
||||||
return c.json(
|
return c.json(
|
||||||
{ qpu: 500_000, effective_from: "1970-01-01T00:00:00Z" },
|
{ qpu: 500_000, effective_from: 0 },
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
qpu: latest.qpu,
|
qpu: latest.qpu,
|
||||||
effective_from: unixToIso(latest.effective_from)!,
|
effective_from: toUnixSec(latest.effective_from)!,
|
||||||
},
|
},
|
||||||
200,
|
200,
|
||||||
);
|
);
|
||||||
@@ -208,7 +208,7 @@ export function adminUsageRoutes() {
|
|||||||
token_name: v.token_name,
|
token_name: v.token_name,
|
||||||
member_id: v.member_id,
|
member_id: v.member_id,
|
||||||
status: v.status,
|
status: v.status,
|
||||||
data_cutoff: unixToIso(v.log_synced_at),
|
data_cutoff: toUnixSec(v.log_synced_at),
|
||||||
usage: sumUsage(v.rows, timeline),
|
usage: sumUsage(v.rows, timeline),
|
||||||
}))
|
}))
|
||||||
.sort((a, b) => b.usage.quota - a.usage.quota);
|
.sort((a, b) => b.usage.quota - a.usage.quota);
|
||||||
@@ -248,13 +248,13 @@ export function adminUsageRoutes() {
|
|||||||
member_id: b.member_id,
|
member_id: b.member_id,
|
||||||
member_name: b.member_name,
|
member_name: b.member_name,
|
||||||
key_count: b.key_count,
|
key_count: b.key_count,
|
||||||
data_cutoff: unixToIso(b.cutoff),
|
data_cutoff: toUnixSec(b.cutoff),
|
||||||
usage: sumUsage(b.rows, timeline),
|
usage: sumUsage(b.rows, timeline),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return c.json(
|
return c.json(
|
||||||
{
|
{
|
||||||
data_cutoff: unixToIso(data_cutoff),
|
data_cutoff: toUnixSec(data_cutoff),
|
||||||
unassigned_subscription,
|
unassigned_subscription,
|
||||||
by_source,
|
by_source,
|
||||||
by_member,
|
by_member,
|
||||||
@@ -326,7 +326,7 @@ export function adminUsageRoutes() {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
api_key_id: row.api_key_id,
|
api_key_id: row.api_key_id,
|
||||||
created_at: unixToIso(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
token_name: row.token_name,
|
token_name: row.token_name,
|
||||||
model_name: row.model_name,
|
model_name: row.model_name,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import {
|
|||||||
ERR,
|
ERR,
|
||||||
resolveQpuTimeline,
|
resolveQpuTimeline,
|
||||||
sumUsage,
|
sumUsage,
|
||||||
unixToIso,
|
toUnixSec,
|
||||||
type LogItem,
|
type LogItem,
|
||||||
type PaymentItem,
|
type PaymentItem,
|
||||||
} from "@new-api-key-usage/core";
|
} from "@new-api-key-usage/core";
|
||||||
@@ -156,7 +156,7 @@ export function memberViewRoutes() {
|
|||||||
const keys = [...keyMap.entries()].map(([key_id, v]) => ({
|
const keys = [...keyMap.entries()].map(([key_id, v]) => ({
|
||||||
key_id,
|
key_id,
|
||||||
token_name: v.token_name,
|
token_name: v.token_name,
|
||||||
log_synced_at: unixToIso(v.log_synced_at),
|
log_synced_at: toUnixSec(v.log_synced_at),
|
||||||
usage: sumUsage(v.rows, timeline),
|
usage: sumUsage(v.rows, timeline),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -185,11 +185,11 @@ export function memberViewRoutes() {
|
|||||||
payments.push({
|
payments.push({
|
||||||
id: p.id,
|
id: p.id,
|
||||||
kind: p.kind as PaymentItem["kind"],
|
kind: p.kind as PaymentItem["kind"],
|
||||||
paid_at: unixToIso(p.paid_at)!,
|
paid_at: toUnixSec(p.paid_at)!,
|
||||||
paid_amount: p.paid_amount,
|
paid_amount: p.paid_amount,
|
||||||
remark: p.remark,
|
remark: p.remark,
|
||||||
period_start: unixToIso(p.period_start),
|
period_start: toUnixSec(p.period_start),
|
||||||
period_end: unixToIso(p.period_end),
|
period_end: toUnixSec(p.period_end),
|
||||||
subscription_id: p.subscription_id,
|
subscription_id: p.subscription_id,
|
||||||
quota_granted: p.quota_granted,
|
quota_granted: p.quota_granted,
|
||||||
quota_usd:
|
quota_usd:
|
||||||
@@ -272,7 +272,7 @@ export function memberViewRoutes() {
|
|||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
api_key_id: row.api_key_id,
|
api_key_id: row.api_key_id,
|
||||||
created_at: unixToIso(row.created_at)!,
|
created_at: toUnixSec(row.created_at)!,
|
||||||
token_name: row.token_name ?? "",
|
token_name: row.token_name ?? "",
|
||||||
model_name: row.model_name,
|
model_name: row.model_name,
|
||||||
type: row.type,
|
type: row.type,
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/** 必须在 core 的 zod schema 被求值之前执行(api-schemas 首行 side-effect import) */
|
||||||
|
import { z } from "zod";
|
||||||
|
import { extendZodWithOpenApi } from "@hono/zod-openapi";
|
||||||
|
|
||||||
|
extendZodWithOpenApi(z);
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -9,6 +9,14 @@
|
|||||||
"typescript": "^7.0.2",
|
"typescript": "^7.0.2",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"apps/engine": {
|
||||||
|
"name": "@new-api-key-usage/engine",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"@new-api-key-usage/core": "workspace:*",
|
||||||
|
"@new-api-key-usage/db": "workspace:*",
|
||||||
|
},
|
||||||
|
},
|
||||||
"apps/server": {
|
"apps/server": {
|
||||||
"name": "@new-api-key-usage/server",
|
"name": "@new-api-key-usage/server",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
@@ -291,6 +299,8 @@
|
|||||||
|
|
||||||
"@new-api-key-usage/db": ["@new-api-key-usage/db@workspace:packages/db"],
|
"@new-api-key-usage/db": ["@new-api-key-usage/db@workspace:packages/db"],
|
||||||
|
|
||||||
|
"@new-api-key-usage/engine": ["@new-api-key-usage/engine@workspace:apps/engine"],
|
||||||
|
|
||||||
"@new-api-key-usage/server": ["@new-api-key-usage/server@workspace:apps/server"],
|
"@new-api-key-usage/server": ["@new-api-key-usage/server@workspace:apps/server"],
|
||||||
|
|
||||||
"@new-api-key-usage/web": ["@new-api-key-usage/web@workspace:apps/web"],
|
"@new-api-key-usage/web": ["@new-api-key-usage/web@workspace:apps/web"],
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
"test": "bun test",
|
"test": "bun test",
|
||||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
|
"dev:server": "bun --hot --env-file=.env apps/server/src/index.ts",
|
||||||
|
"dev:engine": "bun --env-file=.env apps/engine/src/index.ts",
|
||||||
"dev:web": "bun --filter @new-api-key-usage/web dev"
|
"dev:web": "bun --filter @new-api-key-usage/web dev"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -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;
|
let configured = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -37,7 +51,7 @@ export function setupLogging(level: AppLogLevel = "info"): void {
|
|||||||
sinks: {
|
sinks: {
|
||||||
console: getConsoleSink({
|
console: getConsoleSink({
|
||||||
formatter: (record) => {
|
formatter: (record) => {
|
||||||
const ts = new Date(record.timestamp).toISOString();
|
const ts = formatLogTime(new Date(record.timestamp));
|
||||||
const cat = record.category.join(".");
|
const cat = record.category.join(".");
|
||||||
const msg = redactSecrets(String(record.message));
|
const msg = redactSecrets(String(record.message));
|
||||||
return `${ts} ${record.level.toUpperCase()} [${cat}] ${msg}`;
|
return `${ts} ${record.level.toUpperCase()} [${cat}] ${msg}`;
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ export const usageTotals = z.object({
|
|||||||
export const logItem = z.object({
|
export const logItem = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
api_key_id: z.number().int(),
|
api_key_id: z.number().int(),
|
||||||
created_at: z.string(),
|
created_at: z.number().int(),
|
||||||
token_name: z.string(),
|
token_name: z.string(),
|
||||||
model_name: z.string(),
|
model_name: z.string(),
|
||||||
type: z.number().int(),
|
type: z.number().int(),
|
||||||
@@ -78,14 +78,14 @@ export const keyItem = z.object({
|
|||||||
status: z.enum(["active", "disabled"]),
|
status: z.enum(["active", "disabled"]),
|
||||||
member_id: z.number().int().nullable(),
|
member_id: z.number().int().nullable(),
|
||||||
member_name: z.string().nullable(),
|
member_name: z.string().nullable(),
|
||||||
registered_at: z.string(),
|
registered_at: z.number().int(),
|
||||||
log_synced_at: z.string().nullable(),
|
log_synced_at: z.number().int().nullable(),
|
||||||
usage_synced_at: z.string().nullable(),
|
usage_synced_at: z.number().int().nullable(),
|
||||||
total_used: z.number(),
|
total_used: z.number(),
|
||||||
total_available: z.number(),
|
total_available: z.number(),
|
||||||
total_granted: z.number(),
|
total_granted: z.number(),
|
||||||
unlimited_quota: z.boolean(),
|
unlimited_quota: z.boolean(),
|
||||||
expires_at: z.string().nullable(),
|
expires_at: z.number().int().nullable(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const registerKeyBody = z.object({
|
export const registerKeyBody = z.object({
|
||||||
@@ -103,8 +103,8 @@ export const memberItem = z.object({
|
|||||||
name: z.string(),
|
name: z.string(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
status: z.enum(["active", "disabled"]),
|
status: z.enum(["active", "disabled"]),
|
||||||
token_issued_at: z.string().nullable(),
|
token_issued_at: z.number().int().nullable(),
|
||||||
created_at: z.string(),
|
created_at: z.number().int(),
|
||||||
key_count: z.number().int(),
|
key_count: z.number().int(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -132,11 +132,11 @@ export const memberMe = z.object({
|
|||||||
export const paymentItem = z.object({
|
export const paymentItem = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
kind: z.enum(["subscription", "topup"]),
|
kind: z.enum(["subscription", "topup"]),
|
||||||
paid_at: z.string(),
|
paid_at: z.number().int(),
|
||||||
paid_amount: z.number(),
|
paid_amount: z.number(),
|
||||||
remark: z.string(),
|
remark: z.string(),
|
||||||
period_start: z.string().nullable(),
|
period_start: z.number().int().nullable(),
|
||||||
period_end: z.string().nullable(),
|
period_end: z.number().int().nullable(),
|
||||||
subscription_id: z.number().int().nullable(),
|
subscription_id: z.number().int().nullable(),
|
||||||
quota_granted: z.number().int().nullable(),
|
quota_granted: z.number().int().nullable(),
|
||||||
quota_usd: z.string(),
|
quota_usd: z.string(),
|
||||||
@@ -144,9 +144,10 @@ export const paymentItem = z.object({
|
|||||||
|
|
||||||
export const engineStatus = z.object({
|
export const engineStatus = z.object({
|
||||||
running: z.boolean(),
|
running: z.boolean(),
|
||||||
|
sync_interval_ms: z.number().int().optional(),
|
||||||
last_round: z
|
last_round: z
|
||||||
.object({
|
.object({
|
||||||
started_at: z.string(),
|
started_at: z.number().int(),
|
||||||
keys_total: z.number().int(),
|
keys_total: z.number().int(),
|
||||||
succeeded: z.number().int(),
|
succeeded: z.number().int(),
|
||||||
failed: z.number().int(),
|
failed: z.number().int(),
|
||||||
@@ -157,7 +158,7 @@ export const engineStatus = z.object({
|
|||||||
|
|
||||||
export const auditLogItem = z.object({
|
export const auditLogItem = z.object({
|
||||||
id: z.number().int(),
|
id: z.number().int(),
|
||||||
created_at: z.string(),
|
created_at: z.number().int(),
|
||||||
operator: z.string().nullable(),
|
operator: z.string().nullable(),
|
||||||
domain: z.enum(["key", "payment", "member", "account"]),
|
domain: z.enum(["key", "payment", "member", "account"]),
|
||||||
action: z.string(),
|
action: z.string(),
|
||||||
@@ -182,7 +183,7 @@ export const memberOverview = z.object({
|
|||||||
z.object({
|
z.object({
|
||||||
key_id: z.number().int(),
|
key_id: z.number().int(),
|
||||||
token_name: z.string(),
|
token_name: z.string(),
|
||||||
log_synced_at: z.string().nullable(),
|
log_synced_at: z.number().int().nullable(),
|
||||||
usage: usageTotals,
|
usage: usageTotals,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,18 +1,35 @@
|
|||||||
/** 存储层 UTC Unix 秒 / Date / ISO 字符串 ↔ DTO 层 ISO 8601 UTC */
|
/**
|
||||||
export function unixToIso(
|
* 时间口径(API 响应统一 Unix 秒整数;请求 from/to 仍收 ISO 8601 便于试调)
|
||||||
value: number | string | Date | null | undefined,
|
* 库内 timestamptz ↔ JS Date ↔ Unix 秒
|
||||||
): string | null {
|
*/
|
||||||
|
function toDate(value: number | string | Date | null | undefined): Date | null {
|
||||||
if (value === null || value === undefined) return null;
|
if (value === null || value === undefined) return null;
|
||||||
let d: Date;
|
let d: Date;
|
||||||
if (value instanceof Date) {
|
if (value instanceof Date) {
|
||||||
d = value;
|
d = value;
|
||||||
} else if (typeof value === "number") {
|
} else if (typeof value === "number") {
|
||||||
// Unix 秒(或毫秒:量级区分)
|
|
||||||
d = new Date(value < 1e12 ? value * 1000 : value);
|
d = new Date(value < 1e12 ? value * 1000 : value);
|
||||||
} else {
|
} else {
|
||||||
d = new Date(value);
|
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");
|
return d.toISOString().replace(/\.\d{3}Z$/, "Z");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+15
-15
@@ -1,5 +1,5 @@
|
|||||||
/** ISO 8601 UTC 字符串,如 2026-10-02T14:22:05Z */
|
/** Unix 秒(UTC 时刻)——API 响应时刻字段统一此类型 */
|
||||||
export type Iso8601Utc = string;
|
export type UnixSec = number;
|
||||||
|
|
||||||
/** 支付金额(币种无关数值) */
|
/** 支付金额(币种无关数值) */
|
||||||
export type PaidAmount = number;
|
export type PaidAmount = number;
|
||||||
@@ -24,7 +24,7 @@ export interface UsageTotals {
|
|||||||
export interface LogItem {
|
export interface LogItem {
|
||||||
id: number;
|
id: number;
|
||||||
api_key_id: number;
|
api_key_id: number;
|
||||||
created_at: Iso8601Utc;
|
created_at: UnixSec;
|
||||||
token_name: string;
|
token_name: string;
|
||||||
model_name: string;
|
model_name: string;
|
||||||
type: number;
|
type: number;
|
||||||
@@ -45,24 +45,24 @@ export interface KeyItem {
|
|||||||
status: KeyStatus;
|
status: KeyStatus;
|
||||||
member_id: number | null;
|
member_id: number | null;
|
||||||
member_name: string | null;
|
member_name: string | null;
|
||||||
registered_at: Iso8601Utc;
|
registered_at: UnixSec;
|
||||||
log_synced_at: Iso8601Utc | null;
|
log_synced_at: UnixSec | null;
|
||||||
usage_synced_at: Iso8601Utc | null;
|
usage_synced_at: UnixSec | null;
|
||||||
total_used: number;
|
total_used: number;
|
||||||
total_available: number;
|
total_available: number;
|
||||||
total_granted: number;
|
total_granted: number;
|
||||||
unlimited_quota: boolean;
|
unlimited_quota: boolean;
|
||||||
expires_at: Iso8601Utc | null;
|
expires_at: UnixSec | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentItem {
|
export interface PaymentItem {
|
||||||
id: number;
|
id: number;
|
||||||
kind: PaymentKind;
|
kind: PaymentKind;
|
||||||
paid_at: Iso8601Utc;
|
paid_at: UnixSec;
|
||||||
paid_amount: PaidAmount;
|
paid_amount: PaidAmount;
|
||||||
remark: string;
|
remark: string;
|
||||||
period_start: Iso8601Utc | null;
|
period_start: UnixSec | null;
|
||||||
period_end: Iso8601Utc | null;
|
period_end: UnixSec | null;
|
||||||
subscription_id: number | null;
|
subscription_id: number | null;
|
||||||
quota_granted: number | null;
|
quota_granted: number | null;
|
||||||
quota_usd: QuotaUsd;
|
quota_usd: QuotaUsd;
|
||||||
@@ -73,14 +73,14 @@ export interface MemberItem {
|
|||||||
name: string;
|
name: string;
|
||||||
remark: string;
|
remark: string;
|
||||||
status: MemberStatus;
|
status: MemberStatus;
|
||||||
token_issued_at: Iso8601Utc | null;
|
token_issued_at: UnixSec | null;
|
||||||
created_at: Iso8601Utc;
|
created_at: UnixSec;
|
||||||
key_count: number;
|
key_count: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AuditLogItem {
|
export interface AuditLogItem {
|
||||||
id: number;
|
id: number;
|
||||||
created_at: Iso8601Utc;
|
created_at: UnixSec;
|
||||||
operator: string | null;
|
operator: string | null;
|
||||||
domain: "key" | "payment" | "member" | "account";
|
domain: "key" | "payment" | "member" | "account";
|
||||||
action: string;
|
action: string;
|
||||||
@@ -93,7 +93,7 @@ export interface Paginated<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface EngineLastRound {
|
export interface EngineLastRound {
|
||||||
started_at: Iso8601Utc;
|
started_at: UnixSec;
|
||||||
keys_total: number;
|
keys_total: number;
|
||||||
succeeded: number;
|
succeeded: number;
|
||||||
failed: number;
|
failed: number;
|
||||||
@@ -107,7 +107,7 @@ export interface EngineStatus {
|
|||||||
|
|
||||||
export interface QpuLatest {
|
export interface QpuLatest {
|
||||||
qpu: number;
|
qpu: number;
|
||||||
effective_from: Iso8601Utc;
|
effective_from: UnixSec;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AuditAction =
|
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) {
|
export function createUpstreamClient(opts: UpstreamClientOptions) {
|
||||||
const timeoutMs = opts.timeoutMs ?? 8_000;
|
const timeoutMs = opts.timeoutMs ?? 8_000;
|
||||||
|
|
||||||
@@ -87,6 +111,15 @@ export function createUpstreamClient(opts: UpstreamClientOptions) {
|
|||||||
return r;
|
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) */
|
/** 换算率权威来源:GET /api/status(取 quota_per_unit) */
|
||||||
async fetchQuotaPerUnit(): Promise<UpstreamResult<{ quota_per_unit: number }>> {
|
async fetchQuotaPerUnit(): Promise<UpstreamResult<{ quota_per_unit: number }>> {
|
||||||
return requestJson<{ 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,
|
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 };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Db } from "./index.ts";
|
|||||||
import {
|
import {
|
||||||
apiKeys,
|
apiKeys,
|
||||||
auditLogs,
|
auditLogs,
|
||||||
|
engineStatus,
|
||||||
members,
|
members,
|
||||||
payments,
|
payments,
|
||||||
qpuHistory,
|
qpuHistory,
|
||||||
@@ -10,6 +11,7 @@ import {
|
|||||||
users,
|
users,
|
||||||
type ApiKeyRow,
|
type ApiKeyRow,
|
||||||
type AuditLogRow,
|
type AuditLogRow,
|
||||||
|
type EngineStatusRow,
|
||||||
type MemberRow,
|
type MemberRow,
|
||||||
type PaymentRow,
|
type PaymentRow,
|
||||||
type SessionRow,
|
type SessionRow,
|
||||||
@@ -148,6 +150,54 @@ export function createRepos(db: Db) {
|
|||||||
}
|
}
|
||||||
return base;
|
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> {
|
async findById(id: number): Promise<ApiKeyRow | null> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.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: {
|
payments: {
|
||||||
async listAll(): Promise<PaymentRow[]> {
|
async listAll(): Promise<PaymentRow[]> {
|
||||||
return db.select().from(payments).orderBy(desc(payments.paid_at));
|
return db.select().from(payments).orderBy(desc(payments.paid_at));
|
||||||
@@ -354,6 +441,9 @@ export function createRepos(db: Db) {
|
|||||||
.limit(1);
|
.limit(1);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
},
|
},
|
||||||
|
async insertChange(qpu: number, effective_from: Date): Promise<void> {
|
||||||
|
await db.insert(qpuHistory).values({ qpu, effective_from });
|
||||||
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
logs: {
|
logs: {
|
||||||
|
|||||||
@@ -191,6 +191,19 @@ export const auditLogs = pgTable(
|
|||||||
(t) => [index("audit_logs_created_at_idx").on(t.created_at)],
|
(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 MemberRow = typeof members.$inferSelect;
|
||||||
export type ApiKeyRow = typeof apiKeys.$inferSelect;
|
export type ApiKeyRow = typeof apiKeys.$inferSelect;
|
||||||
export type UsageLogRow = typeof usageLogs.$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 UserRow = typeof users.$inferSelect;
|
||||||
export type SessionRow = typeof sessions.$inferSelect;
|
export type SessionRow = typeof sessions.$inferSelect;
|
||||||
export type AuditLogRow = typeof auditLogs.$inferSelect;
|
export type AuditLogRow = typeof auditLogs.$inferSelect;
|
||||||
|
export type EngineStatusRow = typeof engineStatus.$inferSelect;
|
||||||
|
|||||||
Reference in New Issue
Block a user