349 lines
11 KiB
TypeScript
349 lines
11 KiB
TypeScript
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;
|
||
}
|