feat: manage epay settings securely
Deploy staging to test server / deploy (push) Failing after 5m31s
Deploy staging to test server / deploy (push) Failing after 5m31s
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
- 修复版本:待提交(本地可测)
|
||||
|
||||
Generated
+7
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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); }}>
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
export { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "./encryption-core";
|
||||
@@ -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;
|
||||
$$;
|
||||
@@ -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'/);
|
||||
});
|
||||
@@ -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",
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user