Files
new-api-key-usage/apps/server/src/routes/admin-keys.ts
T
skycurtain b0e9ea750b feat: 账本域 billing_domain 与成员支付可见性收紧
三表增加账本域标签,成员支付参数改为同域透明、跨域隔离、未标域默认藏;管理面登记/筛选/行内编辑与文档中心同步对齐。
2026-10-13 20:42:26 +08:00

349 lines
11 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { OpenAPIHono, createRoute } from "@hono/zod-openapi";
import {
ERR,
KeyCrypto,
normalizeExpiresAt,
toUnixSec,
type KeyItem,
} from "@new-api-key-usage/core";
import type { ApiKeyRow } from "@new-api-key-usage/db";
import {
jsonError,
requireAdminSession,
type AppVars,
} from "../middleware.ts";
import {
err401,
err404,
err409,
err422,
keyItemSchema,
patchKeyBodySchema,
registerKeyBodySchema,
} from "../api-schemas.ts";
import { z } from "zod";
function toKeyItem(row: ApiKeyRow & { member_name?: string | null }): KeyItem {
return {
id: row.id,
token_name: row.token_name,
status: row.status as "active" | "disabled",
member_id: row.member_id,
member_name: row.member_name ?? null,
billing_domain: row.billing_domain,
registered_at: toUnixSec(row.registered_at)!,
log_synced_at: toUnixSec(row.log_synced_at),
usage_synced_at: toUnixSec(row.usage_synced_at),
total_used: row.total_used,
total_available: row.total_available,
total_granted: row.total_granted,
unlimited_quota: row.unlimited_quota,
expires_at: toUnixSec(row.expires_at),
};
}
export function adminKeyRoutes() {
const r = new OpenAPIHono<{ Variables: AppVars }>();
// 鉴权优先于请求体校验(未登录恒 401)
r.use("*", requireAdminSession);
r.openapi(
createRoute({
method: "get",
path: "/keys",
summary: "key 清单",
description:
"全量直返(基数 10–25,不分页)。key_hash / key_ciphertext 永不出 API。",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
query: z.object({
member_id: z.string().optional(),
status: z.enum(["active", "disabled"]).optional(),
billing_domain: z.string().optional(),
recent_failed: z.enum(["true", "false"]).optional(),
}),
},
responses: {
200: {
description: "key 数组",
content: {
"application/json": { schema: z.array(keyItemSchema) },
},
},
401: err401,
},
}),
async (c) => {
const q = c.req.query();
const filter: {
member_id?: number | "unassigned";
status?: string;
billing_domain?: string | "none";
} = {};
if (q.member_id === "unassigned") filter.member_id = "unassigned";
else if (q.member_id) filter.member_id = Number(q.member_id);
if (q.status === "active" || q.status === "disabled") {
filter.status = q.status;
}
if (q.billing_domain === "none") filter.billing_domain = "none";
else if (q.billing_domain) filter.billing_domain = q.billing_domain;
let rows = await c.get("repos").keys.listAll(filter);
if (q.recent_failed === "true") {
const snap = await c.get("repos").engine.getSnapshot();
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));
}
return c.json(rows.map(toKeyItem), 200);
},
);
r.openapi(
createRoute({
method: "post",
path: "/keys",
summary: "登记 key",
description:
"四态:201 成功 / 409 已登记(附 key_id)/ 422 上游拒绝 / 502 上游不可达。查库优先于上游验证。",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
body: {
content: { "application/json": { schema: registerKeyBodySchema } },
required: true,
},
},
responses: {
201: {
description: "key 实体",
content: { "application/json": { schema: keyItemSchema } },
},
401: err401,
409: err409,
422: err422,
502: {
description: "上游不可达",
content: {
"application/json": {
schema: err422.content["application/json"].schema,
},
},
},
},
}),
async (c) => {
const body = await c.req.json().catch(() => null);
const sk_key = typeof body?.sk_key === "string" ? body.sk_key.trim() : "";
let member_id: number | null = null;
if (body?.member_id !== undefined && body?.member_id !== null) {
const n = Number(body.member_id);
if (!Number.isInteger(n) || n <= 0) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员 id 无效" }),
);
}
member_id = n;
}
let billing_domain: string | null = null;
if (typeof body?.billing_domain === "string" && body.billing_domain.trim()) {
billing_domain = body.billing_domain.trim();
}
if (!sk_key || !sk_key.startsWith("sk-")) {
return jsonError(
c,
422,
ERR.validation_failed({
sk_key: "请粘贴有效的 sk- 开头的 key",
}),
);
}
const repos = c.get("repos");
const crypto = await KeyCrypto.fromEncryptionSecret(
c.get("skKeyEncryptionSecret"),
);
const key_hash = await crypto.hashKey(sk_key);
const existing = await repos.keys.findByHash(key_hash);
if (existing) {
return jsonError(c, 409, ERR.key_already_registered(existing.id));
}
if (member_id != null) {
const m = await repos.members.findById(member_id);
if (!m) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员不存在" }),
);
}
}
const key_ciphertext = await crypto.sealKey(sk_key);
// 上游验证(登记四态:422 拒绝 / 502 不可达)
const fetchUsage =
c.get("fetchTokenUsage") ??
(async (key: string) => {
const { createUpstreamClient } = await import(
"@new-api-key-usage/core"
);
const client = createUpstreamClient({
baseUrl: c.get("newapiBase"),
});
return client.fetchTokenUsage(key);
});
const upstream = await fetchUsage(sk_key);
if (!upstream.ok) {
if (upstream.kind === "rejected") {
return jsonError(
c,
422,
ERR.validation_failed({
sk_key: "上游拒绝了这枚 key——可能不存在或已被禁用",
}),
);
}
return jsonError(c, 502, ERR.upstream_unreachable());
}
const usage = upstream.data;
const row = await repos.keys.insert({
key_hash,
key_ciphertext,
member_id,
billing_domain,
token_name: usage.name ?? "",
});
// 首次登记即写入上游额度字段(表设计 §4:首轮日志同步异步补)
if (row.id) {
await repos.keys.update(row.id, {
token_name: usage.name ?? "",
total_granted: usage.total_granted,
total_used: usage.total_used,
total_available: usage.total_available,
unlimited_quota: usage.unlimited_quota,
model_limits: usage.model_limits,
model_limits_enabled: usage.model_limits_enabled,
expires_at: normalizeExpiresAt(usage.expires_at),
usage_synced_at: new Date(),
});
}
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "key",
action: "key.register",
summary: `登记 key #${row.id}${row.token_name ? ` ${row.token_name}` : ""}`,
});
const fresh = (await repos.keys.findById(row.id)) ?? row;
return c.json(toKeyItem({ ...fresh, member_name: null }), 201);
},
);
r.openapi(
createRoute({
method: "patch",
path: "/keys/{id}",
summary: "启停 / 归属 / 账本域变更",
description: "部分更新;审计按实际变更字段各落一行",
tags: ["Key 管理"],
security: [{ adminSession: [] }],
request: {
params: z.object({ id: z.string() }),
body: {
content: { "application/json": { schema: patchKeyBodySchema } },
required: true,
},
},
responses: {
200: {
description: "key 实体",
content: { "application/json": { schema: keyItemSchema } },
},
401: err401,
404: err404,
422: err422,
},
}),
async (c) => {
const id = Number(c.req.param("id"));
const body = await c.req.json().catch(() => null);
const repos = c.get("repos");
const before = await repos.keys.findById(id);
if (!before) return jsonError(c, 404, ERR.not_found());
const patch: Partial<ApiKeyRow> = {};
const actions: Array<{ action: string; summary: string }> = [];
if (body?.status === "active" || body?.status === "disabled") {
if (body.status !== before.status) {
patch.status = body.status;
actions.push({
action: body.status === "active" ? "key.enable" : "key.disable",
summary: `key #${before.id} · ${body.status === "active" ? "启用" : "停用"}`,
});
}
}
if (body && "member_id" in body) {
let nextMember: number | null = null;
if (body.member_id != null) {
const n = Number(body.member_id);
if (!Number.isInteger(n) || n <= 0) {
return jsonError(
c,
422,
ERR.validation_failed({ member_id: "成员 id 无效" }),
);
}
nextMember = n;
}
if (nextMember !== before.member_id) {
patch.member_id = nextMember;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 归属变更`,
});
}
}
if (body && "billing_domain" in body) {
let nextDomain: string | null = null;
if (typeof body.billing_domain === "string" && body.billing_domain.trim()) {
nextDomain = body.billing_domain.trim();
}
if (nextDomain !== before.billing_domain) {
patch.billing_domain = nextDomain;
actions.push({
action: "key.reassign",
summary: `key #${before.id} · 账本域变更`,
});
}
}
if (Object.keys(patch).length === 0) {
return jsonError(
c,
422,
ERR.validation_failed({ body: "没有需要更新的字段" }),
);
}
const after = await repos.keys.update(id, patch);
for (const a of actions) {
await repos.audit.insert({
operator: c.get("adminUsername") ?? null,
domain: "key",
action: a.action,
summary: a.summary,
});
}
return c.json(toKeyItem({ ...after, member_name: null }), 200);
},
);
return r;
}