feat(server): 管理面与成员视图 API + Scalar 在线文档
契约 19 路径全量:引导/会话/改密、engine 桩、keys、overview/logs、payments、 qpu、members、audit、成员视图。鉴权优先、422 信封、登录节流、上游验证接线。
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user