diff --git a/deploy/.env.staging.identity.example b/deploy/.env.staging.identity.example index 36f6a653..fb5f7365 100644 --- a/deploy/.env.staging.identity.example +++ b/deploy/.env.staging.identity.example @@ -16,4 +16,6 @@ BETTER_AUTH_USER_SECRET= RESEND_API_KEY= RESEND_FROM_EMAIL=Jyotisha Staging ADMIN_EMAILS= +EPAY_CONFIG_ENCRYPTION_KEY= +EPAY_CHAT_ENABLED=false JYOTISH_DYNAMIC_RECTIFICATION_TOKEN= diff --git a/deploy/README.md b/deploy/README.md index 74dd1f97..3b36b0af 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -73,6 +73,14 @@ NEXT_PUBLIC_SUPABASE_ANON_KEY=... SUPABASE_SERVICE_ROLE_KEY=... ADMIN_EMAILS=... +# Required to save/read database-backed 易支付 settings. Base64 decoding must +# produce exactly 32 random bytes. Generate independently; never reuse auth keys. +EPAY_CONFIG_ENCRYPTION_KEY= +# Legacy EPAY_GATEWAY_URL / EPAY_PID / EPAY_KEY / EPAY_NOTIFY_URL / +# EPAY_RETURN_URL / EPAY_SITE_NAME remain fallback-only when no database row exists. +# Online packages stay hidden by default; only explicit true enables the fallback. +EPAY_CHAT_ENABLED=false + # Conversational birth-time rectification rollout controls. # Keep migrations false until the ordered database gate below has passed. RECTIFICATION_PRICE_CREDITS=3 diff --git a/deploy/validate-staging-env.sh b/deploy/validate-staging-env.sh index 93bd3ec7..807af650 100755 --- a/deploy/validate-staging-env.sh +++ b/deploy/validate-staging-env.sh @@ -97,6 +97,13 @@ if [[ "$LITERAL_VALUE" != *@* ]]; then echo "invalid staging identity setting: ADMIN_EMAILS" >&2 exit 1 fi +require_literal EPAY_CONFIG_ENCRYPTION_KEY 44 +if [ "${#LITERAL_VALUE}" -ne 44 ] || + [[ ! "$LITERAL_VALUE" =~ ^[A-Za-z0-9+/]{43}=$ ]]; then + echo "invalid staging identity setting: EPAY_CONFIG_ENCRYPTION_KEY" >&2 + exit 1 +fi +require_selector EPAY_CHAT_ENABLED false require_literal JYOTISH_DYNAMIC_RECTIFICATION_TOKEN 32 echo "staging environment selectors: valid" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index a0b306f7..588963b2 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1617,13 +1617,13 @@ - 状态:resolved - 首次发现:2026-07-29 - 最近更新:2026-07-29 -- 影响面:后台 Refine 侧栏、`/admin/payments`、`/admin/packages` 与后台退出区域;支付 API 数据合同不受影响。 -- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话。 +- 影响面:后台 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,并追加折叠态可访问的“返回对话”链接到 `/`。 -- 验证:`frontend/tests/admin-contracts.test.ts` 锁定单一支付资源、自定义 Sider 不调用 logout 和返回对话链接;`frontend/tests/admin-payments-contract.test.ts` 锁定统一页面组件、两类 fetch、套餐 Modal、POST/PATCH/DELETE、旧路由重定向以及 `requireAdminSession`。相关测试、Next.js build 与 `git diff --check` 结果记录在本次交付报告。 -- 防复发:同一后台领域默认收敛为单一资源和统一 Refine 页面;套餐创建只能出现在 Modal;旧深链接必须显式重定向;后台侧栏返回主站不得调用 `authProvider.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。 - 相关记录:BUG-087、BUG-092 - 复发自:BUG-093 - 修复版本:待提交(本地可测) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0adbd951..0fc0cfeb 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -28,6 +28,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", @@ -10464,6 +10465,12 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/server-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", + "integrity": "sha512-qepMx2JxAa5jjfzxgGoiBhbJmAW3EvM8OcSqn3cuJa2Cpe3cvtXEzHPVfVEAWrhyLGIKZ4GvU14t6lmIeJGNSg==", + "license": "MIT" + }, "node_modules/sonner": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", diff --git a/frontend/package.json b/frontend/package.json index 4811a6ca..9f1c2ea1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -42,6 +42,7 @@ "react-dom": "19.2.4", "react-markdown": "^10.1.0", "remark-gfm": "^4.0.1", + "server-only": "^0.0.1", "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "tailwindcss": "^4.3.2", diff --git a/frontend/src/app/api/admin/epay-settings/route.ts b/frontend/src/app/api/admin/epay-settings/route.ts new file mode 100644 index 00000000..706e9808 --- /dev/null +++ b/frontend/src/app/api/admin/epay-settings/route.ts @@ -0,0 +1,121 @@ +import crypto from "node:crypto"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requireAdminSession } from "@/lib/admin/auth"; +import { adminErrorResponse } from "@/lib/admin/http"; +import { suggestedEpayUrls } from "@/lib/epay/config"; +import { encryptEpayKey } from "@/lib/epay/encryption"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; + +export const runtime = "nodejs"; + +const httpUrl = z.string().trim().min(1).max(2048).url().refine((value) => /^https?:\/\//i.test(value), "必须使用 HTTP(S)"); +const settingsSchema = z.object({ + gatewayUrl: httpUrl, + pid: z.string().trim().min(1).max(200), + notifyUrl: httpUrl, + returnUrl: httpUrl, + siteName: z.string().trim().min(1).max(100), + chatEnabled: z.boolean(), + newKey: z.string().min(1).max(1000).optional(), +}).strict(); + +type SettingsRow = { + gateway_url: string; + pid: string; + encrypted_key: string; + notify_url: string; + return_url: string; + site_name: string; + chat_enabled: boolean; + updated_at?: string; +}; + +function publicSettings(row: SettingsRow, source: "database" | "environment") { + return { + gatewayUrl: row.gateway_url, + pid: row.pid, + notifyUrl: row.notify_url, + returnUrl: row.return_url, + siteName: row.site_name, + 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, + updatedAt: source === "database" ? row.updated_at ?? null : null, + }; +} + +function environmentSettings() { + const defaults = suggestedEpayUrls(); + const row: SettingsRow = { + gateway_url: process.env.EPAY_GATEWAY_URL?.trim() || "", + pid: process.env.EPAY_PID?.trim() || "", + encrypted_key: process.env.EPAY_KEY?.trim() ? "configured" : "", + notify_url: process.env.EPAY_NOTIFY_URL?.trim() || defaults.notifyUrl, + return_url: process.env.EPAY_RETURN_URL?.trim() || defaults.returnUrl, + site_name: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha", + chat_enabled: ["true", "1"].includes(process.env.EPAY_CHAT_ENABLED?.trim().toLowerCase() || ""), + }; + return publicSettings(row, "environment"); +} + +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") + .eq("id", true) + .maybeSingle(); + if (error?.code === "42P01") return null; + if (error) throw new Error(); + return data as SettingsRow | null; +} + +export async function GET() { + try { + await requireAdminSession("read"); + const row = await databaseRow(); + if (row) return NextResponse.json(publicSettings(row, "database")); + const settings = environmentSettings(); + return NextResponse.json(settings.complete || settings.keyConfigured + ? settings + : { ...settings, source: "unconfigured" }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function PUT(request: Request) { + try { + const session = await requireAdminSession("write"); + const parsed = settingsSchema.safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "易支付配置参数不正确" }, { status: 400 }); + + const existing = await databaseRow(); + if (!existing && !parsed.data.newKey) { + return NextResponse.json({ error: "首次保存数据库配置时必须输入新的商户密钥" }, { status: 400 }); + } + const encryptedKey = parsed.data.newKey + ? encryptEpayKey(parsed.data.newKey) + : existing!.encrypted_key; + const { error } = await createAdminSupabaseClient().rpc("admin_save_epay_settings", { + p_actor_user_id: session.user.id, + p_actor_email: session.user.email, + p_actor_role: session.role, + p_request_id: crypto.randomUUID(), + p_gateway_url: parsed.data.gatewayUrl.replace(/\/+$/, ""), + p_pid: parsed.data.pid, + p_encrypted_key: encryptedKey, + p_notify_url: parsed.data.notifyUrl, + p_return_url: parsed.data.returnUrl, + p_site_name: parsed.data.siteName, + p_chat_enabled: parsed.data.chatEnabled, + p_key_changed: Boolean(parsed.data.newKey), + }); + if (error) return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 }); + const saved = await databaseRow(); + if (!saved) return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 }); + return NextResponse.json(publicSettings(saved, "database")); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/epay-settings/test/route.ts b/frontend/src/app/api/admin/epay-settings/test/route.ts new file mode 100644 index 00000000..de157d34 --- /dev/null +++ b/frontend/src/app/api/admin/epay-settings/test/route.ts @@ -0,0 +1,46 @@ +import { NextResponse } from "next/server"; +import { AdminAuthorizationError, requireAdminSession } from "@/lib/admin/auth"; +import { adminErrorResponse } from "@/lib/admin/http"; +import { epaySubmitUrl, readEpayConfig } from "@/lib/epay/config"; +import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy"; + +export const runtime = "nodejs"; + +function reachableStatus(status: number) { + return status >= 200 && status < 500; +} + +export async function POST() { + try { + await requireAdminSession("write"); + const config = await readEpayConfig(); + const submitUrl = epaySubmitUrl(config.gatewayUrl); + await assertPublicGatewayUrl(submitUrl); + const startedAt = performance.now(); + let response = await fetch(submitUrl, { + method: "HEAD", + redirect: "manual", + signal: AbortSignal.timeout(8_000), + }); + if (response.status === 405 || response.status === 501) { + response = await fetch(submitUrl, { + method: "GET", + redirect: "manual", + signal: AbortSignal.timeout(8_000), + }); + } + const available = reachableStatus(response.status); + return NextResponse.json({ + available, + message: available ? "当前已保存的易支付配置可访问" : "当前已保存的易支付配置暂不可用", + latencyMs: Math.round(performance.now() - startedAt), + status: response.status, + }); + } catch (error) { + if (error instanceof AdminAuthorizationError) return adminErrorResponse(error); + return NextResponse.json({ + available: false, + message: "当前已保存的易支付配置暂不可用", + }); + } +} diff --git a/frontend/src/app/api/payment/epay/create/route.ts b/frontend/src/app/api/payment/epay/create/route.ts index c4f20234..f661d01c 100644 --- a/frontend/src/app/api/payment/epay/create/route.ts +++ b/frontend/src/app/api/payment/epay/create/route.ts @@ -4,7 +4,9 @@ import { z } from "zod"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; import { createServerSupabaseClient } from "@/lib/supabase/server"; import { epaySign } from "@/lib/epay/sign"; +import { readEpayAvailability } from "@/lib/epay/availability"; import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config"; +import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy"; export const runtime = "nodejs"; const schema = z.object({ packageId: z.string().uuid() }); @@ -14,14 +16,19 @@ export async function POST(request: Request) { 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 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(); if (packError || !pack) return NextResponse.json({ error: "套餐不存在或已下架" }, { status: 404 }); - const config = readEpayConfig(); const orderNo = `JY${Date.now().toString(36)}${crypto.randomBytes(10).toString("hex")}`; + 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(epaySubmitUrl(config.gatewayUrl), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded" }, body, signal: AbortSignal.timeout(10_000) }); + 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); diff --git a/frontend/src/app/api/payment/epay/notify/route.ts b/frontend/src/app/api/payment/epay/notify/route.ts index c47e8004..7c3b7523 100644 --- a/frontend/src/app/api/payment/epay/notify/route.ts +++ b/frontend/src/app/api/payment/epay/notify/route.ts @@ -6,7 +6,7 @@ import { readEpayConfig } from "@/lib/epay/config"; export const runtime = "nodejs"; async function notify(request: Request) { try { - const config = readEpayConfig(); const raw = request.method === "GET" ? new URL(request.url).search.slice(1) : await request.text(); const params = new URLSearchParams(raw); const values: Record = {}; params.forEach((value, key) => { values[key] = value; }); + const config = await readEpayConfig(); const raw = request.method === "GET" ? new URL(request.url).search.slice(1) : await request.text(); const params = new URLSearchParams(raw); const values: Record = {}; params.forEach((value, key) => { values[key] = value; }); if (!timingSafeSignEqual(values.sign, epaySign(values, config.key)) || values.pid !== config.pid || values.trade_status !== "TRADE_SUCCESS" || !values.out_trade_no || !values.money) return new NextResponse("success", { status: 200 }); const moneyCents = Math.round(Number(values.money) * 100); if (!Number.isSafeInteger(moneyCents) || moneyCents <= 0) return new NextResponse("success", { status: 200 }); const hash = crypto.createHash("sha256").update(raw).digest("hex"); diff --git a/frontend/src/app/api/payment/packages/route.ts b/frontend/src/app/api/payment/packages/route.ts index f54e0e59..062128af 100644 --- a/frontend/src/app/api/payment/packages/route.ts +++ b/frontend/src/app/api/payment/packages/route.ts @@ -1,4 +1,28 @@ import { NextResponse } from "next/server"; +import { readEpayAvailability } from "@/lib/epay/availability"; import { createAdminSupabaseClient } from "@/lib/supabase/admin"; + export const runtime = "nodejs"; -export async function GET() { const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("id,name,description,price_cents,credits,sort_order").eq("enabled", true).order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取充值套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map((p) => ({ id: p.id, name: p.name, description: p.description, priceCents: p.price_cents, credits: p.credits })) }); } + +export async function GET() { + const availability = await readEpayAvailability(); + if (!availability.enabled) return NextResponse.json({ enabled: false, packages: [] }); + + const { data, error } = await createAdminSupabaseClient() + .from("payment_packages") + .select("id,name,description,price_cents,credits,sort_order") + .eq("enabled", true) + .order("sort_order") + .order("created_at"); + if (error) return NextResponse.json({ enabled: false, packages: [] }); + return NextResponse.json({ + enabled: true, + packages: (data || []).map((item) => ({ + id: item.id, + name: item.name, + description: item.description, + priceCents: item.price_cents, + credits: item.credits, + })), + }); +} diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index ec93444e..b0d5f6dc 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -925,6 +925,7 @@ export default function Home() { const [redeemError, setRedeemError] = useState(""); const [redeemMessage, setRedeemMessage] = useState(""); const [redeeming, setRedeeming] = useState(false); + const [paymentEnabled, setPaymentEnabled] = useState(false); const [paymentPackages, setPaymentPackages] = useState>([]); const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null); const [paymentError, setPaymentError] = useState(""); @@ -1675,6 +1676,10 @@ export default function Home() { case "redeem": setRedeemError(""); setRedeemMessage(""); + setPaymentEnabled(false); + setPaymentPackages([]); + setPaymentOrder(null); + setPaymentError(""); break; case "logout": break; @@ -1951,7 +1956,10 @@ export default function Home() { if (activeAccountDialog !== "redeem") return; void fetch("/api/payment/packages", { cache: "no-store" }).then(async (response) => { const payload = await response.json().catch(() => null); - if (response.ok) setPaymentPackages(payload.packages || []); + if (response.ok && payload?.enabled === true) { + setPaymentEnabled(true); + setPaymentPackages(payload.packages || []); + } }); }, [activeAccountDialog]); @@ -3269,12 +3277,12 @@ export default function Home() { {redeemError &&

