From 10d8f4c915060610628ec4d6c549fcb48b0a76d6 Mon Sep 17 00:00:00 2001 From: skycurtain Date: Fri, 9 Oct 2026 22:02:20 +0800 Subject: [PATCH] =?UTF-8?q?feat(server):=20=E8=AE=A2=E9=98=85/=E5=85=85?= =?UTF-8?q?=E5=80=BC=20CRUD=20=E4=B8=8E=E5=B9=B3=E5=8F=B0=E9=80=89?= =?UTF-8?q?=E7=94=A8=E5=88=86=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit admin-subscriptions/topups 取代 payments;member overview 分两集; platform/topups 单页代拉;上游 token 失效回 422,避免误踢管理端会话。 --- apps/server/src/api-schemas.ts | 13 +- apps/server/src/app.ts | 6 +- apps/server/src/routes/admin-payments.ts | 287 ------------------ apps/server/src/routes/admin-platform.ts | 71 ++++- apps/server/src/routes/admin-subscriptions.ts | 282 +++++++++++++++++ apps/server/src/routes/admin-topups.ts | 238 +++++++++++++++ apps/server/src/routes/member-view.ts | 87 +++--- 7 files changed, 651 insertions(+), 333 deletions(-) delete mode 100644 apps/server/src/routes/admin-payments.ts create mode 100644 apps/server/src/routes/admin-subscriptions.ts create mode 100644 apps/server/src/routes/admin-topups.ts diff --git a/apps/server/src/api-schemas.ts b/apps/server/src/api-schemas.ts index 2819da9..a7b24f8 100644 --- a/apps/server/src/api-schemas.ts +++ b/apps/server/src/api-schemas.ts @@ -3,6 +3,8 @@ import "./zod-openapi-setup.ts"; import { changePasswordBody, createMemberBody, + createSubscriptionBody, + createTopupBody, engineStatus, errorEnvelope, keyItem, @@ -14,7 +16,8 @@ import { memberWithToken, patchKeyBody, patchMemberBody, - paymentItem, + subscriptionItem, + topupItem, auditLogItem, registerKeyBody, setupBody, @@ -50,12 +53,18 @@ export const memberOverviewSchema = nameSchema( ); export const logItemSchema = nameSchema(logItem, "LogItem"); export const logPageSchema = nameSchema(logPage, "LogPage"); -export const paymentItemSchema = nameSchema(paymentItem, "PaymentItem"); +export const subscriptionItemSchema = nameSchema( + subscriptionItem, + "SubscriptionItem", +); +export const topupItemSchema = nameSchema(topupItem, "TopupItem"); export const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem"); export { changePasswordBody as changePasswordBodySchema, createMemberBody as createMemberBodySchema, + createSubscriptionBody as createSubscriptionBodySchema, + createTopupBody as createTopupBodySchema, loginBody as loginBodySchema, patchKeyBody as patchKeyBodySchema, patchMemberBody as patchMemberBodySchema, diff --git a/apps/server/src/app.ts b/apps/server/src/app.ts index 71cc71d..e9157d0 100644 --- a/apps/server/src/app.ts +++ b/apps/server/src/app.ts @@ -9,7 +9,8 @@ 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 { adminSubscriptionRoutes } from "./routes/admin-subscriptions.ts"; +import { adminTopupRoutes } from "./routes/admin-topups.ts"; import { adminAuditRoutes } from "./routes/admin-audit.ts"; import { memberViewRoutes } from "./routes/member-view.ts"; @@ -117,7 +118,8 @@ export function createApp(opts: CreateAppOptions) { admin.route("/", adminKeyRoutes()); admin.route("/", adminMemberRoutes()); admin.route("/", adminUsageRoutes()); - admin.route("/", adminPaymentRoutes()); + admin.route("/", adminSubscriptionRoutes()); + admin.route("/", adminTopupRoutes()); admin.route("/", adminAuditRoutes()); admin.route("/", adminPlatformRoutes()); app.route("/api/admin", admin); diff --git a/apps/server/src/routes/admin-payments.ts b/apps/server/src/routes/admin-payments.ts deleted file mode 100644 index 6550221..0000000 --- a/apps/server/src/routes/admin-payments.ts +++ /dev/null @@ -1,287 +0,0 @@ -import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; -import { - ERR, - qpuAt, - quotaToUsdStr, - resolveQpuTimeline, - toUnixSec, - 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: toUnixSec(row.paid_at)!, - paid_amount: row.paid_amount, - remark: row.remark, - period_start: toUnixSec(row.period_start), - period_end: toUnixSec(row.period_end), - subscription_id: row.subscription_id, - quota_granted: row.quota_granted, - quota_usd, - }; -} - -function validatePaymentBody( - body: Record, -): - | { fields: Record } - | { - values: { - kind: string; - paid_at: Date; - paid_amount: number; - remark: string; - period_start: Date | null; - period_end: Date | null; - subscription_id: number | null; - quota_granted: number | null; - }; - } { - const fields: Record = {}; - const kind = body?.kind; - if (kind !== "subscription" && kind !== "topup") { - fields.kind = "请选择订阅或充值"; - return { fields }; - } - if (!body?.paid_at) fields.paid_at = "请填写支付时间"; - if (typeof body?.paid_amount !== "number") { - fields.paid_amount = "请填写支付金额"; - } - if (body?.quota_granted == null) fields.quota_granted = "请填写本笔额度"; - if (kind === "subscription") { - if (!body?.period_start) fields.period_start = "订阅必须填写期间起点"; - if (body?.subscription_id == null) { - fields.subscription_id = "订阅必须填写订阅编号"; - } - } - if (Object.keys(fields).length > 0) return { fields }; - return { - values: { - kind: kind as string, - paid_at: new Date(String(body.paid_at)), - paid_amount: Number(body.paid_amount), - remark: typeof body.remark === "string" ? body.remark : "", - period_start: - kind === "subscription" && body.period_start - ? new Date(String(body.period_start)) - : null, - period_end: - body.period_end != null && body.period_end !== "" - ? new Date(String(body.period_end)) - : null, - subscription_id: - kind === "subscription" && body.subscription_id != null - ? Number(body.subscription_id) - : null, - quota_granted: Number(body.quota_granted), - }, - }; -} - -export function adminPaymentRoutes() { - const r = new OpenAPIHono<{ Variables: AppVars }>(); - r.use("*", requireAdminSession); - - r.openapi( - createRoute({ - method: "get", - path: "/payments", - summary: "支付记录清单", - description: "全量直返、支付时间倒序;quota_usd 按 paid_at 时刻 QPU 换算", - tags: ["支付记录"], - security: [{ adminSession: [] }], - responses: { - 200: { - description: "payment 数组", - content: { - "application/json": { schema: z.array(paymentItemSchema) }, - }, - }, - 401: err401, - }, - }), - async (c) => { - const 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 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 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 id = Number(c.req.param("id")); - const repos = c.get("repos"); - const before = await repos.payments.findById(id); - if (!before) return jsonError(c, 404, ERR.not_found()); - await repos.payments.remove(id); - await repos.audit.insert({ - operator: c.get("adminUsername") ?? null, - domain: "payment", - action: "payment.delete", - summary: `删除支付 #${id}`, - }); - return c.body(null, 204); - }, - ); - - return r; -} diff --git a/apps/server/src/routes/admin-platform.ts b/apps/server/src/routes/admin-platform.ts index 592d4d2..c45e554 100644 --- a/apps/server/src/routes/admin-platform.ts +++ b/apps/server/src/routes/admin-platform.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ERR, fetchPlatformSubscriptions, + fetchPlatformTopups, platformLogin, } from "@new-api-key-usage/core"; import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts"; @@ -131,7 +132,8 @@ export function adminPlatformRoutes() { const subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token); if (!subs.ok) { if (subs.kind === "rejected") { - return jsonError(c, 401, ERR.session_expired()); + // 上游 token 失效 ≠ 管理端会话过期:绝不能回 401(前端会误登出) + return jsonError(c, 422, ERR.platform_token_invalid()); } return jsonError(c, 502, ERR.upstream_unreachable()); } @@ -139,5 +141,72 @@ export function adminPlatformRoutes() { }, ); + r.openapi( + createRoute({ + method: "post", + path: "/platform/topups", + summary: "分页拉取充值单(登记辅助)", + tags: ["支付"], + security: [{ adminSession: [] }], + request: { + body: { + content: { + "application/json": { + schema: listBody.extend({ + page: z.number().int().positive().optional(), + page_size: z.number().int().positive().max(50).optional(), + }), + }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "topups page", + content: { + "application/json": { + schema: z.object({ + items: z.array(z.looseObject({ id: z.number().int() })), + total: z.number().int(), + page: z.number().int(), + page_size: z.number().int(), + }), + }, + }, + }, + 401: err401, + 422: err422, + 502: err502, + }, + }), + async (c) => { + const body = await c.req.json().catch(() => null); + const token = String(body?.access_token ?? ""); + if (!token) { + return jsonError( + c, + 422, + ERR.validation_failed({ access_token: "缺少 access_token" }), + ); + } + const page = Number(body?.page ?? 1) || 1; + const pageSize = Math.min(50, Number(body?.page_size ?? 10) || 10); + const tops = await fetchPlatformTopups( + c.get("newapiBase"), + token, + page, + pageSize, + ); + if (!tops.ok) { + if (tops.kind === "rejected") { + return jsonError(c, 422, ERR.platform_token_invalid()); + } + return jsonError(c, 502, ERR.upstream_unreachable()); + } + return c.json(tops.data, 200); + }, + ); + return r; } diff --git a/apps/server/src/routes/admin-subscriptions.ts b/apps/server/src/routes/admin-subscriptions.ts new file mode 100644 index 0000000..748e463 --- /dev/null +++ b/apps/server/src/routes/admin-subscriptions.ts @@ -0,0 +1,282 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { + ERR, + errorBody, + qpuAt, + quotaToUsdStr, + resolveQpuTimeline, + toUnixSec, + type SubscriptionItem, +} from "@new-api-key-usage/core"; +import type { SubscriptionRow } from "@new-api-key-usage/db"; +import { + jsonError, + requireAdminSession, + type AppVars, +} from "../middleware.ts"; +import { + createSubscriptionBodySchema, + err401, + err404, + err422, + errorSchema, + subscriptionItemSchema, +} from "../api-schemas.ts"; +import { z } from "zod"; + +function toSubscriptionItem( + row: SubscriptionRow, + quota_usd: string, +): SubscriptionItem { + return { + id: row.id, + subscription_id: row.subscription_id, + period_start: toUnixSec(row.period_start)!, + period_end: toUnixSec(row.period_end), + quota_granted: row.quota_granted, + quota_usd, + paid_amount: row.paid_amount, + paid_at: toUnixSec(row.paid_at), + remark: row.remark, + created_at: toUnixSec(row.created_at)!, + }; +} + +function parseSubBody(body: z.infer): + | { fields: Record } + | { + values: { + subscription_id: number; + period_start: Date; + period_end: Date | null; + quota_granted: number; + paid_amount: number | null; + paid_at: Date | null; + remark: string; + }; + } { + const fields: Record = {}; + if (body.subscription_id == null || body.subscription_id <= 0) { + fields.subscription_id = "请填写订阅编号"; + } + if (!body.period_start) fields.period_start = "请填写期间起点"; + if (body.quota_granted == null || body.quota_granted <= 0) { + fields.quota_granted = "请填写套餐总额度"; + } + if (Object.keys(fields).length > 0) return { fields }; + return { + values: { + subscription_id: body.subscription_id, + period_start: new Date(body.period_start), + period_end: body.period_end + ? new Date(body.period_end) + : null, + quota_granted: body.quota_granted, + paid_amount: body.paid_amount ?? null, + paid_at: body.paid_at ? new Date(body.paid_at) : null, + remark: body.remark ?? "", + }, + }; +} + +export function adminSubscriptionRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "get", + path: "/subscriptions", + summary: "订阅权益清单", + tags: ["支付记录"], + security: [{ adminSession: [] }], + responses: { + 200: { + description: "subscription 数组", + content: { + "application/json": { schema: z.array(subscriptionItemSchema) }, + }, + }, + 401: err401, + }, + }), + async (c) => { + const repos = c.get("repos"); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const rows = await repos.subscriptions.listAll(); + return c.json( + rows.map((row) => + toSubscriptionItem( + row, + quotaToUsdStr( + row.quota_granted, + qpuAt(row.paid_at ?? row.created_at, timeline), + ), + ), + ), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/subscriptions", + summary: "登记订阅权益", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { + body: { + content: { + "application/json": { schema: createSubscriptionBodySchema }, + }, + }, + }, + responses: { + 201: { + description: "subscription", + content: { + "application/json": { schema: subscriptionItemSchema }, + }, + }, + 401: err401, + 409: { + description: "重复登记", + content: { "application/json": { schema: errorSchema } }, + }, + 422: err422, + }, + }), + async (c) => { + const body = c.req.valid("json"); + const parsed = parseSubBody(body); + if ("fields" in parsed) { + return jsonError(c, 422, ERR.validation_failed(parsed.fields)); + } + const repos = c.get("repos"); + const exists = await repos.subscriptions.findBySubscriptionId( + parsed.values.subscription_id, + ); + if (exists) { + return jsonError( + c, + 409, + errorBody( + "duplicate", + `订阅 #${parsed.values.subscription_id} 已有登记`, + ), + ); + } + const row = await repos.subscriptions.insert(parsed.values); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "subscription.create", + summary: `订阅 #${row.subscription_id} · 登记`, + }); + return c.json( + toSubscriptionItem( + row, + quotaToUsdStr( + row.quota_granted, + qpuAt(row.paid_at ?? row.created_at, timeline), + ), + ), + 201, + ); + }, + ); + + r.openapi( + createRoute({ + method: "put", + path: "/subscriptions/{id}", + summary: "编辑订阅权益", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { + params: z.object({ id: z.string() }), + body: { + content: { + "application/json": { schema: createSubscriptionBodySchema }, + }, + }, + }, + responses: { + 200: { + description: "subscription", + content: { + "application/json": { schema: subscriptionItemSchema }, + }, + }, + 401: err401, + 404: err404, + 422: err422, + }, + }), + async (c) => { + const id = Number(c.req.param("id")); + const body = c.req.valid("json"); + const parsed = parseSubBody(body); + if ("fields" in parsed) { + return jsonError(c, 422, ERR.validation_failed(parsed.fields)); + } + const repos = c.get("repos"); + if (!(await repos.subscriptions.findById(id))) { + return jsonError(c, 404, ERR.not_found()); + } + const row = await repos.subscriptions.update(id, parsed.values); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "subscription.update", + summary: `订阅 #${row.subscription_id} · 编辑`, + }); + return c.json( + toSubscriptionItem( + row, + quotaToUsdStr( + row.quota_granted, + qpuAt(row.paid_at ?? row.created_at, timeline), + ), + ), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: "delete", + path: "/subscriptions/{id}", + summary: "删除订阅权益", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: "deleted" }, + 401: err401, + 404: err404, + }, + }), + async (c) => { + const id = Number(c.req.param("id")); + const repos = c.get("repos"); + const before = await repos.subscriptions.findById(id); + if (!before) return jsonError(c, 404, ERR.not_found()); + await repos.subscriptions.remove(id); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "subscription.delete", + summary: `订阅 #${before.subscription_id} · 删除`, + }); + return c.body(null, 204); + }, + ); + + return r; +} diff --git a/apps/server/src/routes/admin-topups.ts b/apps/server/src/routes/admin-topups.ts new file mode 100644 index 0000000..15055ce --- /dev/null +++ b/apps/server/src/routes/admin-topups.ts @@ -0,0 +1,238 @@ +import { OpenAPIHono, createRoute } from "@hono/zod-openapi"; +import { + ERR, + qpuAt, + quotaToUsdStr, + resolveQpuTimeline, + toUnixSec, + type TopupItem, +} from "@new-api-key-usage/core"; +import type { TopupRow } from "@new-api-key-usage/db"; +import { + jsonError, + requireAdminSession, + type AppVars, +} from "../middleware.ts"; +import { + createTopupBodySchema, + err401, + err404, + err422, + topupItemSchema, +} from "../api-schemas.ts"; +import { z } from "zod"; + +function toTopupItem(row: TopupRow, qpu: number): TopupItem { + const quota_derived = Math.round(row.amount * qpu); + return { + id: row.id, + upstream_id: row.upstream_id, + amount: row.amount, + money: row.money, + trade_no: row.trade_no, + payment_method: row.payment_method, + payment_provider: row.payment_provider, + status: row.status, + paid_at: toUnixSec(row.paid_at)!, + quota_derived, + quota_usd: quotaToUsdStr(quota_derived, qpu), + remark: row.remark, + created_at: toUnixSec(row.created_at)!, + }; +} + +function parseTopupBody(body: z.infer): + | { fields: Record } + | { + values: { + upstream_id: number | null; + amount: number; + money: number; + trade_no: string | null; + payment_method: string | null; + payment_provider: string | null; + status: string | null; + paid_at: Date; + remark: string; + }; + } { + const fields: Record = {}; + if (!body.paid_at) fields.paid_at = "请填写入账时间"; + if (typeof body.amount !== "number") fields.amount = "请填写计费单位金额"; + if (typeof body.money !== "number") fields.money = "请填写实付金额"; + if (Object.keys(fields).length > 0) return { fields }; + return { + values: { + upstream_id: body.upstream_id ?? null, + amount: body.amount, + money: body.money, + trade_no: body.trade_no ?? null, + payment_method: body.payment_method ?? null, + payment_provider: body.payment_provider ?? null, + status: body.status ?? "success", + paid_at: new Date(body.paid_at), + remark: body.remark ?? "", + }, + }; +} + +export function adminTopupRoutes() { + const r = new OpenAPIHono<{ Variables: AppVars }>(); + r.use("*", requireAdminSession); + + r.openapi( + createRoute({ + method: "get", + path: "/topups", + summary: "充值入账清单", + tags: ["支付记录"], + security: [{ adminSession: [] }], + responses: { + 200: { + description: "topup 数组", + content: { + "application/json": { schema: z.array(topupItemSchema) }, + }, + }, + 401: err401, + }, + }), + async (c) => { + const repos = c.get("repos"); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + const rows = await repos.topups.listAll(); + return c.json( + rows.map((row) => toTopupItem(row, qpuAt(row.paid_at, timeline))), + 200, + ); + }, + ); + + r.openapi( + createRoute({ + method: "post", + path: "/topups", + summary: "登记充值", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { + body: { + content: { + "application/json": { schema: createTopupBodySchema }, + }, + required: true, + }, + }, + responses: { + 201: { + description: "topup", + content: { "application/json": { schema: topupItemSchema } }, + }, + 401: err401, + 422: err422, + }, + }), + async (c) => { + const body = (await c.req.json().catch(() => ({}))) as z.infer< + typeof createTopupBodySchema + >; + const parsed = parseTopupBody(body as z.infer); + if ("fields" in parsed) { + return jsonError(c, 422, ERR.validation_failed(parsed.fields)); + } + const repos = c.get("repos"); + const row = await repos.topups.insert(parsed.values); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "topup.create", + summary: `充值 #${row.id} · 登记`, + }); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 201); + }, + ); + + r.openapi( + createRoute({ + method: "put", + path: "/topups/{id}", + summary: "编辑充值", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { + params: z.object({ id: z.string() }), + body: { + content: { + "application/json": { schema: createTopupBodySchema }, + }, + required: true, + }, + }, + responses: { + 200: { + description: "topup", + content: { "application/json": { schema: topupItemSchema } }, + }, + 401: err401, + 404: err404, + 422: err422, + }, + }), + async (c) => { + const id = Number(c.req.param("id")); + const body = (await c.req.json().catch(() => ({}))) as z.infer< + typeof createTopupBodySchema + >; + const parsed = parseTopupBody(body as z.infer); + if ("fields" in parsed) { + return jsonError(c, 422, ERR.validation_failed(parsed.fields)); + } + const repos = c.get("repos"); + if (!(await repos.topups.findById(id))) { + return jsonError(c, 404, ERR.not_found()); + } + const row = await repos.topups.update(id, parsed.values); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "topup.update", + summary: `充值 #${row.id} · 编辑`, + }); + const timeline = resolveQpuTimeline(await repos.qpu.listTimeline()); + return c.json(toTopupItem(row, qpuAt(row.paid_at, timeline)), 200); + }, + ); + + r.openapi( + createRoute({ + method: "delete", + path: "/topups/{id}", + summary: "删除充值", + tags: ["支付记录"], + security: [{ adminSession: [] }], + request: { params: z.object({ id: z.string() }) }, + responses: { + 204: { description: "deleted" }, + 401: err401, + 404: err404, + }, + }), + async (c) => { + const id = Number(c.req.param("id")); + const repos = c.get("repos"); + const before = await repos.topups.findById(id); + if (!before) return jsonError(c, 404, ERR.not_found()); + await repos.topups.remove(id); + await repos.audit.insert({ + operator: c.get("adminUsername") ?? null, + domain: "payment", + action: "topup.delete", + summary: `充值 #${before.id} · 删除`, + }); + return c.body(null, 204); + }, + ); + + return r; +} diff --git a/apps/server/src/routes/member-view.ts b/apps/server/src/routes/member-view.ts index bed1c93..6648b65 100644 --- a/apps/server/src/routes/member-view.ts +++ b/apps/server/src/routes/member-view.ts @@ -6,7 +6,6 @@ import { usageRowPrecise, toUnixSec, type LogItem, - type PaymentItem, } from "@new-api-key-usage/core"; import { jsonError, @@ -19,7 +18,6 @@ import { logPageSchema, memberMeSchema, memberOverviewSchema, - paymentItemSchema, } from "../api-schemas.ts"; import { z } from "zod"; @@ -161,42 +159,55 @@ export function memberViewRoutes() { usage: sumUsage(v.rows, timeline), })); - // 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to) - const paymentRows = await repos.payments.listAll(); + // 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to) + const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core"); + const subRows = await repos.subscriptions.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 subscriptionsOut = []; + for (const s of subRows) { 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; + s.period_end == null || + s.period_end > new Date() || + (s.period_start <= range.to && + (s.period_end ?? new Date()) >= range.from); + const involved = usedSubIds.has(s.subscription_id) || ongoing; if (!involved) continue; - const qpu = qpuAt(p.paid_at, timeline); - payments.push({ - id: p.id, - kind: p.kind as PaymentItem["kind"], - paid_at: toUnixSec(p.paid_at)!, - paid_amount: p.paid_amount, - remark: p.remark, - period_start: toUnixSec(p.period_start), - period_end: toUnixSec(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", + const qpu = qpuAt(s.paid_at ?? s.created_at, timeline); + subscriptionsOut.push({ + id: s.id, + subscription_id: s.subscription_id, + period_start: toUnixSec(s.period_start)!, + period_end: toUnixSec(s.period_end), + quota_granted: s.quota_granted, + quota_usd: quotaToUsdStr(s.quota_granted, qpu), + paid_amount: s.paid_amount, + paid_at: toUnixSec(s.paid_at), + remark: s.remark, + created_at: toUnixSec(s.created_at)!, + }); + } + const topupRows = await repos.topups.listAll(); + const topupsOut = []; + for (const t of topupRows) { + if (!(t.paid_at >= range.from && t.paid_at < range.to)) continue; + const qpu = qpuAt(t.paid_at, timeline); + const quota_derived = Math.round(t.amount * qpu); + topupsOut.push({ + id: t.id, + upstream_id: t.upstream_id, + amount: t.amount, + money: t.money, + trade_no: t.trade_no, + payment_method: t.payment_method, + payment_provider: t.payment_provider, + status: t.status, + paid_at: toUnixSec(t.paid_at)!, + quota_derived, + quota_usd: quotaToUsdStr(quota_derived, qpu), + remark: t.remark, + created_at: toUnixSec(t.created_at)!, }); } @@ -206,14 +217,8 @@ export function memberViewRoutes() { 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, - })), + subscriptions: subscriptionsOut, + topups: topupsOut, }, 200, );