feat(server): 订阅/充值 CRUD 与平台选用分页

admin-subscriptions/topups 取代 payments;member overview 分两集;
platform/topups 单页代拉;上游 token 失效回 422,避免误踢管理端会话。
This commit is contained in:
2026-10-09 22:02:20 +08:00
parent c7cc6f6dfd
commit 10d8f4c915
7 changed files with 651 additions and 333 deletions
+11 -2
View File
@@ -3,6 +3,8 @@ import "./zod-openapi-setup.ts";
import { import {
changePasswordBody, changePasswordBody,
createMemberBody, createMemberBody,
createSubscriptionBody,
createTopupBody,
engineStatus, engineStatus,
errorEnvelope, errorEnvelope,
keyItem, keyItem,
@@ -14,7 +16,8 @@ import {
memberWithToken, memberWithToken,
patchKeyBody, patchKeyBody,
patchMemberBody, patchMemberBody,
paymentItem, subscriptionItem,
topupItem,
auditLogItem, auditLogItem,
registerKeyBody, registerKeyBody,
setupBody, setupBody,
@@ -50,12 +53,18 @@ export const memberOverviewSchema = nameSchema(
); );
export const logItemSchema = nameSchema(logItem, "LogItem"); export const logItemSchema = nameSchema(logItem, "LogItem");
export const logPageSchema = nameSchema(logPage, "LogPage"); 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 const auditLogItemSchema = nameSchema(auditLogItem, "AuditLogItem");
export { export {
changePasswordBody as changePasswordBodySchema, changePasswordBody as changePasswordBodySchema,
createMemberBody as createMemberBodySchema, createMemberBody as createMemberBodySchema,
createSubscriptionBody as createSubscriptionBodySchema,
createTopupBody as createTopupBodySchema,
loginBody as loginBodySchema, loginBody as loginBodySchema,
patchKeyBody as patchKeyBodySchema, patchKeyBody as patchKeyBodySchema,
patchMemberBody as patchMemberBodySchema, patchMemberBody as patchMemberBodySchema,
+4 -2
View File
@@ -9,7 +9,8 @@ import { adminEngineRoutes } from "./routes/admin-engine.ts";
import { adminKeyRoutes } from "./routes/admin-keys.ts"; import { adminKeyRoutes } from "./routes/admin-keys.ts";
import { adminMemberRoutes } from "./routes/admin-members.ts"; import { adminMemberRoutes } from "./routes/admin-members.ts";
import { adminUsageRoutes } from "./routes/admin-usage.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 { adminAuditRoutes } from "./routes/admin-audit.ts";
import { memberViewRoutes } from "./routes/member-view.ts"; import { memberViewRoutes } from "./routes/member-view.ts";
@@ -117,7 +118,8 @@ export function createApp(opts: CreateAppOptions) {
admin.route("/", adminKeyRoutes()); admin.route("/", adminKeyRoutes());
admin.route("/", adminMemberRoutes()); admin.route("/", adminMemberRoutes());
admin.route("/", adminUsageRoutes()); admin.route("/", adminUsageRoutes());
admin.route("/", adminPaymentRoutes()); admin.route("/", adminSubscriptionRoutes());
admin.route("/", adminTopupRoutes());
admin.route("/", adminAuditRoutes()); admin.route("/", adminAuditRoutes());
admin.route("/", adminPlatformRoutes()); admin.route("/", adminPlatformRoutes());
app.route("/api/admin", admin); app.route("/api/admin", admin);
-287
View File
@@ -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<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 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;
}
+70 -1
View File
@@ -3,6 +3,7 @@ import { z } from "zod";
import { import {
ERR, ERR,
fetchPlatformSubscriptions, fetchPlatformSubscriptions,
fetchPlatformTopups,
platformLogin, platformLogin,
} from "@new-api-key-usage/core"; } from "@new-api-key-usage/core";
import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts"; import { jsonError, requireAdminSession, type AppVars } from "../middleware.ts";
@@ -131,7 +132,8 @@ export function adminPlatformRoutes() {
const subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token); const subs = await fetchPlatformSubscriptions(c.get("newapiBase"), token);
if (!subs.ok) { if (!subs.ok) {
if (subs.kind === "rejected") { 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()); 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; return r;
} }
@@ -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<typeof createSubscriptionBodySchema>):
| { fields: Record<string, string> }
| {
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<string, string> = {};
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;
}
+238
View File
@@ -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<typeof createTopupBodySchema>):
| { fields: Record<string, string> }
| {
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<string, string> = {};
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<typeof createTopupBodySchema>);
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<typeof createTopupBodySchema>);
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;
}
+46 -41
View File
@@ -6,7 +6,6 @@ import {
usageRowPrecise, usageRowPrecise,
toUnixSec, toUnixSec,
type LogItem, type LogItem,
type PaymentItem,
} from "@new-api-key-usage/core"; } from "@new-api-key-usage/core";
import { import {
jsonError, jsonError,
@@ -19,7 +18,6 @@ import {
logPageSchema, logPageSchema,
memberMeSchema, memberMeSchema,
memberOverviewSchema, memberOverviewSchema,
paymentItemSchema,
} from "../api-schemas.ts"; } from "../api-schemas.ts";
import { z } from "zod"; import { z } from "zod";
@@ -161,42 +159,55 @@ export function memberViewRoutes() {
usage: sumUsage(v.rows, timeline), usage: sumUsage(v.rows, timeline),
})); }));
// 「该时段涉及」支付:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to) // 「该时段涉及」:订阅 = 日志出现过的 subscription_id ∪ 进行中;充值 = paid_at ∈ [from,to)
const paymentRows = await repos.payments.listAll(); const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core");
const subRows = await repos.subscriptions.listAll();
const usedSubIds = new Set( const usedSubIds = new Set(
rows.map((x) => x.subscription_id).filter((v): v is number => v != null), rows.map((x) => x.subscription_id).filter((v): v is number => v != null),
); );
const payments: PaymentItem[] = []; const subscriptionsOut = [];
const { quotaToUsdStr, qpuAt } = await import("@new-api-key-usage/core"); for (const s of subRows) {
for (const p of paymentRows) {
const inRange = p.paid_at >= range.from && p.paid_at < range.to;
const ongoing = const ongoing =
p.kind === "subscription" && s.period_end == null ||
(p.period_end == null || s.period_end > new Date() ||
p.period_end > new Date() || (s.period_start <= range.to &&
(p.period_start != null && (s.period_end ?? new Date()) >= range.from);
p.period_start <= range.to && const involved = usedSubIds.has(s.subscription_id) || ongoing;
(p.period_end ?? new Date()) >= range.from));
const involved =
p.kind === "topup"
? inRange
: usedSubIds.has(p.subscription_id ?? -1) || ongoing;
if (!involved) continue; if (!involved) continue;
const qpu = qpuAt(p.paid_at, timeline); const qpu = qpuAt(s.paid_at ?? s.created_at, timeline);
payments.push({ subscriptionsOut.push({
id: p.id, id: s.id,
kind: p.kind as PaymentItem["kind"], subscription_id: s.subscription_id,
paid_at: toUnixSec(p.paid_at)!, period_start: toUnixSec(s.period_start)!,
paid_amount: p.paid_amount, period_end: toUnixSec(s.period_end),
remark: p.remark, quota_granted: s.quota_granted,
period_start: toUnixSec(p.period_start), quota_usd: quotaToUsdStr(s.quota_granted, qpu),
period_end: toUnixSec(p.period_end), paid_amount: s.paid_amount,
subscription_id: p.subscription_id, paid_at: toUnixSec(s.paid_at),
quota_granted: p.quota_granted, remark: s.remark,
quota_usd: created_at: toUnixSec(s.created_at)!,
p.quota_granted != null });
? quotaToUsdStr(p.quota_granted, qpu) }
: "0.00", 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, usage,
by_source, by_source,
keys, keys,
payments: payments.map((p) => ({ subscriptions: subscriptionsOut,
...p, topups: topupsOut,
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, 200,
); );