From dd8e2ad9c7e76d0152b4563c43a45b1e26137035 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Thu, 30 Jul 2026 09:33:41 +0800 Subject: [PATCH] fix: restore staging package and epay management 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. --- docs/BUG_HISTORY.md | 12 +- .../src/app/api/admin/epay-settings/route.ts | 68 ++++++----- frontend/src/app/api/admin/packages/route.ts | 115 ++++++++++++++++-- frontend/src/app/globals.css | 2 + frontend/src/components/admin/admin-app.tsx | 48 ++++---- .../components/admin/payment-management.tsx | 59 +++++---- frontend/src/lib/admin/database.ts | 19 +-- .../tests/admin-payments-contract.test.ts | 29 ++++- frontend/tests/epay-settings.test.ts | 15 ++- 9 files changed, 262 insertions(+), 105 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 33a38568..b2740fa1 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 - 修复版本:待提交(本地可测) diff --git a/frontend/src/app/api/admin/epay-settings/route.ts b/frontend/src/app/api/admin/epay-settings/route.ts index 9ba77399..50cdd7b5 100644 --- a/frontend/src/app/api/admin/epay-settings/route.ts +++ b/frontend/src/app/api/admin/epay-settings/route.ts @@ -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(` + 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(` + 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); } diff --git a/frontend/src/app/api/admin/packages/route.ts b/frontend/src/app/api/admin/packages/route.ts index bc10b7e6..6601686d 100644 --- a/frontend/src/app/api/admin/packages/route.ts +++ b/frontend/src/app/api/admin/packages/route.ts @@ -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) { 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(` + 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(` + 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(` + 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); + } +} diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index a1d94030..853985d8 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -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); } diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 0651455e..045daa24 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -41,15 +41,16 @@ function AdminSider() { export function AdminApp({ children }: { children: ReactNode }) { const notificationProvider = useNotificationProvider(); return ( - - - + + + } }, { name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: } }, { name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: } }, @@ -58,21 +59,22 @@ export function AdminApp({ children }: { children: ReactNode }) { { name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: } }, { name: "audit-logs", list: "/admin/codes?resource=audit-logs", meta: { label: "审计日志", icon: } }, ]} - options={{ - syncWithLocation: true, - warnWhenUnsavedChanges: true, - title: { text: "Jyotisha 后台" }, - }} - > - 正在验证后台权限} + options={{ + syncWithLocation: true, + warnWhenUnsavedChanges: true, + title: { text: "Jyotisha 后台" }, + }} > - {children} - - - - + 正在验证后台权限} + > + {children} + + + + + ); } diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx index fa7bfce8..cedadbcd 100644 --- a/frontend/src/components/admin/payment-management.tsx +++ b/frontend/src/components/admin/payment-management.tsx @@ -7,6 +7,7 @@ import { Button, Card, Col, + Collapse, DatePicker, Form, Input, @@ -210,31 +211,39 @@ export default function PaymentManagement() { - } - > - - 配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。 - {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} - {epayError && void loadEpaySettings()}>重试} />} - - form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional"> - - - - - - - - - - - - - - + } + > + + 配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。 + {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} + {epayError && void loadEpaySettings()}>重试} />} + + form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional"> + + + + + + + + + + + + + + + ), + }]} + /> 共 {total} 条平台订单}> diff --git a/frontend/src/lib/admin/database.ts b/frontend/src/lib/admin/database.ts index 433e7319..3adeac35 100644 --- a/frontend/src/lib/admin/database.ts +++ b/frontend/src/lib/admin/database.ts @@ -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( sql: string, values: readonly unknown[] = [], ): Promise { - const result = await adminReadPool().query(sql, [...values]); + const result = await adminDatabasePool().query(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 = { data: T[]; total: number }; export function pageOffset(page: number, pageSize: number) { diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts index bd43cded..5ff9c475 100644 --- a/frontend/tests/admin-payments-contract.test.ts +++ b/frontend/tests/admin-payments-contract.test.ts @@ -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, //); 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/); + 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, /
[\s\S]* \*, \.admin-app-shell \.ant-layout \{ min-height: 100%; \}/); +}); diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts index b8d6be37..aabfc9f1 100644 --- a/frontend/tests/epay-settings.test.ts +++ b/frontend/tests/epay-settings.test.ts @@ -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/); + 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, /