feat(server): 管理面与成员视图 API + Scalar 在线文档
契约 19 路径全量:引导/会话/改密、engine 桩、keys、overview/logs、payments、 qpu、members、audit、成员视图。鉴权优先、422 信封、登录节流、上游验证接线。
This commit is contained in:
@@ -0,0 +1,2 @@
|
|||||||
|
# deps
|
||||||
|
node_modules/
|
||||||
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, string | undefined>): 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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<string, Entry>();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, unknown>,
|
||||||
|
):
|
||||||
|
| { fields: Record<string, string> }
|
||||||
|
| {
|
||||||
|
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<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<typeof setCookie>[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<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, string> } {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<string, string> } {
|
||||||
|
const fields: Record<string, string> = {};
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"strict": true,
|
||||||
|
"types": ["bun"]
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user