fix: restore staging payment management
Deploy staging to test server / deploy (push) Successful in 9m55s
Deploy staging to test server / deploy (push) Successful in 9m55s
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.
This commit is contained in:
+4
-4
@@ -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
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import { PackageManagement } from "@/components/admin/package-management";
|
||||
|
||||
export default function AdminPackagesPage() {
|
||||
redirect("/admin/payments");
|
||||
return <PackageManagement />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PaymentOrderRow>(`
|
||||
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<PaymentStatsRow>(`
|
||||
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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> = {}; 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 });
|
||||
|
||||
@@ -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 && <p className="form-success" role="status">{redeemMessage}</p>}
|
||||
</form>
|
||||
{paymentEnabled && <div className="payment-packages">
|
||||
<h3>充值套餐</h3>
|
||||
{paymentPackages.map((item) => <div className="payment-package" key={item.id}><div><strong>{item.name}</strong><span>{item.description || `${item.credits} 点`}</span></div><b>¥{(item.priceCents / 100).toFixed(2)}</b><button className="button-secondary" type="button" onClick={() => void createPayment(item.id)} disabled={Boolean(payingPackageId)}>{payingPackageId === item.id ? "创建中" : "支付宝支付"}</button></div>)}
|
||||
{paymentError && <p className="form-error" role="alert">{paymentError}</p>}
|
||||
{paymentOrder && <div className="payment-status" role="status"><p>订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}</p>{paymentOrder.qrCode && <div className="payment-qr-wrap"><img src={paymentOrder.qrCode} alt="支付宝支付二维码" /><span className="payment-qr-badge" aria-label="支付宝"><svg viewBox="0 0 48 48" role="img" aria-hidden="true"><rect width="48" height="48" rx="10" fill="#1677ff" /><path d="M13 16.5h15.8c2.3 0 4.2 1.9 4.2 4.2v6.1c0 2.3-1.9 4.2-4.2 4.2H22l-5.9 4.4v-4.4h-1.1c-2.3 0-4.2-1.9-4.2-4.2v-6.1c0-2.3 1.9-4.2 4.2-4.2Z" fill="none" stroke="#fff" strokeWidth="2.5" strokeLinejoin="round" /><path d="M18.5 22.2h8.8M18.5 26h5.4" stroke="#fff" strokeWidth="2.2" strokeLinecap="round" /></svg></span></div>}{paymentOrder.payUrl && <a href={paymentOrder.payUrl} target="_blank" rel="noreferrer">打开支付页面</a>}</div>}
|
||||
<h3>套餐充值</h3>
|
||||
{paymentPackages.map((item) => <div className="payment-package" key={item.id}><div><strong>{item.name}</strong><span>{item.description || `${item.credits} 点`}</span></div><b>¥{(item.priceCents / 100).toFixed(2)}</b><button className="button-secondary" type="button" onClick={() => void createPayment(item.id)} disabled={Boolean(payingPackageId)}>{payingPackageId === item.id ? "创建中" : "立即支付"}</button></div>)}
|
||||
{paymentOrder && <div className="payment-status" role="status"><p>订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}</p></div>}
|
||||
</div>}
|
||||
{paymentError && <p className="form-error" role="alert">{paymentError}</p>}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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: <GiftOutlined /> } },
|
||||
{ name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: <CreditCardOutlined /> } },
|
||||
{ name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: <ShoppingOutlined /> } },
|
||||
{ name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: <TeamOutlined /> } },
|
||||
{ name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: <TransactionOutlined /> } },
|
||||
{ name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
|
||||
|
||||
@@ -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<PaymentPackage, "id" | "priceCents"> & { 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<PackageFormValues>();
|
||||
const [packages, setPackages] = useState<PaymentPackage[]>([]);
|
||||
const [packagesLoading, setPackagesLoading] = useState(true);
|
||||
const [packagesError, setPackagesError] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<PaymentPackage | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [disablingId, setDisablingId] = useState<string | null>(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<PaymentPackage> = [
|
||||
{ title: "名称", dataIndex: "name", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.description || "暂无描述"}</Text></Space> },
|
||||
{ title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney },
|
||||
{ title: "点数", dataIndex: "credits", align: "right" },
|
||||
{ title: "排序", dataIndex: "sortOrder", align: "right" },
|
||||
{ title: "状态", dataIndex: "enabled", render: (enabled) => <Tag color={enabled ? "green" : "default"}>{enabled ? "启用" : "停用"}</Tag> },
|
||||
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}>编辑</Button>{item.enabled && <Popconfirm title="停用此套餐?" description="停用后用户将无法继续购买。" okText="停用" cancelText="取消" onConfirm={() => disablePackage(item.id)}><Button type="link" danger loading={disablingId === item.id}>停用</Button></Popconfirm>}</Space> },
|
||||
];
|
||||
|
||||
return (
|
||||
<List title="套餐管理">
|
||||
<Card title="套餐设置" extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>添加套餐</Button>}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{packagesError && <Alert type="error" showIcon message="套餐列表读取失败" description={packagesError} action={<Button size="small" onClick={() => void loadPackages()}>重试</Button>} />}
|
||||
<Table<PaymentPackage> rowKey="id" columns={columns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} />
|
||||
</Space>
|
||||
</Card>
|
||||
<Modal title={editingPackage ? "编辑套餐" : "添加套餐"} open={modalOpen} okText={editingPackage ? "保存修改" : "添加套餐"} cancelText="取消" confirmLoading={saving} onOk={() => packageForm.submit()} onCancel={closeModal} destroyOnHidden afterClose={() => packageForm.resetFields()} maskClosable={!saving} keyboard={!saving}>
|
||||
<Form<PackageFormValues> form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入套餐名称" }, { max: 80 }]}><Input autoFocus /></Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}><Form.Item name="priceYuan" label="价格(元)" extra="保存时转换为 API 的 priceCents" rules={[{ required: true, message: "请输入价格" }]}><InputNumber min={0.01} max={1_000_000} precision={2} step={1} style={{ width: "100%" }} prefix="¥" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="credits" label="点数" rules={[{ required: true, message: "请输入点数" }]}><InputNumber min={1} max={10_000_000} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="sortOrder" label="排序" rules={[{ required: true, message: "请输入排序" }]}><InputNumber min={-100_000} max={100_000} precision={0} style={{ width: "100%" }} /></Form.Item>
|
||||
<Form.Item name="description" label="描述" rules={[{ max: 500 }]}><Input.TextArea rows={3} showCount maxLength={500} /></Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
@@ -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<PaymentPackage, "id" | "priceCents"> & { 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<PaymentFilters>();
|
||||
const [packageForm] = Form.useForm<PackageFormValues>();
|
||||
const [epayForm] = Form.useForm<EpaySettingsForm>();
|
||||
const [orders, setOrders] = useState<Order[]>([]);
|
||||
const [stats, setStats] = useState(initialStats);
|
||||
@@ -114,13 +98,6 @@ export default function PaymentManagement() {
|
||||
const [filters, setFilters] = useState<PaymentFilters>({});
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [packages, setPackages] = useState<PaymentPackage[]>([]);
|
||||
const [packagesLoading, setPackagesLoading] = useState(true);
|
||||
const [packagesError, setPackagesError] = useState("");
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingPackage, setEditingPackage] = useState<PaymentPackage | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [disablingId, setDisablingId] = useState<string | null>(null);
|
||||
const [epaySettings, setEpaySettings] = useState<EpaySettings | null>(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<Order> = [
|
||||
{ title: "订单号", dataIndex: "orderNo", render: (value) => <Text code copyable>{value}</Text> },
|
||||
{ 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<PaymentPackage> = [
|
||||
{ title: "名称", dataIndex: "name", render: (_, item) => <Space direction="vertical" size={0}><Text strong>{item.name}</Text><Text type="secondary">{item.description || "暂无描述"}</Text></Space> },
|
||||
{ title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney },
|
||||
{ title: "点数", dataIndex: "credits", align: "right" },
|
||||
{ title: "排序", dataIndex: "sortOrder", align: "right" },
|
||||
{ title: "状态", dataIndex: "enabled", render: (enabled) => <Tag color={enabled ? "green" : "default"}>{enabled ? "启用" : "停用"}</Tag> },
|
||||
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}>编辑</Button>{item.enabled && <Popconfirm title="停用此套餐?" description="停用后用户将无法继续购买。" okText="停用" cancelText="取消" onConfirm={() => disablePackage(item.id)}><Button type="link" danger loading={disablingId === item.id}>停用</Button></Popconfirm>}</Space> },
|
||||
];
|
||||
|
||||
return (
|
||||
<List title="支付管理">
|
||||
<Space direction="vertical" size="large" style={{ width: "100%" }}>
|
||||
@@ -331,11 +211,12 @@ export default function PaymentManagement() {
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
title="易支付系统配置"
|
||||
title="Z-Pay(易支付)渠道配置"
|
||||
loading={epayLoading}
|
||||
extra={<Space><Button disabled={!epaySettings?.keyConfigured || !epaySettings.complete} loading={epayTesting} onClick={() => void testEpayAvailability()}>测试可用性(当前已保存配置)</Button><Button type="primary" loading={epaySaving} onClick={() => epayForm.submit()}>保存配置</Button></Space>}
|
||||
>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
<Text type="secondary">配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。</Text>
|
||||
{epaySettings && <Space wrap><Tag color={epaySettings.source === "database" ? "blue" : "default"}>来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}</Tag><Tag color={epaySettings.complete ? "green" : "orange"}>{epaySettings.complete ? "配置完整" : "配置不完整"}</Tag><Tag color={epaySettings.keyConfigured ? "green" : "orange"}>{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}</Tag><Tag color={epaySettings.chatEnabled ? "green" : "default"}>{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}</Tag></Space>}
|
||||
{epayError && <Alert type="error" showIcon message="易支付配置读取失败" description={epayError} action={<Button size="small" onClick={() => void loadEpaySettings()}>重试</Button>} />}
|
||||
<Alert type="info" showIcon message="商户密钥不会回显" description="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
|
||||
@@ -375,39 +256,7 @@ export default function PaymentManagement() {
|
||||
/>
|
||||
</Space>
|
||||
</Card>
|
||||
|
||||
<Card title="套餐列表" extra={<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}>添加套餐</Button>}>
|
||||
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
|
||||
{packagesError && <Alert type="error" showIcon message="套餐列表读取失败" description={packagesError} action={<Button size="small" onClick={() => void loadPackages()}>重试</Button>} />}
|
||||
<Table<PaymentPackage> rowKey="id" columns={packageColumns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} />
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
<Modal
|
||||
title={editingPackage ? "编辑套餐" : "添加套餐"}
|
||||
open={modalOpen}
|
||||
okText={editingPackage ? "保存修改" : "添加套餐"}
|
||||
cancelText="取消"
|
||||
confirmLoading={saving}
|
||||
onOk={() => packageForm.submit()}
|
||||
onCancel={closeModal}
|
||||
destroyOnHidden
|
||||
afterClose={() => packageForm.resetFields()}
|
||||
maskClosable={!saving}
|
||||
keyboard={!saving}
|
||||
>
|
||||
<Form<PackageFormValues> form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}>
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true, message: "请输入套餐名称" }, { max: 80 }]}><Input autoFocus /></Form.Item>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}><Form.Item name="priceYuan" label="价格(元)" extra="保存时转换为 API 的 priceCents" rules={[{ required: true, message: "请输入价格" }]}><InputNumber min={0.01} max={1_000_000} precision={2} step={1} style={{ width: "100%" }} prefix="¥" /></Form.Item></Col>
|
||||
<Col span={12}><Form.Item name="credits" label="点数" rules={[{ required: true, message: "请输入点数" }]}><InputNumber min={1} max={10_000_000} precision={0} style={{ width: "100%" }} /></Form.Item></Col>
|
||||
</Row>
|
||||
<Form.Item name="sortOrder" label="排序" rules={[{ required: true, message: "请输入排序" }]}><InputNumber min={-100_000} max={100_000} precision={0} style={{ width: "100%" }} /></Form.Item>
|
||||
<Form.Item name="description" label="描述" rules={[{ max: 500 }]}><Input.TextArea rows={3} showCount maxLength={500} /></Form.Item>
|
||||
<Form.Item name="enabled" label="启用" valuePropName="checked"><Switch checkedChildren="启用" unCheckedChildren="停用" /></Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</List>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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<PaymentOrderRow>/);
|
||||
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, /<Card title="套餐列表" extra=\{<Button[\s\S]*?添加套餐<\/Button>\}>/);
|
||||
assert.doesNotMatch(management, /<List[\s\S]{0,120}headerButtons/);
|
||||
assert.match(management, /<Modal/);
|
||||
assert.match(management, /title=\{editingPackage \? "编辑套餐" : "添加套餐"\}/);
|
||||
assert.match(management, /价格(元)/);
|
||||
assert.match(management, /priceCents: Math\.round\(values\.priceYuan \* 100\)/);
|
||||
for (const method of ["POST", "PATCH", "DELETE"]) assert.match(management, new RegExp(`method: .*${method}`));
|
||||
assert.match(management, /message\.success/);
|
||||
assert.match(management, /message\.error/);
|
||||
assert.match(management, /setPackagesLoading/);
|
||||
assert.match(management, /packagesError/);
|
||||
assert.match(management, /packageForm\.resetFields\(\)/);
|
||||
test("套餐管理是支付管理之后的独立资源和页面", () => {
|
||||
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, /<List title="套餐管理">/);
|
||||
assert.match(packageManagement, /<Card title="套餐设置"/);
|
||||
});
|
||||
|
||||
test("套餐完整设置、加载重试及新增编辑停用操作保留", () => {
|
||||
for (const field of ["名称", "描述", "价格(元)", "点数", "排序", "启用"]) assert.match(packageManagement, new RegExp(field));
|
||||
assert.match(packageManagement, /套餐列表读取失败/);
|
||||
assert.match(packageManagement, /loadPackages\(\)/);
|
||||
assert.match(packageManagement, />重试<\/Button>/);
|
||||
assert.match(packageManagement, /<Modal/);
|
||||
assert.match(packageManagement, /title=\{editingPackage \? "编辑套餐" : "添加套餐"\}/);
|
||||
assert.match(packageManagement, /priceCents: Math\.round\(values\.priceYuan \* 100\)/);
|
||||
for (const method of ["POST", "PATCH", "DELETE"]) assert.match(packageManagement, new RegExp(`method: .*${method}`));
|
||||
assert.match(packageManagement, /message\.success/);
|
||||
assert.match(packageManagement, /message\.error/);
|
||||
assert.match(packageManagement, /packageForm\.resetFields\(\)/);
|
||||
assert.match(packagesRoute, /requireAdminSession\("read"\)/);
|
||||
assert.match(packagesRoute, /requireAdminSession\("write"\)/);
|
||||
});
|
||||
|
||||
test("旧套餐路由服务端重定向到统一支付管理页", () => {
|
||||
assert.match(packagesPage, /import \{ redirect \} from "next\/navigation"/);
|
||||
assert.match(packagesPage, /redirect\("\/admin\/payments"\)/);
|
||||
assert.doesNotMatch(packagesPage, /use client|<form|\/api\/admin\/packages/);
|
||||
});
|
||||
|
||||
@@ -72,6 +72,8 @@ test("管理员 API 不回显任何密钥并强制首次显式录入", () => {
|
||||
assert.match(route, /crypto\.randomUUID\(\)/);
|
||||
assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/);
|
||||
assert.match(route, /chatEnabled: z\.boolean\(\)/);
|
||||
assert.match(route, /chatEnabled: row\.chat_enabled/);
|
||||
assert.match(route, /select\("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled,updated_at"\)/);
|
||||
assert.match(route, /p_chat_enabled: parsed\.data\.chatEnabled/);
|
||||
assert.match(route, /keyConfigured/);
|
||||
assert.doesNotMatch(route, /NextResponse\.json\([^\n]*(?:encrypted_key|newKey|encryptedKey|maskedKey|keyMask)/);
|
||||
@@ -91,8 +93,8 @@ test("支付调用点等待异步数据库配置", () => {
|
||||
assert.match(notifyRoute, /export async function GET/);
|
||||
});
|
||||
|
||||
test("统一支付页面含系统配置 Card 与永不预填的 Password", () => {
|
||||
assert.match(management, /title="易支付系统配置"/);
|
||||
test("统一支付页面含 Z-Pay 渠道配置 Card 与永不预填的 Password", () => {
|
||||
assert.match(management, /title="Z-Pay(易支付)渠道配置"/);
|
||||
assert.match(management, /\/api\/admin\/epay-settings/);
|
||||
assert.match(management, /<Input\.Password/);
|
||||
assert.match(management, /placeholder="留空保持原密钥"/);
|
||||
@@ -118,6 +120,23 @@ test("支付开关贯通迁移、公共套餐 API、创建订单和对话页", (
|
||||
assert.match(page, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/);
|
||||
});
|
||||
|
||||
test("创建订单返回已签名收银台 URL 且不服务端请求网关", () => {
|
||||
assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/);
|
||||
assert.match(createRoute, /const signedParams = \{ \.\.\.params, sign: epaySign\(params, config\.key\), sign_type: "MD5" \}/);
|
||||
assert.match(createRoute, /const payUrl = new URL\(submitUrl\)/);
|
||||
assert.match(createRoute, /payUrl\.searchParams\.set\(name, value\)/);
|
||||
assert.match(createRoute, /NextResponse\.json\(\{ orderNo, payUrl: payUrl\.toString\(\), qrCode: null \}\)/);
|
||||
for (const field of ["money", "name", "notify_url", "out_trade_no", "pid", "return_url", "sitename", "type", "sign", "sign_type"]) assert.match(createRoute, new RegExp(field));
|
||||
assert.doesNotMatch(createRoute, /fetch\(submitUrl|document\.createElement\("form"\)|submitUrl:|fields[, }]/);
|
||||
assert.doesNotMatch(createRoute, /NextResponse\.json\([^\n]*config\.key|searchParams\.set\([^\n]*config\.key/);
|
||||
|
||||
assert.match(page, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/);
|
||||
assert.match(page, /<h3>套餐充值<\/h3>/);
|
||||
assert.match(page, /立即支付/);
|
||||
assert.match(page, /套餐支付暂时不可用,请稍后重试/);
|
||||
assert.doesNotMatch(page, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/);
|
||||
});
|
||||
|
||||
test("网关探测只使用 HEAD/GET、受限响应且共享 SSRF 门禁", () => {
|
||||
assert.match(testRoute, /requireAdminSession\("write"\)/);
|
||||
assert.match(testRoute, /method: "HEAD"/);
|
||||
|
||||
Reference in New Issue
Block a user