契约 19 路径全量:引导/会话/改密、engine 桩、keys、overview/logs、payments、 qpu、members、audit、成员视图。鉴权优先、422 信封、登录节流、上游验证接线。
296 lines
8.6 KiB
TypeScript
296 lines
8.6 KiB
TypeScript
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;
|
|
}
|