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}> + form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}> + + + + + + + + + + + + ); +} 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} - > - form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}> - - - - - - - - - - ); } 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| { 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, / { + 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>/); + 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"/);