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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user