feat: manage epay settings securely
Deploy staging to test server / deploy (push) Failing after 5m31s

This commit is contained in:
linmeng
2026-07-29 16:36:35 +08:00
parent 9a20b748e0
commit d956da2993
22 changed files with 921 additions and 52 deletions
+2
View File
@@ -16,4 +16,6 @@ BETTER_AUTH_USER_SECRET=<independent-openssl-rand-base64-32-output>
RESEND_API_KEY=<staging-only-resend-api-key>
RESEND_FROM_EMAIL=Jyotisha Staging <login@staging.jyotisha.chat>
ADMIN_EMAILS=<comma-separated-staging-admin-emails>
EPAY_CONFIG_ENCRYPTION_KEY=<independent-openssl-rand-base64-32-output>
EPAY_CHAT_ENABLED=false
JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=<independent-openssl-rand-base64-32-output>
+8
View File
@@ -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=<independent-base64-encoded-32-byte-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
+7
View File
@@ -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"
+5 -5
View File
@@ -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
- 修复版本:待提交(本地可测)
+7
View File
@@ -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",
+1
View File
@@ -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",
@@ -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);
}
}
@@ -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: "当前已保存的易支付配置暂不可用",
});
}
}
@@ -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<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);
@@ -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<string, string> = {}; 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<string, string> = {}; 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");
+25 -1
View File
@@ -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,
})),
});
}
+11 -3
View File
@@ -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<Array<{ id: string; name: string; description: string; priceCents: number; credits: number }>>([]);
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 && <p className="form-error" role="alert">{redeemError}</p>}
{redeemMessage && <p className="form-success" role="status">{redeemMessage}</p>}
</form>
<div className="payment-packages">
{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>}
</div>
</div>}
</>
)}
@@ -62,6 +62,18 @@ type PaymentPackage = {
type PackageFormValues = Omit<PaymentPackage, "id" | "priceCents"> & { 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<EpaySettings, "gatewayUrl" | "pid" | "notifyUrl" | "returnUrl" | "siteName" | "chatEnabled"> & { newKey?: string };
const initialStats: PaymentStats = {
totalOrders: 0,
@@ -94,6 +106,7 @@ 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);
const [paymentLoading, setPaymentLoading] = useState(true);
@@ -108,6 +121,11 @@ export default function PaymentManagement() {
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);
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() {
</Row>
</Card>
<Card
title="易支付系统配置"
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%" }}>
{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="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="gatewayUrl" label="网关地址" rules={[{ required: true, message: "请输入网关地址" }, { type: "url", message: "请输入有效 URL" }]}><Input placeholder="https://pay.example.com" /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="pid" label="商户 ID" rules={[{ required: true, message: "请输入商户 ID" }, { max: 200 }]}><Input /></Form.Item></Col>
</Row>
<Form.Item name="newKey" label="商户密钥" extra="留空保持当前数据库密钥;系统绝不预填或回显密钥。"><Input.Password autoComplete="new-password" placeholder="留空保持原密钥" /></Form.Item>
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="notifyUrl" label="异步通知地址" rules={[{ required: true, message: "请输入异步通知地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="returnUrl" label="支付完成返回地址" rules={[{ required: true, message: "请输入支付完成返回地址" }, { type: "url", message: "请输入有效 URL" }]}><Input /></Form.Item></Col>
</Row>
<Form.Item name="siteName" label="网站名称" rules={[{ required: true, message: "请输入网站名称" }, { max: 100 }]}><Input /></Form.Item>
<Form.Item name="chatEnabled" label="在对话页开放支付" valuePropName="checked"><Switch checkedChildren="开放" unCheckedChildren="关闭" /></Form.Item>
</Form>
</Space>
</Card>
<Card title="支付记录" extra={<Text type="secondary">共 {total} 条平台订单</Text>}>
<Space direction="vertical" size="middle" style={{ width: "100%" }}>
<Form form={filterForm} layout="inline" onValuesChange={(_, values) => { setOffset(0); setFilters(values); }}>
+43
View File
@@ -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 };
}
}
+106
View File
@@ -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<EpaySettingsRow | null>,
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;
}
+22 -40
View File
@@ -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 };
+46
View File
@@ -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();
}
}
+3
View File
@@ -0,0 +1,3 @@
import "server-only";
export { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "./encryption-core";
+64
View File
@@ -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;
}
@@ -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;
$$;
+159
View File
@@ -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, /<Input\.Password/);
assert.match(management, /placeholder="留空保持原密钥"/);
assert.match(management, /setFieldValue\("newKey", ""\)/);
assert.doesNotMatch(management, /value=\{.*(?:key|secret)/i);
});
test("支付开关贯通迁移、公共套餐 API、创建订单和对话页", () => {
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 && <div className="payment-packages">/);
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'/);
});
+9
View File
@@ -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 <login@staging.jyotisha.chat>",
"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",
[