fix: restore staging package and epay management
Deploy staging to test server / deploy (push) Successful in 3m42s
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:
+6
-6
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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%" }}>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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%; \}/);
|
||||
});
|
||||
|
||||
@@ -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="留空保持原密钥"/);
|
||||
|
||||
Reference in New Issue
Block a user