/);
+ assert.match(page, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/);
+});
+
+test("网关探测只使用 HEAD/GET、受限响应且共享 SSRF 门禁", () => {
+ assert.match(testRoute, /requireAdminSession\("write"\)/);
+ assert.match(testRoute, /method: "HEAD"/);
+ assert.match(testRoute, /response\.status === 405 \|\| response\.status === 501/);
+ assert.match(testRoute, /method: "GET"/);
+ assert.doesNotMatch(testRoute, /method: "POST"|payment_orders|\.text\(\)|\.json\(\)/);
+ assert.match(testRoute, /AbortSignal\.timeout\(8_000\)/);
+ assert.match(testRoute, /redirect: "manual"/);
+ assert.match(testRoute, /available,[\s\S]*message:[\s\S]*latencyMs:[\s\S]*status:/);
+ assert.doesNotMatch(testRoute, /pid:|key:|gatewayUrl:|headers:|body:|payment_orders/);
+ assert.match(testRoute, /assertPublicGatewayUrl\(submitUrl\)/);
+ assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/);
+});
+
+test("纯地址判断拒绝私网、回环、链路本地并接受公网", () => {
+ for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1", "fe80::1", "2001:db8::1"]) {
+ assert.equal(isPublicEpayAddress(address), false, address);
+ }
+ for (const address of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"]) {
+ assert.equal(isPublicEpayAddress(address), true, address);
+ }
+ assert.throws(() => assertPublicEpayGateway("http://localhost/pay"));
+ assert.throws(() => assertPublicEpayGateway("http://127.0.0.1/pay"));
+ assert.doesNotThrow(() => assertPublicEpayGateway("https://pay.example.com"));
+});
+
+test("迁移锁定单行、RLS、最小权限与脱敏原子审计", () => {
+ assert.match(migration, /create table public\.epay_settings/);
+ assert.match(migration, /id boolean primary key default true check \(id\)/);
+ assert.match(migration, /alter table public\.epay_settings enable row level security/);
+ assert.match(migration, /revoke all on table public\.epay_settings from public, anon, authenticated, service_role/);
+ assert.match(migration, /grant select on table public\.epay_settings to service_role/);
+ assert.match(migration, /security definer/);
+ assert.match(migration, /insert into audit\.admin_audit_logs/);
+ assert.match(migration, /'keyConfigured'/);
+ assert.match(migration, /'keyChanged'/);
+ const auditBlock = migration.slice(migration.indexOf("insert into audit.admin_audit_logs"));
+ assert.doesNotMatch(auditBlock, /'encryptedKey'|'encrypted_key'|'secret'|jsonb_build_object\([\s\S]*?'key'/);
+});
diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts
index 1e15bce6..aea3b45f 100644
--- a/frontend/tests/health-deployment.test.ts
+++ b/frontend/tests/health-deployment.test.ts
@@ -246,6 +246,8 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
"RESEND_API_KEY=re_test_key_that_must_not_be_printed",
"RESEND_FROM_EMAIL=Jyotisha Staging
",
"ADMIN_EMAILS=admin@example.com",
+ "EPAY_CONFIG_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
+ "EPAY_CHAT_ENABLED=false",
"JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes",
];
const run = () =>
@@ -269,6 +271,13 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi
);
writeEnv(validSelectors);
assert.equal(run().status, 0);
+
+ writeEnv(validSelectors.map((line) => line.startsWith("EPAY_CONFIG_ENCRYPTION_KEY=") ? "EPAY_CONFIG_ENCRYPTION_KEY=invalid" : line));
+ assert.notEqual(run().status, 0);
+ writeEnv(validSelectors.map((line) => line.startsWith("EPAY_CHAT_ENABLED=") ? "EPAY_CHAT_ENABLED=true" : line));
+ assert.notEqual(run().status, 0);
+ writeEnv(validSelectors);
+
const shellOverride = spawnSync(
"docker",
[
--
2.52.0
From 4a044570b2364f3a558a09d62405d375fcdeae07 Mon Sep 17 00:00:00 2001
From: linmeng <819991304@qq.com>
Date: Wed, 29 Jul 2026 16:46:54 +0800
Subject: [PATCH 42/46] fix: refresh server-only integrity
---
frontend/package-lock.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 0fc0cfeb..da1047c3 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -10468,7 +10468,7 @@
"node_modules/server-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz",
- "integrity": "sha512-qepMx2JxAa5jjfzxgGoiBhbJmAW3EvM8OcSqn3cuJa2Cpe3cvtXEzHPVfVEAWrhyLGIKZ4GvU14t6lmIeJGNSg==",
+ "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==",
"license": "MIT"
},
"node_modules/sonner": {
--
2.52.0
From c11f3b109e00dd1b35e455d5a7028ce421cd5f59 Mon Sep 17 00:00:00 2001
From: linmeng <819991304@qq.com>
Date: Wed, 29 Jul 2026 16:50:46 +0800
Subject: [PATCH 43/46] ci: retry staging web build
--
2.52.0
From 3fb635be1b2916a47ae17b1a51041573b8f7c723 Mon Sep 17 00:00:00 2001
From: linmeng <819991304@qq.com>
Date: Wed, 29 Jul 2026 17:45:49 +0800
Subject: [PATCH 44/46] fix: restore staging payment management
Use self-hosted PostgreSQL for payment records and expose separate package and Z-Pay flows so administrators and chat users can complete the payment journey reliably.
---
docs/BUG_HISTORY.md | 8 +-
frontend/src/app/admin/packages/page.tsx | 4 +-
.../src/app/api/admin/epay-settings/route.ts | 3 +-
frontend/src/app/api/admin/payments/route.ts | 128 +++++++++-----
.../src/app/api/payment/epay/create/route.ts | 36 ++--
frontend/src/app/page.tsx | 17 +-
frontend/src/components/admin/admin-app.tsx | 2 +
.../components/admin/package-management.tsx | 163 ++++++++++++++++++
.../components/admin/payment-management.tsx | 157 +----------------
frontend/tests/admin-contracts.test.ts | 5 +-
.../tests/admin-payments-contract.test.ts | 96 +++++------
frontend/tests/epay-settings.test.ts | 23 ++-
12 files changed, 370 insertions(+), 272 deletions(-)
create mode 100644 frontend/src/components/admin/package-management.tsx
diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index 588963b2..33a38568 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -1620,10 +1620,10 @@
- 影响面:后台 Refine 侧栏、`/admin/payments`、`/admin/packages`、易支付配置与对话页充值入口。
- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话;对话页支付入口缺少安全默认关闭和服务端创建订单硬门禁。
- 触发条件:进入同域 `/admin` 后管理支付记录或套餐,或点击 Refine 侧栏底部默认 Logout。
-- 根因:BUG-093 首轮只补齐了两个缺失资源,没有收敛同一支付领域的信息架构;支付与套餐页沿用 Refine 接入前的原生页面,默认 `ThemedSider` logout 也未按后台工作流定制。
-- 修复:后台只注册一个“支付管理”资源;`/admin/payments` 使用统一的 Ant Design + Refine `List/Card/Table/Form/Tag/Alert/Statistic` 页面,同时读取支付记录与套餐,保留筛选、统计、分页和订单字段。套餐新增/编辑收进可重置 Modal,保存和停用通过 message 反馈并刷新列表;旧 `/admin/packages` 服务端重定向。自定义 `ThemedSider` 保留资源 items、忽略默认 logout,并追加折叠态可访问的“返回对话”链接到 `/`。本轮继续在同一支付页补齐“易支付系统配置” Card:管理员可修改完整配置,商户密钥使用独立 32-byte 主密钥和 AES-256-GCM 加密保存且永不回显;数据库配置优先、旧 `EPAY_*` 环境变量仅作无数据库行时的兼容回退;保存配置与包含 `chatEnabled`、且只含 `keyConfigured/keyChanged` 密钥状态的脱敏审计在数据库 RPC 内原子完成。`chat_enabled` 默认关闭,公共套餐接口故障时 fail closed,创建订单在套餐查询、订单写入和网关请求前执行同一硬门禁;对话页仅在接口明确返回 `enabled=true` 时展示整块充值 UI。后台可对当前已保存配置执行无副作用 HEAD/GET 可达性测试,探测使用 8 秒超时、手动重定向与公网 HTTP(S) SSRF 门禁,不发送商户参数或请求体。
-- 验证:`frontend/tests/admin-contracts.test.ts` 锁定单一支付资源、自定义 Sider 不调用 logout 和返回对话链接;`frontend/tests/admin-payments-contract.test.ts` 锁定统一页面组件、两类 fetch、套餐 Modal、POST/PATCH/DELETE、旧路由重定向以及 `requireAdminSession`。`frontend/tests/epay-settings.test.ts` 锁定加密往返/篡改/错误主密钥、数据库优先级、API 不回显、异步调用点、Password 表单、单行 RLS 权限与审计脱敏合同。相关测试、Next.js build、ESLint 与 `git diff --check` 结果记录在本次交付报告。
-- 防复发:同一后台领域默认收敛为单一资源和统一 Refine 页面;易支付密钥不得进入 API 响应、表单初值、日志或审计 JSON;数据库配置变更必须通过原子审计 RPC。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;任何服务端网关 fetch 必须先执行共享 SSRF guard,可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。
+- 根因:首轮支付后台实现依赖 Supabase 专用关联 select、分页、计数和 Admin Auth 查询;self-hosted staging 的本地 PostgreSQL adapter 不支持这些 builder 能力,支付记录因此统一降级为“支付记录服务暂时不可用”。同页套餐设计也不符合最新后台信息架构,易支付配置响应漏投影 `chat_enabled`,chat 创建订单又依赖服务端提交网关后猜测跳转地址,不兼容标准易支付收银台表单页。
+- 修复:支付记录改为通过 `queryAdminRows` 执行参数化 SQL,联表 `public.payment_orders`、`public.payment_packages` 和 `identity.users`,以窗口计数保留分页合同并用独立聚合 SQL 输出统计;不再使用 Supabase builder 或 Admin Auth。后台在支付管理之后新增独立“套餐管理”资源和页面,套餐新增、编辑、停用、错误重试及原字段保持完整,支付页只保留概览、Z-Pay(易支付)渠道配置和支付记录。配置读取补回 `chat_enabled` 与 `chatEnabled`。创建订单完成登录、开关、配置、SSRF、套餐和订单校验后,直接返回带 `sign/sign_type` 的标准 `submit.php` 收银台 URL,不服务端请求网关、不返回商户密钥;对话页用浏览器打开该 URL,套餐加载异常显示安全错误,正常 `enabled=false` 仍静默隐藏。
+- 验证:`frontend/tests/admin-contracts.test.ts` 锁定支付、套餐资源顺序;`frontend/tests/admin-payments-contract.test.ts` 锁定本地参数化 SQL、`identity.users` 联表、独立套餐页面和完整套餐操作;`frontend/tests/epay-settings.test.ts` 锁定 `chatEnabled` 回显、数据库 select、Z-Pay 标题、已签名收银台 URL、不服务端 fetch 和不泄露 key。相关测试、ESLint 与 `git diff --check` 结果记录在本次交付报告。
+- 防复发:self-hosted staging 后台查询不得依赖 LocalPostgresDataClient 未实现的 Supabase builder 或 Admin Auth 能力;支付与套餐必须保持独立资源顺序。易支付配置读写测试必须同时覆盖数据库列和公开字段;创建订单只生成经公网 SSRF 校验的签名收银台 URL,商户密钥只能参与服务端签名,不得进入 URL、响应、日志或审计。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。
- 相关记录:BUG-087、BUG-092
- 复发自:BUG-093
- 修复版本:待提交(本地可测)
diff --git a/frontend/src/app/admin/packages/page.tsx b/frontend/src/app/admin/packages/page.tsx
index 524e8fb2..2ab9a048 100644
--- a/frontend/src/app/admin/packages/page.tsx
+++ b/frontend/src/app/admin/packages/page.tsx
@@ -1,5 +1,5 @@
-import { redirect } from "next/navigation";
+import { PackageManagement } from "@/components/admin/package-management";
export default function AdminPackagesPage() {
- redirect("/admin/payments");
+ return ;
}
diff --git a/frontend/src/app/api/admin/epay-settings/route.ts b/frontend/src/app/api/admin/epay-settings/route.ts
index 706e9808..9ba77399 100644
--- a/frontend/src/app/api/admin/epay-settings/route.ts
+++ b/frontend/src/app/api/admin/epay-settings/route.ts
@@ -38,6 +38,7 @@ function publicSettings(row: SettingsRow, source: "database" | "environment") {
notifyUrl: row.notify_url,
returnUrl: row.return_url,
siteName: row.site_name,
+ chatEnabled: row.chat_enabled,
keyConfigured: Boolean(row.encrypted_key),
complete: Boolean(row.gateway_url && row.pid && row.encrypted_key && row.notify_url && row.return_url && row.site_name),
source,
@@ -62,7 +63,7 @@ function environmentSettings() {
async function databaseRow() {
const { data, error } = await createAdminSupabaseClient()
.from("epay_settings")
- .select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,updated_at")
+ .select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled,updated_at")
.eq("id", true)
.maybeSingle();
if (error?.code === "42P01") return null;
diff --git a/frontend/src/app/api/admin/payments/route.ts b/frontend/src/app/api/admin/payments/route.ts
index 829569c1..f0399050 100644
--- a/frontend/src/app/api/admin/payments/route.ts
+++ b/frontend/src/app/api/admin/payments/route.ts
@@ -1,9 +1,8 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
+import { queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse } from "@/lib/admin/http";
-import { createAdminSupabaseClient } from "@/lib/supabase/admin";
-import { isSupabaseConfigurationError } from "@/lib/supabase/config";
export const runtime = "nodejs";
@@ -15,6 +14,28 @@ const querySchema = z.object({
offset: z.coerce.number().int().min(0).default(0),
});
+type PaymentOrderRow = {
+ order_no: string;
+ user_email: string | null;
+ package_name: string | null;
+ money_cents: number;
+ credits: number;
+ status: string;
+ epay_trade_no: string | null;
+ created_at: Date;
+ paid_at: Date | null;
+ total_count: string;
+};
+
+type PaymentStatsRow = {
+ total_orders: string;
+ paid_orders: string;
+ pending_orders: string;
+ failed_expired_orders: string;
+ paid_amount_cents: string;
+ granted_credits: string;
+};
+
export async function GET(request: Request) {
try {
await requireAdminSession("read");
@@ -25,44 +46,70 @@ export async function GET(request: Request) {
const { status, from, to, limit, offset } = parsed.data;
if (from && to && new Date(from) > new Date(to)) return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 });
- const admin = createAdminSupabaseClient();
- let ordersQuery = admin
- .from("payment_orders")
- .select("order_no,user_id,money_cents,credits,status,epay_trade_no,paid_at,created_at,payment_packages(name)", { count: "exact" })
- .order("created_at", { ascending: false })
- .range(offset, offset + limit - 1);
- if (status) ordersQuery = ordersQuery.eq("status", status);
- if (from) ordersQuery = ordersQuery.gte("created_at", from);
- if (to) ordersQuery = ordersQuery.lte("created_at", to);
+ const values: unknown[] = [];
+ const conditions: string[] = [];
+ if (status) {
+ values.push(status);
+ conditions.push(`o.status = $${values.length}`);
+ }
+ if (from) {
+ values.push(from);
+ conditions.push(`o.created_at >= $${values.length}::timestamptz`);
+ }
+ if (to) {
+ values.push(to);
+ conditions.push(`o.created_at <= $${values.length}::timestamptz`);
+ }
+ const statsValues: unknown[] = [];
+ const dateConditions: string[] = [];
+ if (from) {
+ statsValues.push(from);
+ dateConditions.push(`o.created_at >= $${statsValues.length}::timestamptz`);
+ }
+ if (to) {
+ statsValues.push(to);
+ dateConditions.push(`o.created_at <= $${statsValues.length}::timestamptz`);
+ }
+ values.push(limit, offset);
- const statsPromise = admin.rpc("get_payment_order_stats", {
- p_from: from ?? null,
- p_to: to ?? null,
- });
- const [{ data, error, count }, statsResult] = await Promise.all([ordersQuery, statsPromise]);
- if (error || statsResult.error) return NextResponse.json({ error: "暂时无法读取支付记录" }, { status: 500 });
+ const [rows, statsRows] = await Promise.all([
+ queryAdminRows(`
+ select
+ o.order_no, u.email as user_email, p.name as package_name,
+ o.money_cents, o.credits, o.status, o.epay_trade_no,
+ o.created_at, o.paid_at, count(*) over()::text as total_count
+ from public.payment_orders o
+ left join public.payment_packages p on p.id = o.package_id
+ left join identity.users u on u.id = o.user_id
+ ${conditions.length ? `where ${conditions.join(" and ")}` : ""}
+ order by o.created_at desc, o.order_no asc
+ limit $${values.length - 1} offset $${values.length}
+ `, values),
+ queryAdminRows(`
+ select
+ count(*)::text as total_orders,
+ count(*) filter (where o.status = 'paid')::text as paid_orders,
+ count(*) filter (where o.status = 'pending')::text as pending_orders,
+ count(*) filter (where o.status in ('failed', 'expired'))::text as failed_expired_orders,
+ coalesce(sum(o.money_cents) filter (where o.status = 'paid'), 0)::text as paid_amount_cents,
+ coalesce(sum(o.credits) filter (where o.status = 'paid'), 0)::text as granted_credits
+ from public.payment_orders o
+ ${dateConditions.length ? `where ${dateConditions.join(" and ")}` : ""}
+ `, statsValues),
+ ]);
- const emailEntries = await Promise.all([...new Set((data ?? []).map((row) => row.user_id))].map(async (userId) => {
- const result = await admin.auth.admin.getUserById(userId);
- return [userId, result.data.user?.email ?? null] as const;
+ const orders = rows.map((row) => ({
+ orderNo: row.order_no,
+ userEmail: row.user_email,
+ packageName: row.package_name,
+ moneyCents: row.money_cents,
+ credits: row.credits,
+ status: row.status,
+ epayTradeNo: row.epay_trade_no,
+ createdAt: row.created_at.toISOString(),
+ paidAt: row.paid_at?.toISOString() ?? null,
}));
- const emails = new Map(emailEntries);
- const orders = (data ?? []).map((row) => {
- const relation = row.payment_packages as { name?: string } | { name?: string }[] | null;
- const packageName = Array.isArray(relation) ? relation[0]?.name : relation?.name;
- return {
- orderNo: row.order_no,
- userEmail: emails.get(row.user_id) ?? null,
- packageName: packageName ?? null,
- moneyCents: row.money_cents,
- credits: row.credits,
- status: row.status,
- epayTradeNo: row.epay_trade_no,
- createdAt: row.created_at,
- paidAt: row.paid_at,
- };
- });
- const rawStats = Array.isArray(statsResult.data) ? statsResult.data[0] : statsResult.data;
+ const rawStats = statsRows[0];
const stats = {
totalOrders: Number(rawStats?.total_orders ?? 0),
paidOrders: Number(rawStats?.paid_orders ?? 0),
@@ -71,12 +118,11 @@ export async function GET(request: Request) {
paidAmountCents: Number(rawStats?.paid_amount_cents ?? 0),
grantedCredits: Number(rawStats?.granted_credits ?? 0),
};
- const total = count ?? 0;
+ const total = Number(rows[0]?.total_count ?? 0);
return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } });
} catch (error) {
- if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 });
- const authError = adminErrorResponse(error);
- if (authError.status === 401 || authError.status === 403) return authError;
+ const response = adminErrorResponse(error);
+ if (response.status === 401 || response.status === 403) return response;
return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 });
}
}
diff --git a/frontend/src/app/api/payment/epay/create/route.ts b/frontend/src/app/api/payment/epay/create/route.ts
index f661d01c..9686accb 100644
--- a/frontend/src/app/api/payment/epay/create/route.ts
+++ b/frontend/src/app/api/payment/epay/create/route.ts
@@ -10,30 +10,42 @@ import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy";
export const runtime = "nodejs";
const schema = z.object({ packageId: z.string().uuid() });
-function safeUpstreamUrl(value: unknown, gateway: URL) { if (typeof value !== "string") return null; try { const url = new URL(value, gateway); return url.origin === gateway.origin ? url.toString() : null; } catch { return null; } }
+
export async function POST(request: Request) {
try {
- const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser();
+ const client = await createServerSupabaseClient();
+ const { data: { user } } = await client.auth.getUser();
if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 });
- const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "请选择有效套餐" }, { status: 400 });
+ const parsed = schema.safeParse(await request.json().catch(() => null));
+ if (!parsed.success) return NextResponse.json({ error: "请选择有效套餐" }, { status: 400 });
+
const availability = await readEpayAvailability();
if (!availability.enabled) return NextResponse.json({ error: "在线支付暂未开放", code: "EPAY_DISABLED" }, { status: 403 });
const config = await readEpayConfig();
const submitUrl = epaySubmitUrl(config.gatewayUrl);
await assertPublicGatewayUrl(submitUrl);
- const admin = createAdminSupabaseClient(); const { data: pack, error: packError } = await admin.from("payment_packages").select("id,name,price_cents,credits,enabled").eq("id", parsed.data.packageId).eq("enabled", true).maybeSingle();
+
+ const admin = createAdminSupabaseClient();
+ const { data: pack, error: packError } = await admin.from("payment_packages").select("id,name,price_cents,credits,enabled").eq("id", parsed.data.packageId).eq("enabled", true).maybeSingle();
if (packError || !pack) return NextResponse.json({ error: "套餐不存在或已下架" }, { status: 404 });
const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`;
const { error: orderError } = await admin.from("payment_orders").insert({ order_no: orderNo, user_id: user.id, package_id: pack.id, money_cents: pack.price_cents, credits: pack.credits });
if (orderError) return NextResponse.json({ error: "创建订单失败" }, { status: 500 });
- const params = { money: (pack.price_cents / 100).toFixed(2), name: pack.name, notify_url: config.notifyUrl, out_trade_no: orderNo, pid: config.pid, return_url: config.returnUrl, sitename: config.siteName, type: "alipay" };
- const body = new URLSearchParams({ ...params, sign: epaySign(params, config.key), sign_type: "MD5" });
- const upstream = await fetch(submitUrl, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10_000) });
- if (!upstream.ok) return NextResponse.json({ error: "支付网关暂时不可用" }, { status: 502 });
- const text = await upstream.text(); let payload: Record = {}; try { const json = JSON.parse(text); if (json && typeof json === "object") payload = json; } catch { /* gateway may return HTML */ }
- const payUrl = safeUpstreamUrl(payload.payurl ?? payload.pay_url ?? payload.url ?? (text.trim().startsWith("http") ? text.trim() : null), config.gatewayUrl);
- const qrCode = safeUpstreamUrl(payload.qrcode ?? payload.qr_code, config.gatewayUrl);
- return NextResponse.json({ orderNo, payUrl, qrCode });
+
+ const params = {
+ money: (pack.price_cents / 100).toFixed(2),
+ name: pack.name,
+ notify_url: config.notifyUrl,
+ out_trade_no: orderNo,
+ pid: config.pid,
+ return_url: config.returnUrl,
+ sitename: config.siteName,
+ type: "alipay",
+ };
+ const signedParams = { ...params, sign: epaySign(params, config.key), sign_type: "MD5" };
+ const payUrl = new URL(submitUrl);
+ for (const [name, value] of Object.entries(signedParams)) payUrl.searchParams.set(name, value);
+ return NextResponse.json({ orderNo, payUrl: payUrl.toString(), qrCode: null });
} catch (error) {
if (error instanceof EpayConfigurationError) return NextResponse.json({ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" }, { status: 503 });
return NextResponse.json({ error: "创建支付失败" }, { status: 500 });
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index b0d5f6dc..9309c3cc 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -1959,7 +1959,11 @@ export default function Home() {
if (response.ok && payload?.enabled === true) {
setPaymentEnabled(true);
setPaymentPackages(payload.packages || []);
+ return;
}
+ if (!response.ok) setPaymentError("套餐支付暂时不可用,请稍后重试");
+ }).catch(() => {
+ setPaymentError("套餐支付暂时不可用,请稍后重试");
});
}, [activeAccountDialog]);
@@ -1983,8 +1987,9 @@ export default function Home() {
const response = await fetch("/api/payment/epay/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId }) });
const payload = await response.json().catch(() => null);
if (!response.ok) throw new Error(payload?.error || "创建支付失败");
- setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode, status: "pending" });
- if (payload.payUrl) window.open(payload.payUrl, "_blank", "noopener,noreferrer");
+ if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") throw new Error("创建支付失败");
+ setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode ?? null, status: "pending" });
+ window.open(payload.payUrl, "_blank", "noopener,noreferrer");
} catch (caught) { setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); } finally { setPayingPackageId(null); }
}
@@ -3278,11 +3283,11 @@ export default function Home() {
{redeemMessage && {redeemMessage}
}
{paymentEnabled &&
-
充值套餐
- {paymentPackages.map((item) =>
{item.name}{item.description || `${item.credits} 点`}
¥{(item.priceCents / 100).toFixed(2)})}
- {paymentError &&
{paymentError}
}
- {paymentOrder &&
订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}
{paymentOrder.qrCode &&
}{paymentOrder.payUrl &&
打开支付页面}
}
+
套餐充值
+ {paymentPackages.map((item) =>
{item.name}{item.description || `${item.credits} 点`}
¥{(item.priceCents / 100).toFixed(2)})}
+ {paymentOrder &&
订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}
}
}
+ {paymentError && {paymentError}
}
>
)}
diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx
index 57bd63d9..0651455e 100644
--- a/frontend/src/components/admin/admin-app.tsx
+++ b/frontend/src/components/admin/admin-app.tsx
@@ -5,6 +5,7 @@ import {
AuditOutlined,
CreditCardOutlined,
GiftOutlined,
+ ShoppingOutlined,
MessageOutlined,
TeamOutlined,
TransactionOutlined,
@@ -51,6 +52,7 @@ export function AdminApp({ children }: { children: ReactNode }) {
resources={[
{ name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: } },
{ name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: } },
+ { name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: } },
{ name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: } },
{ name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: } },
{ name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: } },
diff --git a/frontend/src/components/admin/package-management.tsx b/frontend/src/components/admin/package-management.tsx
new file mode 100644
index 00000000..b77d2731
--- /dev/null
+++ b/frontend/src/components/admin/package-management.tsx
@@ -0,0 +1,163 @@
+"use client";
+
+import { PlusOutlined } from "@ant-design/icons";
+import { List } from "@refinedev/antd";
+import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Row, Col, Space, Switch, Table, Tag, Typography, type TableColumnsType } from "antd";
+import { useCallback, useEffect, useState } from "react";
+
+const { Text } = Typography;
+
+type PaymentPackage = {
+ id: string;
+ name: string;
+ description: string;
+ priceCents: number;
+ credits: number;
+ sortOrder: number;
+ enabled: boolean;
+};
+
+type PackageFormValues = Omit & { priceYuan: number };
+
+function formatMoney(cents: number) {
+ return `¥${(cents / 100).toFixed(2)}`;
+}
+
+async function responsePayload(response: Response) {
+ const payload = await response.json().catch(() => ({}));
+ if (!response.ok) throw new Error(payload.error || "请求失败");
+ return payload;
+}
+
+export function PackageManagement() {
+ const { message } = App.useApp();
+ const [packageForm] = Form.useForm();
+ const [packages, setPackages] = useState([]);
+ const [packagesLoading, setPackagesLoading] = useState(true);
+ const [packagesError, setPackagesError] = useState("");
+ const [modalOpen, setModalOpen] = useState(false);
+ const [editingPackage, setEditingPackage] = useState(null);
+ const [saving, setSaving] = useState(false);
+ const [disablingId, setDisablingId] = useState(null);
+
+ const loadPackages = useCallback(async () => {
+ setPackagesLoading(true);
+ setPackagesError("");
+ try {
+ const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" }));
+ setPackages(payload.packages);
+ } catch (error) {
+ setPackagesError(error instanceof Error ? error.message : "读取套餐失败");
+ } finally {
+ setPackagesLoading(false);
+ }
+ }, []);
+
+ useEffect(() => {
+ const timer = window.setTimeout(() => void loadPackages(), 0);
+ return () => window.clearTimeout(timer);
+ }, [loadPackages]);
+
+ function openCreateModal() {
+ setEditingPackage(null);
+ packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true });
+ setModalOpen(true);
+ }
+
+ function openEditModal(item: PaymentPackage) {
+ setEditingPackage(item);
+ packageForm.setFieldsValue({
+ name: item.name,
+ description: item.description,
+ priceYuan: item.priceCents / 100,
+ credits: item.credits,
+ sortOrder: item.sortOrder,
+ enabled: item.enabled,
+ });
+ setModalOpen(true);
+ }
+
+ function closeModal() {
+ if (saving) return;
+ setModalOpen(false);
+ setEditingPackage(null);
+ packageForm.resetFields();
+ }
+
+ async function savePackage(values: PackageFormValues) {
+ setSaving(true);
+ try {
+ await responsePayload(await fetch("/api/admin/packages", {
+ method: editingPackage ? "PATCH" : "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({
+ ...(editingPackage ? { id: editingPackage.id } : {}),
+ name: values.name.trim(),
+ description: values.description?.trim() ?? "",
+ priceCents: Math.round(values.priceYuan * 100),
+ credits: values.credits,
+ sortOrder: values.sortOrder,
+ enabled: values.enabled,
+ }),
+ }));
+ message.success(editingPackage ? "套餐已更新" : "套餐已添加");
+ setModalOpen(false);
+ setEditingPackage(null);
+ packageForm.resetFields();
+ await loadPackages();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "保存套餐失败");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ async function disablePackage(id: string) {
+ setDisablingId(id);
+ try {
+ await responsePayload(await fetch("/api/admin/packages", {
+ method: "DELETE",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ id }),
+ }));
+ message.success("套餐已停用");
+ await loadPackages();
+ } catch (error) {
+ message.error(error instanceof Error ? error.message : "停用套餐失败");
+ } finally {
+ setDisablingId(null);
+ }
+ }
+
+ const columns: TableColumnsType = [
+ { title: "名称", dataIndex: "name", render: (_, item) => {item.name}{item.description || "暂无描述"} },
+ { title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney },
+ { title: "点数", dataIndex: "credits", align: "right" },
+ { title: "排序", dataIndex: "sortOrder", align: "right" },
+ { title: "状态", dataIndex: "enabled", render: (enabled) => {enabled ? "启用" : "停用"} },
+ { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && disablePackage(item.id)}>} },
+ ];
+
+ return (
+
+ } onClick={openCreateModal}>添加套餐}>
+
+ {packagesError && void loadPackages()}>重试} />}
+ rowKey="id" columns={columns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} />
+
+
+ packageForm.submit()} onCancel={closeModal} destroyOnHidden afterClose={() => packageForm.resetFields()} maskClosable={!saving} keyboard={!saving}>
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx
index 02a94ad9..fa7bfce8 100644
--- a/frontend/src/components/admin/payment-management.tsx
+++ b/frontend/src/components/admin/payment-management.tsx
@@ -1,6 +1,5 @@
"use client";
-import { PlusOutlined } from "@ant-design/icons";
import { List } from "@refinedev/antd";
import {
Alert,
@@ -11,9 +10,6 @@ import {
DatePicker,
Form,
Input,
- InputNumber,
- Modal,
- Popconfirm,
Row,
Select,
Space,
@@ -50,17 +46,6 @@ type PaymentStats = {
grantedCredits: number;
};
-type PaymentPackage = {
- id: string;
- name: string;
- description: string;
- priceCents: number;
- credits: number;
- sortOrder: number;
- enabled: boolean;
-};
-
-type PackageFormValues = Omit & { priceYuan: number };
type PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] };
type EpaySettings = {
gatewayUrl: string;
@@ -105,7 +90,6 @@ async function responsePayload(response: Response) {
export default function PaymentManagement() {
const { message } = App.useApp();
const [filterForm] = Form.useForm();
- const [packageForm] = Form.useForm();
const [epayForm] = Form.useForm();
const [orders, setOrders] = useState([]);
const [stats, setStats] = useState(initialStats);
@@ -114,13 +98,6 @@ export default function PaymentManagement() {
const [filters, setFilters] = useState({});
const [offset, setOffset] = useState(0);
const [total, setTotal] = useState(0);
- const [packages, setPackages] = useState([]);
- const [packagesLoading, setPackagesLoading] = useState(true);
- const [packagesError, setPackagesError] = useState("");
- const [modalOpen, setModalOpen] = useState(false);
- const [editingPackage, setEditingPackage] = useState(null);
- const [saving, setSaving] = useState(false);
- const [disablingId, setDisablingId] = useState(null);
const [epaySettings, setEpaySettings] = useState(null);
const [epayLoading, setEpayLoading] = useState(true);
const [epaySaving, setEpaySaving] = useState(false);
@@ -146,19 +123,6 @@ export default function PaymentManagement() {
}
}, [filters, offset]);
- const loadPackages = useCallback(async () => {
- setPackagesLoading(true);
- setPackagesError("");
- try {
- const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" }));
- setPackages(payload.packages);
- } catch (error) {
- setPackagesError(error instanceof Error ? error.message : "读取套餐失败");
- } finally {
- setPackagesLoading(false);
- }
- }, []);
-
const loadEpaySettings = useCallback(async () => {
setEpayLoading(true);
setEpayError("");
@@ -185,10 +149,6 @@ export default function PaymentManagement() {
const timer = window.setTimeout(() => void loadPayments(), 0);
return () => window.clearTimeout(timer);
}, [loadPayments]);
- useEffect(() => {
- const timer = window.setTimeout(() => void loadPackages(), 0);
- return () => window.clearTimeout(timer);
- }, [loadPackages]);
useEffect(() => {
const timer = window.setTimeout(() => void loadEpaySettings(), 0);
return () => window.clearTimeout(timer);
@@ -216,7 +176,7 @@ export default function PaymentManagement() {
body: JSON.stringify({ ...values, newKey: values.newKey || undefined }),
}));
epayForm.setFieldValue("newKey", "");
- message.success("易支付配置已保存");
+ message.success("Z-Pay(易支付)配置已保存");
await loadEpaySettings();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存易支付配置失败");
@@ -225,77 +185,6 @@ export default function PaymentManagement() {
}
}
- function openCreateModal() {
- setEditingPackage(null);
- packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true });
- setModalOpen(true);
- }
-
- function openEditModal(item: PaymentPackage) {
- setEditingPackage(item);
- packageForm.setFieldsValue({
- name: item.name,
- description: item.description,
- priceYuan: item.priceCents / 100,
- credits: item.credits,
- sortOrder: item.sortOrder,
- enabled: item.enabled,
- });
- setModalOpen(true);
- }
-
- function closeModal() {
- if (saving) return;
- setModalOpen(false);
- setEditingPackage(null);
- packageForm.resetFields();
- }
-
- async function savePackage(values: PackageFormValues) {
- setSaving(true);
- try {
- await responsePayload(await fetch("/api/admin/packages", {
- method: editingPackage ? "PATCH" : "POST",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({
- ...(editingPackage ? { id: editingPackage.id } : {}),
- name: values.name.trim(),
- description: values.description?.trim() ?? "",
- priceCents: Math.round(values.priceYuan * 100),
- credits: values.credits,
- sortOrder: values.sortOrder,
- enabled: values.enabled,
- }),
- }));
- message.success(editingPackage ? "套餐已更新" : "套餐已添加");
- setModalOpen(false);
- setEditingPackage(null);
- packageForm.resetFields();
- await loadPackages();
- } catch (error) {
- message.error(error instanceof Error ? error.message : "保存套餐失败");
- } finally {
- setSaving(false);
- }
- }
-
- async function disablePackage(id: string) {
- setDisablingId(id);
- try {
- await responsePayload(await fetch("/api/admin/packages", {
- method: "DELETE",
- headers: { "content-type": "application/json" },
- body: JSON.stringify({ id }),
- }));
- message.success("套餐已停用");
- await loadPackages();
- } catch (error) {
- message.error(error instanceof Error ? error.message : "停用套餐失败");
- } finally {
- setDisablingId(null);
- }
- }
-
const orderColumns: TableColumnsType = [
{ title: "订单号", dataIndex: "orderNo", render: (value) => {value} },
{ title: "用户邮箱", dataIndex: "userEmail", render: (value) => value || "—" },
@@ -307,15 +196,6 @@ export default function PaymentManagement() {
{ title: "创建时间", dataIndex: "createdAt", render: formatDate },
{ title: "支付时间", dataIndex: "paidAt", render: formatDate },
];
- const packageColumns: TableColumnsType = [
- { title: "名称", dataIndex: "name", render: (_, item) => {item.name}{item.description || "暂无描述"} },
- { title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney },
- { title: "点数", dataIndex: "credits", align: "right" },
- { title: "排序", dataIndex: "sortOrder", align: "right" },
- { title: "状态", dataIndex: "enabled", render: (enabled) => {enabled ? "启用" : "停用"} },
- { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && disablePackage(item.id)}>} },
- ];
-
return (
@@ -331,11 +211,12 @@ export default function PaymentManagement() {
}
>
+ 配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。
{epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}}
{epayError && void loadEpaySettings()}>重试} />}
@@ -375,39 +256,7 @@ export default function PaymentManagement() {
/>
-
- } onClick={openCreateModal}>添加套餐}>
-
- {packagesError && void loadPackages()}>重试} />}
- rowKey="id" columns={packageColumns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} />
-
-
-
- packageForm.submit()}
- onCancel={closeModal}
- destroyOnHidden
- afterClose={() => packageForm.resetFields()}
- maskClosable={!saving}
- keyboard={!saving}
- >
-
-
-
-
-
-
-
-
-
-
);
}
diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts
index 003999d8..207666a8 100644
--- a/frontend/tests/admin-contracts.test.ts
+++ b/frontend/tests/admin-contracts.test.ts
@@ -44,10 +44,11 @@ test("self-hosted account entry checks only the persisted admin role", () => {
assert.match(auth, /authorizeAdminAccess\(user, access\)/);
});
-test("admin navigation exposes one unified payment resource", () => {
+test("admin navigation exposes payment and package resources", () => {
assert.match(adminApp, /name: "payments", list: "\/admin\/payments", meta: \{ label: "支付管理"/);
- assert.doesNotMatch(adminApp, /name: "packages"|list: "\/admin\/packages"|SettingOutlined/);
+ assert.match(adminApp, /name: "packages", list: "\/admin\/packages", meta: \{ label: "套餐管理"/);
assert.match(adminApp, /CreditCardOutlined/);
+ assert.match(adminApp, /ShoppingOutlined/);
});
test("admin sider replaces logout with a collapsed-aware return-to-chat link", () => {
diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts
index 4f046847..bd43cded 100644
--- a/frontend/tests/admin-payments-contract.test.ts
+++ b/frontend/tests/admin-payments-contract.test.ts
@@ -5,67 +5,67 @@ import test from "node:test";
const root = new URL("../", import.meta.url);
const route = readFileSync(new URL("src/app/api/admin/payments/route.ts", root), "utf8");
const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8");
-const page = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8");
+const paymentPage = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8");
const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", root), "utf8");
-const management = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8");
-const migration = readFileSync(new URL("supabase/migrations/20260727030000_payment_admin_stats.sql", root), "utf8");
+const paymentManagement = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8");
+const packageManagement = readFileSync(new URL("src/components/admin/package-management.tsx", root), "utf8");
+const adminApp = readFileSync(new URL("src/components/admin/admin-app.tsx", root), "utf8");
-test("支付后台接口只允许管理员并查询平台订单", () => {
+test("支付后台接口使用 self-hosted PostgreSQL 联表且不依赖 Supabase builder", () => {
assert.match(route, /requireAdminSession\("read"\)/);
- assert.doesNotMatch(route, /isAdminUser\(user\)/);
- assert.match(route, /from\("payment_orders"\)/);
- assert.match(route, /auth\.admin\.getUserById/);
- assert.match(route, /payment_packages\(name\)/);
- assert.match(route, /order_no|orderNo/);
- assert.doesNotMatch(route, /SUPABASE_SERVICE_ROLE_KEY/);
- assert.doesNotMatch(route, /raw_notify_payload/);
+ assert.match(route, /queryAdminRows/);
+ assert.match(route, /from public\.payment_orders o/);
+ assert.match(route, /left join public\.payment_packages p on p\.id = o\.package_id/);
+ assert.match(route, /left join identity\.users u on u\.id = o\.user_id/);
+ assert.match(route, /count\(\*\) over\(\)::text as total_count/);
+ assert.match(route, /order by o\.created_at desc, o\.order_no asc/);
+ assert.doesNotMatch(route, /createAdminSupabaseClient|auth\.admin|\.from\(|\.range\(|count: "exact"/);
+ assert.doesNotMatch(route, /userId|user_id:|raw_notify_payload|SUPABASE_SERVICE_ROLE_KEY/);
});
-test("支付接口包含筛选、统计和分页契约", () => {
+test("支付接口使用参数化筛选、独立日期统计与 ISO 日期输出", () => {
for (const field of ["status", "from", "to", "limit", "offset"]) assert.match(route, new RegExp(field));
for (const field of ["totalOrders", "paidOrders", "pendingOrders", "failedExpiredOrders", "paidAmountCents", "grantedCredits"]) assert.match(route, new RegExp(field));
assert.match(route, /max\(100\)/);
- assert.match(route, /count: "exact"/);
+ assert.match(route, /\$\$\{values\.length\}/);
+ assert.match(route, /statsValues/);
+ assert.match(route, /created_at\.toISOString\(\)/);
+ assert.match(route, /paid_at\?\.toISOString\(\) \?\? null/);
+ assert.match(route, /支付记录服务暂时不可用/);
assert.match(route, /hasMore/);
- assert.match(migration, /get_payment_order_stats/);
- assert.match(migration, /status = 'paid'/);
});
-test("统一支付管理页面使用 Ant Design 与 Refine 并保留支付数据合同", () => {
- assert.match(page, /PaymentManagement/);
- for (const component of ["List", "Card", "Table", "Form", "Tag", "Alert", "Statistic"]) {
- assert.match(management, new RegExp(`\\b${component}\\b`));
- }
- assert.match(management, /\/api\/admin\/payments/);
- assert.match(management, /\/api\/admin\/packages/);
- for (const field of ["orderNo", "userEmail", "packageName", "moneyCents", "credits", "status", "epayTradeNo", "createdAt", "paidAt"]) {
- assert.match(management, new RegExp(field));
- }
- assert.match(management, /paymentLoading/);
- assert.match(management, /paymentError/);
- assert.match(management, /pagination=\{\{/);
- assert.doesNotMatch(management, /standalone-page|admin-header|admin-section/);
+test("支付管理页只保留概览、Z-Pay 配置与支付记录", () => {
+ assert.match(paymentPage, /PaymentManagement/);
+ for (const component of ["List", "Card", "Table", "Form", "Tag", "Alert", "Statistic"]) assert.match(paymentManagement, new RegExp(`\\b${component}\\b`));
+ assert.match(paymentManagement, /支付概览/);
+ assert.match(paymentManagement, /Z-Pay(易支付)渠道配置/);
+ assert.match(paymentManagement, /支付记录/);
+ assert.match(paymentManagement, /\/api\/admin\/payments/);
+ assert.doesNotMatch(paymentManagement, /套餐列表|套餐设置|添加套餐|\/api\/admin\/packages|PackageManagement/);
});
-test("套餐列表、Modal 与新增编辑停用操作收敛到支付管理页", () => {
- assert.match(management, /\}>/);
- assert.doesNotMatch(management, / {
+ assert.match(adminApp, /name: "payments", list: "\/admin\/payments"[\s\S]*name: "packages", list: "\/admin\/packages", meta: \{ label: "套餐管理"[\s\S]*name: "users"/);
+ assert.match(adminApp, /ShoppingOutlined/);
+ assert.match(packagesPage, /PackageManagement/);
+ assert.doesNotMatch(packagesPage, /redirect/);
+ assert.match(packageManagement, //);
+ assert.match(packageManagement, / {
+ for (const field of ["名称", "描述", "价格(元)", "点数", "排序", "启用"]) assert.match(packageManagement, new RegExp(field));
+ assert.match(packageManagement, /套餐列表读取失败/);
+ assert.match(packageManagement, /loadPackages\(\)/);
+ assert.match(packageManagement, />重试<\/Button>/);
+ assert.match(packageManagement, / {
- assert.match(packagesPage, /import \{ redirect \} from "next\/navigation"/);
- assert.match(packagesPage, /redirect\("\/admin\/payments"\)/);
- assert.doesNotMatch(packagesPage, /use client|