feat(server): 订阅/充值 CRUD 与平台选用分页
admin-subscriptions/topups 取代 payments;member overview 分两集; platform/topups 单页代拉;上游 token 失效回 422,避免误踢管理端会话。
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user