diff --git a/apps/server/.gitignore b/apps/server/.gitignore new file mode 100644 index 0000000..506e4c3 --- /dev/null +++ b/apps/server/.gitignore @@ -0,0 +1,2 @@ +# deps +node_modules/ diff --git a/apps/server/README.md b/apps/server/README.md new file mode 100644 index 0000000..4709d23 --- /dev/null +++ b/apps/server/README.md @@ -0,0 +1,13 @@ +# apps/server + +母体后端(Hono + Bun)。 + +```sh +bun install +bun run dev +``` + +默认端口 **18456**(可用 `PORT` 覆盖): + +- 在线文档:http://127.0.0.1:18456/api/docs +- OpenAPI:http://127.0.0.1:18456/api/openapi.json diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..78416c6 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,23 @@ +{ + "name": "@new-api-key-usage/server", + "private": true, + "version": "0.0.0", + "type": "module", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "dev": "bun run --hot src/index.ts" + }, + "dependencies": { + "@hono/zod-openapi": "^1.6.3", + "@new-api-key-usage/core": "workspace:*", + "@new-api-key-usage/db": "workspace:*", + "@scalar/hono-api-reference": "^0.12.9", + "hono": "^4.13.13", + "zod": "^4.6.5" + }, + "devDependencies": { + "@types/bun": "latest" + } +} \ No newline at end of file diff --git a/apps/server/src/api-schemas.ts b/apps/server/src/api-schemas.ts new file mode 100644 index 0000000..1f80587 --- /dev/null +++ b/apps/server/src/api-schemas.ts @@ -0,0 +1,73 @@ +import { z } from "zod"; +import { extendZodWithOpenApi } from "@hono/zod-openapi"; +import { + changePasswordBody, + createMemberBody, + engineStatus, + errorEnvelope, + keyItem, + logItem, + logPage, + memberItem, + memberMe, + memberOverview, + memberWithToken, + patchKeyBody, + patchMemberBody, + paymentItem, + auditLogItem, + registerKeyBody, + setupBody, + setupState, + loginBody, + tokenOnly, + usernameBody, +} from "@new-api-key-usage/core"; + +// OpenAPI 组件命名只在 server 层装饰,core 保持 Web 标准中立 +extendZodWithOpenApi(z); + +export const errorSchema = errorEnvelope.openapi("ErrorEnvelope"); +export const setupStateSchema = setupState.openapi("SetupState"); +export const engineStatusSchema = engineStatus.openapi("EngineStatus"); +export const keyItemSchema = keyItem.openapi("KeyItem"); +export const memberItemSchema = memberItem.openapi("MemberItem"); +export const memberWithTokenSchema = memberWithToken.openapi("MemberWithToken"); +export const memberMeSchema = memberMe.openapi("MemberMe"); +export const memberOverviewSchema = memberOverview.openapi("MemberOverview"); +export const logItemSchema = logItem.openapi("LogItem"); +export const logPageSchema = logPage.openapi("LogPage"); +export const paymentItemSchema = paymentItem.openapi("PaymentItem"); +export const auditLogItemSchema = auditLogItem.openapi("AuditLogItem"); + +export { + changePasswordBody as changePasswordBodySchema, + createMemberBody as createMemberBodySchema, + loginBody as loginBodySchema, + patchKeyBody as patchKeyBodySchema, + patchMemberBody as patchMemberBodySchema, + registerKeyBody as registerKeyBodySchema, + setupBody as setupBodySchema, + tokenOnly as tokenOnlySchema, + usernameBody as usernameSchema, +}; + +export const err401 = { + description: "凭证无效或过期", + content: { "application/json": { schema: errorSchema } }, +} as const; + +export const err404 = { + description: "资源不存在", + content: { "application/json": { schema: errorSchema } }, +} as const; + +export const err409 = { + description: "冲突", + content: { "application/json": { schema: errorSchema } }, +} as const; + +export const err422 = { + description: "校验失败", + content: { "application/json": { schema: errorSchema } }, +} as const; diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts new file mode 100644 index 0000000..499af19 --- /dev/null +++ b/apps/server/src/app.ts @@ -0,0 +1,118 @@ +import { OpenAPIHono } from "@hono/zod-openapi"; +import { apiReference } from "@scalar/hono-api-reference"; +import { logger as honoLogger } from "hono/logger"; +import { ERR, appLogger } from "@new-api-key-usage/core"; +import { createRepos, type Db } from "@new-api-key-usage/db"; +import { jsonError, type AppVars } from "./middleware.ts"; +import { adminSessionRoutes } from "./routes/admin-session.ts"; +import { adminEngineRoutes } from "./routes/admin-engine.ts"; +import { adminKeyRoutes } from "./routes/admin-keys.ts"; +import { adminMemberRoutes } from "./routes/admin-members.ts"; +import { adminUsageRoutes } from "./routes/admin-usage.ts"; +import { adminPaymentRoutes } from "./routes/admin-payments.ts"; +import { adminAuditRoutes } from "./routes/admin-audit.ts"; +import { memberViewRoutes } from "./routes/member-view.ts"; + +export interface CreateAppOptions { + db: Db; + skKeyEncryptionSecret: string; + newapiBase: string; + cookieSecure?: boolean; + adminInitUsername?: string; + adminInitPassword?: string; + /** 可注入的上游验证(测试);默认走 real HTTP */ + fetchTokenUsage?: AppVars["fetchTokenUsage"]; +} + +export function createApp(opts: CreateAppOptions) { + const app = new OpenAPIHono<{ Variables: AppVars }>({ + defaultHook: (result, c) => { + if (!result.success) { + const fields: Record = {}; + for (const issue of result.error.issues) { + const key = issue.path.join(".") || "body"; + fields[key] = issue.message; + } + return jsonError(c, 422, ERR.validation_failed(fields)); + } + }, + }); + const repos = createRepos(opts.db); + + app.use("*", honoLogger()); + app.use("*", async (c, next) => { + c.set("repos", repos); + c.set("skKeyEncryptionSecret", opts.skKeyEncryptionSecret); + c.set("newapiBase", opts.newapiBase); + c.set("cookieSecure", opts.cookieSecure ?? true); + if (opts.fetchTokenUsage) c.set("fetchTokenUsage", opts.fetchTokenUsage); + if (opts.adminInitUsername) + c.set("adminInitUsername", opts.adminInitUsername); + if (opts.adminInitPassword) + c.set("adminInitPassword", opts.adminInitPassword); + await next(); + }); + + app.notFound((c) => { + if (c.req.path.startsWith("/api")) { + return jsonError(c, 404, ERR.not_found()); + } + return c.text("Not Found", 404); + }); + + app.onError((err, c) => { + appLogger("server").error`unhandled ${c.req.method} ${c.req.path}: ${err}`; + return jsonError(c, 500, ERR.internal()); + }); + + app.openAPIRegistry.registerComponent("securitySchemes", "adminSession", { + type: "apiKey", + in: "cookie", + name: "admin_session", + description: "管理面会话 cookie(HttpOnly · Path=/api/admin)", + }); + app.openAPIRegistry.registerComponent("securitySchemes", "memberToken", { + type: "http", + scheme: "bearer", + description: "成员视图 Bearer 令牌", + }); + + app.doc("/api/openapi.json", { + openapi: "3.1.0", + info: { + title: "new-api-key-usage", + version: "0.0.0", + description: + "NewAPI key 用量统计——管理面 `/api/admin/*`(会话 cookie)与成员视图 `/api/member/*`(Bearer)。成功响应无信封,错误见 ErrorEnvelope。", + }, + tags: [ + { name: "引导与会话", description: "三态判定 / 登录 / 改密" }, + { name: "引擎", description: "同步引擎状态" }, + { name: "Key 管理", description: "登记 / 启停 / 归属" }, + { name: "成员管理", description: "开户 / 令牌 / 启停" }, + { name: "成员视图", description: "令牌自助查询" }, + ], + }); + + app.get( + "/api/docs", + apiReference({ + theme: "kepler", + spec: { url: "/api/openapi.json" }, + }), + ); + + const admin = new OpenAPIHono<{ Variables: AppVars }>(); + admin.route("/", adminSessionRoutes()); + admin.route("/", adminEngineRoutes()); + admin.route("/", adminKeyRoutes()); + admin.route("/", adminMemberRoutes()); + admin.route("/", adminUsageRoutes()); + admin.route("/", adminPaymentRoutes()); + admin.route("/", adminAuditRoutes()); + app.route("/api/admin", admin); + + app.route("/api/member", memberViewRoutes()); + + return app; +} diff --git a/apps/server/src/env.ts b/apps/server/src/env.ts new file mode 100644 index 0000000..3608871 --- /dev/null +++ b/apps/server/src/env.ts @@ -0,0 +1,69 @@ +export interface Env { + /** 本服务监听端口(避开 3000/3001 等占用) */ + PORT: number; + /** PostgreSQL 连接串 */ + DATABASE_URL: string; + /** + * 已登记 sk-key 的加密密钥(≥32 字节 base64url)。 + * 经 HKDF 派生 hmac / aes 子密钥;**入库后不可更换**,否则存量 key_ciphertext 无法解密。 + */ + SK_KEY_ENCRYPTION_SECRET: string; + /** 上游 NewAPI 实例根地址(非本服务) */ + NEWAPI_BASE: string; + /** 会话 cookie 是否带 Secure;默认 true。本地 HTTP 调试可设 false */ + COOKIE_SECURE: boolean; + /** 业务日志级别,默认 info */ + LOG_LEVEL: "debug" | "info" | "warning" | "error" | "fatal"; + /** 空库 + 二者均存在时静默落库管理员(与引导页双通道) */ + ADMIN_INIT_USERNAME?: string; + ADMIN_INIT_PASSWORD?: string; +} + +function parseBool(value: string | undefined, fallback: boolean): boolean { + if (value === undefined || value === "") return fallback; + const v = value.trim().toLowerCase(); + if (v === "1" || v === "true" || v === "yes" || v === "on") return true; + if (v === "0" || v === "false" || v === "no" || v === "off") return false; + throw new Error(`invalid boolean: ${value}`); +} + +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): Env { + const databaseUrl = source.DATABASE_URL; + const skKeyEncryptionSecret = source.SK_KEY_ENCRYPTION_SECRET; + if (!databaseUrl) throw new Error("DATABASE_URL is required"); + if (!skKeyEncryptionSecret) { + throw new Error("SK_KEY_ENCRYPTION_SECRET is required"); + } + + const port = Number(source.PORT ?? 18456); + if (!Number.isInteger(port) || port < 1 || port > 65535) { + throw new Error("PORT must be an integer in 1..65535"); + } + + return { + PORT: port, + DATABASE_URL: databaseUrl, + SK_KEY_ENCRYPTION_SECRET: skKeyEncryptionSecret, + NEWAPI_BASE: source.NEWAPI_BASE ?? "http://127.0.0.1:8080", + COOKIE_SECURE: parseBool(source.COOKIE_SECURE, true), + LOG_LEVEL: parseLogLevel(source.LOG_LEVEL), + ADMIN_INIT_USERNAME: source.ADMIN_INIT_USERNAME, + ADMIN_INIT_PASSWORD: source.ADMIN_INIT_PASSWORD, + }; +} diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts new file mode 100644 index 0000000..763fabd --- /dev/null +++ b/apps/server/src/index.ts @@ -0,0 +1,25 @@ +import { createApp } from "./app.ts"; +import { loadEnv } from "./env.ts"; +import { createDb } from "@new-api-key-usage/db"; +import { appLogger, setupLogging } from "@new-api-key-usage/core"; + +const env = loadEnv(process.env); +setupLogging(env.LOG_LEVEL); + +const db = createDb(env.DATABASE_URL); + +const app = createApp({ + db, + skKeyEncryptionSecret: env.SK_KEY_ENCRYPTION_SECRET, + newapiBase: env.NEWAPI_BASE, + cookieSecure: env.COOKIE_SECURE, + adminInitUsername: env.ADMIN_INIT_USERNAME, + adminInitPassword: env.ADMIN_INIT_PASSWORD, +}); + +appLogger("server").info`listening on port ${env.PORT} (cookie_secure=${env.COOKIE_SECURE}, log_level=${env.LOG_LEVEL})`; + +export default { + port: env.PORT, + fetch: app.fetch, +}; diff --git a/apps/server/src/login-throttle.ts b/apps/server/src/login-throttle.ts new file mode 100644 index 0000000..59f9355 --- /dev/null +++ b/apps/server/src/login-throttle.ts @@ -0,0 +1,44 @@ +/** + * 登录节流(契约:429,最小实现) + * 同一用户名失败计数 + 固定退避;进程内存即可(单机母体)。 + */ + +const WINDOW_MS = 5 * 60_000; +const MAX_FAILURES = 5; +const LOCK_MS = 60_000; + +type Entry = { count: number; firstAt: number; lockedUntil: number }; + +const buckets = new Map(); + +export function isLoginThrottled(username: string): boolean { + const e = buckets.get(username); + if (!e) return false; + if (e.lockedUntil > Date.now()) return true; + if (Date.now() - e.firstAt > WINDOW_MS) buckets.delete(username); + return false; +} + +export function recordLoginFailure(username: string): void { + const now = Date.now(); + const e = buckets.get(username); + if (!e || now - e.firstAt > WINDOW_MS) { + buckets.set(username, { count: 1, firstAt: now, lockedUntil: 0 }); + return; + } + e.count += 1; + if (e.count >= MAX_FAILURES) { + e.lockedUntil = now + LOCK_MS; + e.count = 0; + e.firstAt = now; + } +} + +export function recordLoginSuccess(username: string): void { + buckets.delete(username); +} + +/** 测试用 */ +export function resetLoginThrottle(): void { + buckets.clear(); +} diff --git a/apps/server/src/middleware.ts b/apps/server/src/middleware.ts new file mode 100644 index 0000000..1bd6c50 --- /dev/null +++ b/apps/server/src/middleware.ts @@ -0,0 +1,63 @@ +import type { Context, Next } from "hono"; +import { getCookie } from "hono/cookie"; +import { ERR, hashToken, type ErrorBody } from "@new-api-key-usage/core"; +import type { Repos } from "@new-api-key-usage/db"; + +export const SESSION_COOKIE = "admin_session"; +export const SESSION_TTL_SECONDS = 7 * 24 * 60 * 60; + +export type AppVars = { + repos: Repos; + skKeyEncryptionSecret: string; + newapiBase: string; + cookieSecure: boolean; + /** 登记时调上游验证;测试可注入 */ + fetchTokenUsage?: (skKey: string) => Promise< + | { ok: true; data: import("@new-api-key-usage/core").TokenUsage } + | { ok: false; kind: string } + >; + adminInitUsername?: string; + adminInitPassword?: string; + adminUsername?: string; + adminUserId?: number; + memberId?: number; +}; + +/** 错误信封统一出口;返回 any 以兼容 OpenAPIHono 严格响应联合 */ +export function jsonError( + c: Context, + status: 400 | 401 | 404 | 409 | 422 | 429 | 500 | 502, + body: ErrorBody, +): any { + return c.json(body, status); +} + +export async function requireAdminSession( + c: Context<{ Variables: AppVars }>, + next: Next, +) { + const token = getCookie(c, SESSION_COOKIE); + if (!token) return jsonError(c, 401, ERR.session_expired()); + const tokenHash = await hashToken(token); + const row = await c.get("repos").sessions.findValid(tokenHash, new Date()); + if (!row) return jsonError(c, 401, ERR.session_expired()); + c.set("adminUserId", row.user_id); + c.set("adminUsername", row.username); + await next(); +} + +export async function requireMemberToken( + c: Context<{ Variables: AppVars }>, + next: Next, +) { + const header = c.req.header("Authorization") ?? ""; + const m = /^Bearer\s+(.+)$/i.exec(header); + if (!m?.[1]) return jsonError(c, 401, ERR.session_expired()); + const tokenHash = await hashToken(m[1]); + const row = await c.get("repos").members.findByTokenHash(tokenHash); + if (!row || row.status !== "active") { + return jsonError(c, 401, ERR.session_expired()); + } + c.set("memberId", row.id); + await next(); +} diff --git a/apps/server/src/routes/admin-audit.ts b/apps/server/src/routes/admin-audit.ts new file mode 100644 index 0000000..efccdaa --- /dev/null +++ b/apps/server/src/routes/admin-audit.ts @@ -0,0 +1,72 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { unixToIso, type AuditLogItem } from "@new-api-key-usage/core"; +import { + requireAdminSession, + type AppVars, +} from "../middleware.ts"; +import { auditLogItemSchema, err401, err422 } from "../api-schemas.ts"; +import { z } from "zod"; + +const pageSchema = z.object({ + items: z.array(auditLogItemSchema), + total: z.number().int(), +}); + +export function adminAuditRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "get", + path: "/audit-logs", + summary: "审计日志", + description: "分页、时间倒序;可按 domain 过滤(key/payment/member/account)", + tags: ["审计"], + security: [{ adminSession: [] }], + request: { + query: z.object({ + page: z.string().optional(), + page_size: z.string().optional(), + domain: z + .enum(["key", "payment", "member", "account"]) + .optional(), + }), + }, + responses: { + 200: { + description: "items/total", + content: { "application/json": { schema: pageSchema } }, + }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const q = c.req.query(); + const page = Math.max(1, Number(q.page ?? 1) || 1); + const page_size = Math.min( + 200, + Math.max(1, Number(q.page_size ?? 50) || 50), + ); + const { rows, total } = await c.get("repos").audit.listPage({ + domain: q.domain, + page, + page_size, + }); + const items: AuditLogItem[] = rows.map((row) => ({ + id: row.id, + created_at: unixToIso(row.created_at)!, + operator: row.operator, + domain: row.domain as AuditLogItem["domain"], + action: row.action, + summary: row.summary, + })); + return c.json({ items, total }, 200); + }, + ); + + return r; +} diff --git a/apps/server/src/routes/admin-engine.ts b/apps/server/src/routes/admin-engine.ts new file mode 100644 index 0000000..edd886d --- /dev/null +++ b/apps/server/src/routes/admin-engine.ts @@ -0,0 +1,49 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import type { EngineStatus } from "@new-api-key-usage/core"; +import { requireAdminSession, type AppVars } from "../middleware.ts"; +import { engineStatusSchema, err401 } from "../api-schemas.ts"; + +/** 状态存储归同步引擎设计;此处只暴露契约字段 */ +let engineStatus: EngineStatus = { + running: false, + last_round: null, +}; + +export function adminEngineRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "get", + path: "/engine", + summary: "引擎状态", + description: + "最近轮次摘要;last_round 为 null 表示引擎尚未跑过。状态存储细节归同步引擎设计。", + tags: ["引擎"], + security: [{ adminSession: [] }], + responses: { + 200: { + description: "status", + content: { "application/json": { schema: engineStatusSchema } }, + }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + return c.json(engineStatus, 200); + }, + ); + + return r; +} + +export function setEngineStatus(next: EngineStatus) { + engineStatus = next; +} + +export function getEngineStatus(): EngineStatus { + return engineStatus; +} diff --git a/apps/server/src/routes/admin-keys.ts b/apps/server/src/routes/admin-keys.ts new file mode 100644 index 0000000..daf64bb --- /dev/null +++ b/apps/server/src/routes/admin-keys.ts @@ -0,0 +1,310 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { + ERR, + KeyCrypto, + normalizeExpiresAt, + unixToIso, + 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, + registered_at: unixToIso(row.registered_at)!, + log_synced_at: unixToIso(row.log_synced_at), + usage_synced_at: unixToIso(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: unixToIso(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(), + recent_failed: z.enum(["true", "false"]).optional(), + }), + }, + responses: { + 200: { + description: "key 数组", + content: { + "application/json": { schema: z.array(keyItemSchema) }, + }, + }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const q = c.req.query(); + const filter: { + member_id?: number | "unassigned"; + status?: string; + } = {}; + 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; + } + let rows = await c.get("repos").keys.listAll(filter); + if (q.recent_failed === "true") { + const { getEngineStatus } = await import("./admin-engine.ts"); + const failed = new Set(getEngineStatus().last_round?.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 guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const body = await c.req.json().catch(() => null); + const sk_key = typeof body?.sk_key === "string" ? body.sk_key.trim() : ""; + const member_id = + body?.member_id === undefined || body?.member_id === null + ? null + : Number(body.member_id); + 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, + 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 guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + 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 = {}; + 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) { + const nextMember = + body.member_id == null ? null : Number(body.member_id); + if (nextMember !== before.member_id) { + patch.member_id = nextMember; + 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; +} diff --git a/apps/server/src/routes/admin-members.ts b/apps/server/src/routes/admin-members.ts new file mode 100644 index 0000000..45bca47 --- /dev/null +++ b/apps/server/src/routes/admin-members.ts @@ -0,0 +1,225 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { ERR, generateToken, unixToIso } from "@new-api-key-usage/core"; +import type { MemberRow } from "@new-api-key-usage/db"; +import { + jsonError, + requireAdminSession, + type AppVars, +} from "../middleware.ts"; +import { + createMemberBodySchema, + err401, + err404, + err409, + err422, + memberItemSchema, + memberWithTokenSchema, + patchMemberBodySchema, + tokenOnlySchema, +} from "../api-schemas.ts"; +import { z } from "zod"; + +function toMemberItem(row: MemberRow, key_count: number) { + return { + id: row.id, + name: row.name, + remark: row.remark, + status: row.status as "active" | "disabled", + token_issued_at: unixToIso(row.token_issued_at), + created_at: unixToIso(row.created_at)!, + key_count, + }; +} + +export function adminMemberRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "get", + path: "/members", + summary: "成员清单", + tags: ["成员管理"], + security: [{ adminSession: [] }], + responses: { + 200: { + description: "成员数组", + content: { + "application/json": { schema: z.array(memberItemSchema) }, + }, + }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const repos = c.get("repos"); + const rows = await repos.members.listAll(); + const items = []; + for (const row of rows) { + items.push(toMemberItem(row, await repos.members.countKeys(row.id))); + } + return c.json(items, 200); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/members", + summary: "开户(自动签发令牌)", + description: "响应附明文 token——仅此一次;名称冲突 409", + tags: ["成员管理"], + security: [{ adminSession: [] }], + request: { + body: { + content: { "application/json": { schema: createMemberBodySchema } }, + required: true, + }, + }, + responses: { + 201: { + description: "member + 明文 token", + content: { "application/json": { schema: memberWithTokenSchema } }, + }, + 401: err401, + 409: err409, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const body = await c.req.json().catch(() => null); + const name = typeof body?.name === "string" ? body.name.trim() : ""; + const remark = typeof body?.remark === "string" ? body.remark : ""; + if (!name) { + return jsonError( + c, + 422, + ERR.validation_failed({ name: "请填写成员名称" }), + ); + } + const repos = c.get("repos"); + if (await repos.members.findByName(name)) { + return jsonError(c, 409, ERR.member_name_taken()); + } + const token = await generateToken(); + const row = await repos.members.insert({ + name, + remark, + token_hash: token.hash, + token_issued_at: new Date(), + }); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "member", + action: "member.create", + summary: `开户 ${name}`, + }); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "member", + action: "member.token_issue", + summary: `签发令牌 ${name}`, + }); + return c.json( + { + member: toMemberItem(row, 0), + token: token.plaintext, + }, + 201, + ); + }, + ); + + r.openapi( + createRoute({ + method: "patch", + path: "/members/{id}", + summary: "停用 / 启用", + tags: ["成员管理"], + security: [{ adminSession: [] }], + request: { + params: z.object({ id: z.string() }), + body: { + content: { "application/json": { schema: patchMemberBodySchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "成员实体", + content: { "application/json": { schema: memberItemSchema } }, + }, + 401: err401, + 404: err404, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const id = Number(c.req.param("id")); + const body = await c.req.json().catch(() => null); + const status = body?.status; + if (status !== "active" && status !== "disabled") { + return jsonError(c, 422, ERR.validation_failed({ status: "非法状态" })); + } + const repos = c.get("repos"); + const before = await repos.members.findById(id); + if (!before) return jsonError(c, 404, ERR.not_found()); + const row = await repos.members.updateStatus(id, status); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "member", + action: status === "active" ? "member.enable" : "member.disable", + summary: `成员 ${row.name} · ${status === "active" ? "启用" : "停用"}`, + }); + return c.json(toMemberItem(row, 0), 200); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/members/{id}/token", + summary: "令牌重发", + description: "旧令牌即刻失效;明文仅此响应一次", + tags: ["成员管理"], + security: [{ adminSession: [] }], + request: { + params: z.object({ id: z.string() }), + }, + responses: { + 201: { + description: "明文 token", + content: { "application/json": { schema: tokenOnlySchema } }, + }, + 401: err401, + 404: err404, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const id = Number(c.req.param("id")); + const repos = c.get("repos"); + const before = await repos.members.findById(id); + if (!before) return jsonError(c, 404, ERR.not_found()); + const token = await generateToken(); + await repos.members.reissueToken(id, token.hash, new Date()); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "member", + action: "member.token_reissue", + summary: `重发令牌 ${before.name}`, + }); + return c.json({ token: token.plaintext }, 201); + }, + ); + + return r; +} diff --git a/apps/server/src/routes/admin-payments.ts b/apps/server/src/routes/admin-payments.ts new file mode 100644 index 0000000..bd89e2f --- /dev/null +++ b/apps/server/src/routes/admin-payments.ts @@ -0,0 +1,295 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { + ERR, + qpuAt, + quotaToUsdStr, + resolveQpuTimeline, + unixToIso, + type PaymentItem, +} from "@new-api-key-usage/core"; +import type { PaymentRow } from "@new-api-key-usage/db"; +import { + jsonError, + requireAdminSession, + type AppVars, +} from "../middleware.ts"; +import { err401, err404, err422, paymentItemSchema } from "../api-schemas.ts"; +import { z } from "zod"; + +const createBody = z.object({ + kind: z.enum(["subscription", "topup"]), + paid_at: z.string(), + paid_amount: z.number(), + remark: z.string().optional().default(""), + period_start: z.string().nullable().optional(), + period_end: z.string().nullable().optional(), + subscription_id: z.number().int().positive().nullable().optional(), + quota_granted: z.number().int().nullable().optional(), +}); + +function toPaymentItem(row: PaymentRow, quota_usd: string): PaymentItem { + return { + id: row.id, + kind: row.kind as PaymentItem["kind"], + paid_at: unixToIso(row.paid_at)!, + paid_amount: row.paid_amount, + remark: row.remark, + period_start: unixToIso(row.period_start), + period_end: unixToIso(row.period_end), + subscription_id: row.subscription_id, + quota_granted: row.quota_granted, + quota_usd, + }; +} + +function validatePaymentBody( + body: Record, +): + | { fields: Record } + | { + values: { + kind: string; + paid_at: Date; + paid_amount: number; + remark: string; + period_start: Date | null; + period_end: Date | null; + subscription_id: number | null; + quota_granted: number | null; + }; + } { + const fields: Record = {}; + const kind = body?.kind; + if (kind !== "subscription" && kind !== "topup") { + fields.kind = "请选择订阅或充值"; + return { fields }; + } + if (!body?.paid_at) fields.paid_at = "请填写支付时间"; + if (typeof body?.paid_amount !== "number") { + fields.paid_amount = "请填写支付金额"; + } + if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度"; + if (kind === "subscription") { + if (!body?.period_start) fields.period_start = "订阅必须填写期间起点"; + if (body?.subscription_id == null) { + fields.subscription_id = "订阅必须填写订阅编号"; + } + } + if (Object.keys(fields).length > 0) return { fields }; + return { + values: { + kind: kind as string, + paid_at: new Date(String(body.paid_at)), + paid_amount: Number(body.paid_amount), + remark: typeof body.remark === "string" ? body.remark : "", + period_start: + kind === "subscription" && body.period_start + ? new Date(String(body.period_start)) + : null, + period_end: + body.period_end != null && body.period_end !== "" + ? new Date(String(body.period_end)) + : null, + subscription_id: + kind === "subscription" && body.subscription_id != null + ? Number(body.subscription_id) + : null, + quota_granted: Number(body.quota_granted), + }, + }; +} + +export function adminPaymentRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "get", + path: "/payments", + summary: "支付记录清单", + description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算", + tags: ["支付记录"], + security: [{ adminSession: [] }], + responses: { + 200: { + description: "payment 数组", + content: { + "application/json": { schema: z.array(paymentItemSchema) }, + }, + }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const repos = c.get("repos"); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const rows = await repos.payments.listAll(); + const items = rows.map((row) => { + const qpu = qpuAt(row.paid_at, timeline); + return toPaymentItem( + row, + row.quota_granted != null + ? quotaToUsdStr(row.quota_granted, qpu) + : "0.00", + ); + }); + return c.json(items, 200); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/payments", + summary: "登记支付", + description: "订阅/充值同构;kind 决定期间与订阅编号是否必填", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { + body: { + content: { "application/json": { schema: createBody } }, + required: true, + }, + }, + responses: { + 201: { + description: "payment", + content: { "application/json": { schema: paymentItemSchema } }, + }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const body = (await c.req.json().catch(() => ({}))) as Record< + string, + unknown + >; + const parsed = validatePaymentBody(body); + if ("fields" in parsed) { + return jsonError(c, 422, ERR.validation_failed(parsed.fields)); + } + const repos = c.get("repos"); + const row = await repos.payments.insert(parsed.values); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "payment.create", + summary: `登记支付 #${row.id} ${row.kind}`, + }); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const qpu = qpuAt(row.paid_at, timeline); + return c.json( + toPaymentItem( + row, + row.quota_granted != null + ? quotaToUsdStr(row.quota_granted, qpu) + : "0.00", + ), + 201, + ); + }, + ); + + r.openapi( + createRoute({ + method: "put", + path: "/payments/{id}", + summary: "编辑支付", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { + params: z.object({ id: z.string() }), + body: { + content: { "application/json": { schema: createBody } }, + required: true, + }, + }, + responses: { + 200: { + description: "payment", + content: { "application/json": { schema: paymentItemSchema } }, + }, + 401: err401, + 404: err404, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const id = Number(c.req.param("id")); + const body = (await c.req.json().catch(() => ({}))) as Record< + string, + unknown + >; + const parsed = validatePaymentBody(body); + if ("fields" in parsed) { + return jsonError(c, 422, ERR.validation_failed(parsed.fields)); + } + const repos = c.get("repos"); + if (!(await repos.payments.findById(id))) { + return jsonError(c, 404, ERR.not_found()); + } + const row = await repos.payments.update(id, parsed.values); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "payment.update", + summary: `编辑支付 #${row.id}`, + }); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const qpu = qpuAt(row.paid_at, timeline); + return c.json( + toPaymentItem( + row, + row.quota_granted != null + ? quotaToUsdStr(row.quota_granted, qpu) + : "0.00", + ), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: "delete", + path: "/payments/{id}", + summary: "删除支付", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { + params: z.object({ id: z.string() }), + }, + responses: { + 204: { description: "No Content" }, + 401: err401, + 404: err404, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const id = Number(c.req.param("id")); + const repos = c.get("repos"); + const before = await repos.payments.findById(id); + if (!before) return jsonError(c, 404, ERR.not_found()); + await repos.payments.remove(id); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "payment.delete", + summary: `删除支付 #${id}`, + }); + return c.body(null, 204); + }, + ); + + return r; +} diff --git a/apps/server/src/routes/admin-session.ts b/apps/server/src/routes/admin-session.ts new file mode 100644 index 0000000..ec80a91 --- /dev/null +++ b/apps/server/src/routes/admin-session.ts @@ -0,0 +1,313 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { setCookie, deleteCookie, getCookie } from "hono/cookie"; +import { + ERR, + generateToken, + hashPassword, + hashToken, + verifyPassword, +} from "@new-api-key-usage/core"; +import { + jsonError, + requireAdminSession, + SESSION_COOKIE, + SESSION_TTL_SECONDS, + type AppVars, +} from "../middleware.ts"; +import { + changePasswordBodySchema, + err401, + err409, + err422, + loginBodySchema, + setupBodySchema, + setupStateSchema, + usernameSchema, +} from "../api-schemas.ts"; +import { + isLoginThrottled, + recordLoginFailure, + recordLoginSuccess, +} from "../login-throttle.ts"; + +function setSessionCookie( + c: Parameters[0], + plaintext: string, + secure: boolean, +) { + setCookie(c, SESSION_COOKIE, plaintext, { + httpOnly: true, + secure, + sameSite: "Lax", + path: "/api/admin", + maxAge: SESSION_TTL_SECONDS, + }); +} + +/** 哑哈希:用户不存在时也跑一次 PBKDF2,抹平时序侧信道 */ +const DUMMY_PASSWORD_HASH = + "pbkdf2$210000$AAAAAAAAAAAAAAAAAAAAAA$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + +export function adminSessionRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + + r.openapi( + createRoute({ + method: "get", + path: "/setup-state", + summary: "入口三态判定", + description: + "users 非空 → true(登录页);空库 + env 凭证在 → 静默落库后 true;空库 + 无凭证 → false(引导页)", + tags: ["引导与会话"], + responses: { + 200: { + description: "initialized", + content: { "application/json": { schema: setupStateSchema } }, + }, + }, + }), + async (c) => { + const repos = c.get("repos"); + if ((await repos.users.countAll()) > 0) { + return c.json({ initialized: true }, 200); + } + const initUser = c.get("adminInitUsername"); + const initPass = c.get("adminInitPassword"); + if (initUser && initPass) { + const password_hash = await hashPassword(initPass); + await repos.users.insert(initUser, password_hash); + await repos.audit.insert({ + operator: null, + domain: "account", + action: "account.init", + summary: "空库 env 静默重建管理员", + }); + return c.json({ initialized: true }, 200); + } + return c.json({ initialized: false }, 200); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/setup", + summary: "引导页落库(一次性)", + description: "仅空库 + 无 env 凭证时有效;成功即建立会话", + tags: ["引导与会话"], + request: { + body: { + content: { "application/json": { schema: setupBodySchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "username + Set-Cookie", + content: { "application/json": { schema: usernameSchema } }, + }, + 409: err409, + 422: err422, + }, + }), + async (c) => { + const body = await c.req.json().catch(() => null); + const username = + typeof body?.username === "string" ? body.username.trim() : ""; + const password = typeof body?.password === "string" ? body.password : ""; + const fields: Record = {}; + if (!username) fields.username = "请填写用户名"; + if (password.length < 8) fields.password = "密码至少 8 位"; + if (Object.keys(fields).length > 0) { + return jsonError(c, 422, ERR.validation_failed(fields)); + } + const repos = c.get("repos"); + if ((await repos.users.countAll()) > 0) { + return jsonError(c, 409, ERR.already_initialized()); + } + const password_hash = await hashPassword(password); + const user = await repos.users.insert(username, password_hash); + const token = await generateToken(); + const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000); + await repos.sessions.insert(user.id, token.hash, expires_at); + await repos.audit.insert({ + operator: username, + domain: "account", + action: "account.init", + summary: `引导页创建管理员 ${username}`, + }); + setSessionCookie(c, token.plaintext, c.get("cookieSecure")); + return c.json({ username: user.username }, 200); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/session", + summary: "登录", + tags: ["引导与会话"], + request: { + body: { + content: { "application/json": { schema: loginBodySchema } }, + required: true, + }, + }, + responses: { + 200: { + description: "username + Set-Cookie", + content: { "application/json": { schema: usernameSchema } }, + }, + 401: err401, + 429: { + description: "登录过于频繁", + content: { + "application/json": { + schema: err422.content["application/json"].schema, + }, + }, + }, + }, + }), + async (c) => { + const body = await c.req.json().catch(() => null); + const username = typeof body?.username === "string" ? body.username : ""; + const password = typeof body?.password === "string" ? body.password : ""; + if (isLoginThrottled(username)) { + return jsonError(c, 429, ERR.too_many_requests()); + } + const repos = c.get("repos"); + const user = await repos.users.findByUsername(username); + const ok = await verifyPassword( + password, + user?.password_hash ?? DUMMY_PASSWORD_HASH, + ); + if (!user || !ok) { + recordLoginFailure(username || "_"); + return jsonError(c, 401, ERR.invalid_credentials()); + } + recordLoginSuccess(username); + + const token = await generateToken(); + const expires_at = new Date(Date.now() + SESSION_TTL_SECONDS * 1000); + await repos.sessions.insert(user.id, token.hash, expires_at); + setSessionCookie(c, token.plaintext, c.get("cookieSecure")); + return c.json({ username: user.username }, 200); + }, + ); + + r.openapi( + createRoute({ + method: "get", + path: "/session", + summary: "当前会话", + tags: ["引导与会话"], + security: [{ adminSession: [] }], + responses: { + 200: { + description: "username", + content: { "application/json": { schema: usernameSchema } }, + }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + return c.json({ username: c.get("adminUsername") ?? "" }, 200); + }, + ); + + r.openapi( + createRoute({ + method: "delete", + path: "/session", + summary: "登出", + tags: ["引导与会话"], + security: [{ adminSession: [] }], + responses: { + 204: { description: "No Content" }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const token = getCookie(c, SESSION_COOKIE); + if (token) { + await c.get("repos").sessions.deleteByTokenHash(await hashToken(token)); + } + deleteCookie(c, SESSION_COOKIE, { path: "/api/admin" }); + return c.body(null, 204); + }, + ); + + r.openapi( + createRoute({ + method: "put", + path: "/password", + summary: "改密", + description: "成功后删除其他会话、保留当前", + tags: ["引导与会话"], + security: [{ adminSession: [] }], + request: { + body: { + content: { "application/json": { schema: changePasswordBodySchema } }, + required: true, + }, + }, + responses: { + 204: { description: "No Content" }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const body = await c.req.json().catch(() => null); + const old_password = String(body?.old_password ?? ""); + const new_password = String(body?.new_password ?? ""); + if (new_password.length < 8) { + return jsonError( + c, + 422, + ERR.validation_failed({ new_password: "密码至少 8 位" }), + ); + } + const repos = c.get("repos"); + const userId = c.get("adminUserId")!; + const user = await repos.users.findById(userId); + if (!user) return jsonError(c, 401, ERR.session_expired()); + const ok = await verifyPassword(old_password, user.password_hash); + if (!ok) { + return jsonError( + c, + 422, + ERR.validation_failed({ old_password: "旧密码不正确" }), + ); + } + await repos.users.updatePassword( + userId, + await hashPassword(new_password), + ); + const token = getCookie(c, SESSION_COOKIE); + const currentHash = token ? await hashToken(token) : null; + const all = await repos.sessions.listByUser(userId); + for (const s of all) { + if (s.token_hash !== currentHash) { + await repos.sessions.deleteById(s.id); + } + } + await repos.audit.insert({ + operator: user.username, + domain: "account", + action: "account.password_change", + summary: "修改密码,其他会话已失效", + }); + return c.body(null, 204); + }, + ); + + return r; +} diff --git a/apps/server/src/routes/admin-usage.ts b/apps/server/src/routes/admin-usage.ts new file mode 100644 index 0000000..2c6ab23 --- /dev/null +++ b/apps/server/src/routes/admin-usage.ts @@ -0,0 +1,357 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { + ERR, + resolveQpuTimeline, + sumUsage, + unixToIso, + type LogItem, +} from "@new-api-key-usage/core"; +import { + jsonError, + requireAdminSession, + type AppVars, +} from "../middleware.ts"; +import { + err401, + err422, + logPageSchema, + keyItemSchema, +} from "../api-schemas.ts"; +import { z } from "zod"; + +const usageTotals = z.object({ + count: z.number().int(), + quota: z.number(), + quota_usd: z.string(), +}); + +const overviewSchema = z.object({ + data_cutoff: z.string().nullable(), + unassigned_subscription: usageTotals, + by_source: z.array( + z.object({ + billing_source: z.string(), + count: z.number().int(), + quota: z.number(), + quota_usd: z.string(), + }), + ), + by_member: z.array( + z.object({ + member_id: z.number().int().nullable(), + member_name: z.string().nullable(), + key_count: z.number().int(), + data_cutoff: z.string().nullable(), + usage: usageTotals, + }), + ), + by_key: z.array( + z.object({ + key_id: z.number().int(), + token_name: z.string(), + member_id: z.number().int().nullable(), + status: z.string(), + data_cutoff: z.string().nullable(), + usage: usageTotals, + }), + ), +}); + +const qpuLatestSchema = z.object({ + qpu: z.number(), + effective_from: z.string(), +}); + +const timeRange = z.object({ + from: z.string(), + to: z.string(), +}); + +function parseRange( + query: { from?: string; to?: string }, +): { from: Date; to: Date } | { fields: Record } { + const fields: Record = {}; + if (!query.from) fields.from = "缺少起始时间"; + if (!query.to) fields.to = "缺少结束时间"; + if (Object.keys(fields).length > 0) return { fields }; + const from = new Date(query.from!); + const to = new Date(query.to!); + if (Number.isNaN(from.getTime())) fields.from = "时间格式无效"; + if (Number.isNaN(to.getTime())) fields.to = "时间格式无效"; + else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间"; + if (Object.keys(fields).length > 0) return { fields }; + return { from, to }; +} + +export function adminUsageRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "get", + path: "/qpu/latest", + summary: "当前换算率", + description: "支付表单美元 ↔ quota 双输入换算用;库空时回退 500000", + tags: ["用量"], + security: [{ adminSession: [] }], + responses: { + 200: { + description: "qpu", + content: { "application/json": { schema: qpuLatestSchema } }, + }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const latest = await c.get("repos").qpu.latest(); + if (!latest) { + return c.json( + { qpu: 500_000, effective_from: "1970-01-01T00:00:00Z" }, + 200, + ); + } + return c.json( + { + qpu: latest.qpu, + effective_from: unixToIso(latest.effective_from)!, + }, + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: "get", + path: "/overview", + summary: "全员总览", + description: + "from/to 必填、左闭右开;聚合仅 type=2。unassigned_subscription 为健康指示器。", + tags: ["用量"], + security: [{ adminSession: [] }], + request: { query: timeRange }, + responses: { + 200: { + description: "overview", + content: { "application/json": { schema: overviewSchema } }, + }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const q = c.req.query(); + const range = parseRange(q); + if ("fields" in range) { + return jsonError(c, 422, ERR.validation_failed(range.fields)); + } + const repos = c.get("repos"); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const rows = await repos.logs.listForAggregate(range.from, range.to); + + const data_cutoff = await repos.keysExtra.slowestLogSyncedAt(); + + const unassigned_rows = rows.filter( + (x) => x.billing_source === "subscription" && x.subscription_id == null, + ); + const unassigned_subscription = sumUsage(unassigned_rows, timeline); + + const bySourceMap = new Map< + string, + Array<{ quota: number; created_at: Date }> + >(); + for (const x of rows) { + const key = x.billing_source ?? "unknown"; + const list = bySourceMap.get(key) ?? []; + list.push({ quota: x.quota, created_at: x.created_at }); + bySourceMap.set(key, list); + } + const by_source = [...bySourceMap.entries()].map(([billing_source, list]) => ({ + billing_source, + ...sumUsage(list, timeline), + })); + + // 按 key / 成员(含未归属) + const keyMap = new Map< + number, + { + token_name: string; + member_id: number | null; + member_name: string | null; + status: string; + log_synced_at: Date | null; + rows: Array<{ quota: number; created_at: Date }>; + } + >(); + const keyMeta = await repos.keysExtra.listIdsWithCutoff(); + for (const k of keyMeta) { + keyMap.set(k.id, { + token_name: k.token_name, + member_id: k.member_id, + member_name: null, + status: k.status, + log_synced_at: k.log_synced_at, + rows: [], + }); + } + for (const x of rows) { + const entry = keyMap.get(x.api_key_id); + if (entry) { + entry.rows.push({ quota: x.quota, created_at: x.created_at }); + if (x.member_name) entry.member_name = x.member_name; + } + } + + const by_key = [...keyMap.entries()] + .map(([key_id, v]) => ({ + key_id, + token_name: v.token_name, + member_id: v.member_id, + status: v.status, + data_cutoff: unixToIso(v.log_synced_at), + usage: sumUsage(v.rows, timeline), + })) + .sort((a, b) => b.usage.quota - a.usage.quota); + + const memberBuckets = new Map< + string, + { + member_id: number | null; + member_name: string | null; + key_count: number; + cutoff: Date | null; + rows: Array<{ quota: number; created_at: Date }>; + } + >(); + for (const [key_id, v] of keyMap) { + const mid = v.member_id == null ? "null" : String(v.member_id); + const b = memberBuckets.get(mid) ?? { + member_id: v.member_id, + member_name: v.member_name, + key_count: 0, + cutoff: null, + rows: [], + }; + b.key_count += 1; + if ( + v.log_synced_at && + (b.cutoff === null || v.log_synced_at < b.cutoff) + ) { + b.cutoff = v.log_synced_at; + } + b.rows.push(...v.rows); + if (!b.member_name && v.member_name) b.member_name = v.member_name; + memberBuckets.set(mid, b); + } + + const by_member = [...memberBuckets.values()].map((b) => ({ + member_id: b.member_id, + member_name: b.member_name, + key_count: b.key_count, + data_cutoff: unixToIso(b.cutoff), + usage: sumUsage(b.rows, timeline), + })); + + return c.json( + { + data_cutoff: unixToIso(data_cutoff), + unassigned_subscription, + by_source, + by_member, + by_key, + }, + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: "get", + path: "/logs", + summary: "日志明细(下钻)", + description: + "分页;from/to 必填;默认全类型(可 type 筛);token_name 取当前名", + tags: ["用量"], + security: [{ adminSession: [] }], + request: { + query: timeRange.extend({ + page: z.string().optional(), + page_size: z.string().optional(), + api_key_id: z.string().optional(), + member_id: z.string().optional(), + billing_source: z.string().optional(), + subscription_id: z.string().optional(), + type: z.string().optional(), + }), + }, + responses: { + 200: { + description: "items/total", + content: { "application/json": { schema: logPageSchema } }, + }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const guard = await requireAdminSession(c, async () => {}); + if (guard) return guard; + const q = c.req.query(); + const range = parseRange(q); + if ("fields" in range) { + return jsonError(c, 422, ERR.validation_failed(range.fields)); + } + const page = Math.max(1, Number(q.page ?? 1) || 1); + const page_size = Math.min(200, Math.max(1, Number(q.page_size ?? 50) || 50)); + const repos = c.get("repos"); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const { rows, total } = await repos.logs.listPage({ + from: range.from, + to: range.to, + page, + page_size, + api_key_id: q.api_key_id ? Number(q.api_key_id) : undefined, + member_id: q.member_id ? Number(q.member_id) : undefined, + billing_source: q.billing_source || undefined, + subscription_id: q.subscription_id + ? Number(q.subscription_id) + : undefined, + type: q.type != null && q.type !== "" ? Number(q.type) : undefined, + }); + + const items: LogItem[] = rows.map((row) => { + const { quota_usd } = sumUsage( + [{ quota: row.quota, created_at: row.created_at }], + timeline, + ); + return { + id: row.id, + api_key_id: row.api_key_id, + created_at: unixToIso(row.created_at)!, + token_name: row.token_name, + model_name: row.model_name, + type: row.type, + content: row.content, + quota: row.quota, + quota_usd, + prompt_tokens: row.prompt_tokens, + completion_tokens: row.completion_tokens, + use_time: row.use_time, + is_stream: row.is_stream, + billing_source: row.billing_source as LogItem["billing_source"], + subscription_id: row.subscription_id, + }; + }); + + return c.json({ items, total }, 200); + }, + ); + + return r; +} diff --git a/apps/server/src/routes/member-view.ts b/apps/server/src/routes/member-view.ts new file mode 100644 index 0000000..fde416d --- /dev/null +++ b/apps/server/src/routes/member-view.ts @@ -0,0 +1,301 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { + ERR, + resolveQpuTimeline, + sumUsage, + unixToIso, + type LogItem, + type PaymentItem, +} from "@new-api-key-usage/core"; +import { + jsonError, + requireMemberToken, + type AppVars, +} from "../middleware.ts"; +import { + err401, + err422, + logPageSchema, + memberMeSchema, + memberOverviewSchema, + paymentItemSchema, +} from "../api-schemas.ts"; +import { z } from "zod"; + +function parseRange( + query: { from?: string; to?: string }, +): { from: Date; to: Date } | { fields: Record } { + const fields: Record = {}; + if (!query.from) fields.from = "缺少起始时间"; + if (!query.to) fields.to = "缺少结束时间"; + if (Object.keys(fields).length > 0) return { fields }; + const from = new Date(query.from!); + const to = new Date(query.to!); + if (Number.isNaN(from.getTime())) fields.from = "时间格式无效"; + if (Number.isNaN(to.getTime())) fields.to = "时间格式无效"; + else if (to.getTime() <= from.getTime()) fields.to = "结束时间必须晚于起始时间"; + if (Object.keys(fields).length > 0) return { fields }; + return { from, to }; +} + +/** 成员视图(`/api/member/*`,Bearer)——与管理面物理隔离 */ +export function memberViewRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireMemberToken); + + r.openapi( + createRoute({ + method: "get", + path: "/me", + summary: "令牌验证 + 页头", + description: "不建会话;令牌即长期凭证", + tags: ["成员视图"], + security: [{ memberToken: [] }], + responses: { + 200: { + description: "member", + content: { "application/json": { schema: memberMeSchema } }, + }, + 401: err401, + }, + }), + async (c) => { + const guard = await requireMemberToken(c, async () => {}); + if (guard) return guard; + const row = await c.get("repos").members.findById(c.get("memberId")!); + if (!row) return jsonError(c, 401, ERR.session_expired()); + return c.json({ id: row.id, name: row.name }, 200); + }, + ); + + r.openapi( + createRoute({ + method: "get", + path: "/overview", + summary: "单页聚合", + description: + "名下 key 的 type=2 用量 + 来源分解 + 支付参数(完全公开透明)", + tags: ["成员视图"], + security: [{ memberToken: [] }], + request: { + query: z.object({ from: z.string(), to: z.string() }), + }, + responses: { + 200: { + description: "overview", + content: { "application/json": { schema: memberOverviewSchema } }, + }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const guard = await requireMemberToken(c, async () => {}); + if (guard) return guard; + const range = parseRange(c.req.query()); + if ("fields" in range) { + return jsonError(c, 422, ERR.validation_failed(range.fields)); + } + const repos = c.get("repos"); + const memberId = c.get("memberId")!; + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const member = await repos.members.findById(memberId); + const rows = await repos.logs.listMemberForAggregate( + memberId, + range.from, + range.to, + ); + + const usage = sumUsage( + rows.map((x) => ({ quota: x.quota, created_at: x.created_at })), + timeline, + ); + + const sourceMap = new Map< + string, + Array<{ quota: number; created_at: Date }> + >(); + const keyMap = new Map< + number, + { + token_name: string; + log_synced_at: Date | null; + rows: Array<{ quota: number; created_at: Date }>; + } + >(); + for (const x of rows) { + const skey = x.billing_source ?? "unknown"; + const s = sourceMap.get(skey) ?? []; + s.push({ quota: x.quota, created_at: x.created_at }); + sourceMap.set(skey, s); + + const k = keyMap.get(x.api_key_id) ?? { + token_name: x.token_name, + log_synced_at: x.log_synced_at, + rows: [], + }; + k.rows.push({ quota: x.quota, created_at: x.created_at }); + keyMap.set(x.api_key_id, k); + } + + // 名下 key 全量(即使时段无用量) + const allKeys = (await repos.keys.listAll()).filter( + (k) => k.member_id === memberId, + ); + for (const k of allKeys) { + if (!keyMap.has(k.id)) { + keyMap.set(k.id, { + token_name: k.token_name, + log_synced_at: k.log_synced_at, + rows: [], + }); + } + } + + const by_source = [...sourceMap.entries()].map(([billing_source, list]) => ({ + billing_source, + ...sumUsage(list, timeline), + })); + + const keys = [...keyMap.entries()].map(([key_id, v]) => ({ + key_id, + token_name: v.token_name, + log_synced_at: unixToIso(v.log_synced_at), + usage: sumUsage(v.rows, timeline), + })); + + // 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to) + const paymentRows = await repos.payments.listAll(); + const usedSubIds = new Set( + rows.map((x) => x.subscription_id).filter((v): v is number => v != null), + ); + const payments: PaymentItem[] = []; + const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core"); + for (const p of paymentRows) { + const inRange = p.paid_at >= range.from && p.paid_at < range.to; + const ongoing = + p.kind === "subscription" && + (p.period_end == null || + p.period_end > new Date() || + (p.period_start != null && + p.period_start <= range.to && + (p.period_end ?? new Date()) >= range.from)); + const involved = + p.kind === "topup" + ? inRange + : usedSubIds.has(p.subscription_id ?? -1) || ongoing; + if (!involved) continue; + const qpu = qpuAt(p.paid_at, timeline); + payments.push({ + id: p.id, + kind: p.kind as PaymentItem["kind"], + paid_at: unixToIso(p.paid_at)!, + paid_amount: p.paid_amount, + remark: p.remark, + period_start: unixToIso(p.period_start), + period_end: unixToIso(p.period_end), + subscription_id: p.subscription_id, + quota_granted: p.quota_granted, + quota_usd: + p.quota_granted != null + ? quotaToUsdStr(p.quota_granted, qpu) + : "0.00", + }); + } + + return c.json( + { + member: { id: memberId, name: member?.name ?? null }, + usage, + by_source, + keys, + payments: payments.map((p) => ({ + ...p, + kind: p.kind as string, + period_start: p.period_start ?? null, + period_end: p.period_end ?? null, + subscription_id: p.subscription_id ?? null, + quota_granted: p.quota_granted ?? null, + })), + }, + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: "get", + path: "/logs", + summary: "日志明细(名下 key)", + description: "分页;恒 type=2;DTO 与管理面同构", + tags: ["成员视图"], + security: [{ memberToken: [] }], + request: { + query: z.object({ + from: z.string(), + to: z.string(), + page: z.string().optional(), + page_size: z.string().optional(), + }), + }, + responses: { + 200: { + description: "items/total", + content: { "application/json": { schema: logPageSchema } }, + }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const guard = await requireMemberToken(c, async () => {}); + if (guard) return guard; + const q = c.req.query(); + const range = parseRange(q); + if ("fields" in range) { + return jsonError(c, 422, ERR.validation_failed(range.fields)); + } + const page = Math.max(1, Number(q.page ?? 1) || 1); + const page_size = Math.min( + 200, + Math.max(1, Number(q.page_size ?? 50) || 50), + ); + const repos = c.get("repos"); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const { rows, total } = await repos.logs.listMemberPage({ + member_id: c.get("memberId")!, + from: range.from, + to: range.to, + page, + page_size, + }); + const items: LogItem[] = rows.map((row) => { + const { quota_usd } = sumUsage( + [{ quota: row.quota, created_at: row.created_at }], + timeline, + ); + return { + id: row.id, + api_key_id: row.api_key_id, + created_at: unixToIso(row.created_at)!, + token_name: row.token_name ?? "", + model_name: row.model_name, + type: row.type, + content: row.content, + quota: row.quota, + quota_usd, + prompt_tokens: row.prompt_tokens, + completion_tokens: row.completion_tokens, + use_time: row.use_time, + is_stream: row.is_stream, + billing_source: row.billing_source as LogItem["billing_source"], + subscription_id: row.subscription_id, + }; + }); + return c.json({ items, total }, 200); + }, + ); + + return r; +} diff --git a/apps/server/tsconfig.json b/apps/server/tsconfig.json new file mode 100644 index 0000000..768cbf0 --- /dev/null +++ b/apps/server/tsconfig.json @@ -0,0 +1,6 @@ +{ + "compilerOptions": { + "strict": true, + "types": ["bun"] + } +}