{redeemError}

} {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 && 打开支付页面}
} -
+
} )} diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx index 0517bc39..02a94ad9 100644 --- a/frontend/src/components/admin/payment-management.tsx +++ b/frontend/src/components/admin/payment-management.tsx @@ -62,6 +62,18 @@ type PaymentPackage = { type PackageFormValues = Omit & { priceYuan: number }; type PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] }; +type EpaySettings = { + gatewayUrl: string; + pid: string; + notifyUrl: string; + returnUrl: string; + siteName: string; + chatEnabled: boolean; + keyConfigured: boolean; + complete: boolean; + source: "database" | "environment" | "unconfigured"; +}; +type EpaySettingsForm = Pick & { newKey?: string }; const initialStats: PaymentStats = { totalOrders: 0, @@ -94,6 +106,7 @@ 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); const [paymentLoading, setPaymentLoading] = useState(true); @@ -108,6 +121,11 @@ export default function PaymentManagement() { 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); + const [epayTesting, setEpayTesting] = useState(false); + const [epayError, setEpayError] = useState(""); const loadPayments = useCallback(async () => { setPaymentLoading(true); @@ -141,6 +159,28 @@ export default function PaymentManagement() { } }, []); + const loadEpaySettings = useCallback(async () => { + setEpayLoading(true); + setEpayError(""); + try { + const payload: EpaySettings = await responsePayload(await fetch("/api/admin/epay-settings", { cache: "no-store" })); + setEpaySettings(payload); + epayForm.setFieldsValue({ + gatewayUrl: payload.gatewayUrl, + pid: payload.pid, + notifyUrl: payload.notifyUrl, + returnUrl: payload.returnUrl, + siteName: payload.siteName, + chatEnabled: payload.chatEnabled, + newKey: "", + }); + } catch (error) { + setEpayError(error instanceof Error ? error.message : "读取易支付配置失败"); + } finally { + setEpayLoading(false); + } + }, [epayForm]); + useEffect(() => { const timer = window.setTimeout(() => void loadPayments(), 0); return () => window.clearTimeout(timer); @@ -149,6 +189,41 @@ export default function PaymentManagement() { const timer = window.setTimeout(() => void loadPackages(), 0); return () => window.clearTimeout(timer); }, [loadPackages]); + useEffect(() => { + const timer = window.setTimeout(() => void loadEpaySettings(), 0); + return () => window.clearTimeout(timer); + }, [loadEpaySettings]); + + async function testEpayAvailability() { + setEpayTesting(true); + try { + const payload = await responsePayload(await fetch("/api/admin/epay-settings/test", { method: "POST" })); + if (payload.available) message.success(`${payload.message}(${payload.status},${payload.latencyMs}ms)`); + else message.error(payload.message || "当前已保存的易支付配置暂不可用"); + } catch (error) { + message.error(error instanceof Error ? error.message : "当前已保存的易支付配置暂不可用"); + } finally { + setEpayTesting(false); + } + } + + async function saveEpaySettings(values: EpaySettingsForm) { + setEpaySaving(true); + try { + await responsePayload(await fetch("/api/admin/epay-settings", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ ...values, newKey: values.newKey || undefined }), + })); + epayForm.setFieldValue("newKey", ""); + message.success("易支付配置已保存"); + await loadEpaySettings(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存易支付配置失败"); + } finally { + setEpaySaving(false); + } + } function openCreateModal() { setEditingPackage(null); @@ -255,6 +330,31 @@ export default function PaymentManagement() { + } + > + + {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} + {epayError && void loadEpaySettings()}>重试} />} + + form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional"> + + + + + + + + + + + + + + + 共 {total} 条平台订单}>
{ setOffset(0); setFilters(values); }}> diff --git a/frontend/src/lib/epay/availability.ts b/frontend/src/lib/epay/availability.ts new file mode 100644 index 00000000..006a5e0e --- /dev/null +++ b/frontend/src/lib/epay/availability.ts @@ -0,0 +1,43 @@ +import "server-only"; + +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; + +function strictEnvironmentChatEnabled() { + const value = process.env.EPAY_CHAT_ENABLED?.trim().toLowerCase(); + return value === "true" || value === "1"; +} + +function environmentConfigComplete() { + return Boolean( + process.env.EPAY_GATEWAY_URL?.trim() + && process.env.EPAY_PID?.trim() + && process.env.EPAY_KEY?.trim(), + ); +} + +export async function readEpayAvailability() { + try { + const { data, error } = await createAdminSupabaseClient() + .from("epay_settings") + .select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled") + .eq("id", true) + .maybeSingle(); + if (error?.code === "42P01") { + return { enabled: strictEnvironmentChatEnabled() && environmentConfigComplete() }; + } + if (error || !data) return { enabled: false }; + return { + enabled: Boolean( + data.chat_enabled + && data.gateway_url + && data.pid + && data.encrypted_key + && data.notify_url + && data.return_url + && data.site_name, + ), + }; + } catch { + return { enabled: false }; + } +} diff --git a/frontend/src/lib/epay/config-core.ts b/frontend/src/lib/epay/config-core.ts new file mode 100644 index 00000000..0f58f389 --- /dev/null +++ b/frontend/src/lib/epay/config-core.ts @@ -0,0 +1,106 @@ +import { decryptEpayKey } from "./encryption-core"; + +export type EpaySettingsRow = { + gateway_url: string; + pid: string; + encrypted_key: string; + notify_url: string; + return_url: string; + site_name: string; + chat_enabled: boolean; +}; + +export class EpayConfigurationError extends Error { + constructor(message = "易支付配置不可用") { + super(message); + this.name = "EpayConfigurationError"; + } +} + +function validHttpUrl(value: string, label: string) { + try { + const url = new URL(value); + if (!/^https?:$/.test(url.protocol)) throw new Error(); + return url; + } catch { + throw new EpayConfigurationError(`${label} 无效`); + } +} + +export function suggestedEpayUrls(siteAddress = process.env.SITE_ADDRESS) { + let base: URL; + try { + base = new URL(siteAddress?.trim() || "http://localhost:3000"); + if (!/^https?:$/.test(base.protocol)) throw new Error(); + } catch { + base = new URL("http://localhost:3000"); + } + return { + notifyUrl: new URL("/api/payment/epay/notify", base).toString(), + returnUrl: new URL("/", base).toString(), + }; +} + +function environmentChatEnabled(value: string | undefined) { + const normalized = value?.trim().toLowerCase(); + return normalized === "true" || normalized === "1"; +} + +function completeConfig(values: { + gateway: string; + pid: string; + key: string; + notifyUrl: string; + returnUrl: string; + siteName: string; + chatEnabled: boolean; +}) { + if (!values.gateway || !values.pid || !values.key || !values.notifyUrl || !values.returnUrl || !values.siteName) { + throw new EpayConfigurationError(); + } + const gatewayUrl = validHttpUrl(values.gateway.replace(/\/+$/, ""), "易支付网关地址"); + validHttpUrl(values.notifyUrl, "异步通知地址"); + validHttpUrl(values.returnUrl, "支付返回地址"); + return { gatewayUrl, pid: values.pid, key: values.key, notifyUrl: values.notifyUrl, returnUrl: values.returnUrl, siteName: values.siteName, chatEnabled: values.chatEnabled }; +} + +export async function resolveEpayConfig( + loadDatabaseRow: () => Promise, + env: NodeJS.ProcessEnv = process.env, +) { + let row: EpaySettingsRow | null; + try { + row = await loadDatabaseRow(); + } catch { + throw new EpayConfigurationError(); + } + if (row) { + return completeConfig({ + gateway: row.gateway_url.trim(), + pid: row.pid.trim(), + key: decryptEpayKey(row.encrypted_key, env.EPAY_CONFIG_ENCRYPTION_KEY), + notifyUrl: row.notify_url.trim(), + returnUrl: row.return_url.trim(), + siteName: row.site_name.trim(), + chatEnabled: row.chat_enabled, + }); + } + + const defaults = suggestedEpayUrls(env.SITE_ADDRESS); + return completeConfig({ + gateway: env.EPAY_GATEWAY_URL?.trim() || "", + pid: env.EPAY_PID?.trim() || "", + key: env.EPAY_KEY?.trim() || "", + notifyUrl: env.EPAY_NOTIFY_URL?.trim() || defaults.notifyUrl, + returnUrl: env.EPAY_RETURN_URL?.trim() || defaults.returnUrl, + siteName: env.EPAY_SITE_NAME?.trim() || "Jyotisha", + chatEnabled: environmentChatEnabled(env.EPAY_CHAT_ENABLED), + }); +} + +export function epaySubmitUrl(gatewayUrl: URL) { + const url = new URL(gatewayUrl.toString()); + url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`; + url.search = ""; + return url; +} diff --git a/frontend/src/lib/epay/config.ts b/frontend/src/lib/epay/config.ts index f3b5620d..3ff3b11d 100644 --- a/frontend/src/lib/epay/config.ts +++ b/frontend/src/lib/epay/config.ts @@ -1,44 +1,26 @@ import "server-only"; -const DEFAULT_NOTIFY_URL = "https://jyotisha.chat/api/payment/epay/notify"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { + epaySubmitUrl, + EpayConfigurationError, + resolveEpayConfig, + suggestedEpayUrls, + type EpaySettingsRow, +} from "./config-core"; -export class EpayConfigurationError extends Error { - constructor(message: string) { - super(message); - this.name = "EpayConfigurationError"; - } +export { epaySubmitUrl, EpayConfigurationError, resolveEpayConfig, suggestedEpayUrls }; +export type { EpaySettingsRow }; + +export async function readEpayConfig() { + return resolveEpayConfig(async () => { + const { data, error } = await createAdminSupabaseClient() + .from("epay_settings") + .select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled") + .eq("id", true) + .maybeSingle(); + if (error?.code === "42P01") return null; + if (error) throw new Error(); + return data as EpaySettingsRow | null; + }); } - -function required(name: string) { - const value = process.env[name]?.trim(); - if (!value) throw new EpayConfigurationError(`${name} 未配置`); - return value; -} - -export function readEpayConfig() { - const gateway = required("EPAY_GATEWAY_URL").replace(/\/+$/, ""); - let gatewayUrl: URL; - try { - gatewayUrl = new URL(gateway); - } catch { - throw new EpayConfigurationError("EPAY_GATEWAY_URL 无效"); - } - if (!/^https?:$/.test(gatewayUrl.protocol)) throw new EpayConfigurationError("EPAY_GATEWAY_URL 必须使用 HTTP(S)"); - return { - gatewayUrl, - pid: required("EPAY_PID"), - key: required("EPAY_KEY"), - notifyUrl: process.env.EPAY_NOTIFY_URL?.trim() || DEFAULT_NOTIFY_URL, - returnUrl: process.env.EPAY_RETURN_URL?.trim() || "https://jyotisha.chat/", - siteName: process.env.EPAY_SITE_NAME?.trim() || "Jyotisha", - }; -} - -export function epaySubmitUrl(gatewayUrl: URL) { - const url = new URL(gatewayUrl.toString()); - url.pathname = `${url.pathname.replace(/\/$/, "")}/submit.php`; - url.search = ""; - return url; -} - -export { DEFAULT_NOTIFY_URL }; diff --git a/frontend/src/lib/epay/encryption-core.ts b/frontend/src/lib/epay/encryption-core.ts new file mode 100644 index 00000000..015b54a5 --- /dev/null +++ b/frontend/src/lib/epay/encryption-core.ts @@ -0,0 +1,46 @@ +import crypto from "node:crypto"; + +const VERSION = "v1"; + +export class EpayEncryptionError extends Error { + constructor() { + super("易支付配置不可用"); + this.name = "EpayEncryptionError"; + } +} + +function encryptionKey(value = process.env.EPAY_CONFIG_ENCRYPTION_KEY) { + if (!value?.trim()) throw new EpayEncryptionError(); + try { + const key = Buffer.from(value.trim(), "base64"); + if (key.length !== 32 || key.toString("base64") !== value.trim()) throw new Error(); + return key; + } catch { + throw new EpayEncryptionError(); + } +} + +export function encryptEpayKey(plaintext: string, masterKey?: string) { + if (!plaintext) throw new EpayEncryptionError(); + const iv = crypto.randomBytes(12); + const cipher = crypto.createCipheriv("aes-256-gcm", encryptionKey(masterKey), iv); + const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]); + return [VERSION, iv.toString("base64url"), cipher.getAuthTag().toString("base64url"), ciphertext.toString("base64url")].join("."); +} + +export function decryptEpayKey(payload: string, masterKey?: string) { + try { + const [version, ivValue, tagValue, ciphertextValue, extra] = payload.split("."); + if (version !== VERSION || !ivValue || !tagValue || !ciphertextValue || extra) throw new Error(); + const decipher = crypto.createDecipheriv("aes-256-gcm", encryptionKey(masterKey), Buffer.from(ivValue, "base64url")); + decipher.setAuthTag(Buffer.from(tagValue, "base64url")); + const plaintext = Buffer.concat([ + decipher.update(Buffer.from(ciphertextValue, "base64url")), + decipher.final(), + ]).toString("utf8"); + if (!plaintext) throw new Error(); + return plaintext; + } catch { + throw new EpayEncryptionError(); + } +} diff --git a/frontend/src/lib/epay/encryption.ts b/frontend/src/lib/epay/encryption.ts new file mode 100644 index 00000000..2b9b4fa9 --- /dev/null +++ b/frontend/src/lib/epay/encryption.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "./encryption-core"; diff --git a/frontend/src/lib/epay/gateway-policy.ts b/frontend/src/lib/epay/gateway-policy.ts new file mode 100644 index 00000000..768b4eec --- /dev/null +++ b/frontend/src/lib/epay/gateway-policy.ts @@ -0,0 +1,64 @@ +import { promises as dns } from "node:dns"; +import { isIP } from "node:net"; + +const blockedHostnames = new Set([ + "localhost", + "localhost.localdomain", + "metadata.google.internal", +]); + +function blockedIpv4(address: string) { + const parts = address.split(".").map(Number); + if (parts.length !== 4 || parts.some((part) => !Number.isInteger(part) || part < 0 || part > 255)) return true; + const [a, b] = parts; + return a === 0 + || a === 10 + || a === 127 + || (a === 100 && b >= 64 && b <= 127) + || (a === 169 && b === 254) + || (a === 172 && b >= 16 && b <= 31) + || (a === 192 && b === 0) + || (a === 192 && b === 168) + || (a === 198 && (b === 18 || b === 19)) + || a >= 224; +} + +export function isPublicEpayAddress(address: string) { + const version = isIP(address); + if (version === 4) return !blockedIpv4(address); + if (version !== 6) return false; + const normalized = address.toLowerCase().split("%")[0]; + if (normalized.startsWith("::ffff:")) return isPublicEpayAddress(normalized.slice(7)); + return normalized !== "::" + && normalized !== "::1" + && !normalized.startsWith("fc") + && !normalized.startsWith("fd") + && !/^fe[89ab]/.test(normalized) + && !normalized.startsWith("2001:db8:"); +} + +export function assertPublicEpayGateway(value: URL | string) { + const url = value instanceof URL ? value : new URL(value); + const hostname = url.hostname.toLowerCase().replace(/\.$/, ""); + if (!/^https?:$/.test(url.protocol) + || url.username + || url.password + || blockedHostnames.has(hostname) + || hostname.endsWith(".localhost") + || hostname.endsWith(".local") + || (isIP(hostname) && !isPublicEpayAddress(hostname))) { + throw new Error("易支付网关地址不允许指向本机或内网"); + } + return url; +} + +export async function assertPublicGatewayUrl(value: URL | string) { + const url = assertPublicEpayGateway(value); + if (!isIP(url.hostname)) { + const addresses = await dns.lookup(url.hostname, { all: true, verbatim: true }); + if (!addresses.length || addresses.some(({ address }) => !isPublicEpayAddress(address))) { + throw new Error("易支付网关地址不允许解析到本机或内网"); + } + } + return url; +} diff --git a/frontend/supabase/migrations/20260729010000_epay_settings.sql b/frontend/supabase/migrations/20260729010000_epay_settings.sql new file mode 100644 index 00000000..63e7cdef --- /dev/null +++ b/frontend/supabase/migrations/20260729010000_epay_settings.sql @@ -0,0 +1,126 @@ +create table public.epay_settings ( + id boolean primary key default true check (id), + gateway_url text not null, + pid text not null, + encrypted_key text not null, + notify_url text not null, + return_url text not null, + site_name text not null, + chat_enabled boolean not null default false, + updated_by uuid not null, + updated_at timestamptz not null default clock_timestamp() +); + +alter table public.epay_settings enable row level security; +revoke all on table public.epay_settings from public, anon, authenticated, service_role; +grant select on table public.epay_settings to service_role; + +alter table audit.admin_audit_logs + drop constraint if exists admin_audit_logs_action_check, + drop constraint if exists admin_audit_logs_target_type_check; +alter table audit.admin_audit_logs + add constraint admin_audit_logs_action_check check ( + action in ('redemption_code.create', 'redemption_code.update', 'redemption_code.revoke', 'epay_settings.update') + ), + add constraint admin_audit_logs_target_type_check check ( + target_type in ('redemption_code', 'epay_settings') + ); + +create or replace function public.admin_save_epay_settings( + p_actor_user_id uuid, + p_actor_email text, + p_actor_role text, + p_request_id text, + p_gateway_url text, + p_pid text, + p_encrypted_key text, + p_notify_url text, + p_return_url text, + p_site_name text, + p_chat_enabled boolean, + p_key_changed boolean +) +returns public.epay_settings +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_email text; + v_before public.epay_settings; + v_after public.epay_settings; + v_target_id constant uuid := '00000000-0000-0000-0000-000000000001'; +begin + v_email := public.admin_verified_actor_email(p_actor_user_id, p_actor_email, p_actor_role); + + select * into v_before from public.epay_settings where id = true for update; + + insert into public.epay_settings ( + id, gateway_url, pid, encrypted_key, notify_url, return_url, + site_name, chat_enabled, updated_by, updated_at + ) values ( + true, p_gateway_url, p_pid, p_encrypted_key, p_notify_url, p_return_url, + p_site_name, p_chat_enabled, p_actor_user_id, clock_timestamp() + ) + on conflict (id) do update set + gateway_url = excluded.gateway_url, + pid = excluded.pid, + encrypted_key = excluded.encrypted_key, + notify_url = excluded.notify_url, + return_url = excluded.return_url, + site_name = excluded.site_name, + chat_enabled = excluded.chat_enabled, + updated_by = excluded.updated_by, + updated_at = excluded.updated_at + returning * into v_after; + + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, + target_id, before_value, after_value, request_id + ) values ( + p_actor_user_id, v_email, p_actor_role, 'epay_settings.update', 'epay_settings', + v_target_id, + case when v_before.id is null then null else jsonb_build_object( + 'gatewayUrl', v_before.gateway_url, + 'pid', v_before.pid, + 'notifyUrl', v_before.notify_url, + 'returnUrl', v_before.return_url, + 'siteName', v_before.site_name, + 'chatEnabled', v_before.chat_enabled, + 'keyConfigured', true, + 'keyChanged', false + ) end, + jsonb_build_object( + 'gatewayUrl', v_after.gateway_url, + 'pid', v_after.pid, + 'notifyUrl', v_after.notify_url, + 'returnUrl', v_after.return_url, + 'siteName', v_after.site_name, + 'chatEnabled', v_after.chat_enabled, + 'keyConfigured', true, + 'keyChanged', p_key_changed + ), + p_request_id + ); + + return v_after; +end; +$$; + +revoke all on function public.admin_save_epay_settings(uuid, text, text, text, text, text, text, text, text, text, boolean, boolean) + from public, anon, authenticated; +grant execute on function public.admin_save_epay_settings(uuid, text, text, text, text, text, text, text, text, text, boolean, boolean) + to service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + grant select on table public.epay_settings to admin_runtime; + drop policy if exists epay_settings_admin_read on public.epay_settings; + create policy epay_settings_admin_read on public.epay_settings + for select to admin_runtime using (id = true); + grant execute on function public.admin_save_epay_settings(uuid, text, text, text, text, text, text, text, text, text, boolean, boolean) + to admin_runtime; + end if; +end; +$$; diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts new file mode 100644 index 00000000..d1ec85ef --- /dev/null +++ b/frontend/tests/epay-settings.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "../src/lib/epay/encryption-core"; +import { resolveEpayConfig } from "../src/lib/epay/config-core"; +import { assertPublicEpayGateway, isPublicEpayAddress } from "../src/lib/epay/gateway-policy"; + +const root = new URL("../", import.meta.url); +const route = readFileSync(new URL("src/app/api/admin/epay-settings/route.ts", root), "utf8"); +const management = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8"); +const createRoute = readFileSync(new URL("src/app/api/payment/epay/create/route.ts", root), "utf8"); +const notifyRoute = readFileSync(new URL("src/app/api/payment/epay/notify/route.ts", root), "utf8"); +const configRoute = readFileSync(new URL("src/lib/epay/config.ts", root), "utf8"); +const availability = readFileSync(new URL("src/lib/epay/availability.ts", root), "utf8"); +const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.ts", root), "utf8"); +const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8"); +const page = readFileSync(new URL("src/app/page.tsx", root), "utf8"); +const migration = readFileSync(new URL("supabase/migrations/20260729010000_epay_settings.sql", root), "utf8"); + +const key = crypto.randomBytes(32).toString("base64"); + +test("易支付密钥 AES-256-GCM 往返、篡改与错误主密钥", () => { + const encrypted = encryptEpayKey("merchant-secret", key); + assert.match(encrypted, /^v1\.[^.]+\.[^.]+\.[^.]+$/); + assert.equal(decryptEpayKey(encrypted, key), "merchant-secret"); + const tampered = `${encrypted.slice(0, -1)}${encrypted.endsWith("A") ? "B" : "A"}`; + assert.throws(() => decryptEpayKey(tampered, key), EpayEncryptionError); + assert.throws(() => decryptEpayKey(encrypted, crypto.randomBytes(32).toString("base64")), EpayEncryptionError); + assert.throws(() => encryptEpayKey("merchant-secret", "not-base64"), EpayEncryptionError); +}); + +test("配置解析数据库优先且无行时回退环境变量", async () => { + const encrypted = encryptEpayKey("database-secret", key); + const database = await resolveEpayConfig(async () => ({ + gateway_url: "https://database-pay.example.com/", + pid: "database-pid", + encrypted_key: encrypted, + notify_url: "https://staging.example.com/api/payment/epay/notify", + return_url: "https://staging.example.com/", + site_name: "Staging", + chat_enabled: false, + }), { + NODE_ENV: "test", + EPAY_CONFIG_ENCRYPTION_KEY: key, + EPAY_GATEWAY_URL: "https://environment-pay.example.com", + EPAY_PID: "environment-pid", + EPAY_KEY: "environment-secret", + }); + assert.equal(database.gatewayUrl.toString(), "https://database-pay.example.com/"); + assert.equal(database.pid, "database-pid"); + assert.equal(database.key, "database-secret"); + assert.equal(database.chatEnabled, false); + + const environment = await resolveEpayConfig(async () => null, { + NODE_ENV: "test", + SITE_ADDRESS: "https://staging.example.com", + EPAY_GATEWAY_URL: "https://environment-pay.example.com", + EPAY_PID: "environment-pid", + EPAY_KEY: "environment-secret", + EPAY_CHAT_ENABLED: "1", + }); + assert.equal(environment.chatEnabled, true); + assert.equal(environment.notifyUrl, "https://staging.example.com/api/payment/epay/notify"); + assert.equal(environment.returnUrl, "https://staging.example.com/"); +}); + +test("管理员 API 不回显任何密钥并强制首次显式录入", () => { + assert.match(route, /requireAdminSession\("read"\)/); + assert.match(route, /requireAdminSession\("write"\)/); + assert.match(route, /\.strict\(\)/); + assert.match(route, /crypto\.randomUUID\(\)/); + assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/); + assert.match(route, /chatEnabled: z\.boolean\(\)/); + 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)/); + assert.doesNotMatch(route, /BETTER_AUTH_SECRET/); +}); + +test("迁移前仅在配置表不存在时继续使用环境变量", () => { + assert.match(configRoute, /error\?\.code === "42P01"/); + assert.match(route, /error\?\.code === "42P01"/); + assert.match(configRoute, /if \(error\) throw new Error\(\)/); +}); + +test("支付调用点等待异步数据库配置", () => { + assert.match(createRoute, /await readEpayConfig\(\)/); + assert.match(notifyRoute, /await readEpayConfig\(\)/); + assert.match(notifyRoute, /export async function POST/); + assert.match(notifyRoute, /export async function GET/); +}); + +test("统一支付页面含系统配置 Card 与永不预填的 Password", () => { + assert.match(management, /title="易支付系统配置"/); + assert.match(management, /\/api\/admin\/epay-settings/); + assert.match(management, / { + assert.match(migration, /chat_enabled boolean not null default false/); + assert.match(migration, /p_chat_enabled boolean/); + assert.match(migration, /'chatEnabled'/); + assert.match(availability, /EPAY_CHAT_ENABLED/); + assert.doesNotMatch(availability, /EPAY_CONFIG_ENCRYPTION_KEY|decryptEpayKey/); + assert.match(packagesRoute, /enabled: false, packages: \[\]/); + assert.match(packagesRoute, /enabled: true/); + assert.match(createRoute, /在线支付暂未开放/); + assert.match(createRoute, /EPAY_DISABLED/); + assert.match(createRoute, /await readEpayAvailability\(\)/); + assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("await readEpayConfig()")); + assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("payment_packages")); + assert.match(management, /在对话页开放支付/); + assert.match(page, /paymentEnabled &&
/); + assert.match(page, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/); +}); + +test("网关探测只使用 HEAD/GET、受限响应且共享 SSRF 门禁", () => { + assert.match(testRoute, /requireAdminSession\("write"\)/); + assert.match(testRoute, /method: "HEAD"/); + assert.match(testRoute, /response\.status === 405 \|\| response\.status === 501/); + assert.match(testRoute, /method: "GET"/); + assert.doesNotMatch(testRoute, /method: "POST"|payment_orders|\.text\(\)|\.json\(\)/); + assert.match(testRoute, /AbortSignal\.timeout\(8_000\)/); + assert.match(testRoute, /redirect: "manual"/); + assert.match(testRoute, /available,[\s\S]*message:[\s\S]*latencyMs:[\s\S]*status:/); + assert.doesNotMatch(testRoute, /pid:|key:|gatewayUrl:|headers:|body:|payment_orders/); + assert.match(testRoute, /assertPublicGatewayUrl\(submitUrl\)/); + assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/); +}); + +test("纯地址判断拒绝私网、回环、链路本地并接受公网", () => { + for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1", "fe80::1", "2001:db8::1"]) { + assert.equal(isPublicEpayAddress(address), false, address); + } + for (const address of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"]) { + assert.equal(isPublicEpayAddress(address), true, address); + } + assert.throws(() => assertPublicEpayGateway("http://localhost/pay")); + assert.throws(() => assertPublicEpayGateway("http://127.0.0.1/pay")); + assert.doesNotThrow(() => assertPublicEpayGateway("https://pay.example.com")); +}); + +test("迁移锁定单行、RLS、最小权限与脱敏原子审计", () => { + assert.match(migration, /create table public\.epay_settings/); + assert.match(migration, /id boolean primary key default true check \(id\)/); + assert.match(migration, /alter table public\.epay_settings enable row level security/); + assert.match(migration, /revoke all on table public\.epay_settings from public, anon, authenticated, service_role/); + assert.match(migration, /grant select on table public\.epay_settings to service_role/); + assert.match(migration, /security definer/); + assert.match(migration, /insert into audit\.admin_audit_logs/); + assert.match(migration, /'keyConfigured'/); + assert.match(migration, /'keyChanged'/); + const auditBlock = migration.slice(migration.indexOf("insert into audit.admin_audit_logs")); + assert.doesNotMatch(auditBlock, /'encryptedKey'|'encrypted_key'|'secret'|jsonb_build_object\([\s\S]*?'key'/); +}); diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 1e15bce6..aea3b45f 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -246,6 +246,8 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi "RESEND_API_KEY=re_test_key_that_must_not_be_printed", "RESEND_FROM_EMAIL=Jyotisha Staging ", "ADMIN_EMAILS=admin@example.com", + "EPAY_CONFIG_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "EPAY_CHAT_ENABLED=false", "JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes", ]; const run = () => @@ -269,6 +271,13 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi ); writeEnv(validSelectors); assert.equal(run().status, 0); + + writeEnv(validSelectors.map((line) => line.startsWith("EPAY_CONFIG_ENCRYPTION_KEY=") ? "EPAY_CONFIG_ENCRYPTION_KEY=invalid" : line)); + assert.notEqual(run().status, 0); + writeEnv(validSelectors.map((line) => line.startsWith("EPAY_CHAT_ENABLED=") ? "EPAY_CHAT_ENABLED=true" : line)); + assert.notEqual(run().status, 0); + writeEnv(validSelectors); + const shellOverride = spawnSync( "docker", [