fix: restore staging package and epay management
Deploy staging to test server / deploy (push) Successful in 3m42s

Use direct PostgreSQL access for package and Z-Pay administration so self-hosted staging can load and save settings reliably, while restoring admin scrolling and default channel collapse.
This commit is contained in:
linmeng
2026-07-30 09:33:41 +08:00
parent 3fb635be1b
commit dd8e2ad9c7
9 changed files with 262 additions and 105 deletions
+6 -6
View File
@@ -1618,12 +1618,12 @@
- 首次发现:2026-07-29
- 最近更新:2026-07-29
- 影响面:后台 Refine 侧栏、`/admin/payments``/admin/packages`、易支付配置与对话页充值入口。
- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话;对话页支付入口缺少安全默认关闭和服务端创建订单硬门禁。
- 触发条件:进入同域 `/admin` 后管理支付记录或套餐,或点击 Refine 侧栏底部默认 Logout。
- 根因:首轮支付后台实现依赖 Supabase 专用关联 select、分页、计数和 Admin Auth 查询;self-hosted staging 的本地 PostgreSQL adapter 不支持这些 builder 能力,支付记录因此统一降级为“支付记录服务暂时不可用”。同页套餐设计也不符合最新后台信息架构,易支付配置响应漏投影 `chat_enabled`,chat 创建订单又依赖服务端提交网关后猜测跳转地址,不兼容标准易支付收银台表单页。
- 修复:支付记录改为通过 `queryAdminRows` 执行参数化 SQL,联表 `public.payment_orders``public.payment_packages``identity.users`,以窗口计数保留分页合同并用独立聚合 SQL 输出统计;不再使用 Supabase builder 或 Admin Auth。后台在支付管理之后新增独立“套餐管理”资源和页面,套餐新增、编辑、停用、错误重试及原字段保持完整,支付页只保留概览、Z-Pay(易支付)渠道配置和支付记录。配置读取补回 `chat_enabled``chatEnabled`。创建订单完成登录、开关、配置、SSRF、套餐和订单校验后,直接返回带 `sign/sign_type` 的标准 `submit.php` 收银台 URL,不服务端请求网关、不返回商户密钥;对话页用浏览器打开该 URL,套餐加载异常显示安全错误,正常 `enabled=false` 仍静默隐藏。
- 验证:`frontend/tests/admin-contracts.test.ts` 锁定支付、套餐资源顺序;`frontend/tests/admin-payments-contract.test.ts` 锁定本地参数化 SQL、`identity.users` 联表、独立套餐页面和完整套餐操作`frontend/tests/epay-settings.test.ts` 锁定 `chatEnabled` 回显、数据库 select、Z-Pay 标题、已签名收银台 URL、不服务端 fetch 和不泄露 key。相关测试、ESLint 与 `git diff --check` 结果记录在本次交付报告。
- 防复发:self-hosted staging 后台查询不得依赖 LocalPostgresDataClient 未实现的 Supabase builder 或 Admin Auth 能力;支付与套餐必须保持独立资源顺序。易支付配置读写测试必须同时覆盖数据库列和公开字段;创建订单只生成经公网 SSRF 校验的签名收银台 URL,商户密钥只能参与服务端签名,不得进入 URL、响应、日志或审计。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。
- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话;对话页支付入口缺少安全默认关闭和服务端创建订单硬门禁。2026-07-29 复发时,Z-Pay 配置不能折叠且占据长页面,后台受全局 `html/body overflow:hidden` 限制无法纵向滚动,套餐 API 与易支付配置 API 仍调用 self-hosted adapter 不支持的 Supabase builder/RPC。
- 触发条件:进入同域 `/admin` 后管理支付记录或套餐,或点击 Refine 侧栏底部默认 Logout;复发条件为进入支付管理、展开长配置或调用套餐 CRUD / 易支付配置读写
- 根因:首轮支付后台实现依赖 Supabase 专用关联 select、分页、计数和 Admin Auth 查询;self-hosted staging 的本地 PostgreSQL adapter 不支持这些 builder 能力,支付记录因此统一降级为“支付记录服务暂时不可用”。同页套餐设计也不符合最新后台信息架构,易支付配置响应漏投影 `chat_enabled`,chat 创建订单又依赖服务端提交网关后猜测跳转地址,不兼容标准易支付收银台表单页。复发遗漏源于上轮只把支付记录切换到 PostgreSQL,套餐与配置契约测试没有锁定 self-hosted 数据链,且未覆盖聊天全局滚动边界下的后台专用滚动容器。
- 修复:支付记录改为通过 `queryAdminRows` 执行参数化 SQL,联表 `public.payment_orders``public.payment_packages``identity.users`,以窗口计数保留分页合同并用独立聚合 SQL 输出统计;不再使用 Supabase builder 或 Admin Auth。后台在支付管理之后新增独立“套餐管理”资源和页面,套餐新增、编辑、停用、错误重试及原字段保持完整,支付页只保留概览、Z-Pay(易支付)渠道配置和支付记录。配置读取补回 `chat_enabled``chatEnabled`。创建订单完成登录、开关、配置、SSRF、套餐和订单校验后,直接返回带 `sign/sign_type` 的标准 `submit.php` 收银台 URL,不服务端请求网关、不返回商户密钥;对话页用浏览器打开该 URL,套餐加载异常显示安全错误,正常 `enabled=false` 仍静默隐藏。复发修复将 Z-Pay 配置改为默认收起的 Ant Design `Collapse`,展开后才显示表单和操作;为 AdminApp 增加 `admin-app-shell``100dvh` 独立纵向滚动边界而不改聊天全局规则;套餐 CRUD 全部改用 `queryAdminRows` 参数化 SQL、UUID 校验、`returning` 与 404;易支付读取仅在 PostgreSQL `42P01` 时回退环境变量,保存直接参数化调用 `public.admin_save_epay_settings` 并使用函数返回行,保留原子审计和脱敏响应。
- 验证:`frontend/tests/admin-contracts.test.ts` 锁定支付、套餐资源顺序;`frontend/tests/admin-payments-contract.test.ts` 锁定本地参数化 SQL、`identity.users` 联表、套餐 SQL CRUD/UUID/404、独立套餐页面、默认折叠和后台专用滚动容器`frontend/tests/epay-settings.test.ts` 锁定 `chatEnabled` 回显、`queryAdminRows` 读取、参数化 `admin_save_epay_settings`、不依赖 Supabase builder/RPC、默认折叠和不泄露 key。2026-07-29 运行三份契约测试共 27 项全部通过;ESLint、TypeScript 与 `git diff --check` 结果记录在本次交付报告。
- 防复发:self-hosted staging 后台查询不得依赖 LocalPostgresDataClient 未实现的 Supabase builder、RPC 或 Admin Auth 能力;支付与套餐必须保持独立资源顺序。套餐与易支付配置契约必须显式拒绝 Supabase builder/RPC 并锁定参数化 SQL、404、原子函数写入和安全错误响应;支付配置必须默认折叠,后台必须拥有独立滚动容器且不得放宽聊天的全局 `overflow:hidden`易支付配置读写测试必须同时覆盖数据库列和公开字段;创建订单只生成经公网 SSRF 校验的签名收银台 URL,商户密钥只能参与服务端签名,不得进入 URL、响应、日志或审计。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。
- 相关记录:BUG-087、BUG-092
- 复发自:BUG-093
- 修复版本:待提交(本地可测)
@@ -2,10 +2,10 @@ import crypto from "node:crypto";
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { isPostgresError, queryAdminRows } from "@/lib/admin/database";
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";
@@ -28,7 +28,7 @@ type SettingsRow = {
return_url: string;
site_name: string;
chat_enabled: boolean;
updated_at?: string;
updated_at?: Date;
};
function publicSettings(row: SettingsRow, source: "database" | "environment") {
@@ -42,7 +42,7 @@ function publicSettings(row: SettingsRow, source: "database" | "environment") {
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,
updatedAt: source === "database" ? row.updated_at?.toISOString() ?? null : null,
};
}
@@ -61,14 +61,18 @@ function environmentSettings() {
}
async function databaseRow() {
const { data, error } = await createAdminSupabaseClient()
.from("epay_settings")
.select("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled,updated_at")
.eq("id", true)
.maybeSingle();
if (error?.code === "42P01") return null;
if (error) throw new Error();
return data as SettingsRow | null;
try {
const rows = await queryAdminRows<SettingsRow>(`
select gateway_url, pid, encrypted_key, notify_url, return_url, site_name, chat_enabled, updated_at
from public.epay_settings
where id = true
limit 1
`);
return rows[0] ?? null;
} catch (error) {
if (isPostgresError(error) && error.code === "42P01") return null;
throw error;
}
}
export async function GET() {
@@ -98,24 +102,30 @@ export async function PUT(request: Request) {
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"));
try {
const rows = await queryAdminRows<SettingsRow>(`
select * from public.admin_save_epay_settings(
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12
)
`, [
session.user.id,
session.user.email,
session.role,
crypto.randomUUID(),
parsed.data.gatewayUrl.replace(/\/+$/, ""),
parsed.data.pid,
encryptedKey,
parsed.data.notifyUrl,
parsed.data.returnUrl,
parsed.data.siteName,
parsed.data.chatEnabled,
Boolean(parsed.data.newKey),
]);
if (!rows[0]) return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 });
return NextResponse.json(publicSettings(rows[0], "database"));
} catch {
return NextResponse.json({ error: "保存易支付配置失败" }, { status: 500 });
}
} catch (error) {
return adminErrorResponse(error);
}
+108 -7
View File
@@ -1,13 +1,114 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { requireAdminSession } from "@/lib/admin/auth";
import { queryAdminRows } from "@/lib/admin/database";
import { adminErrorResponse } from "@/lib/admin/http";
import { createAdminSupabaseClient } from "@/lib/supabase/admin";
export const runtime = "nodejs";
const schema = z.object({ name: z.string().trim().min(1).max(80), description: z.string().trim().max(500), priceCents: z.number().int().positive().max(100_000_000), credits: z.number().int().positive().max(10_000_000), sortOrder: z.number().int().min(-100_000).max(100_000), enabled: z.boolean() });
function output(row: Record<string, unknown>) { return { id: row.id, name: row.name, description: row.description, priceCents: row.price_cents, credits: row.credits, sortOrder: row.sort_order, enabled: row.enabled, createdAt: row.created_at, updatedAt: row.updated_at }; }
export async function GET() { try { await requireAdminSession("read"); const { data, error } = await createAdminSupabaseClient().from("payment_packages").select("*").order("sort_order").order("created_at"); if (error) return NextResponse.json({ error: "暂时无法读取套餐" }, { status: 500 }); return NextResponse.json({ packages: (data || []).map(output) }); } catch (error) { return adminErrorResponse(error); } }
export async function POST(request: Request) { try { const auth = await requireAdminSession("write"); const parsed = schema.safeParse(await request.json().catch(() => null)); if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").insert({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, created_by: auth.user.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); } catch (error) { return adminErrorResponse(error); } }
export async function PATCH(request: Request) { try { await requireAdminSession("write"); const body = await request.json().catch(() => null); const id = typeof body?.id === "string" ? body.id : ""; const parsed = schema.safeParse(body); if (!id || !parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const p = parsed.data; const { data, error } = await createAdminSupabaseClient().from("payment_packages").update({ name: p.name, description: p.description, price_cents: p.priceCents, credits: p.credits, sort_order: p.sortOrder, enabled: p.enabled, updated_at: new Date().toISOString() }).eq("id", id).select().single(); if (error) return NextResponse.json({ error: "更新套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }); } catch (error) { return adminErrorResponse(error); } }
export async function DELETE(request: Request) { try { await requireAdminSession("write"); const body = await request.json().catch(() => null); if (typeof body?.id !== "string") return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 }); const { error } = await createAdminSupabaseClient().from("payment_packages").update({ enabled: false, updated_at: new Date().toISOString() }).eq("id", body.id); if (error) return NextResponse.json({ error: "停用套餐失败" }, { status: 500 }); return NextResponse.json({ ok: true }); } catch (error) { return adminErrorResponse(error); } }
const schema = z.object({
name: z.string().trim().min(1).max(80),
description: z.string().trim().max(500),
priceCents: z.number().int().positive().max(100_000_000),
credits: z.number().int().positive().max(10_000_000),
sortOrder: z.number().int().min(-100_000).max(100_000),
enabled: z.boolean(),
}).strict();
const updateSchema = schema.extend({ id: z.string().uuid() });
const idSchema = z.object({ id: z.string().uuid() }).strict();
type PackageRow = {
id: string;
name: string;
description: string;
price_cents: number;
credits: number;
sort_order: number;
enabled: boolean;
created_at: Date;
updated_at: Date;
};
function output(row: PackageRow) {
return {
id: row.id,
name: row.name,
description: row.description,
priceCents: row.price_cents,
credits: row.credits,
sortOrder: row.sort_order,
enabled: row.enabled,
createdAt: row.created_at.toISOString(),
updatedAt: row.updated_at.toISOString(),
};
}
export async function GET() {
try {
await requireAdminSession("read");
const rows = await queryAdminRows<PackageRow>(`
select id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at
from public.payment_packages
order by sort_order, created_at
`);
return NextResponse.json({ packages: rows.map(output) });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function POST(request: Request) {
try {
const auth = await requireAdminSession("write");
const parsed = schema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 });
const p = parsed.data;
const rows = await queryAdminRows<PackageRow>(`
insert into public.payment_packages
(name, description, price_cents, credits, sort_order, enabled, created_by)
values ($1, $2, $3, $4, $5, $6, $7)
returning id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at
`, [p.name, p.description, p.priceCents, p.credits, p.sortOrder, p.enabled, auth.user.id]);
return NextResponse.json({ package: output(rows[0]) }, { status: 201 });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function PATCH(request: Request) {
try {
await requireAdminSession("write");
const parsed = updateSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 });
const p = parsed.data;
const rows = await queryAdminRows<PackageRow>(`
update public.payment_packages
set name = $2, description = $3, price_cents = $4, credits = $5,
sort_order = $6, enabled = $7, updated_at = clock_timestamp()
where id = $1
returning id, name, description, price_cents, credits, sort_order, enabled, created_at, updated_at
`, [p.id, p.name, p.description, p.priceCents, p.credits, p.sortOrder, p.enabled]);
if (!rows[0]) return NextResponse.json({ error: "套餐不存在" }, { status: 404 });
return NextResponse.json({ package: output(rows[0]) });
} catch (error) {
return adminErrorResponse(error);
}
}
export async function DELETE(request: Request) {
try {
await requireAdminSession("write");
const parsed = idSchema.safeParse(await request.json().catch(() => null));
if (!parsed.success) return NextResponse.json({ error: "套餐参数不正确" }, { status: 400 });
const rows = await queryAdminRows<{ id: string }>(`
update public.payment_packages
set enabled = false, updated_at = clock_timestamp()
where id = $1
returning id
`, [parsed.data.id]);
if (!rows[0]) return NextResponse.json({ error: "套餐不存在" }, { status: 404 });
return NextResponse.json({ ok: true });
} catch (error) {
return adminErrorResponse(error);
}
}
+2
View File
@@ -744,6 +744,8 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background:
.auth-links { display: flex; flex-wrap: wrap; justify-content: space-between; gap: var(--space-2); }
.auth-links button { min-height: 32px; padding: 0; color: var(--color-action); }
.admin-app-shell { height: 100dvh; min-height: 0; overflow-y: auto; }
.admin-app-shell > *, .admin-app-shell .ant-layout { min-height: 100%; }
.admin-page { background: var(--color-canvas-soft); }
.admin-header { position: sticky; z-index: 4; top: 0; display: flex; align-items: center; justify-content: space-between; gap: 20px; border-bottom: 1px solid var(--color-border); min-height: 88px; padding: 0 var(--space-8); background: var(--color-frosted); backdrop-filter: saturate(130%) blur(20px); }
.admin-header h1 { font-size: var(--type-display-md); }
+25 -23
View File
@@ -41,15 +41,16 @@ function AdminSider() {
export function AdminApp({ children }: { children: ReactNode }) {
const notificationProvider = useNotificationProvider();
return (
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm, token: { colorPrimary: "#c8a96b" } }}>
<AntdApp>
<Refine
routerProvider={routerProvider}
dataProvider={adminDataProvider}
authProvider={adminAuthProvider}
accessControlProvider={adminAccessControlProvider}
notificationProvider={notificationProvider}
resources={[
<div className="admin-app-shell">
<ConfigProvider theme={{ algorithm: theme.darkAlgorithm, token: { colorPrimary: "#c8a96b" } }}>
<AntdApp>
<Refine
routerProvider={routerProvider}
dataProvider={adminDataProvider}
authProvider={adminAuthProvider}
accessControlProvider={adminAccessControlProvider}
notificationProvider={notificationProvider}
resources={[
{ name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: <GiftOutlined /> } },
{ name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: <CreditCardOutlined /> } },
{ name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: <ShoppingOutlined /> } },
@@ -58,21 +59,22 @@ export function AdminApp({ children }: { children: ReactNode }) {
{ name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: <MessageOutlined /> } },
{ name: "audit-logs", list: "/admin/codes?resource=audit-logs", meta: { label: "审计日志", icon: <AuditOutlined /> } },
]}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
title: { text: "Jyotisha 后台" },
}}
>
<Authenticated
key="admin-authenticated"
loading={<div className="admin-loading"><Spin size="large" /><span></span></div>}
options={{
syncWithLocation: true,
warnWhenUnsavedChanges: true,
title: { text: "Jyotisha 后台" },
}}
>
<ThemedLayout Sider={AdminSider}>{children}</ThemedLayout>
</Authenticated>
</Refine>
</AntdApp>
</ConfigProvider>
<Authenticated
key="admin-authenticated"
loading={<div className="admin-loading"><Spin size="large" /><span></span></div>}
>
<ThemedLayout Sider={AdminSider}>{children}</ThemedLayout>
</Authenticated>
</Refine>
</AntdApp>
</ConfigProvider>
</div>
);
}
@@ -7,6 +7,7 @@ import {
Button,
Card,
Col,
Collapse,
DatePicker,
Form,
Input,
@@ -210,31 +211,39 @@ export default function PaymentManagement() {
</Row>
</Card>
<Card
title="Z-Pay(易支付)渠道配置"
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%" }}>
<Text type="secondary"> Z-Pay / </Text>
{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>
<Collapse
defaultActiveKey={[]}
items={[{
key: "epay-settings",
label: "Z-Pay(易支付)渠道配置",
children: (
<Card
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%" }}>
<Text type="secondary"> Z-Pay / </Text>
{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%" }}>
+12 -7
View File
@@ -5,35 +5,40 @@ import { Pool, type QueryResultRow } from "pg";
import { readDatabaseUrl } from "@/lib/db/config";
const poolGlobal = globalThis as typeof globalThis & {
jyotishaAdminReadPool?: Pool;
jyotishaAdminDatabasePool?: Pool;
};
export function adminReadPool(): Pool {
export function adminDatabasePool(): Pool {
if (
process.env.AUTH_PROVIDER?.trim() !== "self-hosted"
|| process.env.APP_ENV?.trim() === "production"
) {
throw new Error("admin reads require the staging self-hosted identity service");
throw new Error("admin database requests require the staging self-hosted identity service");
}
poolGlobal.jyotishaAdminReadPool ??= new Pool({
poolGlobal.jyotishaAdminDatabasePool ??= new Pool({
connectionString: readDatabaseUrl(process.env, "ADMIN_DATABASE_URL"),
max: 10,
idleTimeoutMillis: 30_000,
connectionTimeoutMillis: 5_000,
allowExitOnIdle: true,
application_name: "jyotisha-admin-read",
application_name: "jyotisha-admin-database",
});
return poolGlobal.jyotishaAdminReadPool;
return poolGlobal.jyotishaAdminDatabasePool;
}
export async function queryAdminRows<T extends QueryResultRow>(
sql: string,
values: readonly unknown[] = [],
): Promise<T[]> {
const result = await adminReadPool().query<T>(sql, [...values]);
const result = await adminDatabasePool().query<T>(sql, [...values]);
return result.rows;
}
export function isPostgresError(error: unknown): error is { code: string } {
return typeof error === "object" && error !== null && "code" in error
&& typeof (error as { code?: unknown }).code === "string";
}
export type PageResult<T> = { data: T[]; total: number };
export function pageOffset(page: number, pageSize: number) {
+26 -3
View File
@@ -10,6 +10,7 @@ const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", roo
const paymentManagement = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8");
const packageManagement = readFileSync(new URL("src/components/admin/package-management.tsx", root), "utf8");
const adminApp = readFileSync(new URL("src/components/admin/admin-app.tsx", root), "utf8");
const globalsCss = readFileSync(new URL("src/app/globals.css", root), "utf8");
test("支付后台接口使用 self-hosted PostgreSQL 联表且不依赖 Supabase builder", () => {
assert.match(route, /requireAdminSession\("read"\)/);
@@ -35,11 +36,12 @@ test("支付接口使用参数化筛选、独立日期统计与 ISO 日期输出
assert.match(route, /hasMore/);
});
test("支付管理页只保留概览、Z-Pay 配置与支付记录", () => {
test("支付管理页只保留概览、默认折叠的 Z-Pay 配置与支付记录", () => {
assert.match(paymentPage, /PaymentManagement/);
for (const component of ["List", "Card", "Table", "Form", "Tag", "Alert", "Statistic"]) assert.match(paymentManagement, new RegExp(`\\b${component}\\b`));
for (const component of ["List", "Card", "Collapse", "Table", "Form", "Tag", "Alert", "Statistic"]) assert.match(paymentManagement, new RegExp(`\\b${component}\\b`));
assert.match(paymentManagement, /支付概览/);
assert.match(paymentManagement, /Z-Pay(易支付)渠道配置/);
assert.match(paymentManagement, /<Collapse[\s\S]*defaultActiveKey=\{\[\]\}[\s\S]*label: "Z-Pay(易支付)渠道配置"/);
assert.match(paymentManagement, /children: \([\s\S]*测试可用性(当前已保存配置)[\s\S]*保存配置[\s\S]*<Form<EpaySettingsForm>/);
assert.match(paymentManagement, /支付记录/);
assert.match(paymentManagement, /\/api\/admin\/payments/);
assert.doesNotMatch(paymentManagement, /套餐列表|套餐设置|添加套餐|\/api\/admin\/packages|PackageManagement/);
@@ -69,3 +71,24 @@ test("套餐完整设置、加载重试及新增编辑停用操作保留", () =>
assert.match(packagesRoute, /requireAdminSession\("read"\)/);
assert.match(packagesRoute, /requireAdminSession\("write"\)/);
});
test("套餐 API 使用直接参数化 PostgreSQL CRUD、UUID 校验与未找到响应", () => {
assert.match(packagesRoute, /queryAdminRows<PackageRow>/);
assert.match(packagesRoute, /from public\.payment_packages[\s\S]*order by sort_order, created_at/);
assert.match(packagesRoute, /insert into public\.payment_packages[\s\S]*values \(\$1, \$2, \$3, \$4, \$5, \$6, \$7\)[\s\S]*returning/);
assert.match(packagesRoute, /created_by[\s\S]*auth\.user\.id/);
assert.match(packagesRoute, /updateSchema = schema\.extend\(\{ id: z\.string\(\)\.uuid\(\) \}\)/);
assert.match(packagesRoute, /update public\.payment_packages[\s\S]*where id = \$1[\s\S]*returning/);
assert.match(packagesRoute, /set enabled = false, updated_at = clock_timestamp\(\)[\s\S]*returning id/);
assert.match(packagesRoute, /套餐不存在" \}, \{ status: 404 \}/g);
assert.match(packagesRoute, /created_at\.toISOString\(\)/);
assert.match(packagesRoute, /updated_at\.toISOString\(\)/);
assert.doesNotMatch(packagesRoute, /createAdminSupabaseClient|\.from\(|\.insert\(|\.update\(|\.eq\(|\.select\(/);
});
test("后台使用独立的全视口纵向滚动容器而不修改全局聊天溢出边界", () => {
assert.match(adminApp, /<div className="admin-app-shell">[\s\S]*<ConfigProvider/);
assert.match(globalsCss, /html, body \{[^}]*overflow: hidden;/);
assert.match(globalsCss, /\.admin-app-shell \{ height: 100dvh; min-height: 0; overflow-y: auto; \}/);
assert.match(globalsCss, /\.admin-app-shell > \*, \.admin-app-shell \.ant-layout \{ min-height: 100%; \}/);
});
+10 -5
View File
@@ -73,16 +73,20 @@ test("管理员 API 不回显任何密钥并强制首次显式录入", () => {
assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/);
assert.match(route, /chatEnabled: z\.boolean\(\)/);
assert.match(route, /chatEnabled: row\.chat_enabled/);
assert.match(route, /select\("gateway_url,pid,encrypted_key,notify_url,return_url,site_name,chat_enabled,updated_at"\)/);
assert.match(route, /p_chat_enabled: parsed\.data\.chatEnabled/);
assert.match(route, /queryAdminRows<SettingsRow>/);
assert.match(route, /select gateway_url, pid, encrypted_key, notify_url, return_url, site_name, chat_enabled, updated_at[\s\S]*from public\.epay_settings[\s\S]*where id = true[\s\S]*limit 1/);
assert.match(route, /select \* from public\.admin_save_epay_settings\([\s\S]*\$1, \$2, \$3, \$4, \$5, \$6, \$7, \$8, \$9, \$10, \$11, \$12/);
assert.match(route, /parsed\.data\.chatEnabled,[\s\S]*Boolean\(parsed\.data\.newKey\)/);
assert.match(route, /keyConfigured/);
assert.doesNotMatch(route, /createAdminSupabaseClient|\.from\(|\.rpc\(/);
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(route, /isPostgresError\(error\) && error\.code === "42P01"/);
assert.match(route, /throw error/);
assert.match(configRoute, /if \(error\) throw new Error\(\)/);
});
@@ -93,8 +97,9 @@ test("支付调用点等待异步数据库配置", () => {
assert.match(notifyRoute, /export async function GET/);
});
test("统一支付页面含 Z-Pay 渠道配置 Card 与永不预填的 Password", () => {
assert.match(management, /title="Z-Pay(易支付)渠道配置"/);
test("统一支付页面含默认折叠的 Z-Pay 渠道配置与永不预填的 Password", () => {
assert.match(management, /<Collapse[\s\S]*defaultActiveKey=\{\[\]\}[\s\S]*label: "Z-Pay(易支付)渠道配置"/);
assert.match(management, /children: \([\s\S]*<Card[\s\S]*测试可用性(当前已保存配置)[\s\S]*保存配置/);
assert.match(management, /\/api\/admin\/epay-settings/);
assert.match(management, /<Input\.Password/);
assert.match(management, /placeholder="留空保持原密钥"/);