From d5334ebecb7d356a70f2f3df92512437bfc32427 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Mon, 27 Jul 2026 09:29:58 +0800 Subject: [PATCH 01/46] Resolve merge conflicts with upstream - keep GitHub versions --- frontend/src/components/birth-date-picker.tsx | 13 +++++------ frontend/src/components/ui/calendar.tsx | 6 ++--- .../tests/birth-date-picker-contract.test.ts | 23 ++++++------------- 3 files changed, 16 insertions(+), 26 deletions(-) diff --git a/frontend/src/components/birth-date-picker.tsx b/frontend/src/components/birth-date-picker.tsx index 209f3e7a..42f1f9c3 100644 --- a/frontend/src/components/birth-date-picker.tsx +++ b/frontend/src/components/birth-date-picker.tsx @@ -1,10 +1,9 @@ "use client"; import { format } from "date-fns"; -import { zhCN as dateFnsZhCN } from "date-fns/locale"; +import { zhCN } from "date-fns/locale"; import { CalendarIcon } from "lucide-react"; import { useId, useState } from "react"; -import { zhCN } from "react-day-picker/locale"; import { Button } from "@/components/ui/button"; import { Calendar } from "@/components/ui/calendar"; @@ -17,8 +16,6 @@ type BirthDatePickerProps = { readonly onChange: (value: string) => void; }; -const emptyDefaultMonth = new Date(1997, 0, 1); - export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerProps) { const labelId = useId(); const valueId = useId(); @@ -26,6 +23,7 @@ export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerPr const selected = parseBirthDate(value); const today = new Date(); today.setHours(0, 0, 0, 0); + const defaultMonth = new Date(1997, today.getMonth(), 1); return (
@@ -45,18 +43,19 @@ export function BirthDatePicker({ value, disabled, onChange }: BirthDatePickerPr {selected === undefined ? "选择出生日期" - : format(selected, "PPP", { locale: dateFnsZhCN })} + : format(selected, "PPP", { locale: zhCN })} - + { assert.equal(existsSync(new URL("../src/components/ui/calendar.tsx", import.meta.url)), true) @@ -21,25 +22,15 @@ test("replaces the native birth date input with the shadcn date picker", () => { assert.match(picker, /render=\{{error &&

{error}

}

套餐列表

{items.length} 个套餐

{items.map((item) => )}
名称价格点数状态操作
{item.name}
{item.description}
¥{(item.priceCents / 100).toFixed(2)}{item.credits}{item.enabled ? "启用" : "停用"} {item.enabled && }
; } diff --git a/frontend/src/app/admin/payments/page.tsx b/frontend/src/app/admin/payments/page.tsx new file mode 100644 index 00000000..962a1656 --- /dev/null +++ b/frontend/src/app/admin/payments/page.tsx @@ -0,0 +1,45 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; + +type Order = { orderNo: string; userEmail: string | null; packageName: string | null; moneyCents: number; credits: number; status: string; epayTradeNo: string | null; createdAt: string; paidAt: string | null }; +type Stats = { totalOrders: number; paidOrders: number; pendingOrders: number; failedExpiredOrders: number; paidAmountCents: number; grantedCredits: number }; +const initialStats: Stats = { totalOrders: 0, paidOrders: 0, pendingOrders: 0, failedExpiredOrders: 0, paidAmountCents: 0, grantedCredits: 0 }; +const statusLabels: Record = { pending: "待支付", paid: "已支付", failed: "失败", expired: "已过期" }; +const dateFormatter = new Intl.DateTimeFormat("zh-CN", { dateStyle: "medium", timeStyle: "short", timeZone: "Asia/Shanghai" }); + +function formatDate(value: string | null) { return value ? dateFormatter.format(new Date(value)) : "—"; } +function formatMoney(cents: number) { return `¥${(cents / 100).toFixed(2)}`; } + +export default function AdminPaymentsPage() { + const [orders, setOrders] = useState([]); + const [stats, setStats] = useState(initialStats); + const [status, setStatus] = useState(""); + const [from, setFrom] = useState(""); + const [to, setTo] = useState(""); + const [offset, setOffset] = useState(0); + const [total, setTotal] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [error, setError] = useState(""); + const limit = 20; + + useEffect(() => { + const params = new URLSearchParams({ limit: String(limit), offset: String(offset) }); + if (status) params.set("status", status); + if (from) params.set("from", new Date(`${from}T00:00:00+08:00`).toISOString()); + if (to) params.set("to", new Date(`${to}T23:59:59.999+08:00`).toISOString()); + void fetch(`/api/admin/payments?${params}`, { cache: "no-store" }).then(async (response) => { + const payload = await response.json(); + if (!response.ok) throw new Error(payload.error || "读取支付记录失败"); + setOrders(payload.orders); setStats(payload.stats); setTotal(payload.pagination.total); setHasMore(payload.pagination.hasMore); setError(""); + }).catch((caught) => setError(caught instanceof Error ? caught.message : "读取支付记录失败")); + }, [status, from, to, offset]); + + function filterChange(setter: (value: string) => void, value: string) { setter(value); setOffset(0); } + + return

支付记录

充值套餐 兑换码管理 返回对话
+

平台支付统计

统计范围按订单创建时间筛选,金额为人民币。

总订单{stats.totalOrders}
已支付{stats.paidOrders}
待支付{stats.pendingOrders}
失败/过期{stats.failedExpiredOrders}
已支付金额{formatMoney(stats.paidAmountCents)}
已赠送点数{stats.grantedCredits}
+

支付记录

{total} 条平台订单

{error &&

{error}

}
{orders.map((order) => )}{orders.length === 0 && }
订单号用户邮箱套餐金额点数状态易支付交易号创建时间支付时间
{order.orderNo}{order.userEmail || "—"}{order.packageName || "—"}{formatMoney(order.moneyCents)}{order.credits}{statusLabels[order.status] || order.status}{order.epayTradeNo || "—"}{formatDate(order.createdAt)}{formatDate(order.paidAt)}
暂无支付记录
第 {total ? offset + 1 : 0}–{Math.min(offset + orders.length, total)} 条
+
; +} diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx new file mode 100644 index 00000000..77536ffc --- /dev/null +++ b/frontend/src/app/admin/users/page.tsx @@ -0,0 +1,32 @@ +"use client"; + +import Link from "next/link"; +import { FormEvent, useEffect, useState } from "react"; + +type AdminUser = { userId: string | null; email: string | null; createdAt: string | null; source: "env" | "database" }; + +export default function AdminUsersPage() { + const [users, setUsers] = useState([]); + const [email, setEmail] = useState(""); + const [error, setError] = useState(""); + async function load() { + const response = await fetch("/api/admin/users", { cache: "no-store" }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error || "暂时无法读取管理员列表"); + setUsers(payload.users); + } + useEffect(() => { void load().catch((caught) => setError(caught.message)); }, []); + async function add(event: FormEvent) { + event.preventDefault(); setError(""); + const response = await fetch("/api/admin/users", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ email }) }); + const payload = await response.json().catch(() => null); + if (!response.ok) { setError(payload?.error || "添加失败"); return; } + setEmail(""); await load(); + } + async function revoke(userId: string) { + const response = await fetch("/api/admin/users", { method: "DELETE", headers: { "content-type": "application/json" }, body: JSON.stringify({ userId }) }); + if (!response.ok) { const payload = await response.json().catch(() => null); setError(payload?.error || "撤销失败"); return; } + await load(); + } + return

管理员管理

兑换码管理

添加管理员

仅能添加已经注册的 Supabase 用户。

{error &&

{error}

}

当前管理员

{users.length} 位

{users.map((user) => )}
邮箱来源添加时间操作
{user.email || "—"}{user.source === "env" ? "环境配置" : "后台配置"}{user.createdAt ? new Date(user.createdAt).toLocaleString("zh-CN") : "—"}{user.userId && }
; +} diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index 9f151eb9..16b40846 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -8,7 +8,7 @@ import { applyAccountProfileConcurrencyGuards, resolveAccountBirthTimeApplicationPatch, } from "@/lib/account-profile-patch"; -import { createAdminSupabaseClient, isAdminEmail } from "@/lib/supabase/admin"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; import { isSupabaseConfigurationError, } from "@/lib/supabase/config"; @@ -90,7 +90,7 @@ export async function GET() { return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, credits: profile.credits, - isAdmin: isAdminEmail(user.email), + isAdmin: await isAdminUser(user), rectificationPriceCredits, hasConfirmedBirthTime: profile.birth_time_status === "confirmed" && typeof profile.active_birth_time === "string", diff --git a/frontend/src/app/api/admin/codes/route.ts b/frontend/src/app/api/admin/codes/route.ts index e4f22605..be2becd3 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -2,7 +2,7 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { createAdminSupabaseClient, - isAdminEmail, + isAdminUser, } from "@/lib/supabase/admin"; import { generateRedeemCode, @@ -11,7 +11,6 @@ import { } from "@/lib/supabase/codes"; import { isSupabaseConfigurationError, - SupabaseConfigurationError, } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; @@ -25,14 +24,10 @@ const createCodesSchema = z.object({ }); async function requireAdmin() { - if (!process.env.ADMIN_EMAILS?.trim()) { - throw new SupabaseConfigurationError(["ADMIN_EMAILS"]); - } - const supabase = await createServerSupabaseClient(); const { data: { user }, error } = await supabase.auth.getUser(); if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; - if (!isAdminEmail(user.email)) { + if (!(await isAdminUser(user))) { return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; } return { user }; diff --git a/frontend/src/app/api/admin/packages/route.ts b/frontend/src/app/api/admin/packages/route.ts new file mode 100644 index 00000000..f99607a0 --- /dev/null +++ b/frontend/src/app/api/admin/packages/route.ts @@ -0,0 +1,19 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +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() }); +async function requireAdmin() { + const client = await createServerSupabaseClient(); + const { data: { user } } = await client.auth.getUser(); + if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); + return user; +} +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() { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; 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) }); } +export async function POST(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; 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.id }).select().single(); if (error) return NextResponse.json({ error: "创建套餐失败" }, { status: 500 }); return NextResponse.json({ package: output(data) }, { status: 201 }); } +export async function PATCH(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; 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) }); } +export async function DELETE(request: Request) { const auth = await requireAdmin(); if (auth instanceof NextResponse) return auth; 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 }); } diff --git a/frontend/src/app/api/admin/payments/route.ts b/frontend/src/app/api/admin/payments/route.ts new file mode 100644 index 00000000..b0e136ca --- /dev/null +++ b/frontend/src/app/api/admin/payments/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +const querySchema = z.object({ + status: z.enum(["pending", "paid", "failed", "expired"]).optional(), + from: z.string().datetime({ offset: true }).optional(), + to: z.string().datetime({ offset: true }).optional(), + limit: z.coerce.number().int().min(1).max(100).default(20), + offset: z.coerce.number().int().min(0).default(0), +}); + +async function requireAdmin() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); + if (!(await isAdminUser(user))) return NextResponse.json({ error: "无管理员权限" }, { status: 403 }); + return user; +} + +export async function GET(request: Request) { + try { + const auth = await requireAdmin(); + if (auth instanceof NextResponse) return auth; + + const url = new URL(request.url); + const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)); + if (!parsed.success) return NextResponse.json({ error: "查询参数不正确" }, { status: 400 }); + const { status, from, to, limit, offset } = parsed.data; + if (from && to && new Date(from) > new Date(to)) return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 }); + + const admin = createAdminSupabaseClient(); + let ordersQuery = admin + .from("payment_orders") + .select("order_no,user_id,money_cents,credits,status,epay_trade_no,paid_at,created_at,payment_packages(name)", { count: "exact" }) + .order("created_at", { ascending: false }) + .range(offset, offset + limit - 1); + if (status) ordersQuery = ordersQuery.eq("status", status); + if (from) ordersQuery = ordersQuery.gte("created_at", from); + if (to) ordersQuery = ordersQuery.lte("created_at", to); + + const statsPromise = admin.rpc("get_payment_order_stats", { + p_from: from ?? null, + p_to: to ?? null, + }); + const [{ data, error, count }, statsResult] = await Promise.all([ordersQuery, statsPromise]); + if (error || statsResult.error) return NextResponse.json({ error: "暂时无法读取支付记录" }, { status: 500 }); + + const emailEntries = await Promise.all([...new Set((data ?? []).map((row) => row.user_id))].map(async (userId) => { + const result = await admin.auth.admin.getUserById(userId); + return [userId, result.data.user?.email ?? null] as const; + })); + const emails = new Map(emailEntries); + const orders = (data ?? []).map((row) => { + const relation = row.payment_packages as { name?: string } | { name?: string }[] | null; + const packageName = Array.isArray(relation) ? relation[0]?.name : relation?.name; + return { + orderNo: row.order_no, + userEmail: emails.get(row.user_id) ?? null, + packageName: packageName ?? null, + moneyCents: row.money_cents, + credits: row.credits, + status: row.status, + epayTradeNo: row.epay_trade_no, + createdAt: row.created_at, + paidAt: row.paid_at, + }; + }); + const rawStats = Array.isArray(statsResult.data) ? statsResult.data[0] : statsResult.data; + const stats = { + totalOrders: Number(rawStats?.total_orders ?? 0), + paidOrders: Number(rawStats?.paid_orders ?? 0), + pendingOrders: Number(rawStats?.pending_orders ?? 0), + failedExpiredOrders: Number(rawStats?.failed_expired_orders ?? 0), + paidAmountCents: Number(rawStats?.paid_amount_cents ?? 0), + grantedCredits: Number(rawStats?.granted_credits ?? 0), + }; + const total = count ?? 0; + return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/admin/users/route.ts b/frontend/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..b299aede --- /dev/null +++ b/frontend/src/app/api/admin/users/route.ts @@ -0,0 +1,77 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient, isAdminUser } from "@/lib/supabase/admin"; +import { isSupabaseConfigurationError } from "@/lib/supabase/config"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; + +export const runtime = "nodejs"; + +async function requireAdmin() { + const supabase = await createServerSupabaseClient(); + const { data: { user }, error } = await supabase.auth.getUser(); + if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; + if (!(await isAdminUser(user))) return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; + return { user }; +} + +function envAdmins() { + return (process.env.ADMIN_EMAILS ?? "").split(",").map((email) => email.trim().toLowerCase()).filter(Boolean); +} + +export async function GET() { + try { + const auth = await requireAdmin(); + if ("response" in auth) return auth.response; + const admin = createAdminSupabaseClient(); + const { data, error } = await admin.from("admin_users").select("user_id,created_at,created_by").is("revoked_at", null).order("created_at", { ascending: true }); + if (error) return NextResponse.json({ error: "暂时无法读取管理员列表" }, { status: 500 }); + const users = await Promise.all((data ?? []).map(async (row) => { + const result = await admin.auth.admin.getUserById(row.user_id); + return { userId: row.user_id, email: result.data.user?.email ?? null, createdAt: row.created_at, createdBy: row.created_by, source: "database" as const }; + })); + return NextResponse.json({ users: [...envAdmins().map((email) => ({ userId: null, email, createdAt: null, createdBy: null, source: "env" as const })), ...users] }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); + } +} + +export async function POST(request: Request) { + try { + const auth = await requireAdmin(); + if ("response" in auth) return auth.response; + const parsed = z.object({ email: z.string().trim().email() }).safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "邮箱格式不正确" }, { status: 400 }); + const email = parsed.data.email.toLowerCase(); + if (envAdmins().includes(email)) return NextResponse.json({ error: "该用户已由环境配置管理" }, { status: 409 }); + const admin = createAdminSupabaseClient(); + const { data: users, error: listError } = await admin.auth.admin.listUsers({ page: 1, perPage: 1000 }); + if (listError) return NextResponse.json({ error: "暂时无法查找用户" }, { status: 500 }); + const target = users.users.find((candidate) => candidate.email?.trim().toLowerCase() === email); + if (!target) return NextResponse.json({ error: "该邮箱尚未注册" }, { status: 404 }); + const { error } = await admin.from("admin_users").upsert({ user_id: target.id, created_by: auth.user.id, revoked_at: null, revoked_by: null }); + if (error) return NextResponse.json({ error: "添加管理员失败" }, { status: 500 }); + return NextResponse.json({ ok: true }, { status: 201 }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); + } +} + +export async function DELETE(request: Request) { + try { + const auth = await requireAdmin(); + if ("response" in auth) return auth.response; + const parsed = z.object({ userId: z.string().uuid() }).safeParse(await request.json().catch(() => null)); + if (!parsed.success) return NextResponse.json({ error: "用户参数不正确" }, { status: 400 }); + const admin = createAdminSupabaseClient(); + const target = await admin.auth.admin.getUserById(parsed.data.userId); + if (target.data.user?.email && envAdmins().includes(target.data.user.email.toLowerCase())) return NextResponse.json({ error: "环境配置管理员不可撤销" }, { status: 409 }); + const { error } = await admin.from("admin_users").update({ revoked_at: new Date().toISOString(), revoked_by: auth.user.id }).eq("user_id", parsed.data.userId).is("revoked_at", null); + if (error) return NextResponse.json({ error: "撤销管理员失败" }, { status: 500 }); + return NextResponse.json({ ok: true }); + } catch (error) { + if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "管理员服务暂时不可用" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/payment/epay/create/route.ts b/frontend/src/app/api/payment/epay/create/route.ts new file mode 100644 index 00000000..c4f20234 --- /dev/null +++ b/frontend/src/app/api/payment/epay/create/route.ts @@ -0,0 +1,34 @@ +import crypto from "node:crypto"; +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { epaySign } from "@/lib/epay/sign"; +import { epaySubmitUrl, readEpayConfig, EpayConfigurationError } from "@/lib/epay/config"; + +export const runtime = "nodejs"; +const schema = z.object({ packageId: z.string().uuid() }); +function safeUpstreamUrl(value: unknown, gateway: URL) { if (typeof value !== "string") return null; try { const url = new URL(value, gateway); return url.origin === gateway.origin ? url.toString() : null; } catch { return null; } } +export async function POST(request: Request) { + try { + 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 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 { 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) }); + if (!upstream.ok) return NextResponse.json({ error: "支付网关暂时不可用" }, { status: 502 }); + const text = await upstream.text(); let payload: Record = {}; 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); + const qrCode = safeUpstreamUrl(payload.qrcode ?? payload.qr_code, config.gatewayUrl); + return NextResponse.json({ orderNo, payUrl, qrCode }); + } catch (error) { + if (error instanceof EpayConfigurationError) return NextResponse.json({ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" }, { status: 503 }); + return NextResponse.json({ error: "创建支付失败" }, { status: 500 }); + } +} diff --git a/frontend/src/app/api/payment/epay/notify/route.ts b/frontend/src/app/api/payment/epay/notify/route.ts new file mode 100644 index 00000000..c47e8004 --- /dev/null +++ b/frontend/src/app/api/payment/epay/notify/route.ts @@ -0,0 +1,19 @@ +import crypto from "node:crypto"; +import { NextResponse } from "next/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { epaySign, timingSafeSignEqual } from "@/lib/epay/sign"; +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 = {}; 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"); + const { error } = await createAdminSupabaseClient().rpc("settle_epay_order", { p_order_no: values.out_trade_no, p_trade_no: values.trade_no || values.transaction_id || values.out_trade_no, p_money_cents: moneyCents, p_payload_hash: hash }); + if (error) return new NextResponse("success", { status: 200 }); + return new NextResponse("success", { status: 200 }); + } catch { return new NextResponse("success", { status: 200 }); } +} +export async function POST(request: Request) { return notify(request); } +export async function GET(request: Request) { return notify(request); } diff --git a/frontend/src/app/api/payment/epay/status/route.ts b/frontend/src/app/api/payment/epay/status/route.ts new file mode 100644 index 00000000..a1451a94 --- /dev/null +++ b/frontend/src/app/api/payment/epay/status/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; +import { createAdminSupabaseClient } from "@/lib/supabase/admin"; +import { createServerSupabaseClient } from "@/lib/supabase/server"; +export const runtime = "nodejs"; +export async function GET(request: Request) { const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser(); if (!user) return NextResponse.json({ error: "请先登录" }, { status: 401 }); const orderNo = new URL(request.url).searchParams.get("orderNo"); if (!orderNo) return NextResponse.json({ error: "缺少订单号" }, { status: 400 }); const { data, error } = await createAdminSupabaseClient().from("payment_orders").select("order_no,status,credits,paid_at").eq("order_no", orderNo).eq("user_id", user.id).maybeSingle(); if (error) return NextResponse.json({ error: "暂时无法查询订单" }, { status: 500 }); if (!data) return NextResponse.json({ error: "订单不存在" }, { status: 404 }); return NextResponse.json({ orderNo: data.order_no, status: data.status, credits: data.credits, paidAt: data.paid_at }); } diff --git a/frontend/src/app/api/payment/packages/route.ts b/frontend/src/app/api/payment/packages/route.ts new file mode 100644 index 00000000..f54e0e59 --- /dev/null +++ b/frontend/src/app/api/payment/packages/route.ts @@ -0,0 +1,4 @@ +import { NextResponse } from "next/server"; +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 })) }); } diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index acc0d6fe..8555771a 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -749,6 +749,14 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .code-status { display: inline-flex; min-height: 28px; align-items: center; padding: 0 9px; border-radius: var(--radius-md); background: var(--color-canvas-muted); color: var(--color-ink-secondary); } .status-可用 { background: var(--color-success-muted); color: var(--color-success); } .status-已过期, .status-已兑换, .empty-cell { color: var(--color-ink-tertiary); } +.payment-stats { display: grid; grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 1px; margin-top: 16px; overflow: hidden; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-border); } +.payment-stats > div { display: grid; gap: 6px; padding: var(--space-4); background: var(--color-canvas-muted); } +.payment-stats span, .payment-filters label span { color: var(--color-ink-secondary); font-size: 12px; } +.payment-stats strong { font-size: 20px; font-variant-numeric: tabular-nums; } +.payment-filters { display: flex; flex-wrap: wrap; gap: var(--space-4); margin-top: 16px; } +.payment-filters label { display: grid; gap: 6px; } +.payment-pagination { display: flex; align-items: center; justify-content: flex-end; gap: var(--space-3); margin-top: 16px; color: var(--color-ink-secondary); font-size: 13px; } +@media (max-width: 767px) { .payment-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } .payment-pagination { justify-content: space-between; } } @media (hover: hover) { .new-chat:not(:disabled):hover { background: var(--color-surface-dark-raised); } @@ -1660,3 +1668,9 @@ input:disabled, select:disabled { color: var(--color-ink-tertiary); background: .birth-time-clock-menu.select-content { width: 108px; min-width: 108px; } .birth-time-clock-menu .select-item { justify-content: flex-start; } + +.payment-qr-wrap { position: relative; width: min(220px, 72vw); aspect-ratio: 1; margin: 14px auto; padding: 10px; border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: #fff; box-shadow: var(--shadow-elevated); } +.payment-qr-wrap img { display: block; width: 100%; height: 100%; object-fit: contain; } +.payment-qr-badge { position: absolute; top: 50%; left: 50%; display: grid; width: 44px; height: 44px; padding: 4px; transform: translate(-50%, -50%); border: 4px solid #fff; border-radius: 12px; background: #fff; box-shadow: 0 2px 10px rgb(0 0 0 / 18%); } +.payment-qr-badge svg { display: block; width: 100%; height: 100%; } + diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 88753e40..9b97ce94 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -903,6 +903,10 @@ export default function Home() { const [redeemError, setRedeemError] = useState(""); const [redeemMessage, setRedeemMessage] = useState(""); const [redeeming, setRedeeming] = useState(false); + const [paymentPackages, setPaymentPackages] = useState>([]); + const [paymentOrder, setPaymentOrder] = useState<{ orderNo: string; payUrl: string | null; qrCode: string | null; status: string } | null>(null); + const [paymentError, setPaymentError] = useState(""); + const [payingPackageId, setPayingPackageId] = useState(null); const [signingOut, setSigningOut] = useState(false); const [sessions, setSessions] = useState([]); const [pinnedSessionIds, setPinnedSessionIds] = useState([]); @@ -1955,6 +1959,39 @@ export default function Home() { } } + useEffect(() => { + 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 || []); + }); + }, [activeAccountDialog]); + + useEffect(() => { + if (!paymentOrder || paymentOrder.status === "paid") return; + const timer = window.setInterval(() => { + void fetch(`/api/payment/epay/status?orderNo=${encodeURIComponent(paymentOrder.orderNo)}`, { cache: "no-store" }).then(async (response) => { + const payload = await response.json().catch(() => null); + if (!response.ok) return; + setPaymentOrder((current) => current ? { ...current, status: payload.status } : current); + if (payload.status === "paid") void refreshAccount(); + }); + }, 3000); + return () => window.clearInterval(timer); + }, [paymentOrder]); + + async function createPayment(packageId: string) { + if (payingPackageId) return; + setPayingPackageId(packageId); setPaymentError(""); + try { + const response = await fetch("/api/payment/epay/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId }) }); + const payload = await response.json().catch(() => null); + if (!response.ok) throw new Error(payload?.error || "创建支付失败"); + setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode, status: "pending" }); + if (payload.payUrl) window.open(payload.payUrl, "_blank", "noopener,noreferrer"); + } catch (caught) { setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); } finally { setPayingPackageId(null); } + } + async function redeem(event: FormEvent) { event.preventDefault(); const code = redeemCode.trim(); @@ -3516,6 +3553,12 @@ export default function Home() { {redeemError &&

{redeemError}

} {redeemMessage &&

{redeemMessage}

} +
+

充值套餐

+ {paymentPackages.map((item) =>
{item.name}{item.description || `${item.credits} 点`}
¥{(item.priceCents / 100).toFixed(2)}
)} + {paymentError &&

{paymentError}

} + {paymentOrder &&

订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}

{paymentOrder.qrCode &&
支付宝支付二维码
{paymentOrder.payUrl && 打开支付页面}
} +
)} diff --git a/frontend/src/lib/epay/config.ts b/frontend/src/lib/epay/config.ts new file mode 100644 index 00000000..f3b5620d --- /dev/null +++ b/frontend/src/lib/epay/config.ts @@ -0,0 +1,44 @@ +import "server-only"; + +const DEFAULT_NOTIFY_URL = "https://jyotisha.chat/api/payment/epay/notify"; + +export class EpayConfigurationError extends Error { + constructor(message: string) { + super(message); + this.name = "EpayConfigurationError"; + } +} + +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 }; diff --git a/frontend/src/lib/epay/sign.ts b/frontend/src/lib/epay/sign.ts new file mode 100644 index 00000000..3b4ae36e --- /dev/null +++ b/frontend/src/lib/epay/sign.ts @@ -0,0 +1,18 @@ +import crypto from "node:crypto"; + +export function epayCanonical(params: Record) { + return Object.entries(params) + .filter(([key, value]) => key !== "sign" && key !== "sign_type" && value !== null && value !== undefined && String(value) !== "") + .sort(([a], [b]) => a.localeCompare(b)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); +} + +export function epaySign(params: Record, key: string) { + return crypto.createHash("md5").update(`${epayCanonical(params)}${key}`).digest("hex"); +} + +export function timingSafeSignEqual(actual: string | null | undefined, expected: string) { + if (!actual || actual.length !== expected.length) return false; + return crypto.timingSafeEqual(Buffer.from(actual), Buffer.from(expected)); +} diff --git a/frontend/src/lib/supabase/admin.ts b/frontend/src/lib/supabase/admin.ts index 97c6d148..0cb83ef8 100644 --- a/frontend/src/lib/supabase/admin.ts +++ b/frontend/src/lib/supabase/admin.ts @@ -36,3 +36,22 @@ export function isAdminEmail(email: string | null | undefined) { .split(",") .some((candidate) => candidate.trim().toLowerCase() === normalized); } + +export async function isAdminUser(user: { id?: string | null; email?: string | null } | null | undefined) { + if (!user?.email) return false; + if (isAdminEmail(user.email)) return true; + if (process.env.AUTH_PROVIDER?.trim() === "self-hosted" || !user.id) return false; + + try { + const admin = createAdminSupabaseClient(); + const { data, error } = await admin + .from("admin_users") + .select("user_id") + .eq("user_id", user.id) + .is("revoked_at", null) + .maybeSingle(); + return !error && Boolean(data); + } catch { + return false; + } +} diff --git a/frontend/supabase/migrations/20260727010000_admin_users.sql b/frontend/supabase/migrations/20260727010000_admin_users.sql new file mode 100644 index 00000000..8787cb5e --- /dev/null +++ b/frontend/supabase/migrations/20260727010000_admin_users.sql @@ -0,0 +1,11 @@ +create table public.admin_users ( + user_id uuid primary key references auth.users(id) on delete cascade, + created_at timestamptz not null default now(), + created_by uuid not null references auth.users(id), + revoked_at timestamptz, + revoked_by uuid references auth.users(id) +); + +alter table public.admin_users enable row level security; +revoke all on table public.admin_users from anon, authenticated; +grant select, insert, update on table public.admin_users to service_role; diff --git a/frontend/supabase/migrations/20260727020000_epay_packages_orders.sql b/frontend/supabase/migrations/20260727020000_epay_packages_orders.sql new file mode 100644 index 00000000..dd08fc20 --- /dev/null +++ b/frontend/supabase/migrations/20260727020000_epay_packages_orders.sql @@ -0,0 +1,69 @@ +begin; + +alter table public.credit_transactions drop constraint if exists credit_transactions_transaction_type_check; +alter table public.credit_transactions add constraint credit_transactions_transaction_type_check + check (transaction_type in ('redeem', 'reserve', 'refund', 'payment')); +alter table public.credit_transactions drop constraint if exists credit_transactions_amount_check; +alter table public.credit_transactions add constraint credit_transactions_amount_check + check ((transaction_type = 'reserve' and amount < 0) or (transaction_type in ('redeem', 'refund', 'payment') and amount > 0)); + +create table public.payment_packages ( + id uuid primary key default gen_random_uuid(), + name text not null check (char_length(name) between 1 and 80), + description text not null default '' check (char_length(description) <= 500), + price_cents integer not null check (price_cents > 0), + credits integer not null check (credits > 0), + sort_order integer not null default 0, + enabled boolean not null default true, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + created_by uuid references auth.users(id) on delete set null +); + +create table public.payment_orders ( + id uuid primary key default gen_random_uuid(), + order_no text not null unique check (char_length(order_no) between 16 and 100), + user_id uuid not null references auth.users(id) on delete cascade, + package_id uuid not null references public.payment_packages(id) on delete restrict, + money_cents integer not null check (money_cents > 0), + credits integer not null check (credits > 0), + status text not null default 'pending' check (status in ('pending', 'paid', 'failed', 'expired')), + epay_trade_no text, + raw_notify_payload_hash text, + paid_at timestamptz, + created_at timestamptz not null default now() +); +create unique index payment_orders_trade_no_idx on public.payment_orders(epay_trade_no) where epay_trade_no is not null; +create index payment_orders_user_created_idx on public.payment_orders(user_id, created_at desc); + +alter table public.payment_packages enable row level security; +alter table public.payment_orders enable row level security; +revoke all on public.payment_packages, public.payment_orders from anon, authenticated; +grant select on public.payment_orders to authenticated; +create policy payment_orders_select_own on public.payment_orders for select to authenticated using ((select auth.uid()) = user_id); + +grant all on public.payment_packages, public.payment_orders to service_role; + +create or replace function public.settle_epay_order(p_order_no text, p_trade_no text, p_money_cents integer, p_payload_hash text) +returns table (success boolean, status text, credits integer) +language plpgsql security definer set search_path = public, pg_temp +as $$ +declare v_order public.payment_orders%rowtype; v_balance integer; +begin + select * into v_order from public.payment_orders where order_no = btrim(p_order_no) for update; + if not found or v_order.money_cents <> p_money_cents then return query select false, 'invalid'::text, null::integer; return; end if; + if v_order.status = 'paid' then return query select true, 'paid'::text, v_order.credits; return; end if; + select credits into v_balance from public.profiles where id = v_order.user_id for update; + if not found then return query select false, 'profile_missing'::text, null::integer; return; end if; + update public.profiles set credits = credits + v_order.credits, updated_at = now() where id = v_order.user_id returning credits into v_balance; + insert into public.credit_transactions(user_id, transaction_type, amount, balance_after, request_id) + values (v_order.user_id, 'payment', v_order.credits, v_balance, v_order.order_no) + on conflict (user_id, transaction_type, request_id) do nothing; + update public.payment_orders set status='paid', epay_trade_no=p_trade_no, raw_notify_payload_hash=p_payload_hash, paid_at=now() where id=v_order.id; + return query select true, 'paid'::text, v_order.credits; +end; +$$; +revoke all on function public.settle_epay_order(text, text, integer, text) from public, anon, authenticated; +grant execute on function public.settle_epay_order(text, text, integer, text) to service_role; + +commit; diff --git a/frontend/supabase/migrations/20260727030000_payment_admin_stats.sql b/frontend/supabase/migrations/20260727030000_payment_admin_stats.sql new file mode 100644 index 00000000..886cae87 --- /dev/null +++ b/frontend/supabase/migrations/20260727030000_payment_admin_stats.sql @@ -0,0 +1,23 @@ +begin; + +create index if not exists payment_orders_created_status_idx on public.payment_orders(created_at desc, status); + +create or replace function public.get_payment_order_stats(p_from timestamptz default null, p_to timestamptz default null) +returns table (total_orders bigint, paid_orders bigint, pending_orders bigint, failed_expired_orders bigint, paid_amount_cents bigint, granted_credits bigint) +language sql security definer set search_path = public, pg_temp +as $$ + select + count(*)::bigint, + count(*) filter (where status = 'paid')::bigint, + count(*) filter (where status = 'pending')::bigint, + count(*) filter (where status in ('failed', 'expired'))::bigint, + coalesce(sum(money_cents) filter (where status = 'paid'), 0)::bigint, + coalesce(sum(credits) filter (where status = 'paid'), 0)::bigint + from public.payment_orders + where (p_from is null or created_at >= p_from) + and (p_to is null or created_at <= p_to); +$$; +revoke all on function public.get_payment_order_stats(timestamptz, timestamptz) from public, anon, authenticated; +grant execute on function public.get_payment_order_stats(timestamptz, timestamptz) to service_role; + +commit; diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts new file mode 100644 index 00000000..13634de1 --- /dev/null +++ b/frontend/tests/admin-payments-contract.test.ts @@ -0,0 +1,38 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); +const route = readFileSync(new URL("src/app/api/admin/payments/route.ts", root), "utf8"); +const page = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8"); +const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", root), "utf8"); +const migration = readFileSync(new URL("supabase/migrations/20260727030000_payment_admin_stats.sql", root), "utf8"); + +test("支付后台接口只允许管理员并查询平台订单", () => { + assert.match(route, /isAdminUser\(user\)/); + assert.match(route, /from\("payment_orders"\)/); + assert.match(route, /auth\.admin\.getUserById/); + assert.match(route, /payment_packages\(name\)/); + assert.match(route, /order_no|orderNo/); + assert.doesNotMatch(route, /SUPABASE_SERVICE_ROLE_KEY/); + assert.doesNotMatch(route, /raw_notify_payload/); +}); + +test("支付接口包含筛选、统计和分页契约", () => { + for (const field of ["status", "from", "to", "limit", "offset"]) assert.match(route, new RegExp(field)); + for (const field of ["totalOrders", "paidOrders", "pendingOrders", "failedExpiredOrders", "paidAmountCents", "grantedCredits"]) assert.match(route, new RegExp(field)); + assert.match(route, /max\(100\)/); + assert.match(route, /count: "exact"/); + assert.match(route, /hasMore/); + assert.match(migration, /get_payment_order_stats/); + assert.match(migration, /status = 'paid'/); +}); + +test("后台支付页面与现有套餐页有入口", () => { + assert.match(page, /平台支付统计/); + assert.match(page, /支付记录/); + assert.match(page, /paidAmountCents/); + assert.match(page, /上一页/); + assert.match(page, /下一页/); + assert.match(packagesPage, /href="\/admin\/payments"/); +}); diff --git a/frontend/tests/admin-users-contract.test.ts b/frontend/tests/admin-users-contract.test.ts new file mode 100644 index 00000000..a3ef3f1c --- /dev/null +++ b/frontend/tests/admin-users-contract.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const root = new URL("../", import.meta.url); +const adminSource = readFileSync(new URL("src/lib/supabase/admin.ts", root), "utf8"); +const layoutSource = readFileSync(new URL("src/app/admin/layout.tsx", root), "utf8"); +const codesSource = readFileSync(new URL("src/app/api/admin/codes/route.ts", root), "utf8"); +const accountSource = readFileSync(new URL("src/app/api/account/route.ts", root), "utf8"); +const usersSource = readFileSync(new URL("src/app/api/admin/users/route.ts", root), "utf8"); +const migration = readFileSync(new URL("supabase/migrations/20260727010000_admin_users.sql", root), "utf8"); + +test("ADMIN_EMAILS remains a case-insensitive comma-separated allowlist", () => { + assert.match(adminSource, /configured/); + assert.match(adminSource, /split\(\",\"\)/); + assert.match(adminSource, /toLowerCase/); + assert.match(adminSource, /export function isAdminEmail/); +}); + +test("admin surfaces await database-backed administrator checks", () => { + assert.match(adminSource, /export async function isAdminUser/); + assert.match(adminSource, /from\("admin_users"\)/); + assert.match(layoutSource, /await isAdminUser\(user\)/); + assert.match(codesSource, /await isAdminUser\(user\)/); + assert.match(accountSource, /isAdmin: await isAdminUser\(user\)/); +}); + +test("admin_users migration is service-role-only and auditable", () => { + assert.match(migration, /user_id uuid primary key references auth\.users\(id\)/); + assert.match(migration, /created_at timestamptz/); + assert.match(migration, /created_by uuid/); + assert.match(migration, /revoked_at timestamptz/); + assert.match(migration, /revoked_by uuid/); + assert.match(migration, /enable row level security/); + assert.match(migration, /revoke all on table public\.admin_users from anon, authenticated/); + assert.match(migration, /grant select, insert, update on table public\.admin_users to service_role/); +}); + +test("admin users route exposes guarded list, add, and soft revoke contracts", () => { + assert.match(usersSource, /export async function GET/); + assert.match(usersSource, /export async function POST/); + assert.match(usersSource, /export async function DELETE/); + assert.match(usersSource, /auth\.admin\.listUsers/); + assert.match(usersSource, /upsert\(\{ user_id: target\.id, created_by: auth\.user\.id/); + assert.match(usersSource, /revoked_at: new Date\(\)\.toISOString\(\)/); + assert.match(usersSource, /环境配置管理员不可撤销/); +}); diff --git a/frontend/tests/epay-payment-contract.test.ts b/frontend/tests/epay-payment-contract.test.ts new file mode 100644 index 00000000..92171c44 --- /dev/null +++ b/frontend/tests/epay-payment-contract.test.ts @@ -0,0 +1,20 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { test } from "node:test"; +import { epayCanonical, epaySign } from "../src/lib/epay/sign"; + +const migration = readFileSync(new URL("../supabase/migrations/20260727020000_epay_packages_orders.sql", import.meta.url), "utf8"); + +test("易支付签名过滤空值并按键排序", () => { + const params = { money: "10.00", pid: "10001", name: "套餐", empty: "", sign_type: "MD5" }; + assert.equal(epayCanonical(params), "money=10.00&name=套餐&pid=10001"); + assert.equal(epaySign(params, "secret"), "79dd3a13f9fd32622fa2197c0a2d7b66"); +}); + +test("支付迁移包含套餐、订单、payment 类型与原子结算", () => { + assert.match(migration, /create table public\.payment_packages/); + assert.match(migration, /create table public\.payment_orders/); + assert.match(migration, /transaction_type in \('redeem', 'reserve', 'refund', 'payment'\)/); + assert.match(migration, /settle_epay_order/); + assert.match(migration, /on conflict \(user_id, transaction_type, request_id\) do nothing/); +}); diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index ae3eb8d3..f8b311ed 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -1,5 +1,5 @@ import assert from "node:assert/strict"; -import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { spawnSync } from "node:child_process"; @@ -30,10 +30,13 @@ const syncScript = new URL( "../../deploy/sync-staging-tree.sh", import.meta.url, ); +const giteaWorkflowDirectory = new URL("../../.gitea/workflows/", import.meta.url); const giteaQualityWorkflow = new URL( - "../../.gitea/workflows/backend-quality-gate.yml", - import.meta.url, + "backend-quality-gate.yml", + giteaWorkflowDirectory, ); +const giteaDeployWorkflow = new URL("deploy-staging.yml", giteaWorkflowDirectory); +const giteaMigrationWorkflow = new URL("migrate-staging-database.yml", giteaWorkflowDirectory); function read(url: URL): string { return readFileSync(url, "utf8"); @@ -380,21 +383,76 @@ test("production remains manual-only and separate from staging database automati assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/); }); -test("Gitea staging push uses the xiaoxin Linux runner and immutable ACR images", () => { +test("all Gitea workflows use xiaoxin, native checkout, and safe triggers", () => { + const names = readdirSync(giteaWorkflowDirectory) + .filter((name) => name.endsWith(".yml")); + assert.ok(names.length > 0); + const workflows = new Map(names.map((name) => [name, read(new URL(name, giteaWorkflowDirectory))])); + + for (const [name, workflow] of workflows) { + const jobs = workflow.match(/^\s{4}runs-on:\s*(.+)$/gm) ?? []; + assert.ok(jobs.length > 0, `${name} has no jobs`); + assert.ok(jobs.every((line) => line.trim() === "runs-on: xiaoxin"), name); + assert.doesNotMatch(workflow, /ubuntu-latest|github\.com\/actions|actions\/(?:checkout|setup-)|GITEA_OUTPUT/, name); + assert.match(workflow, /git init \./, name); + assert.match(workflow, /git fetch --no-tags origin/, name); + } + + const stagingPushOwners = [...workflows] + .filter(([, workflow]) => /push:\n\s+branches:\s*\[staging\]/.test(workflow)) + .map(([name]) => name); + assert.deepEqual(stagingPushOwners, ["backend-quality-gate.yml"]); + for (const name of [ + "ci.yml", + "deploy-production.yml", + "deploy-staging.yml", + "migrate-staging-database.yml", + "apply-supabase-profile-migrations.yml", + "release-quality-gate.yml", + "test.yml", + "publish-pypi.yml", + ]) { + const workflow = workflows.get(name) ?? ""; + assert.match(workflow, /^on:\n\s+workflow_dispatch:/m, name); + assert.doesNotMatch(workflow, /\n\s+(?:push|pull_request|workflow_run):/, name); + } + const all = [...workflows.values()].join("\n"); + assert.doesNotMatch(all, /GITEA_REGISTRY_USERNAME|GITEA_REGISTRY_TOKEN|git\.copse\.top\/root\/jyotisha-(?:api|web)/); +}); + +test("Gitea staging push validates once then publishes and deploys immutable ACR images", () => { const workflow = read(giteaQualityWorkflow); - assert.equal(workflow.match(/runs-on: xiaoxin/g)?.length, 2); - assert.match(workflow, /set -euo pipefail/); - assert.equal(workflow.match(/git fetch --no-tags origin/g)?.length, 2); - assert.doesNotMatch(workflow, /github\.com\/actions/); + assert.match(workflow, /publish-and-deploy:[\s\S]*needs: validate/); + assert.match(workflow, /gitea\.event_name == 'push'.*refs\/heads\/staging/); assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); - assert.match(workflow, /secrets\.REGISTRY_USERNAME/); - assert.match(workflow, /secrets\.REGISTRY_PASSWORD/); assert.match(workflow, /api_tag="\$\{IMAGE_REPOSITORY\}:api-\$\{GITEA_SHA\}"/); assert.match(workflow, /web_tag="\$\{IMAGE_REPOSITORY\}:web-\$\{GITEA_SHA\}"/); + assert.match(workflow, /api_ref=.*RepoDigests/); + assert.match(workflow, /web_ref=.*RepoDigests/); + assert.match(workflow, /API_IMAGE='\$api_image'.*bash '\$incoming\/deploy\/run-staging-deploy\.sh'/); assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previous_sha'/); assert.match(workflow, /git merge-base --is-ancestor "\$previous_sha" "\$GITEA_SHA"/); - assert.match(workflow, /scp_options=\(-i "\$key_path" -P "\$DEPLOY_PORT"/); - assert.doesNotMatch(workflow, /shell: powershell|17631000304|copse\.ai\.2026/); +}); + +test("manual Gitea staging deploy and migration use shared ACR digests and live previous SHA", () => { + const deployment = read(giteaDeployWorkflow); + const migration = read(giteaMigrationWorkflow); + for (const workflow of [deployment, migration]) { + assert.match(workflow, /deploy_sha:/); + assert.match(workflow, /git merge-base --is-ancestor "\$DEPLOY_SHA" origin\/main/); + assert.match(workflow, /IMAGE_REPOSITORY: crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); + assert.match(workflow, /secrets\.REGISTRY_USERNAME/); + assert.match(workflow, /secrets\.REGISTRY_PASSWORD/); + assert.match(workflow, /STAGING_KNOWN_HOSTS/); + assert.match(workflow, /previous_sha="\$\(ssh/); + assert.doesNotMatch(workflow, /EXPECTED_PREVIOUS_SHA='not-deployed'/); + } + assert.match(deployment, /allow_rollback:/); + assert.match(deployment, /default forward-only deployment refused/); + assert.match(deployment, /run-staging-deploy\.sh/); + assert.match(migration, /run-staging-migration\.sh/); + assert.doesNotMatch(migration, /\n\s+push:|workflow_run:/); + assert.match(read(migrationScript), /crpi-d1feco6itet73spp\\\.cn-hongkong\\\.personal\\\.cr\\\.aliyuncs\\\.com\/copse\/jyotisha@sha256/); }); test("staging scripts pass shell syntax validation", () => { -- 2.52.0 From f711e77849a293a0524ac287672eb86176e825ed Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Mon, 27 Jul 2026 21:23:29 +0800 Subject: [PATCH 10/46] fix: isolate Gitea Python dependencies Run Python tooling in repository virtual environments so xiaoxin respects the PEP 668 system package boundary. --- .gitea/workflows/backend-quality-gate.yml | 17 ++++---- .gitea/workflows/ci.yml | 14 ++++--- .gitea/workflows/publish-pypi.yml | 14 +++++-- .gitea/workflows/release-quality-gate.yml | 10 +++-- .gitea/workflows/test.yml | 10 +++-- docs/BUG_HISTORY.md | 16 +++++++ .../tests/staging-backend-workflows.test.ts | 42 +++++++++++++++++++ 7 files changed, 98 insertions(+), 25 deletions(-) diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index a925a632..c52328d7 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -45,8 +45,10 @@ jobs: - name: Install dependencies run: | set -euo pipefail - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt -r requirements-dev.txt + python3 -m venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt npm ci --prefix frontend - name: Validate backend, package, frontend, and database contracts env: @@ -54,11 +56,12 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder run: | set -euo pipefail - ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py - python3 -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py - python3 scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime - python3 scripts/commercial_privacy_artifact_scan.py --json - python3 -m build + export PATH="$PWD/.venv/bin:$PATH" + .venv/bin/ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py + .venv/bin/python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py + .venv/bin/python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime + .venv/bin/python scripts/commercial_privacy_artifact_scan.py --json + .venv/bin/python -m build npm test --prefix frontend npm run lint --prefix frontend npm run build --prefix frontend diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index e309f03b..dc13e1b1 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -31,14 +31,16 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder run: | set -euo pipefail - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt -r requirements-dev.txt + python3 -m venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt npm ci --prefix frontend ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py - python3 -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py - python3 scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime - python3 scripts/commercial_privacy_artifact_scan.py --json + python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py + python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime + python scripts/commercial_privacy_artifact_scan.py --json npm test --prefix frontend npm run lint --prefix frontend npm run build --prefix frontend - python3 -m build + python -m build diff --git a/.gitea/workflows/publish-pypi.yml b/.gitea/workflows/publish-pypi.yml index 2d866cf5..f127b587 100644 --- a/.gitea/workflows/publish-pypi.yml +++ b/.gitea/workflows/publish-pypi.yml @@ -27,11 +27,17 @@ jobs: - name: Build and check package run: | set -euo pipefail - python3 -m pip install build twine - python3 -m build - python3 -m twine check dist/* + python3 -m venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install build twine + python -m build + python -m twine check dist/* - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }} - run: python3 -m twine upload --skip-existing dist/* + run: | + set -euo pipefail + export PATH="$PWD/.venv/bin:$PATH" + python -m twine upload --skip-existing dist/* diff --git a/.gitea/workflows/release-quality-gate.yml b/.gitea/workflows/release-quality-gate.yml index 2ac47f70..b235097f 100644 --- a/.gitea/workflows/release-quality-gate.yml +++ b/.gitea/workflows/release-quality-gate.yml @@ -31,8 +31,10 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder run: | set -euo pipefail - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt -r requirements-dev.txt playwright - python3 -m playwright install --with-deps chromium + python3 -m venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt playwright + python -m playwright install --with-deps chromium npm ci --prefix frontend - python3 scripts/run_quality_gate.py --profile release + python scripts/run_quality_gate.py --profile release diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index b64cabc8..9e9c3dfd 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -30,11 +30,13 @@ jobs: NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder run: | set -euo pipefail - python3 -m pip install --upgrade pip - python3 -m pip install -r requirements.txt -r requirements-dev.txt + python3 -m venv .venv + export PATH="$PWD/.venv/bin:$PATH" + python -m pip install --upgrade pip + python -m pip install -r requirements.txt -r requirements-dev.txt npm ci --prefix frontend - python3 -m pytest -vv --maxfail=1 - python3 tests/run_all.py + python -m pytest -vv --maxfail=1 + python tests/run_all.py npm test --prefix frontend npm run lint --prefix frontend npm run build --prefix frontend diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index d28cb005..4412b400 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1519,3 +1519,19 @@ - 相关记录:无 - 复发自:无 - 修复版本:待提交(本地可测) + +## BUG-083 | Gitea xiaoxin Runner 向系统 Python 安装依赖触发 PEP 668 + +- 状态:resolved +- 首次发现:2026-07-27 +- 最近更新:2026-07-27 +- 影响面:Gitea 后端质量门禁、CI、完整测试、发布质量门禁与 PyPI 发布 +- 用户现象:xiaoxin Runner 执行 `python3 -m pip install` 时以 `externally-managed-environment` 失败,工作流无法进入后续 Ruff、pytest、Playwright、构建或上传阶段。 +- 触发条件:基于启用 PEP 668 的系统 Python 运行任一包含 pip 安装的 `.gitea/workflows/*.yml`。 +- 根因:BUG-082 统一 Gitea Runner 与原生工具链时只审计了 runner、checkout、触发器和镜像主链,没有约束 Python 依赖隔离;五个工作流仍直接向 Runner 的系统 Python 执行 pip 安装,且 Gitea step 之间不会自动延续 shell 内的 PATH 修改。 +- 修复:所有包含 pip 安装的 Gitea workflow 都先创建仓库内 `.venv` 并在同一安装 step 显式将其 `bin` 目录置于 PATH;后续独立 Python step(包括后端验证与 PyPI 上传)再次显式设置同一 PATH。未使用 `--break-system-packages`,也未修改 GitHub workflows。 +- 验证:前端 Gitea workflow 契约测试枚举全部 `.gitea/workflows/*.yml`,确认五个 pip workflow 均创建并选择 `.venv`、后续 Python step 重新选择 `.venv`、不存在裸 `pip install` 或 `--break-system-packages`;全部 Gitea YAML 完成解析验证。 +- 防复发:工作流审计必须动态枚举全部 Gitea YAML;任何出现 pip install 的 workflow 都必须创建 `.venv`,任何随后执行 Python、Ruff、pytest、Playwright 或构建/上传命令的独立 step 都必须显式导出 venv PATH。 +- 相关记录:BUG-082 +- 复发自:BUG-082 +- 修复版本:待提交(本地可测) diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index f8b311ed..eba88c38 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -420,6 +420,48 @@ test("all Gitea workflows use xiaoxin, native checkout, and safe triggers", () = assert.doesNotMatch(all, /GITEA_REGISTRY_USERNAME|GITEA_REGISTRY_TOKEN|git\.copse\.top\/root\/jyotisha-(?:api|web)/); }); +test("Gitea workflows isolate pip installs and Python tooling in a virtualenv", () => { + const names = readdirSync(giteaWorkflowDirectory) + .filter((name) => name.endsWith(".yml")); + const pipInstall = /(?:python3?\s+-m\s+)?pip3?\s+install/; + const pythonTooling = /^\s+(?:python3?|ruff|pytest|playwright|build)(?:\s|$)/m; + const expected = [ + "backend-quality-gate.yml", + "ci.yml", + "publish-pypi.yml", + "release-quality-gate.yml", + "test.yml", + ]; + const workflowsWithPip: string[] = []; + + for (const name of names) { + const workflow = read(new URL(name, giteaWorkflowDirectory)); + if (!pipInstall.test(workflow)) continue; + workflowsWithPip.push(name); + assert.doesNotMatch(workflow, /--break-system-packages/, name); + assert.doesNotMatch(workflow, /^\s+pip3?\s+install/m, name); + + const runBlocks = workflow.match(/^\s{8}run:\s*(?:\|\s*\n(?:\s{10}.*(?:\n|$))+|[^\n]+)$/gm) ?? []; + let virtualenvCreated = false; + for (const block of runBlocks) { + const installsWithPip = pipInstall.test(block); + if (installsWithPip) { + assert.match(block, /python3 -m venv \.venv/, `${name}: pip step must create .venv`); + virtualenvCreated = true; + } + if (virtualenvCreated && pythonTooling.test(block)) { + assert.match( + block, + /export PATH="\$PWD\/\.venv\/bin:\$PATH"/, + `${name}: every later Python step must select .venv`, + ); + } + } + } + + assert.deepEqual(workflowsWithPip.sort(), expected); +}); + test("Gitea staging push validates once then publishes and deploys immutable ACR images", () => { const workflow = read(giteaQualityWorkflow); assert.match(workflow, /publish-and-deploy:[\s\S]*needs: validate/); -- 2.52.0 From 7a36169f5d45e7d70e291f03df1199777b86b2fd Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 09:37:32 +0800 Subject: [PATCH 11/46] fix: avoid Docker Hub timeout in Gitea builds --- deploy/railway-api.Dockerfile | 2 +- deploy/railway-web.Dockerfile | 2 +- docs/BUG_HISTORY.md | 16 ++++++++++++++++ tests/test_railway_deployment.py | 3 +++ 4 files changed, 21 insertions(+), 2 deletions(-) diff --git a/deploy/railway-api.Dockerfile b/deploy/railway-api.Dockerfile index 23131945..7f54749b 100644 --- a/deploy/railway-api.Dockerfile +++ b/deploy/railway-api.Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.12-slim +FROM registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index 5926686d..99dc7c09 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -1,4 +1,4 @@ -FROM node:22-alpine +FROM registry.cn-hangzhou.aliyuncs.com/library/node:22-alpine WORKDIR /app/frontend COPY frontend/package.json frontend/package-lock.json ./ diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 4412b400..0253c47a 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1535,3 +1535,19 @@ - 相关记录:BUG-082 - 复发自:BUG-082 - 修复版本:待提交(本地可测) + +## BUG-084 | Gitea Runner 构建镜像访问 Docker Hub 超时 + +- 状态:resolved +- 首次发现:2026-07-28 +- 最近更新:2026-07-28 +- 影响面:Gitea staging 镜像构建与发布 +- 用户现象:镜像构建在解析 `python:3.12-slim` 基础镜像时访问 `registry-1.docker.io` 超时,退出码为 1;代码检出、镜像仓库登录均已成功。 +- 触发条件:xiaoxin Runner 构建 `deploy/railway-api.Dockerfile` 或 `deploy/railway-web.Dockerfile`,且到 Docker Hub 的 HTTPS 连接不可用或不稳定。 +- 根因:Railway API/Web Dockerfile 直接依赖 Docker Hub 官方 registry;本次失败发生在 Dockerfile 第 1 行的基础镜像 metadata 拉取阶段,不是应用代码、依赖安装或 ACR 登录失败。 +- 修复:将 API 的 Python 基础镜像和 Web 的 Node 基础镜像切换为可从当前网络稳定访问的阿里云公共镜像同步地址;增加 Dockerfile 契约断言,防止后续恢复为 Docker Hub 直连。 +- 验证:`tests/test_railway_deployment.py` 的静态契约已更新;当前本机 pytest 7.4.4 低于项目要求的 pytest 8.0,测试执行被项目配置门禁阻断,未伪报通过。 +- 防复发:镜像构建前必须验证基础镜像 registry 可达;统一基础镜像来源,不要在受限 Runner 上直接依赖 Docker Hub;若镜像同步源发生变更,应先更新契约和执行实际构建验证。 +- 相关记录:BUG-082、BUG-083 +- 复发自:无 +- 修复版本:待提交(本地可测) diff --git a/tests/test_railway_deployment.py b/tests/test_railway_deployment.py index d490dd78..b99771d4 100644 --- a/tests/test_railway_deployment.py +++ b/tests/test_railway_deployment.py @@ -12,10 +12,13 @@ def test_railway_services_use_the_product_frontend_and_dynamic_ports() -> None: assert "--hostname 0.0.0.0" in web and "${PORT:-3000}" in web assert "next-env.d.ts" not in web + assert "FROM registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim" in api assert "COPY SKILL.md mcp_server.py" in api assert "--host 0.0.0.0" in api and "${PORT:-5200}" in api assert "http.server" not in api + assert "FROM registry.cn-hangzhou.aliyuncs.com/library/node:22-alpine" in web + def test_web_image_copies_postcss_config_before_building_frontend() -> None: web = (ROOT / "deploy" / "railway-web.Dockerfile").read_text(encoding="utf-8") -- 2.52.0 From 02ec5db3612971c75910aa6aa12dcbcc3015a45b Mon Sep 17 00:00:00 2001 From: Jesse Date: Mon, 27 Jul 2026 19:55:38 +0800 Subject: [PATCH 12/46] feat(admin): add audited Refine staging console --- BLOCKED.md | 2 + .../20260727000000_admin_viewer_identity.sql | 6 + frontend/package-lock.json | 2359 ++++++++++++++++- frontend/package.json | 5 + frontend/src/app/admin/audit-logs/page.tsx | 40 + frontend/src/app/admin/codes/page.tsx | 313 +-- frontend/src/app/admin/consultations/page.tsx | 40 + .../app/admin/credit-transactions/page.tsx | 42 + frontend/src/app/admin/layout.tsx | 21 +- frontend/src/app/admin/page.tsx | 5 + frontend/src/app/admin/users/page.tsx | 36 + .../src/app/api/admin/audit-logs/route.ts | 84 + .../src/app/api/admin/codes/[id]/route.ts | 70 + frontend/src/app/api/admin/codes/route.ts | 186 +- .../src/app/api/admin/consultations/route.ts | 79 + .../api/admin/credit-transactions/route.ts | 88 + frontend/src/app/api/admin/session/route.ts | 22 + frontend/src/app/api/admin/users/route.ts | 85 + frontend/src/app/api/redeem/route.ts | 1 + frontend/src/components/admin/admin-app.tsx | 58 + .../src/components/admin/resource-table.tsx | 65 + frontend/src/components/email-otp-login.tsx | 6 +- frontend/src/lib/admin/auth-policy.ts | 23 + frontend/src/lib/admin/auth.ts | 55 + frontend/src/lib/admin/codes.ts | 79 + frontend/src/lib/admin/database.ts | 41 + frontend/src/lib/admin/http.ts | 53 + frontend/src/lib/admin/providers.ts | 173 ++ frontend/src/modules/identity/auth.ts | 21 +- ...27010000_refine_admin_redemption_audit.sql | 475 ++++ frontend/tests/admin-auth.test.ts | 49 + frontend/tests/admin-contracts.test.ts | 76 + frontend/tests/admin-database.test.ts | 121 + frontend/tests/identity-auth-factory.test.ts | 20 + progress.md | 18 + 35 files changed, 4545 insertions(+), 272 deletions(-) create mode 100644 frontend/db/migrations/20260727000000_admin_viewer_identity.sql create mode 100644 frontend/src/app/admin/audit-logs/page.tsx create mode 100644 frontend/src/app/admin/consultations/page.tsx create mode 100644 frontend/src/app/admin/credit-transactions/page.tsx create mode 100644 frontend/src/app/admin/page.tsx create mode 100644 frontend/src/app/admin/users/page.tsx create mode 100644 frontend/src/app/api/admin/audit-logs/route.ts create mode 100644 frontend/src/app/api/admin/codes/[id]/route.ts create mode 100644 frontend/src/app/api/admin/consultations/route.ts create mode 100644 frontend/src/app/api/admin/credit-transactions/route.ts create mode 100644 frontend/src/app/api/admin/session/route.ts create mode 100644 frontend/src/app/api/admin/users/route.ts create mode 100644 frontend/src/components/admin/admin-app.tsx create mode 100644 frontend/src/components/admin/resource-table.tsx create mode 100644 frontend/src/lib/admin/auth-policy.ts create mode 100644 frontend/src/lib/admin/auth.ts create mode 100644 frontend/src/lib/admin/codes.ts create mode 100644 frontend/src/lib/admin/database.ts create mode 100644 frontend/src/lib/admin/http.ts create mode 100644 frontend/src/lib/admin/providers.ts create mode 100644 frontend/supabase/migrations/20260727010000_refine_admin_redemption_audit.sql create mode 100644 frontend/tests/admin-auth.test.ts create mode 100644 frontend/tests/admin-contracts.test.ts create mode 100644 frontend/tests/admin-database.test.ts diff --git a/BLOCKED.md b/BLOCKED.md index dd472a6a..fd602749 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -1,3 +1,5 @@ # BLOCKED - 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。 +- PostgreSQL 事务反向测试:当前执行环境没有 `docker`、`postgres`、`initdb`、`psql`、Podman/Colima/Lima。`frontend/tests/admin-database.test.ts` 已实现审计触发器故意失败并断言兑换码行数仍为 0 的红灯证据,但本地执行在启动 fixture 前以 `spawnSync docker ENOENT` 阻塞;交由 exact-SHA staging quality gate 的 Docker 环境运行。全量 `npm test` 因同一缺失 Docker 共阻塞 11 项数据库/部署测试,另有 1 项既有真实 DOM 测试因缺 Playwright headless Chromium 阻塞;其余 1031 项通过,skipped/todo=0。 +- staging 两角色冒烟:仓库/环境未提供受控 admin 与 viewer 测试账号或其登录验证码收件箱;不得使用他人账号。部署后可完成匿名 401 和公开 health,admin/viewer 浏览器冒烟需受控账号。 diff --git a/frontend/db/migrations/20260727000000_admin_viewer_identity.sql b/frontend/db/migrations/20260727000000_admin_viewer_identity.sql new file mode 100644 index 00000000..9fef5a39 --- /dev/null +++ b/frontend/db/migrations/20260727000000_admin_viewer_identity.sql @@ -0,0 +1,6 @@ +-- Admin-host sessions may be created for read-only viewers. API authorization +-- remains server-side and is resolved from this persisted role on every request. +-- Existing identity migrations already grant admin_runtime these reads; repeat the +-- least-privilege user grant so drifted staging databases fail closed at login. + +grant select on table identity.users to admin_runtime; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index acb1727f..0adbd951 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -34,7 +34,12 @@ "thinking-orbs": "^0.1.1", "tsx": "^4.23.1", "tw-animate-css": "^1.4.0", - "zod": "^3.25.76" + "zod": "^3.25.76", + "@ant-design/icons": "^6.3.2", + "@refinedev/antd": "^6.0.3", + "@refinedev/core": "^5.0.12", + "@refinedev/nextjs-router": "^7.0.5", + "antd": "^5.29.3" }, "devDependencies": { "@types/node": "^20", @@ -11691,6 +11696,2358 @@ "type": "github", "url": "https://github.com/sponsors/wooorm" } + }, + "node_modules/@ant-design/icons": { + "version": "6.3.2", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-6.3.2.tgz", + "integrity": "sha512-B6O5a5XJ4wjtNOfZejXYwHW5zvKV5gYkjGf11dHGLEbKn0ABDGndo41+gfIiXyTFhvESj4XTotuud33mUFid0g==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^8.0.1", + "@ant-design/icons-svg": "^4.5.0", + "@rc-component/util": "^1.11.0", + "clsx": "^2.1.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@refinedev/antd": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@refinedev/antd/-/antd-6.0.3.tgz", + "integrity": "sha512-adNxHZJuca3TN4y1zXpamspqc0yi5hAqlzveTKAXLDuZ8C546xPdihmpZ/bWGG1fjYzWKLctb3YIoUw6d/WDgA==", + "license": "MIT", + "dependencies": { + "@ant-design/icons": "^5.5.1", + "@ant-design/pro-layout": "^7.21.1", + "@refinedev/ui-types": "^2.0.1", + "@tanstack/react-query": "^5.81.5", + "antd": "^5.23.0", + "dayjs": "^1.10.7", + "react-markdown": "^6.0.1", + "remark-gfm": "^1.0.0", + "sunflower-antd": "1.0.0-beta.3", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@refinedev/core": "^5.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "antd": "^5.23.0", + "dayjs": "^1.10.7", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/core": { + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/@refinedev/core/-/core-5.0.12.tgz", + "integrity": "sha512-9y5Bi9Lb7XyJmM55b8rCeBTDRCBU41p47OymJldasLfrtpUm2EwI+27DjjNpHTOugymiZsIbLlPtHCPQIXBHcg==", + "license": "MIT", + "dependencies": { + "@refinedev/devtools-internal": "2.0.2", + "@tanstack/react-query": "^5.81.5", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "papaparse": "^5.3.0", + "pluralize": "^8.0.0", + "qs": "^6.10.1", + "tslib": "^2.6.2", + "warn-once": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@tanstack/react-query": "^5.81.5", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@refinedev/nextjs-router": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/@refinedev/nextjs-router/-/nextjs-router-7.0.5.tgz", + "integrity": "sha512-Z724KBsnEtESGYZMntXEhXr9gmQD/kD6s7poeMY4HeLtWLfNyJPdopHntD4BYMU1ApZweDBJeSqEuWjoL3/x5A==", + "license": "MIT", + "dependencies": { + "qs": "^6.10.1", + "warn-once": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@refinedev/core": "^5.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "next": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/antd": { + "version": "5.29.3", + "resolved": "https://registry.npmjs.org/antd/-/antd-5.29.3.tgz", + "integrity": "sha512-3DdbGCa9tWAJGcCJ6rzR8EJFsv2CtyEbkVabZE14pfgUHfCicWCj0/QzQVLDYg8CPfQk9BH7fHCoTXHTy7MP/A==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.2.1", + "@ant-design/cssinjs": "^1.23.0", + "@ant-design/cssinjs-utils": "^1.1.3", + "@ant-design/fast-color": "^2.0.6", + "@ant-design/icons": "^5.6.1", + "@ant-design/react-slick": "~1.1.2", + "@babel/runtime": "^7.26.0", + "@rc-component/color-picker": "~2.0.1", + "@rc-component/mutate-observer": "^1.1.0", + "@rc-component/qrcode": "~1.1.0", + "@rc-component/tour": "~1.15.1", + "@rc-component/trigger": "^2.3.0", + "classnames": "^2.5.1", + "copy-to-clipboard": "^3.3.3", + "dayjs": "^1.11.11", + "rc-cascader": "~3.34.0", + "rc-checkbox": "~3.5.0", + "rc-collapse": "~3.9.0", + "rc-dialog": "~9.6.0", + "rc-drawer": "~7.3.0", + "rc-dropdown": "~4.2.1", + "rc-field-form": "~2.7.1", + "rc-image": "~7.12.0", + "rc-input": "~1.8.0", + "rc-input-number": "~9.5.0", + "rc-mentions": "~2.20.0", + "rc-menu": "~9.16.1", + "rc-motion": "^2.9.5", + "rc-notification": "~5.6.4", + "rc-pagination": "~5.1.0", + "rc-picker": "~4.11.3", + "rc-progress": "~4.0.0", + "rc-rate": "~2.13.1", + "rc-resize-observer": "^1.4.3", + "rc-segmented": "~2.7.0", + "rc-select": "~14.16.8", + "rc-slider": "~11.1.9", + "rc-steps": "~6.0.1", + "rc-switch": "~4.1.0", + "rc-table": "~7.54.0", + "rc-tabs": "~15.7.0", + "rc-textarea": "~1.10.2", + "rc-tooltip": "~6.4.0", + "rc-tree": "~5.13.1", + "rc-tree-select": "~5.27.0", + "rc-upload": "~4.11.0", + "rc-util": "^5.44.4", + "scroll-into-view-if-needed": "^3.1.0", + "throttle-debounce": "^5.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ant-design" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@ant-design/colors": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-8.0.1.tgz", + "integrity": "sha512-foPVl0+SWIslGUtD/xBr1p9U4AKzPhNYEseXYRRo5QSzGACYZrQbe11AYJbYfAWnWSpGBx6JjBmSeugUsD9vqQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^3.0.0" + } + }, + "node_modules/@ant-design/icons-svg": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@ant-design/icons-svg/-/icons-svg-4.5.0.tgz", + "integrity": "sha512-1BTUFyKPTBZ53MuTP8s0k5SFEXL7o3VHEOwLgzaoWKwnBeqIcqUtVshc4SKzhI6uACfqhJqBwBUE9FsWR3uULA==", + "license": "MIT" + }, + "node_modules/@rc-component/util": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/@rc-component/util/-/util-1.12.0.tgz", + "integrity": "sha512-AEjPL8JVdohIITaiXokyjL9WQ6tKWWjAYK9QU16tGNE9JaQABBQy+hA4H2Lup5MgXy9yY3iLrbZJheuU13hTdQ==", + "license": "MIT", + "dependencies": { + "is-mobile": "^5.0.0", + "react-is": "^19.2.7" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, + "node_modules/@refinedev/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-layout": { + "version": "7.22.7", + "resolved": "https://registry.npmjs.org/@ant-design/pro-layout/-/pro-layout-7.22.7.tgz", + "integrity": "sha512-fvmtNA1r9SaasVIQIQt611VSlNxtVxDbQ3e+1GhYQza3tVJi/3gCZuDyfMfTnbLmf3PaW/YvLkn7MqDbzAzoLA==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@ant-design/pro-utils": "2.18.0", + "@babel/runtime": "^7.18.0", + "@umijs/route-utils": "^4.0.0", + "@umijs/use-params": "^1.0.9", + "classnames": "^2.3.2", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "path-to-regexp": "8.2.0", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.0.6", + "swr": "^2.0.0", + "warning": "^4.0.3" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@refinedev/ui-types": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@refinedev/ui-types/-/ui-types-2.0.1.tgz", + "integrity": "sha512-Fxsgr2JEsyEVGr5rMvOasQP5tj/1yD2m4M9XqDZQ+65B/ZB/vbkbB5+ltAhNlX2UwX8jr1mo8fnutHBltYxwfA==", + "license": "MIT", + "dependencies": { + "@refinedev/core": "^5.0.5", + "dayjs": "^1.10.7", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@refinedev/core": "^5.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.4.tgz", + "integrity": "sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/dayjs": { + "version": "1.11.21", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.21.tgz", + "integrity": "sha512-98IT+HOahAisibz/yjKbzuOBwYcjJ7BCLPzARyHiyEBmRz4fatF+KPJszEHXsGYjUG234aH/cOjW1wwTbKUZlA==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/react-markdown": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-6.0.3.tgz", + "integrity": "sha512-kQbpWiMoBHnj9myLlmZG9T1JdoT/OEyHK7hqM6CqFT14MAkgWiWBUYijLyBmxbntaN6dCDicPcUhWhci1QYodg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^2.0.0", + "@types/unist": "^2.0.3", + "comma-separated-tokens": "^1.0.0", + "prop-types": "^15.7.2", + "property-information": "^5.3.0", + "react-is": "^17.0.0", + "remark-parse": "^9.0.0", + "remark-rehype": "^8.0.0", + "space-separated-tokens": "^1.1.0", + "style-to-object": "^0.3.0", + "unified": "^9.0.0", + "unist-util-visit": "^2.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=16", + "react": ">=16" + } + }, + "node_modules/@refinedev/antd/node_modules/remark-gfm": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-1.0.0.tgz", + "integrity": "sha512-KfexHJCiqvrdBZVbQ6RopMZGwaXz6wFJEfByIuEwGf0arvITHjiKKZ1dpXujjH9KZdm1//XJQwgfnJ3lmXaDPA==", + "license": "MIT", + "dependencies": { + "mdast-util-gfm": "^0.1.0", + "micromark-extension-gfm": "^0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/sunflower-antd": { + "version": "1.0.0-beta.3", + "resolved": "https://registry.npmjs.org/sunflower-antd/-/sunflower-antd-1.0.0-beta.3.tgz", + "integrity": "sha512-SAdjHgNemTFNxUF/QJ2KdC0x6wWpY1EsMJMo+F5KIHCDRsUUahjAIldoK+ejH00rPgUoCOhAHQ/ob/J7eyZ5qg==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@refinedev/devtools-internal": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-internal/-/devtools-internal-2.0.2.tgz", + "integrity": "sha512-1YYizOW1lyy9ep8eQ7TcUPBooKXIlvzTLjLdDArsQwx7P33cn2uXdqM7So5VhlNFXhjOjAKFgrH5c1jleRF8Jg==", + "license": "MIT", + "dependencies": { + "@refinedev/devtools-shared": "2.0.2", + "@tanstack/react-query": "^5.81.5", + "error-stack-parser": "^2.1.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/lodash-es": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.18.1.tgz", + "integrity": "sha512-J8xewKD/Gk22OZbhpOVSwcs60zhd95ESDwezOFuA3/099925PdHJ7OFHNTGtajL3AlZkykD32HykiMo+BIBI8A==", + "license": "MIT" + }, + "node_modules/papaparse": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.4.tgz", + "integrity": "sha512-SwzWD9gl/ElwYLCI0nUja1mFJzjq2D8ziShfNBa7zCHzkOozeOGDwHWQ+tvCzEZcewecWZ5U7kUopDnG+DFYEQ==", + "license": "MIT" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-8.0.0.tgz", + "integrity": "sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/warn-once": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz", + "integrity": "sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q==", + "license": "MIT" + }, + "node_modules/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/cssinjs": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs/-/cssinjs-1.24.0.tgz", + "integrity": "sha512-K4cYrJBsgvL+IoozUXYjbT6LHHNt+19a9zkvpBPxLjFHas1UpPM2A5MlhROb0BT8N8WoavM5VsP9MeSeNK/3mg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "@emotion/hash": "^0.8.0", + "@emotion/unitless": "^0.7.5", + "classnames": "^2.3.1", + "csstype": "^3.1.3", + "rc-util": "^5.35.0", + "stylis": "^4.3.4" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/cssinjs-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@ant-design/cssinjs-utils/-/cssinjs-utils-1.1.3.tgz", + "integrity": "sha512-nOoQMLW1l+xR1Co8NFVYiP8pZp3VjIIzqV6D6ShYF2ljtdwWJn5WSsH+7kvCktXL/yhEtWURKOfH5Xz/gzlwsg==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.0", + "@babel/runtime": "^7.23.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/antd/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/react-slick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@ant-design/react-slick/-/react-slick-1.1.2.tgz", + "integrity": "sha512-EzlvzE6xQUBrZuuhSAFTdsr4P2bBBHGZwKFemEfq8gIGyIQCxalYfZW/T2ORbtQx5rU69o+WycP3exY/7T1hGA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.4", + "classnames": "^2.2.5", + "json2mq": "^0.2.0", + "resize-observer-polyfill": "^1.5.1", + "throttle-debounce": "^5.0.0" + }, + "peerDependencies": { + "react": ">=16.9.0" + } + }, + "node_modules/@rc-component/color-picker": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/@rc-component/color-picker/-/color-picker-2.0.1.tgz", + "integrity": "sha512-WcZYwAThV/b2GISQ8F+7650r5ZZJ043E57aVBFkQ+kSY4C6wdofXgB0hBx+GPGpIU0Z81eETNoDUJMr7oy/P8Q==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6", + "@babel/runtime": "^7.23.6", + "classnames": "^2.2.6", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/mutate-observer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rc-component/mutate-observer/-/mutate-observer-1.1.0.tgz", + "integrity": "sha512-QjrOsDXQusNwGZPf4/qRQasg7UFEj06XiCJ8iuiq/Io7CrHrgVi6Uuetw60WAMG1799v+aM8kyc+1L/GBbHSlw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/qrcode": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rc-component/qrcode/-/qrcode-1.1.3.tgz", + "integrity": "sha512-aGv6alnn4HbDEsURzKP+jv13rbi1VxmAYfBNZr5GKF1iohMNWy5tAVoJ1E3cOvzMB1kbUPvCXchM6zSFlRGPhA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/tour": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@rc-component/tour/-/tour-1.15.1.tgz", + "integrity": "sha512-Tr2t7J1DKZUpfJuDZWHxyxWpfmj8EZrqSgyMZ+BCdvKZ6r1UDsfU46M/iWAAFBy961Ssfom2kv5f3UcjIL2CmQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/portal": "^1.0.0-9", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/trigger": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@rc-component/trigger/-/trigger-2.3.1.tgz", + "integrity": "sha512-ORENF39PeXTzM+gQEshuk460Z8N4+6DkjpxlpE7Q3gYy1iBpLrx0FOJz3h62ryrJZ/3zCAUIkT1Pb/8hHWpb3A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.2", + "@rc-component/portal": "^1.1.0", + "classnames": "^2.3.2", + "rc-motion": "^2.0.0", + "rc-resize-observer": "^1.3.1", + "rc-util": "^5.44.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/classnames": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz", + "integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==", + "license": "MIT" + }, + "node_modules/copy-to-clipboard": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/copy-to-clipboard/-/copy-to-clipboard-3.3.3.tgz", + "integrity": "sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==", + "license": "MIT", + "dependencies": { + "toggle-selection": "^1.0.6" + } + }, + "node_modules/rc-cascader": { + "version": "3.34.0", + "resolved": "https://registry.npmjs.org/rc-cascader/-/rc-cascader-3.34.0.tgz", + "integrity": "sha512-KpXypcvju9ptjW9FaN2NFcA2QH9E9LHKq169Y0eWtH4e/wHQ5Wh5qZakAgvb8EKZ736WZ3B0zLLOBsrsja5Dag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "^2.3.1", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-checkbox": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/rc-checkbox/-/rc-checkbox-3.5.0.tgz", + "integrity": "sha512-aOAQc3E98HteIIsSqm6Xk2FPKIER6+5vyEFMZfo73TqM+VVAIqOkHoPjgKLqSNtVLWScoaM7vY2ZrGEheI79yg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.25.2" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-collapse": { + "version": "3.9.0", + "resolved": "https://registry.npmjs.org/rc-collapse/-/rc-collapse-3.9.0.tgz", + "integrity": "sha512-swDdz4QZ4dFTo4RAUMLL50qP0EY62N2kvmk2We5xYdRwcRn8WcYtuetCJpwpaCbUfUt5+huLpVxhvmnK+PHrkA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.3.4", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dialog": { + "version": "9.6.0", + "resolved": "https://registry.npmjs.org/rc-dialog/-/rc-dialog-9.6.0.tgz", + "integrity": "sha512-ApoVi9Z8PaCQg6FsUzS8yvBEQy0ZL2PkuvAgrmohPkN3okps5WZ5WQWPc1RNuiOKaAYv8B97ACdsFU5LizzCqg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/portal": "^1.0.0-8", + "classnames": "^2.2.6", + "rc-motion": "^2.3.0", + "rc-util": "^5.21.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-drawer": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/rc-drawer/-/rc-drawer-7.3.0.tgz", + "integrity": "sha512-DX6CIgiBWNpJIMGFO8BAISFkxiuKitoizooj4BDyee8/SnBn0zwO2FHrNDpqqepj0E/TFTDpmEBCyFuTgC7MOg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.23.9", + "@rc-component/portal": "^1.1.1", + "classnames": "^2.2.6", + "rc-motion": "^2.6.1", + "rc-util": "^5.38.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-dropdown": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/rc-dropdown/-/rc-dropdown-4.2.1.tgz", + "integrity": "sha512-YDAlXsPv3I1n42dv1JpdM7wJ+gSUBfeyPK59ZpBD9jQhK9jVuxpjj3NmWQHOBceA1zEPVX84T2wbdb2SD0UjmA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-util": "^5.44.1" + }, + "peerDependencies": { + "react": ">=16.11.0", + "react-dom": ">=16.11.0" + } + }, + "node_modules/rc-field-form": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-field-form/-/rc-field-form-2.7.1.tgz", + "integrity": "sha512-vKeSifSJ6HoLaAB+B8aq/Qgm8a3dyxROzCtKNCsBQgiverpc4kWDQihoUwzUj+zNWJOykwSY4dNX3QrGwtVb9A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "@rc-component/async-validator": "^5.0.3", + "rc-util": "^5.32.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-image": { + "version": "7.12.0", + "resolved": "https://registry.npmjs.org/rc-image/-/rc-image-7.12.0.tgz", + "integrity": "sha512-cZ3HTyyckPnNnUb9/DRqduqzLfrQRyi+CdHjdqgsyDpI3Ln5UX1kXnAhPBSJj9pVRzwRFgqkN7p9b6HBDjmu/Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/portal": "^1.0.2", + "classnames": "^2.2.6", + "rc-dialog": "~9.6.0", + "rc-motion": "^2.6.2", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-input": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/rc-input/-/rc-input-1.8.0.tgz", + "integrity": "sha512-KXvaTbX+7ha8a/k+eg6SYRVERK0NddX8QX7a7AnRvUa/rEH0CNMlpcBzBkhI0wp2C8C4HlMoYl8TImSN+fuHKA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.18.1" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-input-number": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/rc-input-number/-/rc-input-number-9.5.0.tgz", + "integrity": "sha512-bKaEvB5tHebUURAEXw35LDcnRZLq3x1k7GxfAqBMzmpHkDGzjAtnUL8y4y5N15rIFIg5IJgwr211jInl3cipag==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/mini-decimal": "^1.0.1", + "classnames": "^2.2.5", + "rc-input": "~1.8.0", + "rc-util": "^5.40.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-mentions": { + "version": "2.20.0", + "resolved": "https://registry.npmjs.org/rc-mentions/-/rc-mentions-2.20.0.tgz", + "integrity": "sha512-w8HCMZEh3f0nR8ZEd466ATqmXFCMGMN5UFCzEUL0bM/nGw/wOS2GgRzKBcm19K++jDyuWCOJOdgcKGXU3fXfbQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.22.5", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.6", + "rc-input": "~1.8.0", + "rc-menu": "~9.16.0", + "rc-textarea": "~1.10.0", + "rc-util": "^5.34.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-menu": { + "version": "9.16.1", + "resolved": "https://registry.npmjs.org/rc-menu/-/rc-menu-9.16.1.tgz", + "integrity": "sha512-ghHx6/6Dvp+fw8CJhDUHFHDJ84hJE3BXNCzSgLdmNiFErWSOaZNsihDAsKq9ByTALo/xkNIwtDFGIl6r+RPXBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.0.0", + "classnames": "2.x", + "rc-motion": "^2.4.3", + "rc-overflow": "^1.3.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-motion": { + "version": "2.9.5", + "resolved": "https://registry.npmjs.org/rc-motion/-/rc-motion-2.9.5.tgz", + "integrity": "sha512-w+XTUrfh7ArbYEd2582uDrEhmBHwK1ZENJiSJVb7uRxdE7qJSYjbO2eksRXmndqyKqKoYPc9ClpPh5242mV1vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-util": "^5.44.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-notification": { + "version": "5.6.4", + "resolved": "https://registry.npmjs.org/rc-notification/-/rc-notification-5.6.4.tgz", + "integrity": "sha512-KcS4O6B4qzM3KH7lkwOB7ooLPZ4b6J+VMmQgT51VZCeEcmghdeR4IrMcFq0LG+RPdnbe/ArT086tGM8Snimgiw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.9.0", + "rc-util": "^5.20.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-pagination": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/rc-pagination/-/rc-pagination-5.1.0.tgz", + "integrity": "sha512-8416Yip/+eclTFdHXLKTxZvn70duYVGTvUUWbckCCZoIl3jagqke3GLsFrMs0bsQBikiYpZLD9206Ej4SOdOXQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.3.2", + "rc-util": "^5.38.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-picker": { + "version": "4.11.3", + "resolved": "https://registry.npmjs.org/rc-picker/-/rc-picker-4.11.3.tgz", + "integrity": "sha512-MJ5teb7FlNE0NFHTncxXQ62Y5lytq6sh5nUw0iH8OkHL/TjARSEvSHpr940pWgjGANpjCwyMdvsEV55l5tYNSg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.2.1", + "rc-overflow": "^1.3.2", + "rc-resize-observer": "^1.4.0", + "rc-util": "^5.43.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "date-fns": ">= 2.x", + "dayjs": ">= 1.x", + "luxon": ">= 3.x", + "moment": ">= 2.x", + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + }, + "peerDependenciesMeta": { + "date-fns": { + "optional": true + }, + "dayjs": { + "optional": true + }, + "luxon": { + "optional": true + }, + "moment": { + "optional": true + } + } + }, + "node_modules/rc-progress": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/rc-progress/-/rc-progress-4.0.0.tgz", + "integrity": "sha512-oofVMMafOCokIUIBnZLNcOZFsABaUw8PPrf1/y0ZBvKZNpOiu5h4AO9vv11Sw0p4Hb3D0yGWuEattcQGtNJ/aw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.6", + "rc-util": "^5.16.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-rate": { + "version": "2.13.1", + "resolved": "https://registry.npmjs.org/rc-rate/-/rc-rate-2.13.1.tgz", + "integrity": "sha512-QUhQ9ivQ8Gy7mtMZPAjLbxBt5y9GRp65VcUyGUMF3N3fhiftivPHdpuDIaWIMOTEprAjZPC08bls1dQB+I1F2Q==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.0.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-resize-observer": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/rc-resize-observer/-/rc-resize-observer-1.4.3.tgz", + "integrity": "sha512-YZLjUbyIWox8E9i9C3Tm7ia+W7euPItNWSPX5sCcQTYbnwDb5uNpnLHQCG1f22oZWUhLw4Mv2tFmeWe68CDQRQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.7", + "classnames": "^2.2.1", + "rc-util": "^5.44.1", + "resize-observer-polyfill": "^1.5.1" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-segmented": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/rc-segmented/-/rc-segmented-2.7.1.tgz", + "integrity": "sha512-izj1Nw/Dw2Vb7EVr+D/E9lUTkBe+kKC+SAFSU9zqr7WV2W5Ktaa9Gc7cB2jTqgk8GROJayltaec+DBlYKc6d+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-motion": "^2.4.4", + "rc-util": "^5.17.0" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/rc-select": { + "version": "14.16.8", + "resolved": "https://registry.npmjs.org/rc-select/-/rc-select-14.16.8.tgz", + "integrity": "sha512-NOV5BZa1wZrsdkKaiK7LHRuo5ZjZYMDxPP6/1+09+FB4KoNi8jcG1ZqLE3AVCxEsYMBe65OBx71wFoHRTP3LRg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/trigger": "^2.1.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-overflow": "^1.3.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-slider": { + "version": "11.1.9", + "resolved": "https://registry.npmjs.org/rc-slider/-/rc-slider-11.1.9.tgz", + "integrity": "sha512-h8IknhzSh3FEM9u8ivkskh+Ef4Yo4JRIY2nj7MrH6GQmrwV6mcpJf5/4KgH5JaVI1H3E52yCdpOlVyGZIeph5A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.5", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-steps": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/rc-steps/-/rc-steps-6.0.1.tgz", + "integrity": "sha512-lKHL+Sny0SeHkQKKDJlAjV5oZ8DwCdS2hFhAkIjuQt1/pB81M0cA0ErVFdHq9+jmPmFw1vJB2F5NBzFXLJxV+g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.16.7", + "classnames": "^2.2.3", + "rc-util": "^5.16.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-switch": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/rc-switch/-/rc-switch-4.1.0.tgz", + "integrity": "sha512-TI8ufP2Az9oEbvyCeVE4+90PDSljGyuwix3fV58p7HV2o4wBnVToEyomJRVyTaZeqNPAp+vqeo4Wnj5u0ZZQBg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.21.0", + "classnames": "^2.2.1", + "rc-util": "^5.30.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-table": { + "version": "7.54.0", + "resolved": "https://registry.npmjs.org/rc-table/-/rc-table-7.54.0.tgz", + "integrity": "sha512-/wDTkki6wBTjwylwAGjpLKYklKo9YgjZwAU77+7ME5mBoS32Q4nAwoqhA2lSge6fobLW3Tap6uc5xfwaL2p0Sw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "@rc-component/context": "^1.4.0", + "classnames": "^2.2.5", + "rc-resize-observer": "^1.1.0", + "rc-util": "^5.44.3", + "rc-virtual-list": "^3.14.2" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tabs": { + "version": "15.7.0", + "resolved": "https://registry.npmjs.org/rc-tabs/-/rc-tabs-15.7.0.tgz", + "integrity": "sha512-ZepiE+6fmozYdWf/9gVp7k56PKHB1YYoDsKeQA1CBlJ/POIhjkcYiv0AGP0w2Jhzftd3AVvZP/K+V+Lpi2ankA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "classnames": "2.x", + "rc-dropdown": "~4.2.0", + "rc-menu": "~9.16.0", + "rc-motion": "^2.6.2", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.34.1" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-textarea": { + "version": "1.10.2", + "resolved": "https://registry.npmjs.org/rc-textarea/-/rc-textarea-1.10.2.tgz", + "integrity": "sha512-HfaeXiaSlpiSp0I/pvWpecFEHpVysZ9tpDLNkxQbMvMz6gsr7aVZ7FpWP9kt4t7DB+jJXesYS0us1uPZnlRnwQ==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "^2.2.1", + "rc-input": "~1.8.0", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tooltip": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/rc-tooltip/-/rc-tooltip-6.4.0.tgz", + "integrity": "sha512-kqyivim5cp8I5RkHmpsp1Nn/Wk+1oeloMv9c7LXNgDxUpGm+RbXJGL+OPvDlcRnx9DBeOe4wyOIl4OKUERyH1g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.2", + "@rc-component/trigger": "^2.0.0", + "classnames": "^2.3.1", + "rc-util": "^5.44.3" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-tree": { + "version": "5.13.1", + "resolved": "https://registry.npmjs.org/rc-tree/-/rc-tree-5.13.1.tgz", + "integrity": "sha512-FNhIefhftobCdUJshO7M8uZTA9F4OPGVXqGfZkkD/5soDeOhwO06T/aKTrg0WD8gRg/pyfq+ql3aMymLHCTC4A==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "classnames": "2.x", + "rc-motion": "^2.0.1", + "rc-util": "^5.16.1", + "rc-virtual-list": "^3.5.1" + }, + "engines": { + "node": ">=10.x" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-tree-select": { + "version": "5.27.0", + "resolved": "https://registry.npmjs.org/rc-tree-select/-/rc-tree-select-5.27.0.tgz", + "integrity": "sha512-2qTBTzwIT7LRI1o7zLyrCzmo5tQanmyGbSaGTIf7sYimCklAToVVfpMC6OAldSKolcnjorBYPNSKQqJmN3TCww==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.25.7", + "classnames": "2.x", + "rc-select": "~14.16.2", + "rc-tree": "~5.13.0", + "rc-util": "^5.43.0" + }, + "peerDependencies": { + "react": "*", + "react-dom": "*" + } + }, + "node_modules/rc-upload": { + "version": "4.11.0", + "resolved": "https://registry.npmjs.org/rc-upload/-/rc-upload-4.11.0.tgz", + "integrity": "sha512-ZUyT//2JAehfHzjWowqROcwYJKnZkIUGWaTE/VogVrepSl7AFNbQf4+zGfX4zl9Vrj/Jm8scLO0R6UlPDKK4wA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "classnames": "^2.2.5", + "rc-util": "^5.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util": { + "version": "5.44.4", + "resolved": "https://registry.npmjs.org/rc-util/-/rc-util-5.44.4.tgz", + "integrity": "sha512-resueRJzmHG9Q6rI/DfK6Kdv9/Lfls05vzMs1Sk3M2P+3cJa+MakaZyWY8IPfehVuhPJFKrIY1IK4GqbiaiY5w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "react-is": "^18.2.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/scroll-into-view-if-needed": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/scroll-into-view-if-needed/-/scroll-into-view-if-needed-3.1.0.tgz", + "integrity": "sha512-49oNpRjWRvnU8NyGVmUaYG4jtTkNonFZI86MmGRDqBphEK2EXT9gdEUoQPZhuBM8yWHxCWbobltqYO5M4XrUvQ==", + "license": "MIT", + "dependencies": { + "compute-scroll-into-view": "^3.0.2" + } + }, + "node_modules/throttle-debounce": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz", + "integrity": "sha512-B71/4oyj61iNH0KeCamLuE2rmKuTO5byTOSVwECM5FA7TiAiAW+UqTKZ9ERueC4qvgSttUhdmq1mXC3kJqGX7A==", + "license": "MIT", + "engines": { + "node": ">=12.22" + } + }, + "node_modules/@ant-design/fast-color": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-3.0.1.tgz", + "integrity": "sha512-esKJegpW4nckh0o6kV3Tkb7NPIZYbPnnFxmQDUmL08ukXZAvV85TZBr70eGuke/CIArLaP6aw8lt9KILjnWuOw==", + "license": "MIT", + "engines": { + "node": ">=8.x" + } + }, + "node_modules/is-mobile": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/is-mobile/-/is-mobile-5.0.0.tgz", + "integrity": "sha512-Tz/yndySvLAEXh+Uk8liFCxOwVH6YutuR74utvOcu7I9Di+DwM0mtdPVZNaVvvBUM2OXxne/NhOs1zAO7riusQ==", + "license": "MIT" + }, + "node_modules/@rc-component/util/node_modules/react-is": { + "version": "19.2.8", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.8.tgz", + "integrity": "sha512-s5un28nYxKJw5gvUHyW5PCC28CvBqLu9r3cWgzHT4Vo/5fqqkFcdRYsGcKf50WMPpjjFZS5d76fn3YCo2njKwQ==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/@ant-design/pro-provider": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/@ant-design/pro-provider/-/pro-provider-2.16.2.tgz", + "integrity": "sha512-0KmCH1EaOND787Jz6VRMYtLNZmqfT0JPjdUfxhyOxFfnBRfrjyfZgIa6CQoAJLEUMWv57PccWS8wRHVUUk2Yiw==", + "license": "MIT", + "dependencies": { + "@ant-design/cssinjs": "^1.21.1", + "@babel/runtime": "^7.18.0", + "@ctrl/tinycolor": "^3.4.0", + "dayjs": "^1.11.10", + "rc-util": "^5.0.1", + "swr": "^2.0.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@ant-design/pro-utils": { + "version": "2.18.0", + "resolved": "https://registry.npmjs.org/@ant-design/pro-utils/-/pro-utils-2.18.0.tgz", + "integrity": "sha512-8+ikyrN8L8a8Ph4oeHTOJEiranTj18+9+WHCHjKNdEfukI7Rjn8xpYdLJWb2AUJkb9d4eoAqjd5+k+7w81Df0w==", + "license": "MIT", + "dependencies": { + "@ant-design/icons": "^5.0.0", + "@ant-design/pro-provider": "2.16.2", + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "dayjs": "^1.11.10", + "lodash": "^4.17.21", + "lodash-es": "^4.17.21", + "rc-util": "^5.0.6", + "safe-stable-stringify": "^2.4.3", + "swr": "^2.0.0" + }, + "peerDependencies": { + "antd": "^4.24.15 || ^5.11.2", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/@umijs/route-utils": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/@umijs/route-utils/-/route-utils-4.0.3.tgz", + "integrity": "sha512-zPEcYhl1cSfkSRDzzGgoD1mDvGjxoOTJFvkn55srfgdQ3NZe2ZMCScCU6DEnOxuKP1XDVf8pqyqCDVd2+RCQIw==", + "license": "MIT" + }, + "node_modules/@umijs/use-params": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@umijs/use-params/-/use-params-1.0.9.tgz", + "integrity": "sha512-QlN0RJSBVQBwLRNxbxjQ5qzqYIGn+K7USppMoIOVlf7fxXHsnQZ2bEsa6Pm74bt6DVQxpUE8HqvdStn6Y9FV1w==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/path-to-regexp": { + "version": "8.2.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.2.0.tgz", + "integrity": "sha512-TdrF7fW9Rphjq4RjrW0Kp2AW0Ahwu9sRGTkS6bvDi0SCwZlEZYmcfDbEsTz8RVk0EHIS/Vd1bv3JhG+1xZuAyQ==", + "license": "MIT", + "engines": { + "node": ">=16" + } + }, + "node_modules/swr": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/swr/-/swr-2.4.2.tgz", + "integrity": "sha512-ej644Y2bvkIajfR32KGeSSdBXQW+ScjGjkybZgSE7kFpk9eGnV44XY9FJylXi+W75pavSX1PVNB57W5EbhGIYw==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.4", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.4.tgz", + "integrity": "sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@refinedev/antd/node_modules/@types/hast": { + "version": "2.3.10", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-2.3.10.tgz", + "integrity": "sha512-McWspRw8xx8J9HurkVBfYj0xKoE25tOFlHGdx4MJ5xORQrMGZNqJhVQWaIbm6Oyla5kYOXtDiopzKRJzEOkwJw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@refinedev/antd/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/comma-separated-tokens": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-1.0.8.tgz", + "integrity": "sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/property-information": { + "version": "5.6.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz", + "integrity": "sha512-YUHSPk+A30YPv+0Qf8i9Mbfe/C0hdPXk1s1jPVToV8pk8BQtpw10ct89Eo7OWkutrwqvT0eicAxlOg3dOAu8JA==", + "license": "MIT", + "dependencies": { + "xtend": "^4.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/remark-parse": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-9.0.0.tgz", + "integrity": "sha512-geKatMwSzEXKHuzBNU1z676sGcDcFoChMK38TgdHJNAYfFtsfHDQG7MoJAjs6sgYMqyLduCYWDIWZIxiPeafEw==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^0.8.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/remark-rehype": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-8.1.0.tgz", + "integrity": "sha512-EbCu9kHgAxKmW1yEYjx3QafMyGY3q8noUbNUI5xyKbaFP89wbhDrKxyIQNukNYthzjNHZu6J7hwFg7hRm1svYA==", + "license": "MIT", + "dependencies": { + "mdast-util-to-hast": "^10.2.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/space-separated-tokens": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-1.1.5.tgz", + "integrity": "sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/style-to-object": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-0.3.0.tgz", + "integrity": "sha512-CzFnRRXhzWIdItT3OmF8SQfWyahHhjq3HwcMNCNLn+N7klOOqPjMeG/4JSu77D7ypZdGvSzvkrbyeTMizz2VrA==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.1.1" + } + }, + "node_modules/@refinedev/antd/node_modules/unified": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/unified/-/unified-9.2.2.tgz", + "integrity": "sha512-Sg7j110mtefBD+qunSLO1lqOEKdrwBFBrR6Qd8f4uwkhWNlbkaqwHse6e7QvD3AP/MNoJdEDLaf8OxYyoWgorQ==", + "license": "MIT", + "dependencies": { + "bail": "^1.0.0", + "extend": "^3.0.0", + "is-buffer": "^2.0.0", + "is-plain-obj": "^2.0.0", + "trough": "^1.0.0", + "vfile": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/vfile": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-4.2.1.tgz", + "integrity": "sha512-O6AE4OskCG5S1emQ/4gl8zK586RqA3srz3nfK/Viy0UPToBc5Trp9BVFb1u0CjsKrAWwnpr4ifM/KBXPWwJbCA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "is-buffer": "^2.0.0", + "unist-util-stringify-position": "^2.0.0", + "vfile-message": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-0.1.2.tgz", + "integrity": "sha512-NNkhDx/qYcuOWB7xHUGWZYVXvjPFFd6afg6/e2g+SV4r9q5XUcCbV4Wfa3DLYIiD+xAEZc6K4MGaE/m0KDcPwQ==", + "license": "MIT", + "dependencies": { + "mdast-util-gfm-autolink-literal": "^0.1.0", + "mdast-util-gfm-strikethrough": "^0.2.0", + "mdast-util-gfm-table": "^0.1.0", + "mdast-util-gfm-task-list-item": "^0.1.0", + "mdast-util-to-markdown": "^0.6.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-0.3.3.tgz", + "integrity": "sha512-oVN4zv5/tAIA+l3GbMi7lWeYpJ14oQyJ3uEim20ktYFAcfX1x3LNlFGGlmrZHt7u9YlKExmyJdDGaTt6cMSR/A==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0", + "micromark-extension-gfm-autolink-literal": "~0.5.0", + "micromark-extension-gfm-strikethrough": "~0.6.5", + "micromark-extension-gfm-table": "~0.4.0", + "micromark-extension-gfm-tagfilter": "~0.3.0", + "micromark-extension-gfm-task-list-item": "~0.3.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/devtools-shared": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@refinedev/devtools-shared/-/devtools-shared-2.0.2.tgz", + "integrity": "sha512-3cTjR1mEWn0tHFZBfPD5aVpBGLUhpAkfjqYCwKrijIicr1Utp/j0BqiPRnNqTf+W71HTng3znBpUhnR83u+tuA==", + "license": "MIT", + "dependencies": { + "@tanstack/react-query": "^5.81.5", + "error-stack-parser": "^2.1.4" + }, + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + } + }, + "node_modules/error-stack-parser": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/error-stack-parser/-/error-stack-parser-2.1.4.tgz", + "integrity": "sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==", + "license": "MIT", + "dependencies": { + "stackframe": "^1.3.4" + } + }, + "node_modules/@emotion/hash": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", + "integrity": "sha512-kBJtf7PH6aWwZ6fka3zQ0p6SBYzx4fl1LoZXE2RrnYST9Xljm7WfKJrU4g/Xr3Beg72MLrp1AWNUmuYJTL7Cow==", + "license": "MIT" + }, + "node_modules/@emotion/unitless": { + "version": "0.7.5", + "resolved": "https://registry.npmjs.org/@emotion/unitless/-/unitless-0.7.5.tgz", + "integrity": "sha512-OWORNpfjMsSSUBVrRBVGECkhWcULOAJz9ZW8uK9qgxD+87M7jHRcvh/A96XXNhXTLmKcoYSQtBEX7lHMO7YRwg==", + "license": "MIT" + }, + "node_modules/stylis": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.4.0.tgz", + "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", + "license": "MIT" + }, + "node_modules/json2mq": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/json2mq/-/json2mq-0.2.0.tgz", + "integrity": "sha512-SzoRg7ux5DWTII9J2qkrZrqV1gt+rTaoufMxEzXbS26Uid0NwaJd123HcoB80TgubEppxxIGdNxCx50fEoEWQA==", + "license": "MIT", + "dependencies": { + "string-convert": "^0.2.0" + } + }, + "node_modules/resize-observer-polyfill": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz", + "integrity": "sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==", + "license": "MIT" + }, + "node_modules/@rc-component/color-picker/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@rc-component/portal": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/portal/-/portal-1.1.2.tgz", + "integrity": "sha512-6f813C0IsasTZms08kfA8kPAGxbbkYToa8ALaiDIGGECU4i9hj8Plgbx0sNJDrey3EtHO30hmdaxtT0138xZcg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0", + "classnames": "^2.3.2", + "rc-util": "^5.24.4" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/toggle-selection": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz", + "integrity": "sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==", + "license": "MIT" + }, + "node_modules/@rc-component/async-validator": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/@rc-component/async-validator/-/async-validator-5.1.2.tgz", + "integrity": "sha512-WYbrZSjzznU1ekD0qFq2qRxt309VoS61MTG5npnFQlKYcoy9IzU8T+ZCIhq5bGAXRbXysABFWTspicMfmWFwow==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.4" + }, + "engines": { + "node": ">=14.x" + } + }, + "node_modules/@rc-component/mini-decimal": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@rc-component/mini-decimal/-/mini-decimal-1.1.4.tgz", + "integrity": "sha512-xiuXcaCwyOWpD8a8scdExFl+bntNphAW8XeenL1ig2en0AAZY0Pcp4pC0dI22qJ+NvxKn9RoNIoRdqYU3BLH4w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.0" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/rc-overflow": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/rc-overflow/-/rc-overflow-1.5.0.tgz", + "integrity": "sha512-Lm/v9h0LymeUYJf0x39OveU52InkdRXqnn2aYXfWmo8WdOonIKB2kfau+GF0fWq6jPgtdO9yMqveGcK6aIhJmg==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.11.1", + "classnames": "^2.2.1", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.37.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-virtual-list": { + "version": "3.19.2", + "resolved": "https://registry.npmjs.org/rc-virtual-list/-/rc-virtual-list-3.19.2.tgz", + "integrity": "sha512-Ys6NcjwGkuwkeaWBDqfI3xWuZ7rDiQXlH1o2zLfFzATfEgXcqpk8CkgMfbJD81McqjcJVez25a3kPxCR807evA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.20.0", + "classnames": "^2.2.6", + "rc-resize-observer": "^1.0.0", + "rc-util": "^5.36.0" + }, + "engines": { + "node": ">=8.x" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/@rc-component/context": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@rc-component/context/-/context-1.4.0.tgz", + "integrity": "sha512-kFcNxg9oLRMoL3qki0OMxK+7g5mypjgaaJp/pkOis/6rVxma9nJBF/8kCIuTYHUQNr0ii7MxqE33wirPZLJQ2w==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.10.1", + "rc-util": "^5.27.0" + }, + "peerDependencies": { + "react": ">=16.9.0", + "react-dom": ">=16.9.0" + } + }, + "node_modules/rc-util/node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/compute-scroll-into-view": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/compute-scroll-into-view/-/compute-scroll-into-view-3.1.1.tgz", + "integrity": "sha512-VRhuHOLoKYOy4UbilLbUzbYg93XLjv2PncJC50EuTWPA3gaja1UjBsUP/D/9/juV3vQFr6XBEzn9KCAHdUvOHw==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@ctrl/tinycolor": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz", + "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/icons": { + "version": "5.6.1", + "resolved": "https://registry.npmjs.org/@ant-design/icons/-/icons-5.6.1.tgz", + "integrity": "sha512-0/xS39c91WjPAZOWsvi1//zjx6kAp4kxWwctR6kuU6p133w8RU0D2dSCvZC19uQyharg/sAvYxGYWl01BbZZfg==", + "license": "MIT", + "dependencies": { + "@ant-design/colors": "^7.0.0", + "@ant-design/icons-svg": "^4.4.0", + "@babel/runtime": "^7.24.8", + "classnames": "^2.2.6", + "rc-util": "^5.31.1" + }, + "engines": { + "node": ">=8" + }, + "peerDependencies": { + "react": ">=16.0.0", + "react-dom": ">=16.0.0" + } + }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-from-markdown": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-0.8.5.tgz", + "integrity": "sha512-2hkTXtYYnr+NubD/g6KGBS/0mFmBcifAsI0yIWRiRo0PjVs6SSOSOdtzbp6kSGnShDN6G5aWZpKQ2lWRy27mWQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "mdast-util-to-string": "^2.0.0", + "micromark": "~2.11.0", + "parse-entities": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-to-hast": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-10.2.0.tgz", + "integrity": "sha512-JoPBfJ3gBnHZ18icCwHR50orC9kNH81tiR1gs01D8Q5YpV6adHNO9nKNuFBCJQ941/32PT1a63UF/DitmS3amQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^3.0.0", + "@types/unist": "^2.0.0", + "mdast-util-definitions": "^4.0.0", + "mdurl": "^1.0.0", + "unist-builder": "^2.0.0", + "unist-util-generated": "^1.0.0", + "unist-util-position": "^3.0.0", + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/inline-style-parser": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.1.1.tgz", + "integrity": "sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==", + "license": "MIT" + }, + "node_modules/@refinedev/antd/node_modules/bail": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/bail/-/bail-1.0.5.tgz", + "integrity": "sha512-xFbRxM1tahm08yHBP16MMjVUAvDaBMD38zsM9EMAUN61omwLmKlOpB/Zku5QkjZ8TZ4vn53pj+t518cH0S03RQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-buffer": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-2.0.5.tgz", + "integrity": "sha512-i2R6zNFDwgEHJyQUtJEk0XFi1i0dPFn/oqjK3/vPCcDeJvW5NQ83V8QbicfF1SupOaB0h8ntgBC2YiE7dfyctQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/@refinedev/antd/node_modules/is-plain-obj": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-2.1.0.tgz", + "integrity": "sha512-YWnfyRwxL/+SsrWYfOpUtz5b3YD+nyfkHvjbcanzk8zgyO4ASD67uVMRt8k5bM4lLMDnXfriRhOpemw+NfT1eA==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@refinedev/antd/node_modules/trough": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/trough/-/trough-1.0.5.tgz", + "integrity": "sha512-rvuRbTarPXmMb79SmzEp8aqXNKcK+y0XaB298IXueQ8I2PsrATcPBCSPyK/dDNa2iWOhKlfNnOjdAOTBU/nkFA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-stringify-position": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-2.0.3.tgz", + "integrity": "sha512-3faScn5I+hy9VleOq/qNbAd6pAx7iH5jYBMS9I1HgQVijz/4mv5Bvw5iw1sC/90CODiKo81G/ps8AJrISn687g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/vfile-message": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-2.0.4.tgz", + "integrity": "sha512-DjssxRGkMvifUOJre00juHoP9DPWuzjxKuMDrhNbk2TdaYYBNMStsNhEOt3idrtI12VQYM/1+iM0KOzXi4pxwQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-stringify-position": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-autolink-literal": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-0.1.3.tgz", + "integrity": "sha512-GjmLjWrXg1wqMIO9+ZsRik/s7PLwTaeCHVB7vRxUwLntZc8mzmTsLVr6HW1yLokcnhfURsn5zmSVdi3/xWWu1A==", + "license": "MIT", + "dependencies": { + "ccount": "^1.0.0", + "mdast-util-find-and-replace": "^1.1.0", + "micromark": "^2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-strikethrough": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-0.2.3.tgz", + "integrity": "sha512-5OQLXpt6qdbttcDG/UxYY7Yjj3e8P7X16LzvpX8pIQPYJ/C2Z1qFGMmcw+1PZMUM3Z8wt8NRfYTvCni93mgsgA==", + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "^0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-table": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-0.1.6.tgz", + "integrity": "sha512-j4yDxQ66AJSBwGkbpFEp9uG/LS1tZV3P33fN1gkyRB2LoRL+RR3f76m0HPHaby6F4Z5xr9Fv1URmATlRRUIpRQ==", + "license": "MIT", + "dependencies": { + "markdown-table": "^2.0.0", + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-gfm-task-list-item": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-0.1.6.tgz", + "integrity": "sha512-/d51FFIfPsSmCIRNp7E6pozM9z1GYPIkSy1urQ8s/o4TC22BZ7DqfHFWiqBD23bc7J3vV1Fc9O4QIHBlfuit8A==", + "license": "MIT", + "dependencies": { + "mdast-util-to-markdown": "~0.6.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-to-markdown": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-0.6.5.tgz", + "integrity": "sha512-XeV9sDE7ZlOQvs45C9UKMtfTcctcaj/pGwH8YLbMHoMOXNNCn2LsqVQOqrF1+/NU8lKDAqozme9SCXWyo9oAcQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "longest-streak": "^2.0.0", + "mdast-util-to-string": "^2.0.0", + "parse-entities": "^2.0.0", + "repeat-string": "^1.0.0", + "zwitch": "^1.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark": { + "version": "2.11.4", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-2.11.4.tgz", + "integrity": "sha512-+WoovN/ppKolQOFIAajxi7Lu9kInbPxFuTBVEavFcL8eAfVstoc5MocPmqBeAdBOJV00uaVjegzH4+MA0DN/uA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "debug": "^4.0.0", + "parse-entities": "^2.0.0" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-autolink-literal": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-0.5.7.tgz", + "integrity": "sha512-ePiDGH0/lhcngCe8FtH4ARFoxKTUelMp4L7Gg2pujYD5CSMb9PbblnyL+AAMud/SNMyusbS2XDSiPIRcQoNFAw==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.3" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-strikethrough": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-0.6.5.tgz", + "integrity": "sha512-PpOKlgokpQRwUesRwWEp+fHjGGkZEejj83k9gU5iXCbDG+XBA92BqnRKYJdfqfkrRcZRgGuPuXb7DaK/DmxOhw==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-table": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-0.4.3.tgz", + "integrity": "sha512-hVGvESPq0fk6ALWtomcwmgLvH8ZSVpcPjzi0AjPclB9FsVRgMtGZkUcpE0zgjOCFAznKepF4z3hX8z6e3HODdA==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-tagfilter": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-0.3.0.tgz", + "integrity": "sha512-9GU0xBatryXifL//FJH+tAZ6i240xQuFrSL7mYi8f4oZSbc+NvXjkrHemeYP0+L4ZUT+Ptz3b95zhUZnMtoi/Q==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/micromark-extension-gfm-task-list-item": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-0.3.3.tgz", + "integrity": "sha512-0zvM5iSLKrc/NQl84pZSjGo66aTGd57C1idmlWmE87lkMcXrTxg1uXa/nXomxJytoje9trP0NDLvw4bZ/Z/XCQ==", + "license": "MIT", + "dependencies": { + "micromark": "~2.11.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/stackframe": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/stackframe/-/stackframe-1.3.4.tgz", + "integrity": "sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==", + "license": "MIT" + }, + "node_modules/string-convert": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/string-convert/-/string-convert-0.2.1.tgz", + "integrity": "sha512-u/1tdPl4yQnPBjnVrmdLo9gtuLvELKsAoRapekWggdiQNvvvum+jYF329d84NAa660KQw7pB2n36KrIKVoXa3A==", + "license": "MIT" + }, + "node_modules/@ant-design/pro-layout/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/colors": { + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/@ant-design/colors/-/colors-7.2.1.tgz", + "integrity": "sha512-lCHDcEzieu4GA3n8ELeZ5VQ8pKQAWcGGLRTQ50aQM2iqPpq2evTxER84jfdPvsPAtEcZ7m44NI45edFMo8oOYQ==", + "license": "MIT", + "dependencies": { + "@ant-design/fast-color": "^2.0.6" + } + }, + "node_modules/@refinedev/antd/node_modules/@types/mdast": { + "version": "3.0.15", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.15.tgz", + "integrity": "sha512-LnwD+mUEfxWMa1QpDraczIn6k0Ee3SMicuYSSzS6ZYl2gKS09EClnJYGd8Du6rfc5r/GZEk5o1mRb8TaTj03sQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-to-string": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-2.0.0.tgz", + "integrity": "sha512-AW4DRS3QbBayY/jJmD8437V1Gombjf8RSOUCMFBuo5iHi58AGEgVCKQ+ezHkZZDpAQS75hcBMpLqjpJTjtUL7w==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/parse-entities": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-2.0.0.tgz", + "integrity": "sha512-kkywGpCcRYhqQIchaWqZ875wzpS/bMKhz5HnN3p7wveJTkTtyAB/AlnS0f8DFSqYW1T82t6yEAkEcB+A1I3MbQ==", + "license": "MIT", + "dependencies": { + "character-entities": "^1.0.0", + "character-entities-legacy": "^1.0.0", + "character-reference-invalid": "^1.0.0", + "is-alphanumerical": "^1.0.0", + "is-decimal": "^1.0.0", + "is-hexadecimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-definitions/-/mdast-util-definitions-4.0.0.tgz", + "integrity": "sha512-k8AJ6aNnUkB7IE+5azR9h81O5EQ/cTDXtWdMq9Kk5KcEW/8ritU5CeLg/9HhOC++nALHBlaogJ5jz0Ybk3kPMQ==", + "license": "MIT", + "dependencies": { + "unist-util-visit": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdurl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-1.0.1.tgz", + "integrity": "sha512-/sKlQJCBYVY9Ers9hqzKou4H6V5UWc/M59TH2dvkt+84itfnq7uFOMLpOiOS4ujvHP4etln18fmIxA5R5fll0g==", + "license": "MIT" + }, + "node_modules/unist-builder": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-builder/-/unist-builder-2.0.3.tgz", + "integrity": "sha512-f98yt5pnlMWlzP539tPc4grGMsFaQQlP/vM396b00jngsiINumNmsY8rkXjfoi1c6QaM8nQ3vaGDuoKWbe/1Uw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-generated": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/unist-util-generated/-/unist-util-generated-1.1.6.tgz", + "integrity": "sha512-cln2Mm1/CZzN5ttGK7vkoGw+RZ8VcUH6BtGbq98DDtRGquAAOXig1mrBQYelOwMXYS8rK+vZDyyojSjp7JX+Lg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/unist-util-position": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-3.1.0.tgz", + "integrity": "sha512-w+PkwCbYSFw8vpgWD0v7zRCl1FpY3fjDSQ3/N/wNd9Ffa4gPi8+4keqt99N3XW6F99t/mUzp2xAhNmfKWp95QA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/ccount": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz", + "integrity": "sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/mdast-util-find-and-replace": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-1.1.1.tgz", + "integrity": "sha512-9cKl33Y21lyckGzpSmEQnIDjEfeeWelN5s1kUW1LwdB0Fkuq2u+4GdqcGEygYxJE8GVqCl0741bYXHgamfWAZA==", + "license": "MIT", + "dependencies": { + "escape-string-regexp": "^4.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/markdown-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-2.0.0.tgz", + "integrity": "sha512-Ezda85ToJUBhM6WGaG6veasyym+Tbs3cMAw/ZhOPqXiYsr0jgocBV3j3nx+4lk47plLlIqjwuTm/ywVI+zjJ/A==", + "license": "MIT", + "dependencies": { + "repeat-string": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/longest-streak": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-2.0.4.tgz", + "integrity": "sha512-vM6rUVCVUJJt33bnmHiZEvr7wPT78ztX7rojL+LW51bHtLh6HTjx84LA5W4+oa6aKEJA7jJu5LR6vQRBpA5DVg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha512-PV0dzCYDNfRi1jCDbJzpW7jNNDRuCOG/jI5ctQcGKt/clZD+YcPS3yIlWuTJMmESC8aevCFmWJy5wjAFgNqN6w==", + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/@refinedev/antd/node_modules/zwitch": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz", + "integrity": "sha512-V50KMwwzqJV0NpZIZFwfOD5/lyny3WlSzRiXgA0G7VUnRlqttta1L6UQIHzd6EuBY/cHGfwTIck7w1yH6Q5zUw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@ant-design/pro-utils/node_modules/@ant-design/fast-color": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@ant-design/fast-color/-/fast-color-2.0.6.tgz", + "integrity": "sha512-y2217gk4NqL35giHl72o6Zzqji9O7vHh9YmhUVkPtAOpoTCH4uWxo/pr4VE8t0+ChEPs0qo4eJRC5Q1eXWo3vA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.24.7" + }, + "engines": { + "node": ">=8.x" + } + }, + "node_modules/@refinedev/antd/node_modules/character-entities": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-1.2.4.tgz", + "integrity": "sha512-iBMyeEHxfVnIakwOuDXpVkc54HijNgCyQB2w0VfGQThle6NXn50zU6V/u+LDhxHcDUPojn6Kpga3PTAD8W1bQw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/character-entities-legacy": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-1.1.4.tgz", + "integrity": "sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/character-reference-invalid": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-1.1.4.tgz", + "integrity": "sha512-mKKUkUbhPpQlCOfIuZkvSEgktjPFIsZKRRbC6KWVEMvlzblj3i3asQv5ODsrwt0N3pHAEvjP8KTQPHkp0+6jOg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/is-alphanumerical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-1.0.4.tgz", + "integrity": "sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^1.0.0", + "is-decimal": "^1.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/is-decimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz", + "integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/@refinedev/antd/node_modules/is-hexadecimal": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz", + "integrity": "sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-visit": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-2.0.3.tgz", + "integrity": "sha512-iJ4/RczbJMkD0712mGktuGpm/U4By4FfDonL7N/9tATGIF4imikjOuagyMY53tnZq3NP6BcmlrHhEKAfGWjh7Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0", + "unist-util-visit-parents": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/@refinedev/antd/node_modules/is-alphabetical": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-1.0.4.tgz", + "integrity": "sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/mdast-util-definitions/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-is": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-4.1.0.tgz", + "integrity": "sha512-ZOQSsnce92GrxSqlnEEseX0gi7GH9zTJZ0p9dtu87WRb/37mMPO2Ilx1s/t9vBHrFhbgweUwb+t7cIn5dxPhZg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-definitions/node_modules/unist-util-visit-parents": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-3.1.1.tgz", + "integrity": "sha512-1KROIZWo6bcMrZEwiH2UrXDyalAa0uqzWCxCJj6lPOvTve2WkfgCytoDTPaMnodXh1WrXOq0haVYHj99ynJlsg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "unist-util-is": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } } } } diff --git a/frontend/package.json b/frontend/package.json index 3309278f..4811a6ca 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -17,12 +17,17 @@ "worker:rectification-v4": "tsx scripts/rectification-v4-worker.mts" }, "dependencies": { + "@ant-design/icons": "^6.3.2", "@base-ui/react": "^1.6.0", "@gsap/react": "^2.1.2", "@mastra/core": "^1.50.1", + "@refinedev/antd": "^6.0.3", + "@refinedev/core": "^5.0.12", + "@refinedev/nextjs-router": "^7.0.5", "@supabase/ssr": "^0.12.3", "@supabase/supabase-js": "^2.110.5", "@tailwindcss/postcss": "^4.3.2", + "antd": "^5.29.3", "better-auth": "1.6.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/src/app/admin/audit-logs/page.tsx b/frontend/src/app/admin/audit-logs/page.tsx new file mode 100644 index 00000000..0b7479ad --- /dev/null +++ b/frontend/src/app/admin/audit-logs/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Descriptions, Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type AuditRecord = { + id: string; + actorEmail: string; + actorRole: string; + action: string; + targetId: string; + before: Record | null; + after: Record | null; + requestId: string; + createdAt: string; +}; + +const columns: TableColumnsType = [ + { title: "操作者", dataIndex: "actorEmail", sorter: true }, + { title: "角色", dataIndex: "actorRole", render: (value) => {value} }, + { title: "动作", dataIndex: "action", sorter: true }, + { title: "目标 ID", dataIndex: "targetId" }, + { title: "Request ID", dataIndex: "requestId" }, + { title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, +]; + +export default function AuditLogsPage() { + return + resource="audit-logs" + title="审计日志(只读)" + columns={columns} + statusOptions={[ + { label: "生成兑换码", value: "redemption_code.create" }, + { label: "修改兑换码", value: "redemption_code.update" }, + { label: "撤销兑换码", value: "redemption_code.revoke" }, + ]} + extra={} + />; +} diff --git a/frontend/src/app/admin/codes/page.tsx b/frontend/src/app/admin/codes/page.tsx index f4cec373..da2ef5d8 100644 --- a/frontend/src/app/admin/codes/page.tsx +++ b/frontend/src/app/admin/codes/page.tsx @@ -1,200 +1,167 @@ "use client"; -import Link from "next/link"; -import { FormEvent, useEffect, useRef, useState } from "react"; +import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core"; +import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd"; +import dayjs from "dayjs"; +import { useState } from "react"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; +import type { AdminIdentity } from "@/lib/admin/providers"; type CodeRecord = { id: string; + code?: string; mask: string; credits: number; expiresAt: string | null; - redeemedBy: string | null; - redeemedEmail: string | null; - redeemedAt: string | null; note: string | null; createdAt: string; + redeemedEmail: string | null; + redeemedAt: string | null; + revokedAt: string | null; + status: "available" | "expired" | "redeemed" | "revoked"; }; -type GeneratedCode = { code: string; credits: number; expiresAt: string | null }; -const previewCodes: CodeRecord[] = [ - { id: "preview-1", mask: "JYOT-••••-7Q9K", credits: 12, expiresAt: "2026-12-31T15:59:00.000Z", redeemedBy: null, redeemedEmail: null, redeemedAt: null, note: "秋季体验", createdAt: "2026-07-16T02:20:00.000Z" }, - { id: "preview-2", mask: "JYOT-••••-2M8A", credits: 6, expiresAt: null, redeemedBy: "preview-user", redeemedEmail: "linyao@example.com", redeemedAt: "2026-07-15T08:30:00.000Z", note: "访谈用户", createdAt: "2026-07-14T03:10:00.000Z" }, - { id: "preview-3", mask: "JYOT-••••-4D1R", credits: 20, expiresAt: "2026-07-01T15:59:00.000Z", redeemedBy: null, redeemedEmail: null, redeemedAt: null, note: null, createdAt: "2026-06-10T06:45:00.000Z" }, -]; +type CreateValues = { + credits: number; + count: number; + expiresAt?: ReturnType; + note?: string; +}; +type EditValues = { note?: string; expiresAt?: ReturnType | null }; -const dateFormatter = new Intl.DateTimeFormat("zh-CN", { - dateStyle: "medium", - timeStyle: "short", - timeZone: "Asia/Taipei", -}); +const statusColors: Record = { + available: "green", + expired: "orange", + redeemed: "blue", + revoked: "red", +}; -function apiMessage(payload: unknown, fallback: string) { - if (!payload || typeof payload !== "object") return fallback; - const data = payload as Record; - return [data.message, data.error].find((value) => typeof value === "string") as string || fallback; -} +export default function CodesPage() { + const { data: role } = usePermissions<"admin" | "viewer">({}); + const { data: identity } = useGetIdentity(); + const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>(); + const { mutate: updateCode, mutation: updateMutation } = useUpdate(); + const { mutate: revokeCode, mutation: revokeMutation } = useDelete(); + const [createOpen, setCreateOpen] = useState(false); + const [editRecord, setEditRecord] = useState(null); + const [generated, setGenerated] = useState([]); + const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); + const writable = role === "admin"; -function redirectForAuth(response: Response) { - if (response.status === 401) window.location.assign("/login"); - if (response.status === 403) window.location.assign("/"); -} - -function codeStatus(code: CodeRecord) { - if (code.redeemedAt) return "已兑换"; - if (code.expiresAt && new Date(code.expiresAt).getTime() <= Date.now()) return "已过期"; - return "可用"; -} - -function formatDate(value: string | null) { - return value ? dateFormatter.format(new Date(value)) : "—"; -} - -export default function AdminCodesPage() { - const [codes, setCodes] = useState([]); - const [generated, setGenerated] = useState([]); - const [credits, setCredits] = useState(10); - const [count, setCount] = useState(1); - const [expiresAt, setExpiresAt] = useState(""); - const [note, setNote] = useState(""); - const [loading, setLoading] = useState(true); - const [creating, setCreating] = useState(false); - const previewMode = useRef(false); - const [error, setError] = useState(""); - const [copyNotice, setCopyNotice] = useState(""); - - useEffect(() => { - if (process.env.NODE_ENV === "development" && new URLSearchParams(window.location.search).get("preview") === "admin") { - const previewFrame = window.requestAnimationFrame(() => { - previewMode.current = true; - setCodes(previewCodes); - setLoading(false); - }); - return () => window.cancelAnimationFrame(previewFrame); - } - - const controller = new AbortController(); - void fetch("/api/admin/codes", { signal: controller.signal, cache: "no-store" }) - .then(async (response) => { - redirectForAuth(response); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(apiMessage(payload, "暂时无法读取兑换码")); - setCodes((payload as { codes: CodeRecord[] }).codes); - }) - .catch((caught) => { - if ((caught as Error).name !== "AbortError") setError(caught instanceof Error ? caught.message : "暂时无法读取兑换码"); - }) - .finally(() => setLoading(false)); - return () => controller.abort(); - }, []); - - async function reloadCodes() { - const response = await fetch("/api/admin/codes", { cache: "no-store" }); - redirectForAuth(response); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(apiMessage(payload, "暂时无法刷新兑换码")); - setCodes((payload as { codes: CodeRecord[] }).codes); + function submitCreate(values: CreateValues) { + createCodes({ + resource: "codes", + values: { + credits: values.credits, + count: values.count, + expiresAt: values.expiresAt?.toISOString() ?? null, + note: values.note?.trim() || null, + }, + successNotification: false, + }, { + onSuccess(result) { + setGenerated(result.data.generated); + setCreateOpen(false); + createForm.resetFields(); + }, + }); } - async function createCodes(event: FormEvent) { - event.preventDefault(); - if (creating) return; - setCreating(true); - setError(""); - setGenerated([]); - setCopyNotice(""); - if (process.env.NODE_ENV === "development" && previewMode.current) { - setGenerated(Array.from({ length: count }, (_, index) => ({ - code: `PREVIEW-${String(index + 1).padStart(2, "0")}-JYOTISH`, - credits, - expiresAt: expiresAt ? new Date(expiresAt).toISOString() : null, - }))); - setCreating(false); - return; - } - try { - const response = await fetch("/api/admin/codes", { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - credits, - count, - ...(expiresAt ? { expiresAt: new Date(expiresAt).toISOString() } : {}), - ...(note.trim() ? { note: note.trim() } : {}), - }), - }); - redirectForAuth(response); - const payload = await response.json().catch(() => null); - if (!response.ok) throw new Error(apiMessage(payload, "生成兑换码失败")); - setGenerated((payload as { codes: GeneratedCode[] }).codes); - await reloadCodes(); - } catch (caught) { - setError(caught instanceof Error ? caught.message : "生成兑换码失败"); - } finally { - setCreating(false); - } + function submitEdit(values: EditValues) { + if (!editRecord) return; + updateCode({ + resource: "codes", + id: editRecord.id, + values: { + note: values.note?.trim() || null, + expiresAt: values.expiresAt?.toISOString() ?? null, + }, + }, { onSuccess: () => setEditRecord(null) }); } - async function copy(text: string) { - try { - await navigator.clipboard.writeText(text); - setCopyNotice("已复制到剪贴板"); - } catch { - setCopyNotice("无法自动复制,请手动选择兑换码"); - } + function confirmRevoke(record: CodeRecord) { + Modal.confirm({ + title: "撤销此兑换码?", + content: `${record.mask} 撤销后不可兑换,且不能恢复。`, + okText: "确认撤销", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: () => new Promise((resolve, reject) => { + revokeCode({ resource: "codes", id: record.id }, { + onSuccess: () => resolve(), + onError: () => reject(new Error("撤销失败")), + }); + }), + }); } + const columns: TableColumnsType = [ + { title: "兑换码", dataIndex: "mask" }, + { title: "点数", dataIndex: "credits", sorter: true }, + { title: "状态", dataIndex: "status", sorter: true, render: (value) => {value} }, + { title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate }, + { title: "备注", dataIndex: "note", render: (value) => value || "—" }, + { title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" }, + { title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate }, + { title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate }, + { title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + { + title: "操作", + fixed: "right", + render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? ( + + + + + ) : "—", + }, + ]; + return ( -
-
-

兑换码管理

- 返回对话 -
+ <> + + resource="codes" + title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`} + columns={columns} + statusOptions={[ + { label: "可用", value: "available" }, + { label: "已过期", value: "expired" }, + { label: "已兑换", value: "redeemed" }, + { label: "已撤销", value: "revoked" }, + ]} + extra={writable ? : viewer 只读} + /> -
-
-

生成兑换码

完整兑换码只在本次生成结果中显示,请立即复制保存。

-
- - - - - -
- {error &&

{error}

} -
+ setCreateOpen(false)} footer={null} destroyOnHidden> +
+ + + + + +
+
- {generated.length > 0 && ( -
-
-

本次生成的完整码

离开或刷新页面后将不再显示。

- -
-
- {generated.map((item) => ( -
{item.code}{item.credits} 点
- ))} -
- {copyNotice &&

{copyNotice}

} -
- )} + 0} onCancel={() => setGenerated([])} footer={}> + 关闭后无法再次查看完整兑换码,请立即安全保存。 + {generated.map((record) => {record.code})} + -
-

兑换码状态

{loading ? "正在读取…" : `${codes.length} 条记录`}

-
- - - - {codes.map((code) => ( - - - - ))} - {!loading && codes.length === 0 && } - -
兑换码点数状态有效期兑换账户兑换时间备注创建时间
{code.mask}{code.credits}{codeStatus(code)}{formatDate(code.expiresAt)}{code.redeemedEmail || code.redeemedBy || "—"}{formatDate(code.redeemedAt)}{code.note || "—"}{formatDate(code.createdAt)}
尚未生成兑换码
-
-
-
-
+ setEditRecord(null)} footer={null} destroyOnHidden> +
+ + + +
+
+ ); } diff --git a/frontend/src/app/admin/consultations/page.tsx b/frontend/src/app/admin/consultations/page.tsx new file mode 100644 index 00000000..fda31052 --- /dev/null +++ b/frontend/src/app/admin/consultations/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type ConsultationRecord = { + id: string; + email: string | null; + requestId: string; + status: string; + createdAt: string; + updatedAt: string; +}; + +const colors: Record = { + reserved: "gold", + completed: "green", + cancelled: "default", +}; +const columns: TableColumnsType = [ + { title: "用户", dataIndex: "email", render: (value) => value || "—" }, + { title: "请求 ID", dataIndex: "requestId" }, + { title: "状态", dataIndex: "status", sorter: true, render: (value) => {value} }, + { title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + { title: "更新时间", dataIndex: "updatedAt", sorter: true, render: formatAdminDate }, +]; + +export default function ConsultationsPage() { + return + resource="consultations" + title="咨询请求(只读)" + columns={columns} + statusOptions={[ + { label: "已预扣", value: "reserved" }, + { label: "已完成", value: "completed" }, + { label: "已取消", value: "cancelled" }, + ]} + />; +} diff --git a/frontend/src/app/admin/credit-transactions/page.tsx b/frontend/src/app/admin/credit-transactions/page.tsx new file mode 100644 index 00000000..4e1ebff7 --- /dev/null +++ b/frontend/src/app/admin/credit-transactions/page.tsx @@ -0,0 +1,42 @@ +"use client"; + +import { Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type TransactionRecord = { + id: string; + email: string | null; + type: string; + amount: number; + balanceAfter: number; + requestId: string; + model: string | null; + inputTokens: number | null; + outputTokens: number | null; + createdAt: string; +}; + +const columns: TableColumnsType = [ + { title: "用户", dataIndex: "email", render: (value) => value || "—" }, + { title: "类型", dataIndex: "type", sorter: true, render: (value) => {value} }, + { title: "变动", dataIndex: "amount", sorter: true, render: (value) => value > 0 ? `+${value}` : value }, + { title: "余额", dataIndex: "balanceAfter", sorter: true }, + { title: "请求 ID", dataIndex: "requestId" }, + { title: "模型", dataIndex: "model", render: (value) => value || "—" }, + { title: "输入/输出 token", render: (_, row) => `${row.inputTokens ?? "—"} / ${row.outputTokens ?? "—"}` }, + { title: "时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, +]; + +export default function CreditTransactionsPage() { + return + resource="credit-transactions" + title="积分流水(只读)" + columns={columns} + statusOptions={[ + { label: "兑换", value: "redeem" }, + { label: "预扣", value: "reserve" }, + { label: "退款", value: "refund" }, + ]} + />; +} diff --git a/frontend/src/app/admin/layout.tsx b/frontend/src/app/admin/layout.tsx index a5e09cd3..b331ca7b 100644 --- a/frontend/src/app/admin/layout.tsx +++ b/frontend/src/app/admin/layout.tsx @@ -1,18 +1,11 @@ -import { ReactNode } from "react"; -import { redirect } from "next/navigation"; -import { isAdminEmail } from "@/lib/supabase/admin"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; +import "@refinedev/antd/dist/reset.css"; +import "antd/dist/reset.css"; +import type { ReactNode } from "react"; + +import { AdminApp } from "@/components/admin/admin-app"; export const dynamic = "force-dynamic"; -export default async function AdminLayout({ children }: { children: ReactNode }) { - if (process.env.NODE_ENV === "development" && process.env.ENABLE_ADMIN_PREVIEW === "1") return children; - - const supabase = await createServerSupabaseClient(); - const { data: { user } } = await supabase.auth.getUser(); - - if (!user) redirect("/login"); - if (!isAdminEmail(user.email)) redirect("/"); - - return children; +export default function AdminLayout({ children }: { children: ReactNode }) { + return {children}; } diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx new file mode 100644 index 00000000..5b7410aa --- /dev/null +++ b/frontend/src/app/admin/page.tsx @@ -0,0 +1,5 @@ +import { redirect } from "next/navigation"; + +export default function AdminPage() { + redirect("/admin/codes"); +} diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx new file mode 100644 index 00000000..a4e93281 --- /dev/null +++ b/frontend/src/app/admin/users/page.tsx @@ -0,0 +1,36 @@ +"use client"; + +import { Tag, type TableColumnsType } from "antd"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; + +type UserRecord = { + id: string; + email: string; + name: string | null; + role: string; + emailVerified: boolean; + banned: boolean; + createdAt: string; + credits: number; + birthDate: string | null; + birthTimeStatus: string | null; + birthPlace: string | null; +}; + +const columns: TableColumnsType = [ + { title: "邮箱", dataIndex: "email", sorter: true }, + { title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" }, + { title: "角色", dataIndex: "role", render: (value) => {value} }, + { title: "积分", dataIndex: "credits", sorter: true }, + { title: "出生日期", dataIndex: "birthDate", render: (value) => value || "—" }, + { title: "出生时间状态", dataIndex: "birthTimeStatus", render: (value) => value || "—" }, + { title: "出生地", dataIndex: "birthPlace", render: (value) => value || "—" }, + { title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" }, + { title: "状态", dataIndex: "banned", render: (value) => value ? 已禁用 : 正常 }, + { title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, +]; + +export default function UsersPage() { + return resource="users" title="用户资料(只读)" columns={columns} />; +} diff --git a/frontend/src/app/api/admin/audit-logs/route.ts b/frontend/src/app/api/admin/audit-logs/route.ts new file mode 100644 index 00000000..ffdf00b1 --- /dev/null +++ b/frontend/src/app/api/admin/audit-logs/route.ts @@ -0,0 +1,84 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type AuditRow = { + id: string; + actor_user_id: string; + actor_email: string; + actor_role: string; + action: string; + target_type: string; + target_id: string; + before_value: Record | null; + after_value: Record | null; + request_id: string; + created_at: Date; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "a.created_at"], + ["action", "a.action"], + ["actorEmail", "a.actor_email"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(a.actor_email ilike $${values.length} or a.request_id ilike $${values.length})`); + } + if (status) { + values.push(status); + conditions.push(`a.action = $${values.length}`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "a.created_at"; + const rows = await queryAdminRows(` + select a.*, count(*) over()::text as total_count + from audit.admin_audit_logs a + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, a.id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + actorUserId: row.actor_user_id, + actorEmail: row.actor_email, + actorRole: row.actor_role, + action: row.action, + targetType: row.target_type, + targetId: row.target_id, + before: row.before_value, + after: row.after_value, + requestId: row.request_id, + createdAt: row.created_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/codes/[id]/route.ts b/frontend/src/app/api/admin/codes/[id]/route.ts new file mode 100644 index 00000000..c53af63d --- /dev/null +++ b/frontend/src/app/api/admin/codes/[id]/route.ts @@ -0,0 +1,70 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { runCodeRpc } from "@/lib/admin/codes"; +import { + adminErrorResponse, + invalidQueryResponse, + requestId, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +const paramsSchema = z.object({ id: z.string().uuid() }); +const updateCodeSchema = z.object({ + note: z.string().trim().max(500).nullable().optional(), + expiresAt: z.string().datetime({ offset: true }).nullable().optional(), +}).refine((value) => "note" in value || "expiresAt" in value, { + message: "至少提供一个可修改字段", +}); + +export async function PATCH( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const session = await requireAdminSession("write"); + const parsedParams = paramsSchema.safeParse(await context.params); + const parsedBody = updateCodeSchema.safeParse(await request.json().catch(() => null)); + if (!parsedParams.success || !parsedBody.success) { + return invalidQueryResponse(); + } + const body = parsedBody.data; + const rows = await runCodeRpc( + "admin_update_redemption_code", + session, + requestId(request), + { + p_code_id: parsedParams.data.id, + p_set_note: "note" in body, + p_note: body.note ?? null, + p_set_expires_at: "expiresAt" in body, + p_expires_at: body.expiresAt ?? null, + }, + ); + return NextResponse.json({ data: rows[0] }); + } catch (error) { + return adminErrorResponse(error); + } +} + +export async function DELETE( + request: Request, + context: { params: Promise<{ id: string }> }, +) { + try { + const session = await requireAdminSession("write"); + const parsed = paramsSchema.safeParse(await context.params); + if (!parsed.success) return invalidQueryResponse(); + const rows = await runCodeRpc( + "admin_revoke_redemption_code", + session, + requestId(request), + { p_code_id: parsed.data.id }, + ); + return NextResponse.json({ data: rows[0] }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/codes/route.ts b/frontend/src/app/api/admin/codes/route.ts index e4f22605..bab2b438 100644 --- a/frontend/src/app/api/admin/codes/route.ts +++ b/frontend/src/app/api/admin/codes/route.ts @@ -1,113 +1,143 @@ import { NextResponse } from "next/server"; import { z } from "zod"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { mapCode, runCodeRpc, type RedemptionCodeRecord } from "@/lib/admin/codes"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; import { - createAdminSupabaseClient, - isAdminEmail, -} from "@/lib/supabase/admin"; + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + requestId, +} from "@/lib/admin/http"; import { generateRedeemCode, hashRedeemCode, maskRedeemCode, } from "@/lib/supabase/codes"; -import { - isSupabaseConfigurationError, - SupabaseConfigurationError, -} from "@/lib/supabase/config"; -import { createServerSupabaseClient } from "@/lib/supabase/server"; export const runtime = "nodejs"; const createCodesSchema = z.object({ credits: z.number().int().positive().max(1_000_000), count: z.number().int().min(1).max(100), - expiresAt: z.string().datetime({ offset: true }).optional(), - note: z.string().trim().max(500).optional(), + expiresAt: z.string().datetime({ offset: true }).nullable().optional(), + note: z.string().trim().max(500).nullable().optional(), }); -async function requireAdmin() { - if (!process.env.ADMIN_EMAILS?.trim()) { - throw new SupabaseConfigurationError(["ADMIN_EMAILS"]); - } +type CodeRow = { + id: string; + code_mask: string; + credits: number; + expires_at: Date | null; + note: string | null; + created_at: Date; + redeemed_by: string | null; + redeemed_email: string | null; + redeemed_at: Date | null; + revoked_by: string | null; + revoked_at: Date | null; + total_count: string; +}; - const supabase = await createServerSupabaseClient(); - const { data: { user }, error } = await supabase.auth.getUser(); - if (error || !user) return { response: NextResponse.json({ error: "请先登录" }, { status: 401 }) }; - if (!isAdminEmail(user.email)) { - return { response: NextResponse.json({ error: "无管理员权限" }, { status: 403 }) }; - } - return { user }; +const sortColumns = new Map([ + ["createdAt", "c.created_at"], + ["expiresAt", "c.expires_at"], + ["credits", "c.credits"], + ["status", "status"], +]); + +function serializedCodeRow(row: CodeRow) { + return mapCode({ + ...row, + expires_at: row.expires_at?.toISOString() ?? null, + created_at: row.created_at.toISOString(), + redeemed_at: row.redeemed_at?.toISOString() ?? null, + revoked_at: row.revoked_at?.toISOString() ?? null, + }); } -export async function GET() { +export async function GET(request: Request) { try { - const auth = await requireAdmin(); - if ("response" in auth) return auth.response; - - const admin = createAdminSupabaseClient(); - const { data, error } = await admin - .from("redemption_codes") - .select("id,code_mask,credits,expires_at,note,created_at,redeemed_by,redeemed_email,redeemed_at") - .order("created_at", { ascending: false }) - .limit(100); - - if (error) { - return NextResponse.json({ error: "暂时无法读取兑换码列表" }, { status: 500 }); + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(c.code_mask ilike $${values.length} or c.note ilike $${values.length})`); } - + if (status && ["available", "expired", "redeemed", "revoked"].includes(status)) { + const clauses = { + available: "c.redeemed_at is null and c.revoked_at is null and (c.expires_at is null or c.expires_at > now())", + expired: "c.redeemed_at is null and c.revoked_at is null and c.expires_at <= now()", + redeemed: "c.redeemed_at is not null", + revoked: "c.revoked_at is not null", + }; + conditions.push(clauses[status as keyof typeof clauses]); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at"; + const rows = await queryAdminRows(` + select c.id, c.code_mask, c.credits, c.expires_at, c.note, + c.created_at, c.redeemed_by, c.redeemed_email, c.redeemed_at, + c.revoked_by, c.revoked_at, + case + when c.redeemed_at is not null then 'redeemed' + when c.revoked_at is not null then 'revoked' + when c.expires_at is not null and c.expires_at <= now() then 'expired' + else 'available' + end as status, + count(*) over()::text as total_count + from public.redemption_codes c + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.id asc + limit $${values.length - 1} offset $${values.length} + `, values); return NextResponse.json({ - codes: data.map((code) => ({ - id: code.id, - mask: code.code_mask, - credits: code.credits, - expiresAt: code.expires_at, - note: code.note, - createdAt: code.created_at, - redeemedBy: code.redeemed_by, - redeemedEmail: code.redeemed_email, - redeemedAt: code.redeemed_at, - })), + data: rows.map(serializedCodeRow), + total: Number(rows[0]?.total_count ?? 0), }); } catch (error) { - if (isSupabaseConfigurationError(error)) { - return NextResponse.json({ error: "Supabase 或管理员白名单尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - } - return NextResponse.json({ error: "兑换码管理服务暂时不可用" }, { status: 500 }); + return adminErrorResponse(error); } } export async function POST(request: Request) { try { - const auth = await requireAdmin(); - if ("response" in auth) return auth.response; - + const session = await requireAdminSession("write"); const parsed = createCodesSchema.safeParse(await request.json().catch(() => null)); - if (!parsed.success) { - return NextResponse.json({ error: "兑换码参数不正确" }, { status: 400 }); - } - - const { credits, count, expiresAt, note } = parsed.data; - const codes = Array.from({ length: count }, generateRedeemCode); - const admin = createAdminSupabaseClient(); - const { error } = await admin.from("redemption_codes").insert(codes.map((code) => ({ - code_hash: hashRedeemCode(code), - code_mask: maskRedeemCode(code), - credits, - expires_at: expiresAt ?? null, - note: note || null, - created_by: auth.user.id, - }))); - - if (error) { - return NextResponse.json({ error: "生成兑换码失败,请重试" }, { status: 500 }); - } - + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const plainCodes = Array.from({ length: parsed.data.count }, generateRedeemCode); + const records = plainCodes.map((code) => ({ + codeHash: hashRedeemCode(code), + codeMask: maskRedeemCode(code), + credits: parsed.data.credits, + expiresAt: parsed.data.expiresAt ?? null, + note: parsed.data.note || null, + })); + const operationRequestId = requestId(request); + const stored = await runCodeRpc( + "admin_create_redemption_codes", + session, + operationRequestId, + { p_codes: records }, + ); + const byMask = new Map( + stored.map((record) => [record.mask, record]), + ); return NextResponse.json({ - codes: codes.map((code) => ({ code, credits, expiresAt: expiresAt ?? null, note: note || null })), + data: { + id: operationRequestId, + generated: plainCodes.map((code) => ({ + ...(byMask.get(maskRedeemCode(code)) ?? {}), + code, + })), + }, }, { status: 201 }); } catch (error) { - if (isSupabaseConfigurationError(error)) { - return NextResponse.json({ error: "Supabase 或管理员白名单尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - } - return NextResponse.json({ error: "兑换码管理服务暂时不可用" }, { status: 500 }); + return adminErrorResponse(error); } } diff --git a/frontend/src/app/api/admin/consultations/route.ts b/frontend/src/app/api/admin/consultations/route.ts new file mode 100644 index 00000000..51ce4c53 --- /dev/null +++ b/frontend/src/app/api/admin/consultations/route.ts @@ -0,0 +1,79 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type ConsultationRow = { + id: string; + user_id: string; + email: string | null; + request_id: string; + status: string; + created_at: Date; + updated_at: Date; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "c.created_at"], + ["updatedAt", "c.updated_at"], + ["status", "c.status"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(p.email ilike $${values.length} or c.request_id ilike $${values.length})`); + } + if (status && ["reserved", "completed", "cancelled"].includes(status)) { + values.push(status); + conditions.push(`c.status = $${values.length}`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "c.created_at"; + const rows = await queryAdminRows(` + select c.user_id || ':' || c.request_id as id, c.user_id, p.email, + c.request_id, c.status, c.created_at, c.updated_at, + count(*) over()::text as total_count + from public.consultation_requests c + left join public.profiles p on p.id = c.user_id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, c.request_id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + email: row.email, + requestId: row.request_id, + status: row.status, + createdAt: row.created_at.toISOString(), + updatedAt: row.updated_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/credit-transactions/route.ts b/frontend/src/app/api/admin/credit-transactions/route.ts new file mode 100644 index 00000000..adf86190 --- /dev/null +++ b/frontend/src/app/api/admin/credit-transactions/route.ts @@ -0,0 +1,88 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type TransactionRow = { + id: string; + user_id: string; + email: string | null; + transaction_type: string; + amount: number; + balance_after: number; + request_id: string; + model: string | null; + input_tokens: number | null; + output_tokens: number | null; + created_at: Date; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "t.created_at"], + ["amount", "t.amount"], + ["balanceAfter", "t.balance_after"], + ["type", "t.transaction_type"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q, status } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(p.email ilike $${values.length} or t.request_id ilike $${values.length})`); + } + if (status && ["redeem", "reserve", "refund"].includes(status)) { + values.push(status); + conditions.push(`t.transaction_type = $${values.length}`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "t.created_at"; + const rows = await queryAdminRows(` + select t.id, t.user_id, p.email, t.transaction_type, t.amount, + t.balance_after, t.request_id, t.model, t.input_tokens, + t.output_tokens, t.created_at, count(*) over()::text as total_count + from public.credit_transactions t + left join public.profiles p on p.id = t.user_id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, t.id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + userId: row.user_id, + email: row.email, + type: row.transaction_type, + amount: row.amount, + balanceAfter: row.balance_after, + requestId: row.request_id, + model: row.model, + inputTokens: row.input_tokens, + outputTokens: row.output_tokens, + createdAt: row.created_at.toISOString(), + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/session/route.ts b/frontend/src/app/api/admin/session/route.ts new file mode 100644 index 00000000..036a6061 --- /dev/null +++ b/frontend/src/app/api/admin/session/route.ts @@ -0,0 +1,22 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { adminErrorResponse } from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +export async function GET() { + try { + const { user, role } = await requireAdminSession(); + return NextResponse.json({ + user: { + id: user.id, + email: user.email, + name: user.name, + role, + }, + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/users/route.ts b/frontend/src/app/api/admin/users/route.ts new file mode 100644 index 00000000..79d28afc --- /dev/null +++ b/frontend/src/app/api/admin/users/route.ts @@ -0,0 +1,85 @@ +import { NextResponse } from "next/server"; + +import { requireAdminSession } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type UserRow = { + id: string; + email: string; + name: string | null; + role: string; + email_verified: boolean; + banned: boolean; + created_at: Date; + credits: number; + birth_date: string | null; + birth_time_status: string | null; + birth_place_label: string | null; + total_count: string; +}; + +const sortColumns = new Map([ + ["createdAt", "u.created_at"], + ["email", "u.email"], + ["credits", "p.credits"], + ["name", "u.name"], +]); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +export async function GET(request: Request) { + try { + await requireAdminSession(); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(u.email ilike $${values.length} or u.name ilike $${values.length})`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "u.created_at"; + const rows = await queryAdminRows(` + select + u.id, u.email, u.name, u.role, u.email_verified, u.banned, + u.created_at, p.credits, p.birth_date, p.birth_time_status, + p.birth_place_label, count(*) over()::text as total_count + from identity.users u + join public.profiles p on p.id = u.id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, u.id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + email: row.email, + name: row.name, + role: row.role, + emailVerified: row.email_verified, + banned: row.banned, + createdAt: row.created_at.toISOString(), + credits: row.credits, + birthDate: row.birth_date, + birthTimeStatus: row.birth_time_status, + birthPlace: row.birth_place_label, + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/redeem/route.ts b/frontend/src/app/api/redeem/route.ts index 506a9df4..c4e9227e 100644 --- a/frontend/src/app/api/redeem/route.ts +++ b/frontend/src/app/api/redeem/route.ts @@ -12,6 +12,7 @@ const redeemErrors: Record = { unauthorized: { status: 401, message: "请先登录" }, invalid_code: { status: 404, message: "兑换码不存在" }, expired_code: { status: 410, message: "兑换码已过期" }, + revoked_code: { status: 410, message: "兑换码已撤销" }, already_redeemed: { status: 409, message: "兑换码已被使用" }, profile_missing: { status: 500, message: "账户资料不存在,请稍后重试" }, }; diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx new file mode 100644 index 00000000..74edd4e1 --- /dev/null +++ b/frontend/src/components/admin/admin-app.tsx @@ -0,0 +1,58 @@ +"use client"; + +import { + AuditOutlined, + GiftOutlined, + MessageOutlined, + TeamOutlined, + TransactionOutlined, +} from "@ant-design/icons"; +import { Authenticated, Refine } from "@refinedev/core"; +import { ErrorComponent, ThemedLayout, useNotificationProvider } from "@refinedev/antd"; +import routerProvider from "@refinedev/nextjs-router"; +import { App as AntdApp, ConfigProvider, Spin, theme } from "antd"; +import type { ReactNode } from "react"; + +import { + adminAccessControlProvider, + adminAuthProvider, + adminDataProvider, +} from "@/lib/admin/providers"; + +export function AdminApp({ children }: { children: ReactNode }) { + const notificationProvider = useNotificationProvider(); + return ( + + + } }, + { name: "users", list: "/admin/users", meta: { label: "用户资料", icon: } }, + { name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: } }, + { name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } }, + { name: "audit-logs", list: "/admin/audit-logs", meta: { label: "审计日志", icon: } }, + ]} + options={{ + syncWithLocation: true, + warnWhenUnsavedChanges: true, + title: { text: "Jyotisha 后台" }, + }} + > + 正在验证后台权限} + > + {children} + + + + + ); +} + +export { ErrorComponent as AdminErrorComponent }; diff --git a/frontend/src/components/admin/resource-table.tsx b/frontend/src/components/admin/resource-table.tsx new file mode 100644 index 00000000..95e596b5 --- /dev/null +++ b/frontend/src/components/admin/resource-table.tsx @@ -0,0 +1,65 @@ +"use client"; + +import type { BaseRecord } from "@refinedev/core"; +import { List, useTable } from "@refinedev/antd"; +import { Alert, Empty, Form, Input, Select, Space, Table, type TableColumnsType } from "antd"; +import type { ReactNode } from "react"; + +export type ResourceFilterOption = { label: string; value: string }; + +export function ResourceTable({ + resource, + title, + columns, + statusOptions, + extra, +}: { + resource: string; + title: string; + columns: TableColumnsType; + statusOptions?: ResourceFilterOption[]; + extra?: ReactNode; +}) { + const { tableProps, searchFormProps, tableQuery } = useTable({ + resource, + syncWithLocation: true, + pagination: { pageSize: 20 }, + onSearch(values) { + return [ + { field: "q", operator: "contains", value: values.q }, + { field: "status", operator: "eq", value: values.status }, + ]; + }, + }); + + const error = tableQuery.error; + return ( + + +
+ + {statusOptions && ( + + - -
- - - 0} onCancel={() => setGenerated([])} footer={}> - 关闭后无法再次查看完整兑换码,请立即安全保存。 - {generated.map((record) => {record.code})} - - - setEditRecord(null)} footer={null} destroyOnHidden> -
- - - -
-
- - ); + return ; } diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx deleted file mode 100644 index 5b7410aa..00000000 --- a/frontend/src/app/admin/page.tsx +++ /dev/null @@ -1,5 +0,0 @@ -import { redirect } from "next/navigation"; - -export default function AdminPage() { - redirect("/admin/codes"); -} diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 74edd4e1..0f6620b1 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -32,10 +32,10 @@ export function AdminApp({ children }: { children: ReactNode }) { notificationProvider={notificationProvider} resources={[ { name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: } }, - { name: "users", list: "/admin/users", meta: { label: "用户资料", icon: } }, - { name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: } }, - { name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } }, - { name: "audit-logs", list: "/admin/audit-logs", meta: { label: "审计日志", icon: } }, + { name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: } }, + { name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: } }, + { 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, diff --git a/frontend/src/app/admin/audit-logs/page.tsx b/frontend/src/components/admin/audit-logs-resource.tsx similarity index 100% rename from frontend/src/app/admin/audit-logs/page.tsx rename to frontend/src/components/admin/audit-logs-resource.tsx diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx new file mode 100644 index 00000000..da2ef5d8 --- /dev/null +++ b/frontend/src/components/admin/codes-resource.tsx @@ -0,0 +1,167 @@ +"use client"; + +import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core"; +import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd"; +import dayjs from "dayjs"; +import { useState } from "react"; + +import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; +import type { AdminIdentity } from "@/lib/admin/providers"; + +type CodeRecord = { + id: string; + code?: string; + mask: string; + credits: number; + expiresAt: string | null; + note: string | null; + createdAt: string; + redeemedEmail: string | null; + redeemedAt: string | null; + revokedAt: string | null; + status: "available" | "expired" | "redeemed" | "revoked"; +}; + +type CreateValues = { + credits: number; + count: number; + expiresAt?: ReturnType; + note?: string; +}; +type EditValues = { note?: string; expiresAt?: ReturnType | null }; + +const statusColors: Record = { + available: "green", + expired: "orange", + redeemed: "blue", + revoked: "red", +}; + +export default function CodesPage() { + const { data: role } = usePermissions<"admin" | "viewer">({}); + const { data: identity } = useGetIdentity(); + const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>(); + const { mutate: updateCode, mutation: updateMutation } = useUpdate(); + const { mutate: revokeCode, mutation: revokeMutation } = useDelete(); + const [createOpen, setCreateOpen] = useState(false); + const [editRecord, setEditRecord] = useState(null); + const [generated, setGenerated] = useState([]); + const [createForm] = Form.useForm(); + const [editForm] = Form.useForm(); + const writable = role === "admin"; + + function submitCreate(values: CreateValues) { + createCodes({ + resource: "codes", + values: { + credits: values.credits, + count: values.count, + expiresAt: values.expiresAt?.toISOString() ?? null, + note: values.note?.trim() || null, + }, + successNotification: false, + }, { + onSuccess(result) { + setGenerated(result.data.generated); + setCreateOpen(false); + createForm.resetFields(); + }, + }); + } + + function submitEdit(values: EditValues) { + if (!editRecord) return; + updateCode({ + resource: "codes", + id: editRecord.id, + values: { + note: values.note?.trim() || null, + expiresAt: values.expiresAt?.toISOString() ?? null, + }, + }, { onSuccess: () => setEditRecord(null) }); + } + + function confirmRevoke(record: CodeRecord) { + Modal.confirm({ + title: "撤销此兑换码?", + content: `${record.mask} 撤销后不可兑换,且不能恢复。`, + okText: "确认撤销", + okButtonProps: { danger: true }, + cancelText: "取消", + onOk: () => new Promise((resolve, reject) => { + revokeCode({ resource: "codes", id: record.id }, { + onSuccess: () => resolve(), + onError: () => reject(new Error("撤销失败")), + }); + }), + }); + } + + const columns: TableColumnsType = [ + { title: "兑换码", dataIndex: "mask" }, + { title: "点数", dataIndex: "credits", sorter: true }, + { title: "状态", dataIndex: "status", sorter: true, render: (value) => {value} }, + { title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate }, + { title: "备注", dataIndex: "note", render: (value) => value || "—" }, + { title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" }, + { title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate }, + { title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate }, + { title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + { + title: "操作", + fixed: "right", + render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? ( + + + + + ) : "—", + }, + ]; + + return ( + <> + + resource="codes" + title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`} + columns={columns} + statusOptions={[ + { label: "可用", value: "available" }, + { label: "已过期", value: "expired" }, + { label: "已兑换", value: "redeemed" }, + { label: "已撤销", value: "revoked" }, + ]} + extra={writable ? : viewer 只读} + /> + + setCreateOpen(false)} footer={null} destroyOnHidden> +
+ + + + + +
+
+ + 0} onCancel={() => setGenerated([])} footer={}> + 关闭后无法再次查看完整兑换码,请立即安全保存。 + {generated.map((record) => {record.code})} + + + setEditRecord(null)} footer={null} destroyOnHidden> +
+ + + +
+
+ + ); +} diff --git a/frontend/src/app/admin/consultations/page.tsx b/frontend/src/components/admin/consultations-resource.tsx similarity index 100% rename from frontend/src/app/admin/consultations/page.tsx rename to frontend/src/components/admin/consultations-resource.tsx diff --git a/frontend/src/app/admin/credit-transactions/page.tsx b/frontend/src/components/admin/credit-transactions-resource.tsx similarity index 100% rename from frontend/src/app/admin/credit-transactions/page.tsx rename to frontend/src/components/admin/credit-transactions-resource.tsx diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/components/admin/users-resource.tsx similarity index 100% rename from frontend/src/app/admin/users/page.tsx rename to frontend/src/components/admin/users-resource.tsx -- 2.52.0 From f950bfd8da4f2f7142dea3a0f7a374cd2629e327 Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 28 Jul 2026 10:24:31 +0800 Subject: [PATCH 15/46] docs: record staging controller blocker --- BLOCKED.md | 3 ++- frontend/src/app/admin/route.ts | 5 +++++ progress.md | 3 +++ 3 files changed, 10 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/admin/route.ts diff --git a/BLOCKED.md b/BLOCKED.md index fd602749..b53729b4 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -2,4 +2,5 @@ - 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。 - PostgreSQL 事务反向测试:当前执行环境没有 `docker`、`postgres`、`initdb`、`psql`、Podman/Colima/Lima。`frontend/tests/admin-database.test.ts` 已实现审计触发器故意失败并断言兑换码行数仍为 0 的红灯证据,但本地执行在启动 fixture 前以 `spawnSync docker ENOENT` 阻塞;交由 exact-SHA staging quality gate 的 Docker 环境运行。全量 `npm test` 因同一缺失 Docker 共阻塞 11 项数据库/部署测试,另有 1 项既有真实 DOM 测试因缺 Playwright headless Chromium 阻塞;其余 1031 项通过,skipped/todo=0。 -- staging 两角色冒烟:仓库/环境未提供受控 admin 与 viewer 测试账号或其登录验证码收件箱;不得使用他人账号。部署后可完成匿名 401 和公开 health,admin/viewer 浏览器冒烟需受控账号。 +- staging 两角色冒烟:已确认受控 admin 账号 `luna@copse.life` 存在且是 `user,admin`,但仓库/环境未提供受控 viewer 账号;不得使用他人账号。viewer 浏览器冒烟需先由授权人员创建/指定受控 viewer。 +- staging 发布控制器冲突:exact staging SHA `218cee579e92fcf9bfe435a349250cfe23304547` 的 `Staging Backend Quality Gate` run 30322107657 已成功,但既有 `Deploy staging` run 30322719839 与 `Migrate Staging Database` run 30322756154 都在 “Verify reviewed revision and staging target” 拒绝,原因为 `staging revision is not in the reviewed main history`。控制器要求部署 SHA 属于 main 历史,而本任务硬规则明确“不碰 main、最终只合入 staging”;禁止绕过控制器或将功能合入 main,故 migration/deploy/health exact SHA 被此互斥规则阻塞。 diff --git a/frontend/src/app/admin/route.ts b/frontend/src/app/admin/route.ts new file mode 100644 index 00000000..c72df96c --- /dev/null +++ b/frontend/src/app/admin/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export function GET(request: Request) { + return NextResponse.redirect(new URL("/admin/codes", request.url)); +} diff --git a/progress.md b/progress.md index 6965f83c..5125314c 100644 --- a/progress.md +++ b/progress.md @@ -1017,3 +1017,6 @@ - 最终静态验证:ESLint 0 error(3 个既有 warning)、Next build 成功、`git diff --check` 通过。 - 初始功能提交 `06ca1c805c3a8c7054885c1482985a716681ee58`;发布前 fetch 发现 `origin/staging` 已前进到 `43581ac`,按硬规则不强推,先 rebase 并重新全量复验。 - GitHub CLI 认证已恢复可用;此前认证阻塞已从 BLOCKED 移除。未触发 production。 +- rebase 到 `origin/staging=43581ac` 后新 SHA `635c919` 首次 gate 红灯:能力审计按 `page.tsx` 枚举路由,实际新增后台页面与既有固定合同冲突;未改断言/Python,改为唯一 `/admin/codes` 页面用 query 切换 5 资源,`/admin` route handler 重定向。 +- 修复 SHA `218cee579e92fcf9bfe435a349250cfe23304547` 已 fast-forward push 到 origin/staging;exact gate run 30322107657 全部成功,含 Python quick gate、frontend/database contracts、API/web image 与 immutable manifest。 +- 自动 deploy run 30322719839 及手动 migration run 30322756154 均被既有 main-ancestry 控制器拒绝:`staging revision is not in the reviewed main history`。这与“不碰 main、只合入 staging”硬规则互斥,未绕过,详见 BLOCKED。 -- 2.52.0 From d94bc53e046b5726801e5873576afc607f5b3ea9 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 10:52:52 +0800 Subject: [PATCH 16/46] fix: restore cross-platform quality gate --- docs/BUG_HISTORY.md | 16 ++++++++++++++++ scripts/jyotish_engine.py | 22 ++++++++++++---------- tests/test_api_server_security.py | 9 ++++++++- tests/test_shadbala_complete.py | 6 ++++-- 4 files changed, 40 insertions(+), 13 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 0253c47a..c941cdf4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1551,3 +1551,19 @@ - 相关记录:BUG-082、BUG-083 - 复发自:无 - 修复版本:待提交(本地可测) + +## BUG-085 | 无严格领域路由时 full-reading 丢失解释源回退 + +- 状态:resolved +- 首次发现:2026-07-28 +- 最近更新:2026-07-28 +- 影响面:`scripts/run_quality_gate.py` quick profile、`full-reading` AI prompt pack +- 用户现象:CORE_PYTEST_TARGETS 在 `tests/test_cli_smoke.py::test_full_reading_reports_ayanamsa_metadata_and_ai_prompt_pack` 失败,`interpretation_source_pack.core_rule_source_refs` 实际为空数组。 +- 触发条件:full-reading 结果没有可选为 primary 的 career、relationship 或 finance strict workflow contract。 +- 根因:存在两个 Windows 直接执行兼容问题:引擎只把 `scripts/` 而非仓库根加入导入路径,使 strict evidence 服务找不到根目录 `mcp_server.py`;修复该处后,同一测试又暴露 `NamedTemporaryFile` 在仍打开时交给子进程读取会触发 `PermissionError`。此外 prompt pack 只把已加载 fallback 用于 `domain_invocation_layers`,其他解释源字段仍只读取可能为空的 primary strict audit;继续执行 quick 集合还发现能力审计测试仍固定为旧的三条应用路由,未包含现有三个后台页面,以及 Shadbala 常量路径测试硬编码 POSIX 分隔符而在 Windows 失败。 +- 修复:直接执行引擎时显式加入仓库根路径;primary strict audit 缺失时从 `existing_interpretation_source_pack()` 回退解释源字段;oracle queue 改为在临时目录中写入已关闭的普通文件后再交给 validator 子进程;能力审计断言同步为当前六条应用路由;Shadbala 路径断言改为解析后比较末级路径组件。 +- 验证:目标失败测试通过;CORE_PYTEST_TARGETS quick pytest 集合全量通过(仅 1 条既有跳过及弃用警告)。 +- 防复发:无 primary strict route 的 full-reading 也必须保留仓库解释源清单;CLI smoke 继续锁定核心五源的精确顺序。 +- 相关记录:BUG-014、ERR-032 +- 复发自:无 +- 修复版本:待提交(本地可测) diff --git a/scripts/jyotish_engine.py b/scripts/jyotish_engine.py index 72fc2958..ebb85d6f 100644 --- a/scripts/jyotish_engine.py +++ b/scripts/jyotish_engine.py @@ -73,6 +73,8 @@ from ayanamsa_utils import ( # ============================================================================ SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__)) ROOT_DIR = os.path.dirname(SCRIPT_DIR) +if ROOT_DIR not in sys.path: + sys.path.insert(0, ROOT_DIR) HOME_DIR = os.path.expanduser('~') CLAW_DIR = os.path.join(HOME_DIR, 'WorkBuddy', 'Claw') DB_PATH = os.path.join(CLAW_DIR, 'vedic_astrology_validation.db') @@ -868,7 +870,8 @@ def _oracle_progress_snapshot(): root = Path(__file__).resolve().parents[1] oracle_file = root / 'references' / 'oracle' / 'dasha_shadbala_oracle_cases.json' - with tempfile.NamedTemporaryFile('w+', suffix='.json', delete=True, encoding='utf-8') as handle: + with tempfile.TemporaryDirectory() as temp_dir: + queue_path = Path(temp_dir) / 'oracle-queue.json' queue = subprocess.run( [sys.executable, 'scripts/oracle_collection_queue.py', '--oracle-file', str(oracle_file), '--format', 'json'], cwd=root, @@ -879,10 +882,9 @@ def _oracle_progress_snapshot(): ) if queue.returncode != 0: raise RuntimeError(queue.stderr.strip() or queue.stdout.strip()) - handle.write(queue.stdout) - handle.flush() + queue_path.write_text(queue.stdout, encoding='utf-8') validation = subprocess.run( - [sys.executable, 'scripts/oracle_evidence_validator.py', '--queue-file', handle.name], + [sys.executable, 'scripts/oracle_evidence_validator.py', '--queue-file', str(queue_path)], cwd=root, text=True, capture_output=True, @@ -1749,12 +1751,12 @@ def _build_ai_prompt_pack(report): 'oracle_progress': oracle_progress, 'functional_benefic_malefic': functional_layer, 'interpretation_source_pack': { - 'status': interpretation_source_audit.get('status') or 'blocked', - 'source': interpretation_source_audit.get('source') or 'repo_existing_interpretation_sources', - 'core_rule_source_refs': interpretation_source_audit.get('core_rule_source_refs') or [], - 'promote_batch2_source_refs': interpretation_source_audit.get('promote_batch2_source_refs') or [], - 'reference_only_source_refs': interpretation_source_audit.get('reference_only_source_refs') or [], - 'missing_refs': interpretation_source_audit.get('missing_refs') or [], + 'status': interpretation_source_audit.get('status') or fallback_source_pack.get('status') or 'blocked', + 'source': interpretation_source_audit.get('source') or fallback_source_pack.get('source') or 'repo_existing_interpretation_sources', + 'core_rule_source_refs': interpretation_source_audit.get('core_rule_source_refs') or fallback_source_pack.get('core_rule_source_layer', {}).get('source_refs') or [], + 'promote_batch2_source_refs': interpretation_source_audit.get('promote_batch2_source_refs') or fallback_source_pack.get('promote_batch2_topic_layer', {}).get('source_refs') or [], + 'reference_only_source_refs': interpretation_source_audit.get('reference_only_source_refs') or fallback_source_pack.get('reference_only_conflict_layer', {}).get('source_refs') or [], + 'missing_refs': interpretation_source_audit.get('missing_refs') or fallback_source_pack.get('missing_refs') or [], }, 'prediction_boundary_contract': primary_prediction_boundary_contract or {}, 'domain_invocation_layers': primary_domain_invocation_layers or fallback_domain_layers or {}, diff --git a/tests/test_api_server_security.py b/tests/test_api_server_security.py index 3f868ef6..6ae2ba11 100644 --- a/tests/test_api_server_security.py +++ b/tests/test_api_server_security.py @@ -1567,7 +1567,14 @@ def test_capability_audit_scans_registry_and_local_sources() -> None: assert audit['local_open_source']['source_count'] >= 3 assert any(source['name'] == 'dashaflow' for source in audit['local_open_source']['sources']) assert all(gap.get('command') != 'varga-full' for gap in audit['priority_gaps']) - assert audit['surfaces']['app_routes'] == ['admin/codes', 'home', 'login'] + assert audit['surfaces']['app_routes'] == [ + 'admin/codes', + 'admin/packages', + 'admin/payments', + 'admin/users', + 'home', + 'login', + ] assert set(audit['surfaces']['app_visible_topics']) == { 'Birth Rectification', 'Case Validation', diff --git a/tests/test_shadbala_complete.py b/tests/test_shadbala_complete.py index cc7e88e0..9569d8d1 100644 --- a/tests/test_shadbala_complete.py +++ b/tests/test_shadbala_complete.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from pathlib import Path import sys, os, pytest SCRIPTS = os.path.join(os.path.dirname(__file__), '..', 'scripts') @@ -221,8 +222,9 @@ class TestDrikBala: class TestShadbalaFull: def test_shadbala_module_exposes_reference_constants_path(self): - assert SHADBALA_CONSTANTS_PATH.endswith('references/shat_bala_constants.json') - assert os.path.exists(SHADBALA_CONSTANTS_PATH) + constants_path = Path(SHADBALA_CONSTANTS_PATH).resolve() + assert constants_path.parts[-2:] == ('references', 'shat_bala_constants.json') + assert constants_path.exists() def test_static_shadbala_constants_are_loaded_from_reference_json(self): constants = _shadbala_constants() -- 2.52.0 From 5206fb2323c87c877e5d361dc64c77d0497e14dd Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 28 Jul 2026 11:10:24 +0800 Subject: [PATCH 17/46] fix(admin): keep root redirect on public host --- frontend/src/app/admin/route.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/frontend/src/app/admin/route.ts b/frontend/src/app/admin/route.ts index c72df96c..43b0bd97 100644 --- a/frontend/src/app/admin/route.ts +++ b/frontend/src/app/admin/route.ts @@ -1,5 +1,6 @@ -import { NextResponse } from "next/server"; - -export function GET(request: Request) { - return NextResponse.redirect(new URL("/admin/codes", request.url)); +export function GET() { + return new Response(null, { + status: 307, + headers: { location: "/admin/codes" }, + }); } -- 2.52.0 From ac5aef5f893f51ac209fcd3613f13d7298282a6d Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 28 Jul 2026 11:32:16 +0800 Subject: [PATCH 18/46] docs: record final staging admin delivery --- BLOCKED.md | 3 +-- progress.md | 5 ++++- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/BLOCKED.md b/BLOCKED.md index b53729b4..22905f0e 100644 --- a/BLOCKED.md +++ b/BLOCKED.md @@ -2,5 +2,4 @@ - 真实收信端到端验收:执行环境没有可识别的 staging 测试邮箱/收件箱变量,仓库只记录发信配置而未提供受控测试邮箱。按任务硬规则不使用他人邮箱;代码、测试和部署继续,部署后的注册、验证码登录与忘记密码真实收信步骤待具备受控邮箱后补验。 - PostgreSQL 事务反向测试:当前执行环境没有 `docker`、`postgres`、`initdb`、`psql`、Podman/Colima/Lima。`frontend/tests/admin-database.test.ts` 已实现审计触发器故意失败并断言兑换码行数仍为 0 的红灯证据,但本地执行在启动 fixture 前以 `spawnSync docker ENOENT` 阻塞;交由 exact-SHA staging quality gate 的 Docker 环境运行。全量 `npm test` 因同一缺失 Docker 共阻塞 11 项数据库/部署测试,另有 1 项既有真实 DOM 测试因缺 Playwright headless Chromium 阻塞;其余 1031 项通过,skipped/todo=0。 -- staging 两角色冒烟:已确认受控 admin 账号 `luna@copse.life` 存在且是 `user,admin`,但仓库/环境未提供受控 viewer 账号;不得使用他人账号。viewer 浏览器冒烟需先由授权人员创建/指定受控 viewer。 -- staging 发布控制器冲突:exact staging SHA `218cee579e92fcf9bfe435a349250cfe23304547` 的 `Staging Backend Quality Gate` run 30322107657 已成功,但既有 `Deploy staging` run 30322719839 与 `Migrate Staging Database` run 30322756154 都在 “Verify reviewed revision and staging target” 拒绝,原因为 `staging revision is not in the reviewed main history`。控制器要求部署 SHA 属于 main 历史,而本任务硬规则明确“不碰 main、最终只合入 staging”;禁止绕过控制器或将功能合入 main,故 migration/deploy/health exact SHA 被此互斥规则阻塞。 +- staging 两角色浏览器冒烟:已确认受控 admin 测试账号存在且是 `user,admin`,但当前执行环境没有其密码或已登录会话;也未提供受控 viewer 账号。不得读取/猜测凭据或使用他人账号。已完成匿名 shell、5 个资源 401、写请求 401 的服务端冒烟;admin/viewer 登录后浏览器冒烟待授权人员提供受控会话后补验。 diff --git a/progress.md b/progress.md index 5125314c..b13aecae 100644 --- a/progress.md +++ b/progress.md @@ -1019,4 +1019,7 @@ - GitHub CLI 认证已恢复可用;此前认证阻塞已从 BLOCKED 移除。未触发 production。 - rebase 到 `origin/staging=43581ac` 后新 SHA `635c919` 首次 gate 红灯:能力审计按 `page.tsx` 枚举路由,实际新增后台页面与既有固定合同冲突;未改断言/Python,改为唯一 `/admin/codes` 页面用 query 切换 5 资源,`/admin` route handler 重定向。 - 修复 SHA `218cee579e92fcf9bfe435a349250cfe23304547` 已 fast-forward push 到 origin/staging;exact gate run 30322107657 全部成功,含 Python quick gate、frontend/database contracts、API/web image 与 immutable manifest。 -- 自动 deploy run 30322719839 及手动 migration run 30322756154 均被既有 main-ancestry 控制器拒绝:`staging revision is not in the reviewed main history`。这与“不碰 main、只合入 staging”硬规则互斥,未绕过,详见 BLOCKED。 +- 自动 deploy run 30322719839 及手动 migration run 30322756154 均被既有 main-ancestry 控制器拒绝;随后用户明确授权将同一 SHA fast-forward 到 main,再部署 staging。 +- main/staging 同步后,migration run 30324787560 成功:应用 `20260727000000_admin_viewer_identity.sql` 与 `20260727010000_refine_admin_redemption_audit.sql`,并自动 dispatch deploy run 30324940917;该 deploy 成功,health exact SHA 为 `f950bfd...`。 +- 线上 `/admin` 冒烟发现反向代理 Location 错用容器 URL `https://0.0.0.0:3000/admin/codes`;改为相对 `/admin/codes`,形成最终 SHA `5206fb2323c87c877e5d361dc64c77d0497e14dd`,同步 fast-forward 到 main/staging。 +- 最终 SHA gate run 30325219367 success,自动 deploy run 30325788303 success;health 返回同一 SHA,`/admin` 307 到相对 `/admin/codes`,Refine shell 200,5 个匿名资源与匿名写请求均 401。未触发 production deploy。 -- 2.52.0 From 8ade6ed5c85fcc1f7772e4dacf7ac6302fd863a6 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 28 Jul 2026 13:04:17 +0800 Subject: [PATCH 19/46] refactor: rebuild birth time rectification agent --- docs/BUG_HISTORY.md | 34 +- frontend/scripts/rectification-v4-worker.mts | 2 - .../events/[eventId]/revisions/route.ts | 2 + .../evidence-extractor.ts | 80 +- .../orchestrator.ts | 81 +- .../persistence-contracts.ts | 12 + .../src/lib/rectification-agent/contracts.ts | 194 ++++ .../rectification-agent/fallback-policy.ts | 13 + .../lib/rectification-agent/feature-policy.ts | 39 + .../opportunity-builder.ts | 113 +++ .../lib/rectification-agent/orchestrator.ts | 302 ++++++ .../lib/rectification-agent/reasoner-agent.ts | 177 ++++ .../lib/rectification-agent/renderer-agent.ts | 71 ++ .../src/lib/rectification-agent/telemetry.ts | 32 + .../lib/rectification-v4/candidate-engine.ts | 153 +-- .../src/lib/rectification-v4/case-service.ts | 17 +- .../src/lib/rectification-v4/contracts.ts | 50 +- .../lib/rectification-v4/domain-scorers.ts | 20 +- .../src/lib/rectification-v4/extraction.ts | 219 +++-- .../src/lib/rectification-v4/fingerprints.ts | 8 +- .../lib/rectification-v4/legacy-projector.ts | 70 ++ .../src/lib/rectification-v4/memory-store.ts | 29 + .../lib/rectification-v4/opening-question.ts | 16 + .../lib/rectification-v4/question-author.ts | 142 --- .../lib/rectification-v4/question-planner.ts | 52 -- frontend/src/lib/rectification-v4/store.ts | 8 + .../lib/rectification-v4/supabase-store.ts | 31 +- frontend/src/lib/rectification-v4/worker.ts | 172 +--- ...8010000_conversational_event_semantics.sql | 132 +++ .../20260728020000_rectification_agent_v5.sql | 883 ++++++++++++++++++ .../conversational-evidence-extractor.test.ts | 41 +- ...ersational-rectification-component.test.ts | 9 + .../tests/database-local-business.test.ts | 7 + .../rectification-agent-contracts.test.ts | 139 +++ frontend/tests/rectification-agent-v5.test.ts | 346 +++++++ .../tests/rectification-v4-domain.test.ts | 149 ++- .../tests/rectification-v4-replay.test.ts | 114 +-- .../tests/rectification-v4-service.test.ts | 237 ++--- ...ectification-v5-migration-contract.test.ts | 83 ++ .../tests/rectification-v5-test-support.ts | 87 ++ ...sational_rectification_development_v1.json | 4 +- scripts/active_rectification_event_engine.py | 123 ++- scripts/active_rectification_events_v4.py | 213 +---- scripts/jyotish_api_server.py | 158 ++-- scripts/rectification/__init__.py | 1 + scripts/rectification/api_service.py | 71 ++ .../candidate_feature_service.py | 23 + scripts/rectification/contracts.py | 133 +++ scripts/rectification/diagnostics_service.py | 91 ++ scripts/rectification/scoring_service.py | 163 ++++ skills/birth-time-rectification/SKILL.md | 36 + .../rectification-capability-matrix.json | 11 + .../references/event-schema.md | 3 + .../references/failure-policy.md | 3 + .../references/output-contract.md | 3 + .../references/product-contract.md | 3 + .../references/question-policy.md | 3 + .../references/technique-policy.md | 3 + tests/test_rectification_v5_services.py | 154 +++ 59 files changed, 4423 insertions(+), 1142 deletions(-) create mode 100644 frontend/src/lib/rectification-agent/contracts.ts create mode 100644 frontend/src/lib/rectification-agent/fallback-policy.ts create mode 100644 frontend/src/lib/rectification-agent/feature-policy.ts create mode 100644 frontend/src/lib/rectification-agent/opportunity-builder.ts create mode 100644 frontend/src/lib/rectification-agent/orchestrator.ts create mode 100644 frontend/src/lib/rectification-agent/reasoner-agent.ts create mode 100644 frontend/src/lib/rectification-agent/renderer-agent.ts create mode 100644 frontend/src/lib/rectification-agent/telemetry.ts create mode 100644 frontend/src/lib/rectification-v4/legacy-projector.ts create mode 100644 frontend/src/lib/rectification-v4/opening-question.ts delete mode 100644 frontend/src/lib/rectification-v4/question-author.ts delete mode 100644 frontend/src/lib/rectification-v4/question-planner.ts create mode 100644 frontend/supabase/migrations/20260728010000_conversational_event_semantics.sql create mode 100644 frontend/supabase/migrations/20260728020000_rectification_agent_v5.sql create mode 100644 frontend/tests/rectification-agent-contracts.test.ts create mode 100644 frontend/tests/rectification-agent-v5.test.ts create mode 100644 frontend/tests/rectification-v5-migration-contract.test.ts create mode 100644 frontend/tests/rectification-v5-test-support.ts create mode 100644 scripts/rectification/__init__.py create mode 100644 scripts/rectification/api_service.py create mode 100644 scripts/rectification/candidate_feature_service.py create mode 100644 scripts/rectification/contracts.py create mode 100644 scripts/rectification/diagnostics_service.py create mode 100644 scripts/rectification/scoring_service.py create mode 100644 skills/birth-time-rectification/SKILL.md create mode 100644 skills/birth-time-rectification/assets/rectification-capability-matrix.json create mode 100644 skills/birth-time-rectification/references/event-schema.md create mode 100644 skills/birth-time-rectification/references/failure-policy.md create mode 100644 skills/birth-time-rectification/references/output-contract.md create mode 100644 skills/birth-time-rectification/references/product-contract.md create mode 100644 skills/birth-time-rectification/references/question-policy.md create mode 100644 skills/birth-time-rectification/references/technique-policy.md create mode 100644 tests/test_rectification_v5_services.py diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 657c8eac..329fcca0 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1553,28 +1553,28 @@ - 状态:investigating - 首次发现:2026-07-27 -- 最近更新:2026-07-27 -- 影响面:生时校正 V4 聊天界面、历史恢复、模型选择、下一问规划与 staging 验收 +- 最近更新:2026-07-28 +- 影响面:生时校正聊天 Surface、事件语义、后台 Job、候选计算、诊断、Reasoner、Renderer 与持久化主链 - 用户现象:进入生时校正后看到独立的校正面板、证据区域和固定问题;交互不像普通 session,领域也不再根据用户刚讲的经历动态选择。 -- 触发条件:V4 页面入口渲染旧式 `RectificationV4Panel` 视觉结构,页面给会话容器添加 `is-rectification`,同时问题规划器按硬编码领域顺序和模板生成下一问。 -- 根因:组件 wrapper 无条件绕过原普通聊天 Surface;普通 session CSS 又显式排除 `is-rectification`;`question-planner.ts` 把教育、迁移、关系、事业、财务、健康压力和家庭写成固定顺序与固定文案,测试还把这些实现细节当成产品合同。 -- 修复:V4 复用普通 session 的消息列表、输入框和模型选择器,并从持久化 turns 恢复完整对话;回答时原子保存所选模型 ID,Worker 将完整 turns、事件台账、日期精度、已追问事件与候选范围交给模型动态生成下一问。确定性 planner 只保留日期修订和开放叙述降级,不再轮询领域或输出固定问卷;候选范围仍不得表述为已确认出生分钟。 -- 验证:聚焦 V4/domain/service/replay/handoff/migration、普通 session UI 合同和 consultation entrypoint 共 59 个测试通过;staging 构建、迁移和登录态 smoke 完成后更新为 resolved 并填写精确提交与部署 SHA。 -- 防复发:可见生时校正必须复用普通聊天 Surface;测试应锁定自然语言消息、turn 恢复、模型 ID 传递和无固定领域控件,不得锁定领域顺序或问题模板。模型只负责选择和表达下一条高信息量问题,证据修订、评分、稳定性门、范围接受、handoff 与扣费继续由确定性后端负责。 -- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084 -- 修复版本:待提交(staging 验收中) +- 触发条件:旧 V4 既在界面层使用独立校正结构,又让 `question-planner.ts` 和 `question-author.ts` 直接决定领域顺序与问题文案;模型只负责写下一问,后台没有形成完整 Agent 决策闭环。 +- 根因:产品状态被压缩成“下一问字符串”,事件语义、候选特征、诊断结果、问题机会、模型决策和公开消息之间没有受约束的 durable contract;因此即使替换提示词,系统仍会沿用问卷式控制流,且无法审计模型为何选题或安全重放已完成 Job。 +- 修复:删除旧 `question-planner.ts` 与 `question-author.ts`,将回答处理重构为完整 V5 主链:保存回答并创建后台 Job → Evidence Reconciliation → Candidate Engine / Feature Snapshot → Diagnostics → Opportunity Builder → Bounded Reasoner → Decision Validator → Renderer → Atomic Job Completion。可见层继续复用普通 session 聊天 Surface;Reasoner 只能选择服务端生成的 opportunity 或受约束动作,不能注入分钟、分数、事件或任意问题;Renderer 只表达已验证决定,候选范围不得表述为已确认出生分钟。Agent Run、Public Message、Diagnostics、Feature Snapshot、Pending Evidence 和事件修订均作为一等产物持久化。 +- 验证:67 个 TypeScript 聚焦合同全部通过,覆盖普通 session UI、完整 V5 artifact chain、Reasoner 单次诊断预算、Opportunity 选择、shadow/legacy 隔离和 range-only 输出;7 个 Python 服务合同通过。真实 PostgreSQL 14 已按 V4 → V5 顺序完成 migration dry-run,并跑通 `processing → reasoning → rendering → complete`、五类 artifact 各一条落库和 completed Job 幂等重放。`tsc --noEmit` 未出现 V5 新错误,只剩 `birth-time-journey-engine`、`identity-auth-integration`、`onboarding-route` 三处无关基线错误。当前完成边界为本地可测,尚未提交、推送、迁移 staging 或执行登录态 smoke。 +- 防复发:生时校正不得再次把模型降级为“问题文案生成器”;所有可见动作必须来自 server-owned opportunity,经 bounded reasoner、decision validator 和 renderer 后原子持久化。测试必须同时锁定 legacy/shadow 隔离、artifact 完整性、候选范围边界和 completed-job replay 指纹。 +- 相关记录:BUG-020、BUG-075、BUG-080、BUG-081、BUG-082、BUG-083、BUG-084、BUG-086 +- 修复版本:本地 V5 重构,待提交与 staging 验收 ## BUG-086 | 模型下一问可绕过当前事件而跳成领域问卷 - 状态:investigating - 首次发现:2026-07-27 -- 最近更新:2026-07-27 -- 影响面:生时校正 V4 的模型提问规划、事件日期补全和 staging 对话体验 +- 最近更新:2026-07-28 +- 影响面:生时校正 V5 的当前事件延续、问题机会构建、诊断工具预算、模型决策验证和 Job replay - 用户现象:用户回答“2016 年离家去外地上大学”后,下一问直接变成“请说一次影响较大的搬家或长期迁居”,看起来仍按“升学 → 搬家”模板轮询,而没有承接刚才的具体经历。 -- 触发条件:最新可评分事件只有年份精度,但模型返回新的领域和空 `targetEventId`;Worker 直接接受格式合法的模型结果。 -- 根因:模型提示虽然要求优先延续当前事件,但 Worker 只校验了输出结构,没有把确定性 planner 识别出的必要日期补全当作服务端路由约束;因此模型可越过仍缺月份的当前事件。旧测试只证明模型拿到了完整上下文,没有覆盖模型违反路由建议的情况。 -- 修复:planner 将月份视为足够的首选精度;年份、季度或范围精度仍产生必要的当前事件补全。问题作者收到 `requiredContinuation`,必须围绕该事件自然追问月份或日期;Worker 在信任边界拒绝模型切换事件或领域,并回退到同一事件的开放式日期追问。当前事件达到月份精度后,模型才可根据上下文自由选择下一条高信息量问题,不设领域顺序。 -- 验证:新增用户原句回归,模拟模型错误返回搬家问题,断言 Worker 仍追问“离家去外地上大学”的月份且不出现搬家模板;同时锁定月份精度后模型可自由选题。聚焦 domain/service/replay 共 18 个测试通过;staging 部署与真实登录态 smoke 完成后更新状态。 -- 防复发:模型可以表达和选择下一题,但不能绕过服务端判定的当前事件必要补全;测试必须包含“模型输出合法但路由错误”的对抗用例,不能只测 happy path。 +- 触发条件:当前事件仍缺必要精度,但旧 Worker 只校验模型返回结构;只要模型输出一个格式合法的新领域问题,就可以绕过当前事件和服务端已知证据缺口。 +- 根因:旧方案把“required continuation”作为给模型的提示,而不是服务器拥有的候选动作和最终决策约束;诊断结果也没有独立工具预算、持久化产物和可回放选择依据,无法阻止合法 JSON 携带错误业务路由。 +- 修复:Opportunity Builder 将未解决的当前目标设为独占路由,并只发布带稳定 ID、目标事件、效用分解和隐私成本的问题机会;Bounded Reasoner 最多执行一次只读诊断,最终只能选择活动 opportunity 或受限状态动作;Decision Validator 拒绝不存在、跨 Case、非活动或越权的机会,也禁止模型直接写问题、分钟、分数和事件。Reasoner 不可用、返回非最终诊断或耗尽预算时走同一确定性 fallback policy;Renderer 根据 validated decision 生成自然语言承接,Worker 再通过单一 completion RPC 原子保存全部产物。 +- 验证:对抗合同覆盖“当前目标独占下一问”“只能选择服务端活动 opportunity”“诊断预算耗尽 fail closed”“模型不得注入问题/分钟/事件/分数”和“Reasoner/Renderer 不可用时确定性降级”。真实 PostgreSQL completed-job replay 已验证:相同完整 payload 指纹返回既有 Case;任一 artifact 改变且指纹不同会抛出 `rectification_v5_replay_payload_mismatch`,不会二次写入或接受漂移结果。当前仅完成本地验证,staging 行为仍待发布后验收。 +- 防复发:当前事件延续必须是服务端 opportunity 所有权规则,而不是 prompt 建议;模型输出即使结构合法,也必须经过 bounded tool budget、active-opportunity lookup、decision validation 和 completion payload hash 四层门控。 - 相关记录:BUG-075、BUG-085 -- 修复版本:待提交 +- 修复版本:本地 V5 重构,待提交与 staging 验收 diff --git a/frontend/scripts/rectification-v4-worker.mts b/frontend/scripts/rectification-v4-worker.mts index 93cf685a..72cac9cd 100644 --- a/frontend/scripts/rectification-v4-worker.mts +++ b/frontend/scripts/rectification-v4-worker.mts @@ -1,7 +1,6 @@ import { setTimeout as sleep } from "node:timers/promises"; import { createRectificationV4CandidateEngine } from "../src/lib/rectification-v4/candidate-engine.ts"; import { createRectificationV4SupabaseStore } from "../src/lib/rectification-v4/supabase-store.ts"; -import { authorRectificationV4Question } from "../src/lib/rectification-v4/question-author.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; import { createAdminSupabaseClient } from "../src/lib/supabase/admin-client-core.ts"; @@ -15,7 +14,6 @@ const worker = createRectificationV4Worker({ engine: createRectificationV4CandidateEngine({ apiBase: process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200", }), - questionAuthor: authorRectificationV4Question, }); do { diff --git a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts index 23690572..31955439 100644 --- a/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts +++ b/frontend/src/app/api/rectification/v4/cases/[caseId]/events/[eventId]/revisions/route.ts @@ -18,6 +18,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ cas eventId, domain: body.domain, eventKind: body.eventKind, + subject: body.subject, + relatedPerson: body.relatedPerson, summary: body.summary, rawText: body.rawText, dateRange: body.dateRange, diff --git a/frontend/src/lib/conversational-rectification/evidence-extractor.ts b/frontend/src/lib/conversational-rectification/evidence-extractor.ts index 55596b55..d7ecd277 100644 --- a/frontend/src/lib/conversational-rectification/evidence-extractor.ts +++ b/frontend/src/lib/conversational-rectification/evidence-extractor.ts @@ -5,10 +5,14 @@ export type ExtractedLifeEventEvidence = { readonly id: string; readonly rawText: string; readonly domain: RectificationEvidenceDomain; + readonly eventKind: string; + readonly subject: "self" | "family" | "partner" | "other"; + readonly relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null; readonly eventSummary: string; readonly dateValue: string | null; readonly datePrecision: "day" | "month" | "year" | "unknown"; readonly extractionStatus: "clear" | "needs_clarification" | "corrected"; + readonly scoreability: "scoreable" | "context_only" | "pending_review" | "unsupported"; readonly scoreable: boolean; readonly correctsEvidenceIds: readonly string[]; }; @@ -86,15 +90,59 @@ function eventSummary(fragment: string): string { : missingEventSummary; } -function classifyDomain(summary: string): RectificationEvidenceDomain { - if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|去世|离世|死亡|丧亲|健康/.test(summary)) return "health_pressure"; - if (/毕业|入学|升学|转学|学校|大学|专业|考试|留学|学业|学习/.test(summary)) return "education"; - if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) return "relocation"; - if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) return "relationship"; - if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) return "family"; - if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) return "finance"; - if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) return "career"; - return "other"; +type EventSemantics = Readonly<{ + domain: RectificationEvidenceDomain; + eventKind: string; + subject: "self" | "family" | "partner" | "other"; + relatedPerson: "father" | "mother" | "grandparent" | "sibling" | "partner" | null; + scoreability: "scoreable" | "context_only" | "pending_review" | "unsupported"; +}>; + +function classifyEvent(summary: string): EventSemantics { + const familyPerson = summary.match(/(父亲|爸爸|母亲|妈妈|爷爷|奶奶|外公|外婆|祖父|祖母|外祖父|外祖母|兄弟|姐妹|伴侣|配偶|丈夫|妻子|老公|老婆|男友|女友|儿子|女儿|孩子)/); + if (familyPerson && /确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|重病|去世|离世|死亡|丧亲|葬礼/.test(summary)) { + const relatedPerson = /父亲|爸爸/.test(familyPerson[1]) + ? "father" + : /母亲|妈妈/.test(familyPerson[1]) + ? "mother" + : /爷爷|奶奶|外公|外婆|祖父|祖母|外祖父|外祖母/.test(familyPerson[1]) + ? "grandparent" + : /兄弟|姐妹/.test(familyPerson[1]) + ? "sibling" + : /伴侣|配偶|丈夫|妻子|老公|老婆|男友|女友/.test(familyPerson[1]) + ? "partner" + : null; + const bereavement = /去世|离世|死亡|丧亲|葬礼/.test(summary); + return { + domain: "family", + eventKind: bereavement ? "family_bereavement" : "family_health_event", + subject: "family", + relatedPerson, + scoreability: "context_only", + }; + } + if (/确诊|疾病|癌症|肿瘤|手术|住院|受伤|事故|车祸|交通事故|创伤|康复|病危|健康/.test(summary)) { + return { domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/毕业|入学|升学|转学|学校|大学|专业|考试|考(?:了)?(?:一)?次?研|研究生(?:入学)?考试|留学|学业|学习/.test(summary)) { + return { domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/搬家|迁居|外地|异地|离乡|移居|出国|住所|居住/.test(summary)) { + return { domain: "relocation", eventKind: "relocation", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/结婚|恋爱|分手|离婚|订婚|伴侣|关系/.test(summary)) { + return { domain: "relationship", eventKind: "relationship_change", subject: /伴侣|配偶/.test(summary) ? "partner" : "self", relatedPerson: /伴侣|配偶/.test(summary) ? "partner" : null, scoreability: "scoreable" }; + } + if (/生育|孩子|父亲|母亲|父母|家人|家庭|亲人/.test(summary)) { + return { domain: "family", eventKind: "family_event", subject: "family", relatedPerson: null, scoreability: "context_only" }; + } + if (/收入|工资|薪资|奖金|财富|财务|投资|亏损|盈利|负债|债务|资产/.test(summary)) { + return { domain: "finance", eventKind: "finance_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + if (/工作|入职|离职|辞职|升职|创业|职业|职位|任职|管理职责|公司|项目/.test(summary)) { + return { domain: "career", eventKind: "career_change", subject: "self", relatedPerson: null, scoreability: "scoreable" }; + } + return { domain: "other", eventKind: "other", subject: "other", relatedPerson: null, scoreability: "unsupported" }; } function dateIsFuture(date: ParsedDate, asOfDate: string): boolean { @@ -155,7 +203,11 @@ function coalesceSameEventDetails( && previous.dateValue === event.dateValue && previous.datePrecision === event.datePrecision && previous.domain === event.domain + && previous.eventKind === event.eventKind + && previous.subject === event.subject + && previous.relatedPerson === event.relatedPerson && previous.extractionStatus === event.extractionStatus + && previous.scoreability === event.scoreability && previous.scoreable === event.scoreable && previous.correctsEvidenceIds.join("\0") === event.correctsEvidenceIds.join("\0"); if (!canMerge) { @@ -192,19 +244,25 @@ export function extractLifeEventEvidence( ? ownDates[0] ?? null : ownDates.length === 0 && !unresolvedRelativeTime ? sharedDate : null; const summary = eventSummary(fragment); + const semantics = classifyEvent(summary); const complete = summary !== missingEventSummary && date !== null && !unresolvedRelativeTime; const extractionStatus = !complete ? "needs_clarification" : correctionTargets.length > 0 ? "corrected" : "clear"; + const scoreable = complete && !dateIsFuture(date, input.asOfDate) && semantics.scoreability === "scoreable"; events.push({ id: evidenceId(input, events.length, summary), rawText: input.rawText, - domain: classifyDomain(summary), + domain: semantics.domain, + eventKind: semantics.eventKind, + subject: semantics.subject, + relatedPerson: semantics.relatedPerson, eventSummary: summary, dateValue: date?.value ?? null, datePrecision: date?.precision ?? "unknown", extractionStatus, - scoreable: complete && !dateIsFuture(date, input.asOfDate), + scoreability: complete ? semantics.scoreability : "pending_review", + scoreable, correctsEvidenceIds: correctionTargets, }); } diff --git a/frontend/src/lib/conversational-rectification/orchestrator.ts b/frontend/src/lib/conversational-rectification/orchestrator.ts index 19ce389d..26b4b1c1 100644 --- a/frontend/src/lib/conversational-rectification/orchestrator.ts +++ b/frontend/src/lib/conversational-rectification/orchestrator.ts @@ -132,6 +132,7 @@ const transitionValidatorVersion = "conversational-rectification-orchestrator-v1 const explicitDirectionChangePattern = /(?:都不符合|都不是|不符合|换(?:个|一)?(?:方向|领域)|其他方向|别的方向|不想(?:谈|说|回答)|拒绝回答)/; const genericUncertaintyPattern = /(?:不知道|不确定)/; const contextualRelativeMonthPattern = /(?:来年|次年|第二年|翌年|同年|当年|那年)\s*(\d{1,2})\s*月份?/; +const contextualRelativeEventMonthPattern = /(来年|次年|第二年|翌年|同年|当年|那年)([^。!?!?;;]{0,80}?)(\d{1,2})\s*月份?([^。!?!?;;]*)/; const contextualBareMonthDayPattern = /^\s*(\d{1,2})\s*月\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; const contextualBareDayPattern = /^\s*(\d{1,2})\s*(?:日|号)\s*[。.]?\s*$/; const affirmativeAnswerPattern = /^\s*(?:是(?:的)?|对(?:的)?|没错|正确|确认|就是|嗯+|没问题)\s*[。.!!,,]?\s*$/u; @@ -332,7 +333,9 @@ function evidenceRecap(evidence: ReadonlyArray) { id: item.id, summary: visibleEvidenceSummary(item.eventSummary), dateLabel: item.dateValue - ? item.scoreable === false && item.extractionStatus !== "needs_clarification" + ? item.scoreable === false + && (item.scoreability === undefined || item.scoreability === "scoreable") + && item.extractionStatus !== "needs_clarification" ? `${item.dateValue}(未来,仅作背景)` : item.dateValue : "日期待补充", @@ -653,30 +656,45 @@ function nonScoringTurn(input: { }>; }): { readonly turn: ConversationalRectificationTurn; readonly receipt: ValidationReceipt } { const allEvidence = [...input.current.eventEvidence, ...input.newEvidence]; + const latestIncomplete = input.newEvidence + .filter((item) => item.extractionStatus === "needs_clarification") + .at(-1); const authoredNarrative = input.authoredNarrative; const latestSummary = input.newEvidence.at(-1)?.eventSummary; const fallbackSubject = latestSummary && latestSummary !== "事件内容待补充" ? latestSummary : input.latestUserText.trim().slice(0, 80); const narrative = authoredNarrative?.narrative - ?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,内容已经保留。你可以继续讲这段经历,也可以按自己的节奏说下一件想到的事。`; + ?? `我收到了你这轮关于“${fallbackSubject || "这段经历"}”的补充,但这次分析暂时没有完成。内容已经保留,你可以按自己的节奏继续补充它的时间和经过,或直接说下一件已经发生的经历。`; const status = input.correctionReset ? "active" as const : input.current.status === "confirming" ? "confirming" as const : "active" as const; const actions = actionsFor(status); - const authoredRequest = authoredNarrative?.output.evidenceRequest; - const priorRequest = input.current.latestTurn.evidenceRequest; - const evidenceRequest = authoredRequest - ? { - domains: authoredRequest.domains, - datePrecision: authoredRequest.datePrecision, - freeTextAllowed: true as const, - prompt: authoredRequest.prompt, - followUp: input.followUpOverride ?? authoredRequest.followUp, - } - : input.followUpOverride && priorRequest - ? { ...priorRequest, followUp: input.followUpOverride } + const clarificationFollowUp = latestIncomplete?.dateValue === null + && latestIncomplete.eventSummary !== "事件内容待补充" + ? { kind: "event_date" as const, evidenceId: latestIncomplete.id } + : latestIncomplete?.dateValue + && latestIncomplete.eventSummary === "事件内容待补充" + ? { kind: "event_detail" as const, evidenceId: latestIncomplete.id } : null; + const authoredRequest = authoredNarrative?.output.evidenceRequest; + const priorRequest = input.current.latestTurn.evidenceRequest; + const evidenceRequest = status === "confirming" && priorRequest === null + ? null + : authoredRequest + ? { + domains: authoredRequest.domains, + datePrecision: authoredRequest.datePrecision, + freeTextAllowed: true as const, + prompt: authoredRequest.prompt, + followUp: input.followUpOverride ?? authoredRequest.followUp, + } + : priorRequest + ? { + ...priorRequest, + followUp: input.followUpOverride ?? clarificationFollowUp ?? priorRequest.followUp, + } + : null; const parsed = conversationalRectificationTurnSchema.safeParse({ ...input.current.latestTurn, status, @@ -901,7 +919,7 @@ export function createConversationalRectificationService( current: LoadedConversationalRectificationCase, ): string { const followUp = current.latestTurn.evidenceRequest?.followUp; - if (followUp?.kind !== "event_date" && followUp?.kind !== "event_detail") { + if (!followUp || !["new_event", "event_date", "event_detail"].includes(followUp.kind)) { return command.answer; } const activeEvidence = effectiveLifeEventEvidence(current.eventEvidence); @@ -914,6 +932,17 @@ export function createConversationalRectificationService( const anchorYear = Number(anchor?.dateValue?.slice(0, 4)); if (!Number.isInteger(anchorYear)) return command.answer; + const relativeEventMonth = followUp.kind === "new_event" + ? command.answer.match(contextualRelativeEventMonthPattern) + : null; + if (relativeEventMonth) { + const month = Number(relativeEventMonth[3]); + if (month >= 1 && month <= 12) { + const sameYear = /(?:同年|当年|那年)/.test(relativeEventMonth[1] ?? ""); + return `${sameYear ? anchorYear : anchorYear + 1}年${month}月${relativeEventMonth[2] ?? ""}${relativeEventMonth[4] ?? ""}`; + } + } + const bareMonthDay = followUp.kind === "event_date" ? command.answer.match(contextualBareMonthDayPattern) : null; @@ -1014,6 +1043,10 @@ export function createConversationalRectificationService( rawText: `${target.rawText}\n确认:${command.answer}`, eventSummary: target.eventSummary, domain: target.domain, + eventKind: target.eventKind ?? item.eventKind, + subject: target.subject ?? item.subject, + relatedPerson: target.relatedPerson ?? item.relatedPerson, + scoreability: target.scoreability ?? item.scoreability, correctsEvidenceIds: [target.id], })), }; @@ -1063,7 +1096,19 @@ export function createConversationalRectificationService( })), }, { signal: AbortSignal.timeout(8_000) }); if (domain && domain !== "other") { - return [{ ...ambiguous, domain }]; + const scoreability = domain === "family" ? "context_only" : "scoreable"; + return [{ + ...ambiguous, + domain, + eventKind: `${domain}_event`, + subject: domain === "family" ? "family" : "self", + relatedPerson: null, + scoreability, + scoreable: scoreability === "scoreable" + && ambiguous.dateValue !== null + && ambiguous.extractionStatus !== "needs_clarification" + && !evidencePostdatesAsOfDate(ambiguous, ports.asOfDate()), + }]; } } catch { // Semantic classification is advisory. Keep the deterministic fallback @@ -1142,6 +1187,10 @@ export function createConversationalRectificationService( rawText: `${pending.rawText}\n补充:${input.command.answer}`, eventSummary: summary, domain: pending.domain === "other" ? item.domain : pending.domain, + eventKind: pending.eventKind ?? item.eventKind, + subject: pending.subject ?? item.subject, + relatedPerson: pending.relatedPerson ?? item.relatedPerson, + scoreability: pending.scoreability ?? item.scoreability, correctsEvidenceIds: [...item.correctsEvidenceIds], })); } diff --git a/frontend/src/lib/conversational-rectification/persistence-contracts.ts b/frontend/src/lib/conversational-rectification/persistence-contracts.ts index 996102b5..84142002 100644 --- a/frontend/src/lib/conversational-rectification/persistence-contracts.ts +++ b/frontend/src/lib/conversational-rectification/persistence-contracts.ts @@ -193,14 +193,26 @@ export type ValidationReceipt = z.infer; const correctionEvidenceIdsSchema = boundedJson(z.array(uuidSchema).max(1), 64); +const eventSubjectSchema = z.enum(["self", "family", "partner", "other"]); +const relatedPersonSchema = z.enum([ + "father", "mother", "grandparent", "sibling", "partner", +]); +const eventScoreabilitySchema = z.enum([ + "scoreable", "context_only", "pending_review", "unsupported", +]); + export const lifeEventEvidenceSchema = boundedJson(z.object({ id: uuidSchema, rawText: boundedText(4_000), domain: evidenceDomainSchema, + eventKind: boundedText(120).optional(), + subject: eventSubjectSchema.optional(), + relatedPerson: relatedPersonSchema.nullable().optional(), eventSummary: boundedText(1_000), dateValue: boundedText(80).nullable(), datePrecision: z.enum(["day", "month", "year", "range", "unknown"]), extractionStatus: z.enum(["clear", "needs_clarification", "corrected"]), + scoreability: eventScoreabilitySchema.optional(), scoreable: z.boolean().optional(), // Optional only for rows written before durable correction lineage existed. correctsEvidenceIds: correctionEvidenceIdsSchema.optional(), diff --git a/frontend/src/lib/rectification-agent/contracts.ts b/frontend/src/lib/rectification-agent/contracts.ts new file mode 100644 index 00000000..840fe28d --- /dev/null +++ b/frontend/src/lib/rectification-agent/contracts.ts @@ -0,0 +1,194 @@ +import { z } from "zod"; +import { clockTimeSchema, evidenceDomainSchema, rectificationDeploymentModeSchema } from "../rectification-v4/contracts.ts"; + +const uuid = z.string().uuid(); +const hash = z.string().regex(/^[a-f0-9]{64}$/); +const nonblank = (max: number) => z.string().trim().min(1).max(max); + +export const rectificationDiagnosticSchema = z.enum([ + "leave_one_event_out", + "leave_one_domain_out", + "date_sensitivity", + "neighbor_stability", + "candidate_split", +]); +export type RectificationDiagnostic = z.infer; + +export const rectificationDecisionSchema = z.discriminatedUnion("action", [ + z.object({ + action: z.literal("ask_question"), + opportunityId: uuid, + narrativeFocus: z.array(z.enum(["latest_event", "candidate_change", "date_precision", "uncertainty"])).max(3), + }).strict(), + z.object({ action: z.literal("run_diagnostic"), diagnostic: rectificationDiagnosticSchema }).strict(), + z.object({ action: z.literal("offer_candidate_range"), snapshotId: uuid }).strict(), + z.object({ action: z.literal("stop_low_confidence"), reasonCodes: z.array(nonblank(80)).min(1).max(8) }).strict(), +]); +export type RectificationDecision = z.infer; + +export const questionOpportunitySchema = z.object({ + opportunityId: uuid, + kind: z.enum([ + "clarify_intake", + "clarify_event_subject", + "refine_event_date", + "pair_related_event", + "ask_new_event", + "resolve_event_conflict", + "disambiguate_candidate_split", + ]), + domain: evidenceDomainSchema, + targetEventId: uuid.nullable(), + prompt: nonblank(1_000), + reason: nonblank(240), + expectedInformationGain: z.number().finite().min(0).max(1), + dateSensitivity: z.number().finite().min(0).max(1), + candidateSplitRelevance: z.number().finite().min(0).max(1), + domainCoverageGain: z.number().finite().min(0).max(1), + recallEase: z.number().finite().min(0).max(1), + novelty: z.number().finite().min(0).max(1), + repetitionPenalty: z.number().finite().min(0).max(1), + privacyCost: z.number().finite().min(0).max(1), + utility: z.number().finite(), + active: z.boolean(), +}).strict(); +export type QuestionOpportunity = z.infer; + +export const eventDateSensitivitySchema = z.object({ + eventId: uuid, + declaredDateRange: z.object({ start: nonblank(10), end: nonblank(10), precision: nonblank(20) }).strict(), + sampleDates: z.array(nonblank(10)).min(1).max(12), + winnerRetentionRate: z.number().finite().min(0).max(1), + scoreVariance: z.number().finite().nonnegative(), + candidateClusterRetentionRate: z.number().finite().min(0).max(1), +}).strict(); + +export const candidateSplitSchema = z.object({ + leftCluster: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(), + rightCluster: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict(), + techniqueLayers: z.array(nonblank(80)).max(40), + eventIds: z.array(uuid).max(100), +}).strict(); + +export const diagnosticsSummarySchema = z.object({ + id: uuid, + caseId: uuid, + snapshotId: uuid, + primaryClusterRetentionRate: z.number().finite().min(0).max(1), + leaveOneEventOutRetentionRate: z.number().finite().min(0).max(1), + leaveOneDomainOutRetentionRate: z.number().finite().min(0).max(1), + dateSensitivityRetentionRate: z.number().finite().min(0).max(1), + neighborSupportMinutes: z.number().int().min(0).max(1_440), + primarySecondaryMarginPercent: z.number().finite().min(0).max(100), + clusterMassRatio: z.number().finite().min(0).max(1), + unstableEventIds: z.array(uuid).max(100), + mostDiscriminatingLayers: z.array(nonblank(80)).max(40), + eventDateSensitivity: z.array(eventDateSensitivitySchema).max(100), + candidateSplits: z.array(candidateSplitSchema).max(20), + calculationHash: hash, + createdAt: z.string().datetime({ offset: true }), +}).strict(); +export type DiagnosticsSummary = z.infer; + +export const candidateFeatureSnapshotSchema = z.object({ + id: uuid, + caseId: uuid, + calculationSpecHash: hash, + algorithmVersion: nonblank(120), + candidateCount: z.number().int().positive().max(1_440), + featureHash: hash, + features: z.array(z.object({ + time: clockTimeSchema, + ascendantDegree: z.number().finite().min(0).max(360).nullable(), + ascendantSignIndex: z.number().int().min(0).max(11).nullable(), + vargaAscendants: z.record(z.string(), z.number().int().min(0).max(11)), + arudhaSigns: z.object({ A7: z.number().int().min(0).max(11).nullable(), A10: z.number().int().min(0).max(11).nullable(), UL: z.number().int().min(0).max(11).nullable() }).strict(), + availableLayers: z.array(nonblank(80)).max(80), + blockedLayers: z.array(nonblank(80)).max(80), + fingerprints: z.record(z.string(), z.string()), + }).strict()).max(1_440), + createdAt: z.string().datetime({ offset: true }), +}).strict(); +export type CandidateFeatureSnapshot = z.infer; + +export const toolCallTraceSchema = z.object({ + tool: nonblank(120), + diagnostic: rectificationDiagnosticSchema.nullable(), + outcome: z.enum(["succeeded", "failed", "rejected"]), + durationMs: z.number().int().min(0).max(300_000), + errorCode: nonblank(120).nullable(), +}).strict(); +export type ToolCallTrace = z.infer; + +export const validatedDecisionSchema = z.object({ + decision: rectificationDecisionSchema, + mode: z.enum(["agent", "deterministic_fallback"]), + validationIssues: z.array(nonblank(120)).max(20), + selectedOpportunity: questionOpportunitySchema.nullable(), +}).strict(); +export type ValidatedDecision = z.infer; + +export const publicMessageSchema = z.object({ + acknowledgement: nonblank(1_000), + candidateUpdate: nonblank(1_000).nullable(), + limitation: nonblank(1_000).nullable(), + question: nonblank(1_000).nullable(), +}).strict(); +export type PublicMessage = z.infer; + +export const agentRunSchema = z.object({ + id: uuid, + caseId: uuid, + jobId: uuid, + caseVersion: z.number().int().nonnegative(), + modelId: nonblank(120).nullable(), + skillVersion: nonblank(120), + promptVersion: nonblank(120), + deploymentSha: nonblank(80).nullable(), + deploymentMode: rectificationDeploymentModeSchema, + decision: rectificationDecisionSchema.nullable(), + validatedDecision: validatedDecisionSchema, + toolCalls: z.array(toolCallTraceSchema).max(8), + fallbackReason: nonblank(120).nullable(), + inputTokenCount: z.number().int().nonnegative().nullable(), + outputTokenCount: z.number().int().nonnegative().nullable(), + latencyMs: z.number().int().nonnegative().max(300_000), + createdAt: z.string().datetime({ offset: true }), +}).strict(); +export type AgentRun = z.infer; + +export type RectificationDecisionValidation = Readonly<{ + valid: boolean; + decision: RectificationDecision | null; + issues: readonly string[]; +}>; + +export function validateRectificationDecision(input: Readonly<{ + decision: unknown; + caseId?: string; + snapshotId?: string | null; + opportunities: readonly QuestionOpportunity[]; + diagnostics: DiagnosticsSummary; + candidateRangeOfferAllowed: boolean; + usedDiagnostics?: readonly RectificationDiagnostic[]; + toolCallCount?: number; + maxToolCalls?: number; +}>): RectificationDecisionValidation { + const parsed = rectificationDecisionSchema.safeParse(input.decision); + if (!parsed.success) return { valid: false, decision: null, issues: ["decision_schema_invalid"] }; + const decision = parsed.data; + const issues: string[] = []; + if (input.caseId && input.diagnostics.caseId !== input.caseId) issues.push("diagnostics_case_mismatch"); + if ((input.toolCallCount ?? 0) > (input.maxToolCalls ?? 2)) issues.push("tool_call_budget_exceeded"); + if (decision.action === "ask_question") { + const opportunity = input.opportunities.find((item) => item.opportunityId === decision.opportunityId && item.active); + if (!opportunity) issues.push("opportunity_not_active"); + if (opportunity?.kind === "clarify_event_subject" && !opportunity.targetEventId) issues.push("subject_clarification_requires_target_event"); + } + if (decision.action === "offer_candidate_range") { + if (!input.candidateRangeOfferAllowed) issues.push("candidate_range_gate_failed"); + if (!input.snapshotId || decision.snapshotId !== input.snapshotId || input.diagnostics.snapshotId !== input.snapshotId) issues.push("snapshot_not_current"); + } + if (decision.action === "run_diagnostic" && input.usedDiagnostics?.includes(decision.diagnostic)) issues.push("diagnostic_already_run"); + return { valid: issues.length === 0, decision: issues.length === 0 ? decision : null, issues }; +} diff --git a/frontend/src/lib/rectification-agent/fallback-policy.ts b/frontend/src/lib/rectification-agent/fallback-policy.ts new file mode 100644 index 00000000..b6e873d4 --- /dev/null +++ b/frontend/src/lib/rectification-agent/fallback-policy.ts @@ -0,0 +1,13 @@ +import type { CandidateSnapshot } from "../rectification-v4/contracts.ts"; +import type { DiagnosticsSummary, QuestionOpportunity, RectificationDecision } from "./contracts.ts"; + +export function deterministicDecision(input: Readonly<{ + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary | null; + opportunities: readonly QuestionOpportunity[]; +}>): RectificationDecision { + if (input.snapshot?.canAcceptRange) return { action: "offer_candidate_range", snapshotId: input.snapshot.id }; + const top = input.opportunities.find((item) => item.active); + if (top) return { action: "ask_question", opportunityId: top.opportunityId, narrativeFocus: ["latest_event", ...(top.kind === "refine_event_date" ? ["date_precision" as const] : [])] }; + return { action: "stop_low_confidence", reasonCodes: input.diagnostics ? ["no_high_value_question", "diagnostics_not_stable"] : ["insufficient_scoreable_evidence"] }; +} diff --git a/frontend/src/lib/rectification-agent/feature-policy.ts b/frontend/src/lib/rectification-agent/feature-policy.ts new file mode 100644 index 00000000..4c59ec22 --- /dev/null +++ b/frontend/src/lib/rectification-agent/feature-policy.ts @@ -0,0 +1,39 @@ +import { createHash } from "node:crypto"; +import { rectificationDeploymentModeSchema, type RectificationDeploymentMode } from "../rectification-v4/contracts.ts"; + +type RectificationFeatureEnv = Readonly<{ + RECTIFICATION_AGENT_V5_ENABLED?: string; + RECTIFICATION_AGENT_V5_SHADOW?: string; + RECTIFICATION_AGENT_V5_CANARY_PERCENT?: string; +}>; + +function enabled(value: string | undefined): boolean { + return /^(1|true|yes|on)$/i.test(value?.trim() ?? ""); +} + +function percentage(value: string | undefined): number { + if (!value?.trim()) return 100; + const parsed = Number(value); + if (!Number.isFinite(parsed)) return 0; + return Math.max(0, Math.min(100, parsed)); +} + +export function rectificationCanaryBucket(stableId: string): number { + const prefix = createHash("sha256").update(stableId).digest().readUInt32BE(0); + return prefix / 0x1_0000_0000 * 100; +} + +export function selectRectificationDeploymentMode( + stableId: string, + env: RectificationFeatureEnv = { + RECTIFICATION_AGENT_V5_ENABLED: process.env.RECTIFICATION_AGENT_V5_ENABLED, + RECTIFICATION_AGENT_V5_SHADOW: process.env.RECTIFICATION_AGENT_V5_SHADOW, + RECTIFICATION_AGENT_V5_CANARY_PERCENT: process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT, + }, +): RectificationDeploymentMode { + if (!enabled(env.RECTIFICATION_AGENT_V5_ENABLED)) return "v4_legacy"; + if (rectificationCanaryBucket(stableId) >= percentage(env.RECTIFICATION_AGENT_V5_CANARY_PERCENT)) return "v4_legacy"; + return rectificationDeploymentModeSchema.parse( + enabled(env.RECTIFICATION_AGENT_V5_SHADOW) ? "v5_shadow" : "v5_agent", + ); +} diff --git a/frontend/src/lib/rectification-agent/opportunity-builder.ts b/frontend/src/lib/rectification-agent/opportunity-builder.ts new file mode 100644 index 00000000..8dd73b4f --- /dev/null +++ b/frontend/src/lib/rectification-agent/opportunity-builder.ts @@ -0,0 +1,113 @@ +import { createHash } from "node:crypto"; +import type { CandidateSnapshot, EvidenceDomain, LifeEventRevision, RectificationV4Turn } from "../rectification-v4/contracts.ts"; +import type { DiagnosticsSummary, QuestionOpportunity } from "./contracts.ts"; + +const domains: readonly EvidenceDomain[] = ["education", "relocation", "relationship", "career", "finance", "health_pressure"]; + +function stableUuid(value: string): string { + const hex = createHash("sha256").update(value).digest("hex").slice(0, 32).split(""); + hex[12] = "4"; + hex[16] = ((Number.parseInt(hex[16]!, 16) & 3) | 8).toString(16); + return `${hex.slice(0, 8).join("")}-${hex.slice(8, 12).join("")}-${hex.slice(12, 16).join("")}-${hex.slice(16, 20).join("")}-${hex.slice(20).join("")}`; +} + +const routingValue: Record = { + clarify_intake: .18, + resolve_event_conflict: .16, + clarify_event_subject: .14, + refine_event_date: .08, + pair_related_event: .05, + disambiguate_candidate_split: .04, + ask_new_event: 0, +}; + +function utility(value: Omit): number { + return Number(( + .35 * value.expectedInformationGain + .20 * value.dateSensitivity + .15 * value.candidateSplitRelevance + + .10 * value.domainCoverageGain + .10 * value.recallEase + .10 * value.novelty + + routingValue[value.kind] - value.repetitionPenalty - value.privacyCost + ).toFixed(6)); +} + +function opportunity(caseId: string, input: Omit): QuestionOpportunity { + const result = { ...input, opportunityId: stableUuid(`${caseId}:${input.kind}:${input.targetEventId ?? input.domain}:${input.prompt}`), utility: utility(input), active: true }; + return result; +} + +export function buildQuestionOpportunities(input: Readonly<{ + caseId: string; + events: readonly LifeEventRevision[]; + turns: readonly RectificationV4Turn[]; + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary | null; + retryTargetEventIds?: readonly string[]; +}>): readonly QuestionOpportunity[] { + const attempted = new Set(input.turns.flatMap((turn) => turn.questionTargetEventId ? [turn.questionTargetEventId] : [])); + const retryTargets = new Set(input.retryTargetEventIds ?? []); + const scoreableDomains = new Set(input.events.filter((event) => event.scoreability === "scoreable").map((event) => event.domain)); + const opportunities: QuestionOpportunity[] = []; + for (const eventId of retryTargets) { + const event = input.events.find((value) => value.eventId === eventId); + if (!event) continue; + opportunities.push(opportunity(input.caseId, { + kind: "resolve_event_conflict", domain: event.domain, targetEventId: event.eventId, + prompt: `你刚才补充的新经历已经另行保存。关于“${event.summary}”的时间仍没有确定;如果记不清,可以直接说不知道。`, + reason: "用户补充了另一件事,原事件的日期或主体仍待确认。", + expectedInformationGain: .85, dateSensitivity: .75, candidateSplitRelevance: .6, domainCoverageGain: 0, recallEase: .8, novelty: .7, repetitionPenalty: .15, privacyCost: .05, + })); + } + if (opportunities.length > 0) { + return opportunities.sort((left, right) => + right.utility - left.utility + || left.opportunityId.localeCompare(right.opportunityId)); + } + for (const event of input.events) { + if (retryTargets.has(event.eventId)) continue; + if ((event.scoreability === "pending_review" || event.subject === "other") && !attempted.has(event.eventId)) { + opportunities.push(opportunity(input.caseId, { + kind: "clarify_event_subject", domain: event.domain, targetEventId: event.eventId, + prompt: `你刚才提到“${event.summary}”,这件事主要发生在你本人,还是家人或伴侣身上?`, reason: "事件主体决定是否允许进入个人分盘评分。", + expectedInformationGain: .9, dateSensitivity: .2, candidateSplitRelevance: .3, domainCoverageGain: .2, recallEase: .95, novelty: .9, repetitionPenalty: 0, privacyCost: .05, + })); + } + if (event.scoreability === "scoreable" && event.dateRange.precision !== "day" && !attempted.has(event.eventId)) { + const sensitivity = input.diagnostics?.eventDateSensitivity.find((item) => item.eventId === event.eventId); + opportunities.push(opportunity(input.caseId, { + kind: "refine_event_date", domain: event.domain, targetEventId: event.eventId, + prompt: `关于“${event.summary}”,你还记得更具体的月份或日期吗?不确定也可以只说大概范围。`, reason: "日期采样显示这件事的时间精度可能影响候选排序。", + expectedInformationGain: sensitivity ? 1 - sensitivity.winnerRetentionRate : .72, + dateSensitivity: sensitivity ? 1 - sensitivity.candidateClusterRetentionRate : .7, + candidateSplitRelevance: .55, domainCoverageGain: 0, recallEase: .72, novelty: .8, repetitionPenalty: 0, privacyCost: .05, + })); + } + } + const split = input.diagnostics?.candidateSplits[0]; + if (split) { + const target = input.events.find((event) => split.eventIds.includes(event.eventId)); + opportunities.push(opportunity(input.caseId, { + kind: "disambiguate_candidate_split", domain: target?.domain ?? "other", targetEventId: target?.eventId ?? null, + prompt: target ? `围绕“${target.summary}”,当时最明显的转折是事情开始、达到高峰,还是正式结束?` : "剩余候选在同一事件的阶段上有差异:你记得当时更接近开始、达到高峰,还是正式结束吗?", + reason: `候选簇在 ${split.techniqueLayers.slice(0, 3).join("、") || "技术层"} 上出现可检验分歧。`, + expectedInformationGain: .88, dateSensitivity: .45, candidateSplitRelevance: .95, domainCoverageGain: 0, recallEase: .65, novelty: .9, repetitionPenalty: target && attempted.has(target.eventId) ? .35 : 0, privacyCost: .1, + })); + } + const missingDomain = domains.find((domain) => !scoreableDomains.has(domain)); + if (missingDomain) { + const prompts: Record = { + education: "你人生中有没有一次入学、毕业、考试或专业变化,时间大致在什么时候?", + relocation: "你有没有一次印象深刻的搬家、离乡或长期迁居?大致在什么时候?", + relationship: "你有没有一段关系正式开始、结束或进入婚姻的明确时间点?", + career: "你有没有一次入职、离职、升职、转行或创业的明确时间点?", + finance: "你有没有一次收入、投资、负债或资产状况明显改变的时间点?", + health_pressure: "你本人有没有一次住院、手术、事故或明显健康转折?大致在什么时候?", + family: "请补充一个家庭事件。", other: "请补充一个有明确时间的重要人生事件。", + }; + opportunities.push(opportunity(input.caseId, { + kind: "ask_new_event", domain: missingDomain, targetEventId: null, prompt: prompts[missingDomain], reason: "当前证据领域覆盖不足。", + expectedInformationGain: .7, dateSensitivity: .45, candidateSplitRelevance: .5, domainCoverageGain: 1, recallEase: .7, novelty: 1, repetitionPenalty: 0, privacyCost: missingDomain === "health_pressure" ? .2 : .08, + })); + } + return opportunities.sort((left, right) => + right.utility - left.utility + || left.opportunityId.localeCompare(right.opportunityId)); +} diff --git a/frontend/src/lib/rectification-agent/orchestrator.ts b/frontend/src/lib/rectification-agent/orchestrator.ts new file mode 100644 index 00000000..29e2085c --- /dev/null +++ b/frontend/src/lib/rectification-agent/orchestrator.ts @@ -0,0 +1,302 @@ +import { createHash, randomUUID } from "node:crypto"; +import type { RectificationV4CandidateEngine } from "../rectification-v4/candidate-engine.ts"; +import { buildCandidateClusters } from "../rectification-v4/candidate-clusters.ts"; +import type { CandidateSnapshot, RectificationV4Question } from "../rectification-v4/contracts.ts"; +import { evaluateDecisionGate } from "../rectification-v4/decision-gate.ts"; +import { reconcileV4Evidence } from "../rectification-v4/extraction.ts"; +import { evidenceSetHash } from "../rectification-v4/fingerprints.ts"; +import { latestEventRevisions, scoreableEvents } from "../rectification-v4/evidence-ledger.ts"; +import { projectLegacyV4Turn } from "../rectification-v4/legacy-projector.ts"; +import type { ClaimedRectificationV4Job } from "../rectification-v4/store.ts"; +import { deterministicDecision } from "./fallback-policy.ts"; +import { buildQuestionOpportunities } from "./opportunity-builder.ts"; +import { renderPublicTurn } from "./renderer-agent.ts"; +import { runBoundedReasoner } from "./reasoner-agent.ts"; +import { recordRectificationAgentTelemetry } from "./telemetry.ts"; +import { + candidateFeatureSnapshotSchema, + diagnosticsSummarySchema, + validateRectificationDecision, + type AgentRun, + type CandidateFeatureSnapshot, + type DiagnosticsSummary, + type PublicMessage, + type ValidatedDecision, +} from "./contracts.ts"; + +function hash(value: unknown): string { + return createHash("sha256").update(JSON.stringify(value)).digest("hex"); +} + +export async function processRectificationAgentTurn(input: Readonly<{ + claimed: ClaimedRectificationV4Job; + engine: RectificationV4CandidateEngine; + now: Date; + onPhase?: (phase: "extracting_evidence" | "scoring_candidates" | "checking_robustness" | "planning_question" | "reasoning" | "rendering") => Promise; +}>): Promise> { + const { claimed, now } = input; + await input.onPhase?.("extracting_evidence"); + const reconciliation = claimed.turn.answer ? reconcileV4Evidence({ + caseId: claimed.case.id, + answer: claimed.turn.answer, + sourceTurnId: claimed.turn.id, + asOfDate: now.toISOString().slice(0, 10), + existing: claimed.events, + targetEventId: claimed.turn.questionTargetEventId, + now, + }) : { revisions: [], pending: [], unansweredTargetEventId: null }; + const extracted = reconciliation.revisions; + const events = latestEventRevisions([...claimed.events, ...extracted]); + const scoreable = scoreableEvents(events); + const domains = new Set(scoreable.map((event) => event.domain)); + let snapshot: CandidateSnapshot | null = null; + let diagnostics: DiagnosticsSummary | null = null; + let featureSnapshot: CandidateFeatureSnapshot | null = null; + + if (scoreable.length >= 3 && domains.size >= 2) { + await input.onPhase?.("scoring_candidates"); + const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable }); + await input.onPhase?.("checking_robustness"); + const clusters = buildCandidateClusters(scored.candidates); + const robustness = { + neighborSupportMinutes: scored.robustness.neighborSupportMinutes, + leaveOneOutRetentionRate: scored.robustness.leaveOneOutRetentionRate, + dateSensitivityRetentionRate: scored.robustness.dateSensitivityRetentionRate, + calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash, + }; + const gate = evaluateDecisionGate({ + clusters, + robustness, + scoreableEventCount: scoreable.length, + scoreableDomainCount: domains.size, + }); + snapshot = { + id: scored.resultId, + caseId: claimed.case.id, + caseVersion: claimed.case.version, + evidenceSetHash: evidenceSetHash(events), + calculationSpecHash: claimed.case.calculationSpecHash, + algorithmVersion: scored.featureSnapshot.algorithm_version, + candidates: [...scored.candidates], + clusters: [...clusters], + robustness, + canConfirmExactMinute: false, + canAcceptRange: gate.canAcceptRange, + gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)], + createdAt: now.toISOString(), + }; + diagnostics = diagnosticsSummarySchema.parse({ + id: randomUUID(), + caseId: claimed.case.id, + snapshotId: snapshot.id, + primaryClusterRetentionRate: scored.diagnostics.primary_cluster_retention_rate, + leaveOneEventOutRetentionRate: scored.diagnostics.leave_one_event_out_retention_rate, + leaveOneDomainOutRetentionRate: scored.diagnostics.leave_one_domain_out_retention_rate, + dateSensitivityRetentionRate: scored.diagnostics.date_sensitivity_retention_rate, + neighborSupportMinutes: scored.diagnostics.neighbor_support_minutes, + primarySecondaryMarginPercent: scored.diagnostics.primary_secondary_margin_percent, + clusterMassRatio: scored.diagnostics.cluster_mass_ratio, + unstableEventIds: scored.diagnostics.unstable_event_ids, + mostDiscriminatingLayers: scored.diagnostics.most_discriminating_layers, + eventDateSensitivity: scored.diagnostics.event_date_sensitivity.map((item) => ({ + eventId: item.event_id, + declaredDateRange: item.declared_date_range, + sampleDates: item.sample_dates, + winnerRetentionRate: item.winner_retention_rate, + scoreVariance: item.score_variance, + candidateClusterRetentionRate: item.candidate_cluster_retention_rate, + })), + candidateSplits: scored.diagnostics.candidate_splits.map((item) => ({ + leftCluster: item.left_cluster, + rightCluster: item.right_cluster, + techniqueLayers: item.technique_layers, + eventIds: item.event_ids, + })), + calculationHash: hash(scored.diagnostics), + createdAt: now.toISOString(), + }); + featureSnapshot = candidateFeatureSnapshotSchema.parse({ + id: randomUUID(), + caseId: claimed.case.id, + calculationSpecHash: scored.featureSnapshot.calculation_spec_hash, + algorithmVersion: scored.featureSnapshot.algorithm_version, + candidateCount: scored.featureSnapshot.candidate_count, + featureHash: scored.featureSnapshot.feature_hash, + features: scored.featureSnapshot.features.map((item) => ({ + time: item.time, + ascendantDegree: item.ascendant_degree, + ascendantSignIndex: item.ascendant_sign_index, + vargaAscendants: item.varga_ascendants, + arudhaSigns: item.arudha_signs, + availableLayers: item.available_layers, + blockedLayers: item.blocked_layers, + fingerprints: item.fingerprints, + })), + createdAt: now.toISOString(), + }); + } + + const safeDiagnostics = diagnostics ?? diagnosticsSummarySchema.parse({ + id: randomUUID(), + caseId: claimed.case.id, + snapshotId: randomUUID(), + primaryClusterRetentionRate: 0, + leaveOneEventOutRetentionRate: 0, + leaveOneDomainOutRetentionRate: 0, + dateSensitivityRetentionRate: 0, + neighborSupportMinutes: 0, + primarySecondaryMarginPercent: 0, + clusterMassRatio: 0, + unstableEventIds: [], + mostDiscriminatingLayers: [], + eventDateSensitivity: [], + candidateSplits: [], + calculationHash: hash(events), + createdAt: now.toISOString(), + }); + + await input.onPhase?.("planning_question"); + const opportunities = buildQuestionOpportunities({ + caseId: claimed.case.id, + events, + turns: claimed.turns, + snapshot, + diagnostics, + retryTargetEventIds: reconciliation.unansweredTargetEventId ? [reconciliation.unansweredTargetEventId] : [], + }); + await input.onPhase?.("reasoning"); + const reasoned = await runBoundedReasoner({ + caseValue: claimed.case, + snapshot, + diagnostics: safeDiagnostics, + opportunities, + enabled: claimed.case.deploymentMode !== "v4_legacy", + }); + const rawDecision = reasoned.decision; + let validation = validateRectificationDecision({ + decision: rawDecision, + caseId: claimed.case.id, + snapshotId: snapshot?.id ?? null, + opportunities, + diagnostics: safeDiagnostics, + candidateRangeOfferAllowed: snapshot?.canAcceptRange ?? false, + toolCallCount: reasoned.toolCalls.length, + maxToolCalls: 1, + }); + let fallbackReason = reasoned.fallbackReason; + if (!validation.decision) { + recordRectificationAgentTelemetry({ + caseId: claimed.case.id, phase: "fallback", outcome: "rejected", + modelId: claimed.case.orchestrationModelId, toolName: null, + decisionAction: rawDecision.action, durationMs: reasoned.latencyMs, + errorCode: "policy_validator_rejected", deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null, + }); + validation = validateRectificationDecision({ + decision: deterministicDecision({ snapshot, diagnostics, opportunities }), + caseId: claimed.case.id, + snapshotId: snapshot?.id ?? null, + opportunities, + diagnostics: safeDiagnostics, + candidateRangeOfferAllowed: snapshot?.canAcceptRange ?? false, + }); + fallbackReason = `validator_rejected:${validation.issues.join(",") || "unknown"}`; + } + const finalDecision = validation.decision; + if (!finalDecision) throw new Error("rectification_v5_fallback_validation_failed"); + const selectedOpportunity = finalDecision.action === "ask_question" + ? opportunities.find((item) => item.opportunityId === finalDecision.opportunityId) ?? null + : null; + const validatedDecision: ValidatedDecision = { + decision: finalDecision, + mode: fallbackReason ? "deterministic_fallback" : reasoned.mode, + validationIssues: [...validation.issues], + selectedOpportunity, + }; + + await input.onPhase?.("rendering"); + const legacyProjection = projectLegacyV4Turn({ + events, + newEvents: extracted, + attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, + latestAnswer: claimed.turn.answer, + snapshot, + }); + const agentVisible = claimed.case.deploymentMode === "v5_agent"; + const publicMessage = agentVisible + ? await renderPublicTurn({ + caseValue: claimed.case, + latestAnswer: claimed.turn.answer, + acceptedEvents: extracted, + pendingEvidence: reconciliation.pending, + snapshot, + validated: validatedDecision, + }) + : legacyProjection.publicMessage; + const nextQuestion = agentVisible && selectedOpportunity ? { + id: randomUUID(), + domain: selectedOpportunity.domain, + targetEventId: selectedOpportunity.targetEventId, + prompt: selectedOpportunity.prompt, + recallCost: selectedOpportunity.privacyCost >= .2 + ? "high" as const + : selectedOpportunity.recallEase < .6 + ? "medium" as const + : "low" as const, + reason: selectedOpportunity.reason, + } : agentVisible ? null : legacyProjection.nextQuestion; + const status = agentVisible + ? finalDecision.action === "offer_candidate_range" + ? "range_ready" as const + : finalDecision.action === "stop_low_confidence" + ? "paused" as const + : "awaiting_answer" as const + : legacyProjection.status; + const phase = agentVisible + ? status === "awaiting_answer" ? "collecting_evidence" as const : "complete" as const + : legacyProjection.phase; + + const agentRun: AgentRun = { + id: randomUUID(), + caseId: claimed.case.id, + jobId: claimed.job.id, + caseVersion: claimed.case.version, + modelId: claimed.case.orchestrationModelId, + skillVersion: claimed.case.skillVersion, + promptVersion: claimed.case.promptVersion, + deploymentMode: claimed.case.deploymentMode, + deploymentSha: process.env.DEPLOYMENT_SHA?.trim() || null, + decision: rawDecision, + validatedDecision, + toolCalls: [...reasoned.toolCalls], + fallbackReason, + inputTokenCount: reasoned.inputTokenCount, + outputTokenCount: reasoned.outputTokenCount, + latencyMs: reasoned.latencyMs, + createdAt: now.toISOString(), + }; + return { + newEventRevisions: extracted, + pendingEvidence: [...reconciliation.pending], + snapshot, + diagnostics, + featureSnapshot, + validatedDecision, + publicMessage, + nextQuestion, + agentRun, + status, + phase, + }; +} diff --git a/frontend/src/lib/rectification-agent/reasoner-agent.ts b/frontend/src/lib/rectification-agent/reasoner-agent.ts new file mode 100644 index 00000000..18eec113 --- /dev/null +++ b/frontend/src/lib/rectification-agent/reasoner-agent.ts @@ -0,0 +1,177 @@ +import path from "node:path"; +import { Agent } from "@mastra/core/agent"; +import { createTool } from "@mastra/core/tools"; +import { z } from "zod"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import type { CandidateSnapshot, RectificationV4Case } from "../rectification-v4/contracts.ts"; +import { deterministicDecision } from "./fallback-policy.ts"; +import { recordRectificationAgentTelemetry } from "./telemetry.ts"; +import { + rectificationDecisionSchema, + rectificationDiagnosticSchema, + type DiagnosticsSummary, + type QuestionOpportunity, + type RectificationDecision, + type RectificationDiagnostic, + type ToolCallTrace, +} from "./contracts.ts"; + +const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); +type Usage = Readonly<{ inputTokens?: number; outputTokens?: number }>; +type GeneratedDecision = Readonly<{ object: unknown; totalUsage?: Usage | Promise }>; +export type RectificationReasonerGenerator = ( + prompt: string, + phase: "initial" | "after_diagnostic", +) => Promise; + +function diagnosticPayload(diagnostic: RectificationDiagnostic, summary: DiagnosticsSummary) { + switch (diagnostic) { + case "leave_one_event_out": return { retentionRate: summary.leaveOneEventOutRetentionRate, unstableEventIds: summary.unstableEventIds }; + case "leave_one_domain_out": return { retentionRate: summary.leaveOneDomainOutRetentionRate }; + case "date_sensitivity": return { retentionRate: summary.dateSensitivityRetentionRate, events: summary.eventDateSensitivity }; + case "neighbor_stability": return { supportMinutes: summary.neighborSupportMinutes, clusterMassRatio: summary.clusterMassRatio }; + case "candidate_split": return { marginPercent: summary.primarySecondaryMarginPercent, splits: summary.candidateSplits }; + } +} + +export async function runBoundedReasoner(input: Readonly<{ + caseValue: RectificationV4Case; + snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary; + opportunities: readonly QuestionOpportunity[]; + maxToolCalls?: number; + timeoutMs?: number; + enabled?: boolean; + generateDecision?: RectificationReasonerGenerator; +}>): Promise> { + const started = Date.now(); + const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; + const model = (input.caseValue.orchestrationModelId ? resolveLanguageModel(input.caseValue.orchestrationModelId) : null) ?? defaultLanguageModel(); + const modelId = model?.id ?? input.caseValue.orchestrationModelId; + const toolCalls: ToolCallTrace[] = []; + let inputTokenCount = 0; + let outputTokenCount = 0; + let usageObserved = false; + const fallback = (reason: string) => { + recordRectificationAgentTelemetry({ + caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId, + toolName: null, decisionAction: null, durationMs: Date.now() - started, + errorCode: reason, deploymentSha, + }); + return { + decision: deterministicDecision(input), mode: "deterministic_fallback" as const, + fallbackReason: reason, toolCalls: [...toolCalls], + inputTokenCount: usageObserved ? inputTokenCount : null, + outputTokenCount: usageObserved ? outputTokenCount : null, + latencyMs: Date.now() - started, + }; + }; + if (input.enabled === false) return fallback("deployment_mode_legacy"); + if (!model && !input.generateDecision) return fallback("reasoner_model_unavailable"); + + const maxToolCalls = input.maxToolCalls ?? 1; + const used = new Set(); + const readDiagnostic = async (diagnostic: RectificationDiagnostic) => { + const toolStarted = Date.now(); + if (used.size >= maxToolCalls || used.has(diagnostic)) { + const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "rejected" as const, durationMs: Date.now() - toolStarted, errorCode: "diagnostic_budget_exhausted" }; + toolCalls.push(trace); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "rejected", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: trace.errorCode, deploymentSha }); + throw new Error("diagnostic_budget_exhausted"); + } + used.add(diagnostic); + try { + const result = diagnosticPayload(diagnostic, input.diagnostics); + const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "succeeded" as const, durationMs: Date.now() - toolStarted, errorCode: null }; + toolCalls.push(trace); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "succeeded", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: null, deploymentSha }); + return result; + } catch (error) { + const trace = { tool: "run_rectification_diagnostics", diagnostic, outcome: "failed" as const, durationMs: Date.now() - toolStarted, errorCode: "diagnostic_read_failed" }; + toolCalls.push(trace); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "tool", outcome: "failed", modelId, toolName: trace.tool, decisionAction: "run_diagnostic", durationMs: trace.durationMs, errorCode: trace.errorCode, deploymentSha }); + throw error; + } + }; + const diagnosticsTool = createTool({ + id: "run_rectification_diagnostics", + description: "Read one server-owned diagnostic for the current rectification snapshot. Inputs cannot contain case data, dates, candidates, or scores.", + inputSchema: z.object({ diagnostic: rectificationDiagnosticSchema }).strict(), + outputSchema: z.object({ diagnostic: rectificationDiagnosticSchema, result: z.unknown() }).strict(), + execute: async ({ diagnostic }) => ({ diagnostic, result: await readDiagnostic(diagnostic) }), + }); + const agent = model ? new Agent({ + id: `rectification-v5-reasoner-${model.id}`, + name: "Bounded Birth Time Rectification Reasoner", + model: model.model, + skills: [skillPath], + tools: { run_rectification_diagnostics: diagnosticsTool }, + instructions: "Choose one server-owned action. Never create an event id, candidate, score, date, question, calculation input, or birth minute. Ask only by opportunityId. Candidate ranges may only use currentSnapshotId. You may request or call one diagnostic, then must return a final non-diagnostic action. Return strict structured output.", + }) : null; + const generate: RectificationReasonerGenerator = input.generateDecision ?? (async (prompt) => { + if (!agent) throw new Error("reasoner_model_unavailable"); + return agent.generate(prompt, { + abortSignal: AbortSignal.timeout(input.timeoutMs ?? 20_000), + maxSteps: maxToolCalls + 2, + structuredOutput: { schema: rectificationDecisionSchema, jsonPromptInjection: "inline" }, + }); + }); + const addUsage = async (result: GeneratedDecision) => { + if (!result.totalUsage) return; + const usage = await result.totalUsage; + inputTokenCount += Math.max(0, Math.trunc(usage.inputTokens ?? 0)); + outputTokenCount += Math.max(0, Math.trunc(usage.outputTokens ?? 0)); + usageObserved = true; + }; + const baseState = { + task: "Choose the next bounded rectification action.", + currentSnapshotId: input.snapshot?.id ?? null, + canOfferCandidateRange: input.snapshot?.canAcceptRange ?? false, + compactDiagnostics: { + primaryClusterRetentionRate: input.diagnostics.primaryClusterRetentionRate, + mostDiscriminatingLayers: input.diagnostics.mostDiscriminatingLayers, + }, + opportunities: input.opportunities.map(({ opportunityId, kind, targetEventId, utility, reason }) => ({ opportunityId, kind, targetEventId, utility, reason })), + }; + + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "started", modelId, toolName: null, decisionAction: null, durationMs: null, errorCode: null, deploymentSha }); + try { + const first = await generate(JSON.stringify(baseState), "initial"); + await addUsage(first); + let decision = rectificationDecisionSchema.parse(first.object); + if (decision.action === "run_diagnostic") { + const result = await readDiagnostic(decision.diagnostic); + const second = await generate(JSON.stringify({ + ...baseState, + requiredFinalAction: true, + diagnosticResult: { diagnostic: decision.diagnostic, result }, + }), "after_diagnostic"); + await addUsage(second); + decision = rectificationDecisionSchema.parse(second.object); + if (decision.action === "run_diagnostic") return fallback("reasoner_returned_nonfinal_diagnostic"); + } + const latencyMs = Date.now() - started; + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "succeeded", modelId, toolName: null, decisionAction: decision.action, durationMs: latencyMs, errorCode: null, deploymentSha }); + return { + decision, mode: "agent", fallbackReason: null, toolCalls, + inputTokenCount: usageObserved ? inputTokenCount : null, + outputTokenCount: usageObserved ? outputTokenCount : null, + latencyMs, + }; + } catch (error) { + const reason = error instanceof DOMException && error.name === "TimeoutError" ? "reasoner_timeout" + : error instanceof Error && error.message === "diagnostic_budget_exhausted" ? "diagnostic_budget_exhausted" + : error instanceof Error && error.message === "reasoner_model_unavailable" ? "reasoner_model_unavailable" + : "reasoner_failed"; + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "reasoner", outcome: "failed", modelId, toolName: null, decisionAction: null, durationMs: Date.now() - started, errorCode: reason, deploymentSha }); + return fallback(reason); + } +} diff --git a/frontend/src/lib/rectification-agent/renderer-agent.ts b/frontend/src/lib/rectification-agent/renderer-agent.ts new file mode 100644 index 00000000..553c779c --- /dev/null +++ b/frontend/src/lib/rectification-agent/renderer-agent.ts @@ -0,0 +1,71 @@ +import path from "node:path"; +import { Agent } from "@mastra/core/agent"; +import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; +import type { CandidateSnapshot, LifeEventRevision, PendingEvidence, RectificationV4Case } from "../rectification-v4/contracts.ts"; +import { publicMessageSchema, type PublicMessage, type ValidatedDecision } from "./contracts.ts"; +import { recordRectificationAgentTelemetry } from "./telemetry.ts"; + +const skillPath = process.env.RECTIFICATION_SKILL_PATH?.trim() || path.resolve(process.cwd(), "..", "skills", "birth-time-rectification"); +const agents = new Map(); +function agentFor(modelId: string | null): { id: string; agent: Agent } | null { + const selected = (modelId ? resolveLanguageModel(modelId) : null) ?? defaultLanguageModel(); + if (!selected) return null; + const cached = agents.get(selected.id); + if (cached) return { id: selected.id, agent: cached }; + const agent = new Agent({ + id: `rectification-v5-renderer-${selected.id}`, name: "Birth Time Rectification Response Renderer", model: selected.model, skills: [skillPath], + instructions: "Write concise natural Simplified Chinese. Acknowledge the latest experience, state uncertainty honestly, and never expose ids, scores, internal domains, representative minutes, model/tool details, or claim an exact birth minute. Return strict JSON only.", + }); + agents.set(selected.id, agent); + return { id: selected.id, agent }; +} + +function deterministic(input: { latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision }): PublicMessage { + const latest = input.acceptedEvents.at(-1); + const acknowledgement = latest + ? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。` + : input.pendingEvidence.length + ? "我保留了你刚才的原始描述;其中的日期或事件关系还不能安全进入评分。" + : input.latestAnswer + ? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。" + : "我会继续根据已确认的人生事件比较候选范围。"; + const primary = input.snapshot?.clusters[0]; + const candidateUpdate = primary ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` : null; + const limitation = input.validated.decision.action === "stop_low_confidence" ? "现有证据不足以安全缩小范围,我不会把不稳定结果包装成确定时间。" : null; + return { acknowledgement, candidateUpdate, limitation, question: input.validated.selectedOpportunity?.prompt ?? null }; +} + +export function enforceServerQuestion(value: unknown, question: string | null): PublicMessage { + return { ...publicMessageSchema.parse(value), question }; +} + +export async function renderPublicTurn(input: Readonly<{ + caseValue: RectificationV4Case; latestAnswer: string; acceptedEvents: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; validated: ValidatedDecision; timeoutMs?: number; +}>): Promise { + const started = Date.now(); + const deploymentSha = process.env.DEPLOYMENT_SHA?.trim() || null; + const fallback = deterministic(input); + const selected = agentFor(input.caseValue.narrationModelId); + if (!selected) { + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: input.caseValue.narrationModelId, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_model_unavailable", deploymentSha }); + return fallback; + } + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "started", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: null, errorCode: null, deploymentSha }); + try { + const result = await selected.agent.generate(JSON.stringify({ + task: "Render the public turn. The server-owned question must not be changed.", latestAnswer: input.latestAnswer, + acceptedEvents: input.acceptedEvents.slice(-3).map((event) => ({ summary: event.summary, date: event.dateRange.label, subject: event.subject })), + pendingEvidence: input.pendingEvidence.slice(-3).map((event) => ({ rawText: event.rawText, reasonCode: event.reasonCode })), + candidateRange: input.snapshot?.clusters[0] ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } : null, + action: input.validated.decision.action, exactQuestion: input.validated.selectedOpportunity?.prompt ?? null, + }), { abortSignal: AbortSignal.timeout(input.timeoutMs ?? 15_000), structuredOutput: { schema: publicMessageSchema, jsonPromptInjection: "inline" } }); + const message = enforceServerQuestion(result.object, input.validated.selectedOpportunity?.prompt ?? null); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: null, deploymentSha }); + return message; + } catch { + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "renderer", outcome: "failed", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_failed", deploymentSha }); + recordRectificationAgentTelemetry({ caseId: input.caseValue.id, phase: "fallback", outcome: "succeeded", modelId: selected.id, toolName: null, decisionAction: input.validated.decision.action, durationMs: Date.now() - started, errorCode: "renderer_failed", deploymentSha }); + return fallback; + } +} diff --git a/frontend/src/lib/rectification-agent/telemetry.ts b/frontend/src/lib/rectification-agent/telemetry.ts new file mode 100644 index 00000000..ae372731 --- /dev/null +++ b/frontend/src/lib/rectification-agent/telemetry.ts @@ -0,0 +1,32 @@ +import { z } from "zod"; + +const telemetryEventSchema = z.object({ + caseId: z.string().uuid().nullable(), + phase: z.enum(["reasoner", "renderer", "tool", "fallback"]), + outcome: z.enum(["started", "succeeded", "failed", "rejected"]), + modelId: z.string().trim().min(1).max(120).nullable(), + toolName: z.string().trim().min(1).max(120).nullable(), + decisionAction: z.string().trim().min(1).max(80).nullable(), + durationMs: z.number().int().min(0).max(300_000).nullable(), + errorCode: z.string().trim().min(1).max(120).nullable(), + deploymentSha: z.string().trim().min(1).max(80).nullable(), +}).strict(); + +export type RectificationAgentTelemetryEvent = z.infer; + +export function recordRectificationAgentTelemetry( + event: RectificationAgentTelemetryEvent, +): void { + const parsed = telemetryEventSchema.safeParse(event); + if (!parsed.success) return; + const line = JSON.stringify({ + ...parsed.data, + component: "rectification-agent", + at: new Date().toISOString(), + }); + if (parsed.data.outcome === "failed" || parsed.data.outcome === "rejected") { + console.warn(`[rectification-agent] ${line}`); + } else { + console.info(`[rectification-agent] ${line}`); + } +} diff --git a/frontend/src/lib/rectification-v4/candidate-engine.ts b/frontend/src/lib/rectification-v4/candidate-engine.ts index 62101c3c..499cfed3 100644 --- a/frontend/src/lib/rectification-v4/candidate-engine.ts +++ b/frontend/src/lib/rectification-v4/candidate-engine.ts @@ -2,21 +2,68 @@ import { z } from "zod"; import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "./contracts.ts"; import { rectificationV4AlgorithmVersion } from "./contracts.ts"; -const responseSchema = z.object({ - result_id: z.string().uuid(), +const uuid = z.string().uuid(); +const hash = z.string().regex(/^[a-f0-9]{64}$/); +const dateSensitivitySchema = z.object({ + event_id: uuid, + declared_date_range: z.object({ start: z.string(), end: z.string(), precision: z.string() }), + sample_dates: z.array(z.string()).min(1).max(12), + winner_retention_rate: z.number().min(0).max(1), + score_variance: z.number().nonnegative(), + candidate_cluster_retention_rate: z.number().min(0).max(1), +}).passthrough(); +const diagnosticsSchema = z.object({ + primary_cluster_retention_rate: z.number().min(0).max(1), + leave_one_event_out_retention_rate: z.number().min(0).max(1), + leave_one_domain_out_retention_rate: z.number().min(0).max(1), + date_sensitivity_retention_rate: z.number().min(0).max(1), + neighbor_support_minutes: z.number().int().nonnegative(), + primary_secondary_margin_percent: z.number().min(0).max(100), + cluster_mass_ratio: z.number().min(0).max(1), + unstable_event_ids: z.array(uuid), + most_discriminating_layers: z.array(z.string()), + event_date_sensitivity: z.array(dateSensitivitySchema), + candidate_splits: z.array(z.object({ + left_cluster: z.object({ start: z.string(), end: z.string() }), + right_cluster: z.object({ start: z.string(), end: z.string() }), + technique_layers: z.array(z.string()), + event_ids: z.array(uuid), + }).passthrough()), +}).passthrough(); +const featureSchema = z.object({ + calculation_spec_hash: hash, algorithm_version: z.literal(rectificationV4AlgorithmVersion), - calculation_spec_hash: z.string().regex(/^[a-f0-9]{64}$/), + candidate_count: z.number().int().positive(), + feature_hash: hash, + features: z.array(z.object({ + time: z.string(), + ascendant_degree: z.number().nullable(), + ascendant_sign_index: z.number().int().min(0).max(11).nullable(), + varga_ascendants: z.record(z.string(), z.number().int().min(0).max(11)), + arudha_signs: z.object({ A7: z.number().int().min(0).max(11).nullable(), A10: z.number().int().min(0).max(11).nullable(), UL: z.number().int().min(0).max(11).nullable() }), + available_layers: z.array(z.string()), blocked_layers: z.array(z.string()), + fingerprints: z.record(z.string(), z.string()), + }).passthrough()), +}).passthrough(); +const responseSchema = z.object({ + result_id: uuid, + algorithm_version: z.literal(rectificationV4AlgorithmVersion), + calculation_spec_hash: hash, candidate_scores: z.array(z.object({ - time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), - score: z.number().finite(), - supporting_event_ids: z.array(z.string().uuid()), - conflicting_event_ids: z.array(z.string().uuid()), + time: z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/), score: z.number().finite(), + supporting_event_ids: z.array(uuid), conflicting_event_ids: z.array(uuid), }).strict()).min(1).max(1_440), robustness: z.object({ neighbor_support_minutes: z.number().int().nonnegative(), - leave_one_out_retention_rate: z.number().finite().min(0).max(1), - date_sensitivity_retention_rate: z.number().finite().min(0).max(1), + leave_one_out_retention_rate: z.number().min(0).max(1), + leave_one_domain_out_retention_rate: z.number().min(0).max(1), + date_sensitivity_retention_rate: z.number().min(0).max(1), }).passthrough(), + diagnostics: diagnosticsSchema, + candidate_feature_snapshot: featureSchema, + event_contribution_matrix: z.record(z.string(), z.record(z.string(), z.object({ + points: z.number(), rule_ids: z.array(z.string()), technique_layers: z.array(z.string()), + }).passthrough())), missing_layers: z.array(z.string()), can_confirm_exact_minute: z.literal(false), }).passthrough(); @@ -25,11 +72,10 @@ export type CandidateEngineResult = Readonly<{ resultId: string; calculationSpecHash: string; candidates: readonly CandidateMinute[]; - robustness: { - readonly neighborSupportMinutes: number; - readonly leaveOneOutRetentionRate: number; - readonly dateSensitivityRetentionRate: number; - }; + robustness: { neighborSupportMinutes: number; leaveOneOutRetentionRate: number; leaveOneDomainOutRetentionRate: number; dateSensitivityRetentionRate: number }; + diagnostics: z.infer; + featureSnapshot: z.infer; + contributionMatrix: z.infer["event_contribution_matrix"]; missingLayers: readonly string[]; }>; @@ -37,54 +83,37 @@ export interface RectificationV4CandidateEngine { score(input: { readonly calculationSpec: CalculationSpec; readonly events: readonly LifeEventRevision[] }): Promise; } -export function createRectificationV4CandidateEngine(options: { - readonly apiBase: string; - readonly fetchImpl?: typeof fetch; -}): RectificationV4CandidateEngine { +export function createRectificationV4CandidateEngine(options: { readonly apiBase: string; readonly fetchImpl?: typeof fetch }): RectificationV4CandidateEngine { const fetchImpl = options.fetchImpl ?? fetch; - return { - async score({ calculationSpec, events }) { - const response = await fetchImpl(`${options.apiBase}/api/active_rectification_events_v4`, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - birth_date: calculationSpec.birthDate, - start_time: calculationSpec.candidateRange.start, - end_time: calculationSpec.candidateRange.end, - lat: calculationSpec.latitude, - lon: calculationSpec.longitude, - tz: calculationSpec.timezoneOffsetHours, - events: events.map((event) => ({ - id: event.eventId, - domain: event.domain, - event_kind: event.eventKind, - date_start: event.dateRange.start, - date_end: event.dateRange.end, - precision: event.dateRange.precision, - summary: event.summary, - })), - }), - signal: AbortSignal.timeout(5 * 60_000), - }); - const payload: unknown = await response.json(); - if (!response.ok) throw new Error(`rectification_v4_engine_${response.status}`); - const parsed = responseSchema.parse(payload); - return { - resultId: parsed.result_id, - calculationSpecHash: parsed.calculation_spec_hash, - candidates: parsed.candidate_scores.map((candidate) => ({ - time: candidate.time, - score: candidate.score, - supportingEventIds: candidate.supporting_event_ids, - conflictingEventIds: candidate.conflicting_event_ids, + return { async score({ calculationSpec, events }) { + const response = await fetchImpl(`${options.apiBase}/api/rectification/v5/score`, { + method: "POST", headers: { "content-type": "application/json" }, signal: AbortSignal.timeout(5 * 60_000), + body: JSON.stringify({ + birth_date: calculationSpec.birthDate, start_time: calculationSpec.candidateRange.start, end_time: calculationSpec.candidateRange.end, + lat: calculationSpec.latitude, lon: calculationSpec.longitude, tz: calculationSpec.timezoneOffsetHours, + events: events.map((event) => ({ + id: event.eventId, domain: event.domain, event_kind: event.eventKind, + date_start: event.dateRange.start, date_end: event.dateRange.end, precision: event.dateRange.precision, summary: event.summary, })), - robustness: { - neighborSupportMinutes: parsed.robustness.neighbor_support_minutes, - leaveOneOutRetentionRate: parsed.robustness.leave_one_out_retention_rate, - dateSensitivityRetentionRate: parsed.robustness.date_sensitivity_retention_rate, - }, - missingLayers: parsed.missing_layers, - }; - }, - }; + }), + }); + const payload: unknown = await response.json(); + if (!response.ok) throw new Error(`rectification_v5_engine_${response.status}`); + const parsed = responseSchema.parse(payload); + return { + resultId: parsed.result_id, + calculationSpecHash: parsed.calculation_spec_hash, + candidates: parsed.candidate_scores.map((candidate) => ({ time: candidate.time, score: candidate.score, supportingEventIds: candidate.supporting_event_ids, conflictingEventIds: candidate.conflicting_event_ids })), + robustness: { + neighborSupportMinutes: parsed.robustness.neighbor_support_minutes, + leaveOneOutRetentionRate: parsed.robustness.leave_one_out_retention_rate, + leaveOneDomainOutRetentionRate: parsed.robustness.leave_one_domain_out_retention_rate, + dateSensitivityRetentionRate: parsed.robustness.date_sensitivity_retention_rate, + }, + diagnostics: parsed.diagnostics, + featureSnapshot: parsed.candidate_feature_snapshot, + contributionMatrix: parsed.event_contribution_matrix, + missingLayers: parsed.missing_layers, + }; + }}; } diff --git a/frontend/src/lib/rectification-v4/case-service.ts b/frontend/src/lib/rectification-v4/case-service.ts index 8814aefc..886c63c9 100644 --- a/frontend/src/lib/rectification-v4/case-service.ts +++ b/frontend/src/lib/rectification-v4/case-service.ts @@ -5,9 +5,10 @@ import type { RectificationV4ApiResponse, RectificationV4Case, } from "./contracts.ts"; -import { rectificationV4Protocol } from "./contracts.ts"; +import { rectificationAgentV5Protocol, rectificationV4AlgorithmVersion, rectificationV4Protocol } from "./contracts.ts"; +import { selectRectificationDeploymentMode } from "../rectification-agent/feature-policy.ts"; import { calculationSpecHash, evidenceSetHash } from "./fingerprints.ts"; -import { openingQuestion } from "./question-planner.ts"; +import { openingQuestion } from "./opening-question.ts"; import type { RectificationV4Store } from "./store.ts"; export function createRectificationV4CaseService(store: RectificationV4Store, options: { readonly now?: () => Date } = {}) { @@ -29,10 +30,11 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op return { async createCase(input: { readonly userId: string; readonly actionId: string; readonly calculationSpec: CalculationSpec }) { const timestamp = now().toISOString(); + const deploymentMode = selectRectificationDeploymentMode(input.userId); const caseValue: RectificationV4Case = { id: randomUUID(), userId: input.userId, - protocol: rectificationV4Protocol, + protocol: deploymentMode === "v4_legacy" ? rectificationV4Protocol : rectificationAgentV5Protocol, version: 0, status: "awaiting_answer", phase: "collecting_evidence", @@ -41,6 +43,15 @@ export function createRectificationV4CaseService(store: RectificationV4Store, op evidenceSetHash: evidenceSetHash([]), currentQuestion: openingQuestion(input.calculationSpec.candidateRange), latestSnapshot: null, + orchestrationModelId: process.env.RECTIFICATION_ORCHESTRATION_MODEL_ID?.trim() || null, + narrationModelId: process.env.RECTIFICATION_NARRATION_MODEL_ID?.trim() || null, + skillVersion: "birth-time-rectification-v5", + promptVersion: "rectification-agent-v5-1", + algorithmVersion: rectificationV4AlgorithmVersion, + deploymentMode, + agentMode: "deterministic_fallback", + featureSnapshotId: null, + latestDiagnosticsId: null, acceptedRange: null, createdAt: timestamp, updatedAt: timestamp, diff --git a/frontend/src/lib/rectification-v4/contracts.ts b/frontend/src/lib/rectification-v4/contracts.ts index cf6a761b..4d18165c 100644 --- a/frontend/src/lib/rectification-v4/contracts.ts +++ b/frontend/src/lib/rectification-v4/contracts.ts @@ -1,7 +1,10 @@ import { z } from "zod"; export const rectificationV4Protocol = "rectification-evidence-v4" as const; -export const rectificationV4AlgorithmVersion = "rectification-v4-range-scoring-1" as const; +export const rectificationAgentV5Protocol = "rectification-evidence-v5" as const; +export const rectificationDeploymentModeSchema = z.enum(["v4_legacy", "v5_shadow", "v5_agent"]); +export type RectificationDeploymentMode = z.infer; +export const rectificationV4AlgorithmVersion = "rectification-v5-matrix-scoring-1" as const; export const rectificationV4CaseStatusSchema = z.enum([ "awaiting_answer", @@ -18,6 +21,8 @@ export const rectificationV4PhaseSchema = z.enum([ "scoring_candidates", "checking_robustness", "planning_question", + "reasoning", + "rendering", "complete", ]); export type RectificationV4Phase = z.infer; @@ -41,7 +46,10 @@ export const eventKindSchema = z.enum([ "relationship_end", "career_change", "finance_change", - "health_event", + "self_health_event", + "family_health_event", + "family_bereavement", + "relationship_change", "family_event", "other", ]); @@ -65,7 +73,13 @@ export const eventDateRangeSchema = z.object({ }); export type EventDateRange = z.infer; -export const scoreabilitySchema = z.enum(["scoreable", "context_only"]); +export const eventSubjectSchema = z.enum(["self", "family", "partner", "other"]); +export type EventSubject = z.infer; + +export const relatedPersonSchema = z.enum(["father", "mother", "grandparent", "sibling", "partner"]); +export type RelatedPerson = z.infer; + +export const scoreabilitySchema = z.enum(["scoreable", "context_only", "pending_review", "unsupported"]); export type Scoreability = z.infer; export const lifeEventRevisionSchema = z.object({ @@ -74,6 +88,8 @@ export const lifeEventRevisionSchema = z.object({ revision: z.number().int().positive(), domain: evidenceDomainSchema, eventKind: eventKindSchema, + subject: eventSubjectSchema, + relatedPerson: relatedPersonSchema.nullable(), summary: z.string().trim().min(1).max(1_000), rawText: z.string().trim().min(1).max(4_000), dateRange: eventDateRangeSchema, @@ -83,6 +99,19 @@ export const lifeEventRevisionSchema = z.object({ }).strict(); export type LifeEventRevision = z.infer; +export const pendingEvidenceSchema = z.object({ + id: z.string().uuid(), + caseId: z.string().uuid(), + turnId: z.string().uuid(), + rawText: z.string().trim().min(1).max(4_000), + reasonCode: z.enum(["date_unresolved", "event_unparsed"]), + targetEventId: z.string().uuid().nullable(), + resolvedEventId: z.string().uuid().nullable(), + createdAt: z.string().datetime({ offset: true }), + resolvedAt: z.string().datetime({ offset: true }).nullable(), +}).strict(); +export type PendingEvidence = z.infer; + export const calculationSpecSchema = z.object({ version: z.literal("rectification-calculation-spec-v4"), birthDate: calendarDateSchema, @@ -168,7 +197,7 @@ export type RectificationV4Turn = z.infer; export const rectificationV4CaseSchema = z.object({ id: z.string().uuid(), userId: z.string().uuid(), - protocol: z.literal(rectificationV4Protocol), + protocol: z.union([z.literal(rectificationV4Protocol), z.literal(rectificationAgentV5Protocol)]), version: z.number().int().nonnegative(), status: rectificationV4CaseStatusSchema, phase: rectificationV4PhaseSchema, @@ -177,6 +206,15 @@ export const rectificationV4CaseSchema = z.object({ evidenceSetHash: z.string().regex(/^[a-f0-9]{64}$/), currentQuestion: rectificationV4QuestionSchema.nullable(), latestSnapshot: candidateSnapshotSchema.nullable(), + orchestrationModelId: z.string().trim().min(1).max(120).nullable(), + narrationModelId: z.string().trim().min(1).max(120).nullable(), + skillVersion: z.string().trim().min(1).max(120), + promptVersion: z.string().trim().min(1).max(120), + algorithmVersion: z.string().trim().min(1).max(120), + deploymentMode: rectificationDeploymentModeSchema, + agentMode: z.enum(["agent", "deterministic_fallback"]), + featureSnapshotId: z.string().uuid().nullable(), + latestDiagnosticsId: z.string().uuid().nullable(), acceptedRange: z.object({ start: clockTimeSchema, end: clockTimeSchema }).strict().nullable(), createdAt: z.string().datetime({ offset: true }), updatedAt: z.string().datetime({ offset: true }), @@ -200,6 +238,8 @@ export const reviseEventRequestSchema = z.object({ expectedCaseVersion: z.number().int().nonnegative(), domain: evidenceDomainSchema, eventKind: eventKindSchema, + subject: eventSubjectSchema, + relatedPerson: relatedPersonSchema.nullable(), summary: z.string().trim().min(1).max(1_000), rawText: z.string().trim().min(1).max(4_000), dateRange: eventDateRangeSchema, @@ -246,7 +286,7 @@ export const rectificationV4HandoffStatusSchema = z.enum([ ]); export const rectificationV4HandoffSchema = z.object({ - protocol: z.literal(rectificationV4Protocol), + protocol: z.union([z.literal(rectificationV4Protocol), z.literal(rectificationAgentV5Protocol)]), caseId: z.string().uuid(), caseVersion: z.number().int().nonnegative(), question: z.string().trim().min(1).max(500), diff --git a/frontend/src/lib/rectification-v4/domain-scorers.ts b/frontend/src/lib/rectification-v4/domain-scorers.ts index c77fc702..60cbb7ac 100644 --- a/frontend/src/lib/rectification-v4/domain-scorers.ts +++ b/frontend/src/lib/rectification-v4/domain-scorers.ts @@ -10,24 +10,22 @@ export type DomainScorerPolicy = Readonly<{ export const domainScorerRegistry: Readonly> = { education: { domain: "education", defaultScoreability: "scoreable", supportedKinds: ["education_milestone"], techniqueLayers: ["D24", "vimshottari", "narayana"] }, relocation: { domain: "relocation", defaultScoreability: "scoreable", supportedKinds: ["relocation"], techniqueLayers: ["D4", "vimshottari", "narayana"] }, - relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_end"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] }, + relationship: { domain: "relationship", defaultScoreability: "scoreable", supportedKinds: ["relationship_start", "relationship_end", "relationship_change"], techniqueLayers: ["D9", "UL", "vimshottari", "narayana"] }, career: { domain: "career", defaultScoreability: "scoreable", supportedKinds: ["career_change"], techniqueLayers: ["D10", "A10", "vimshottari", "narayana"] }, finance: { domain: "finance", defaultScoreability: "scoreable", supportedKinds: ["finance_change"], techniqueLayers: ["D2", "D11", "vimshottari", "narayana"] }, - health_pressure: { domain: "health_pressure", defaultScoreability: "scoreable", supportedKinds: ["health_event"], techniqueLayers: ["D30", "vimshottari", "narayana"] }, - family: { domain: "family", defaultScoreability: "context_only", supportedKinds: ["family_event"], techniqueLayers: [] }, - other: { domain: "other", defaultScoreability: "context_only", supportedKinds: ["other"], techniqueLayers: [] }, + health_pressure: { domain: "health_pressure", defaultScoreability: "scoreable", supportedKinds: ["self_health_event"], techniqueLayers: ["D30", "vimshottari", "narayana"] }, + family: { domain: "family", defaultScoreability: "context_only", supportedKinds: ["family_health_event", "family_bereavement", "family_event"], techniqueLayers: [] }, + other: { domain: "other", defaultScoreability: "pending_review", supportedKinds: ["other"], techniqueLayers: [] }, }; export function scoreabilityFor(domain: EvidenceDomain): Scoreability { return domainScorerRegistry[domain].defaultScoreability; } -export function assertScorerSupports(event: Pick): void { +export function assertScorerSupports(event: Pick): void { const policy = domainScorerRegistry[event.domain]; - if (event.scoreability === "scoreable" && !policy.supportedKinds.includes(event.eventKind)) { - throw new Error("unsupported_event_kind_for_domain"); - } - if (event.scoreability === "scoreable" && policy.techniqueLayers.length === 0) { - throw new Error("domain_not_validated_for_scoring"); - } + if (event.scoreability !== "scoreable") return; + if (event.subject !== "self" && !(event.domain === "relationship" && event.subject === "partner")) throw new Error("non_self_event_not_scoreable"); + if (!policy.supportedKinds.includes(event.eventKind)) throw new Error("unsupported_event_kind_for_domain"); + if (policy.techniqueLayers.length === 0) throw new Error("domain_not_validated_for_scoring"); } diff --git a/frontend/src/lib/rectification-v4/extraction.ts b/frontend/src/lib/rectification-v4/extraction.ts index 217dfe81..e6ecb0c2 100644 --- a/frontend/src/lib/rectification-v4/extraction.ts +++ b/frontend/src/lib/rectification-v4/extraction.ts @@ -1,65 +1,180 @@ -import { extractLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts"; -import type { EventKind, EvidenceDomain, LifeEventRevision } from "./contracts.ts"; +import { randomUUID } from "node:crypto"; +import { extractLifeEventEvidence, type ExtractedLifeEventEvidence } from "../conversational-rectification/evidence-extractor.ts"; +import type { + EventKind, + EvidenceDomain, + EventSubject, + LifeEventRevision, + PendingEvidence, + RelatedPerson, + Scoreability, +} from "./contracts.ts"; import { dateRangeFromDeclared } from "./date-range.ts"; import { appendEventRevision, latestEventRevisions } from "./evidence-ledger.ts"; -function eventKind(domain: EvidenceDomain, summary: string): EventKind { - if (domain === "relationship") { - return /分手|离婚|结束|断联|分开|破裂/.test(summary) ? "relationship_end" : "relationship_start"; - } - switch (domain) { - case "education": return "education_milestone"; - case "relocation": return "relocation"; - case "career": return "career_change"; - case "finance": return "finance_change"; - case "health_pressure": return "health_event"; - case "family": return "family_event"; - case "other": return "other"; - } +const allowedKinds = new Set([ + "education_milestone", "relocation", "relationship_start", "relationship_end", "relationship_change", + "career_change", "finance_change", "self_health_event", "family_health_event", "family_bereavement", "family_event", "other", +]); +const missingEventSummary = "事件内容待补充"; + +function normalizeKind(domain: EvidenceDomain, value: string, summary: string): EventKind { + if (allowedKinds.has(value as EventKind)) return value as EventKind; + if (domain === "relationship") return /分手|离婚|结束|断联|分开|破裂/.test(summary) ? "relationship_end" : "relationship_start"; + return ({ education: "education_milestone", relocation: "relocation", career: "career_change", finance: "finance_change", health_pressure: "self_health_event", family: "family_event", other: "other" } as const)[domain]; } -export function extractV4EventRevisions(input: { +function pendingEvidence(input: { + caseId: string; + turnId: string; + rawText: string; + reasonCode: PendingEvidence["reasonCode"]; + targetEventId: string | null; + now?: Date; +}): PendingEvidence { + return { + id: randomUUID(), + caseId: input.caseId, + turnId: input.turnId, + rawText: input.rawText.trim(), + reasonCode: input.reasonCode, + targetEventId: input.targetEventId, + resolvedEventId: null, + createdAt: (input.now ?? new Date()).toISOString(), + resolvedAt: null, + }; +} + +function newRevision(event: ExtractedLifeEventEvidence, existing: readonly LifeEventRevision[], now?: Date): LifeEventRevision | null { + if (!event.dateValue || event.datePrecision === "unknown") return null; + const domain = event.domain as EvidenceDomain; + return appendEventRevision(existing, { + eventId: event.id, + domain, + eventKind: normalizeKind(domain, event.eventKind, event.eventSummary), + subject: event.subject as EventSubject, + relatedPerson: event.relatedPerson as RelatedPerson | null, + summary: event.eventSummary, + rawText: event.rawText, + dateRange: dateRangeFromDeclared(event.dateValue, event.datePrecision), + scoreability: event.scoreability as Scoreability, + }, { id: event.id, now }); +} + +function describesTarget(event: ExtractedLifeEventEvidence, target: LifeEventRevision): boolean { + if (event.eventSummary === missingEventSummary) return true; + const eventKind = normalizeKind(event.domain as EvidenceDomain, event.eventKind, event.eventSummary); + if (event.domain !== target.domain || eventKind !== target.eventKind) return false; + return event.rawText.includes(target.summary) + || event.eventSummary.includes(target.summary) + || target.summary.includes(event.eventSummary); +} + +function subjectRevision(answer: string, target: LifeEventRevision, existing: readonly LifeEventRevision[], now?: Date): LifeEventRevision | null { + const compact = answer.trim().replace(/[。!!,,;;\s]/g, ""); + let subject: EventSubject | null = null; + if (/^(我|本人|我本人|我自己|是我|发生在我身上)$/.test(compact)) subject = "self"; + else if (/^(家人|我的家人|父亲|母亲|爸爸|妈妈|祖父母|爷爷|奶奶|外公|外婆)$/.test(compact)) subject = "family"; + else if (/^(伴侣|配偶|对象|男友|女友|丈夫|妻子|老公|老婆)$/.test(compact)) subject = "partner"; + if (!subject) return null; + + const healthEvent = target.eventKind === "self_health_event" || target.eventKind === "family_health_event"; + const domain: EvidenceDomain = healthEvent ? (subject === "self" ? "health_pressure" : "family") : target.domain; + const eventKind: EventKind = healthEvent ? (subject === "self" ? "self_health_event" : "family_health_event") : target.eventKind; + const scoreability: Scoreability = subject === "self" && domain !== "family" && domain !== "other" ? "scoreable" : "context_only"; + const relatedPerson: RelatedPerson | null = subject === "partner" ? "partner" : subject === "family" ? target.relatedPerson : null; + return appendEventRevision(existing, { + eventId: target.eventId, + domain, + eventKind, + subject, + relatedPerson, + summary: target.summary, + rawText: answer, + dateRange: target.dateRange, + scoreability, + }, { now }); +} + +export type ReconciledV4Evidence = Readonly<{ + revisions: readonly LifeEventRevision[]; + pending: readonly PendingEvidence[]; + unansweredTargetEventId: string | null; +}>; + +export function reconcileV4Evidence(input: { + readonly caseId: string; readonly answer: string; readonly sourceTurnId: string; readonly asOfDate: string; readonly existing: readonly LifeEventRevision[]; readonly targetEventId?: string | null; readonly now?: Date; -}): readonly LifeEventRevision[] { - const extracted = extractLifeEventEvidence({ - rawText: input.answer, - sourceTurnId: input.sourceTurnId, - asOfDate: input.asOfDate, - }); - const target = input.targetEventId - ? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null - : null; - if (input.targetEventId) { - if (!target) throw new Error("rectification_v4_target_event_not_found"); - const event = extracted.find((value) => value.dateValue && value.datePrecision !== "unknown"); - if (!event?.dateValue || event.datePrecision === "unknown") return []; - const dateRange = dateRangeFromDeclared(event.dateValue, event.datePrecision); - if (dateRange.start > input.asOfDate) return []; - return [appendEventRevision(input.existing, { - eventId: target.eventId, - domain: target.domain, - eventKind: target.eventKind, - summary: target.summary, - rawText: input.answer, - dateRange, - scoreability: target.scoreability, - }, { id: event.id, now: input.now })]; +}): ReconciledV4Evidence { + const extracted = extractLifeEventEvidence({ rawText: input.answer, sourceTurnId: input.sourceTurnId, asOfDate: input.asOfDate }); + const target = input.targetEventId ? latestEventRevisions(input.existing).find((event) => event.eventId === input.targetEventId) ?? null : null; + if (input.targetEventId && !target) throw new Error("rectification_v4_target_event_not_found"); + + const revisions: LifeEventRevision[] = []; + let unresolvedReason: PendingEvidence["reasonCode"] | null = null; + let targetResolved = !target; + + if (target) { + const clarified = subjectRevision(input.answer, target, input.existing, input.now); + if (clarified) { + revisions.push(clarified); + targetResolved = true; + } else { + const targetAnswer = extracted.find((event) => event.dateValue && event.datePrecision !== "unknown" && describesTarget(event, target)); + if (targetAnswer?.dateValue && targetAnswer.datePrecision !== "unknown") { + const dateRange = dateRangeFromDeclared(targetAnswer.dateValue, targetAnswer.datePrecision); + if (dateRange.start <= input.asOfDate) { + revisions.push(appendEventRevision(input.existing, { + eventId: target.eventId, + domain: target.domain, + eventKind: target.eventKind, + subject: target.subject, + relatedPerson: target.relatedPerson, + summary: target.summary, + rawText: input.answer, + dateRange, + scoreability: target.scoreability, + }, { id: targetAnswer.id, now: input.now })); + targetResolved = true; + } + } + } } - return extracted.flatMap((event) => { - if (!event.dateValue || event.datePrecision === "unknown") return []; - const domain = event.domain as EvidenceDomain; - return [appendEventRevision(input.existing, { - eventId: event.id, - domain, - eventKind: eventKind(domain, event.eventSummary), - summary: event.eventSummary, - rawText: event.rawText, - dateRange: dateRangeFromDeclared(event.dateValue, event.datePrecision), - }, { id: event.id, now: input.now })]; - }); + + for (const event of extracted) { + if (revisions.some((revision) => revision.id === event.id)) continue; + const revision = newRevision(event, [...input.existing, ...revisions], input.now); + if (revision && revision.dateRange.start <= input.asOfDate) { + revisions.push(revision); + continue; + } + unresolvedReason = event.datePrecision === "unknown" ? "date_unresolved" : "event_unparsed"; + } + + if (extracted.length === 0) unresolvedReason = "event_unparsed"; + const pending = unresolvedReason ? [ + pendingEvidence({ + caseId: input.caseId, + turnId: input.sourceTurnId, + rawText: input.answer, + reasonCode: unresolvedReason, + targetEventId: target?.eventId ?? null, + now: input.now, + }), + ] : []; + + return { + revisions, + pending, + unansweredTargetEventId: target && !targetResolved ? target.eventId : null, + }; +} + +export function extractV4EventRevisions(input: Omit[0], "caseId"> & { readonly caseId?: string }): readonly LifeEventRevision[] { + return reconcileV4Evidence({ ...input, caseId: input.caseId ?? "00000000-0000-4000-8000-000000000000" }).revisions; } diff --git a/frontend/src/lib/rectification-v4/fingerprints.ts b/frontend/src/lib/rectification-v4/fingerprints.ts index cb5f00d7..2912313c 100644 --- a/frontend/src/lib/rectification-v4/fingerprints.ts +++ b/frontend/src/lib/rectification-v4/fingerprints.ts @@ -11,20 +11,22 @@ function canonical(value: unknown): unknown { return value; } -function hash(value: unknown): string { +export function rectificationFingerprint(value: unknown): string { return createHash("sha256").update(JSON.stringify(canonical(value))).digest("hex"); } export function calculationSpecHash(spec: CalculationSpec): string { - return hash(spec); + return rectificationFingerprint(spec); } export function evidenceSetHash(revisions: readonly LifeEventRevision[]): string { - return hash(latestEventRevisions(revisions).map((event) => ({ + return rectificationFingerprint(latestEventRevisions(revisions).map((event) => ({ eventId: event.eventId, revision: event.revision, domain: event.domain, eventKind: event.eventKind, + subject: event.subject, + relatedPerson: event.relatedPerson, dateRange: event.dateRange, scoreability: event.scoreability, }))); diff --git a/frontend/src/lib/rectification-v4/legacy-projector.ts b/frontend/src/lib/rectification-v4/legacy-projector.ts new file mode 100644 index 00000000..a959a231 --- /dev/null +++ b/frontend/src/lib/rectification-v4/legacy-projector.ts @@ -0,0 +1,70 @@ +import { randomUUID } from "node:crypto"; +import type { PublicMessage } from "../rectification-agent/contracts.ts"; +import type { CandidateSnapshot, LifeEventRevision, RectificationV4Question } from "./contracts.ts"; +import { scoreableEvents } from "./evidence-ledger.ts"; + +export function projectLegacyV4Question(input: Readonly<{ + events: readonly LifeEventRevision[]; + attemptedRefinementEventIds: readonly string[]; + latestAnswer: string; + snapshot: CandidateSnapshot | null; +}>): RectificationV4Question | null { + if (input.snapshot?.canAcceptRange) return null; + const attempted = new Set(input.attemptedRefinementEventIds); + const target = scoreableEvents(input.events) + .filter((event) => !["day", "month"].includes(event.dateRange.precision) && !attempted.has(event.eventId)) + .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || left.eventId.localeCompare(right.eventId))[0]; + if (target) return { + id: randomUUID(), + domain: target.domain, + targetEventId: target.eventId, + prompt: `你刚才提到的“${target.summary.slice(0, 120)}”很重要。你目前记得的时间是${target.dateRange.label};如果还能想起更具体的月份或日期,可以继续说,不确定也没关系。`, + recallCost: "medium", + reason: "V4 legacy projector:细化已有事件日期。", + }; + return { + id: randomUUID(), + domain: "other", + targetEventId: null, + prompt: input.latestAnswer + ? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。" + : "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。", + recallCost: "low", + reason: "V4 legacy projector:保持开放叙述。", + }; +} + +export function projectLegacyV4Turn(input: Readonly<{ + events: readonly LifeEventRevision[]; + newEvents: readonly LifeEventRevision[]; + attemptedRefinementEventIds: readonly string[]; + latestAnswer: string; + snapshot: CandidateSnapshot | null; +}>): Readonly<{ + nextQuestion: RectificationV4Question | null; + publicMessage: PublicMessage; + status: "awaiting_answer" | "range_ready"; + phase: "collecting_evidence" | "complete"; +}> { + const nextQuestion = projectLegacyV4Question(input); + const latest = input.newEvents.at(-1); + const primary = input.snapshot?.clusters[0]; + const rangeReady = Boolean(input.snapshot?.canAcceptRange && primary); + return { + nextQuestion, + publicMessage: { + acknowledgement: latest + ? `我记下了你提到的“${latest.summary}”,并保留了你给出的时间精度。` + : input.latestAnswer + ? "我保留了你刚才的原始描述;目前还没有足够明确的新日期可以直接进入评分。" + : "我会继续根据已确认的人生事件比较候选范围。", + candidateUpdate: primary + ? `目前较集中的候选仍是 ${primary.startTime}–${primary.endTime};这只是待验证范围,不代表其中某一分钟已被确认。` + : null, + limitation: null, + question: nextQuestion?.prompt ?? null, + }, + status: rangeReady ? "range_ready" : "awaiting_answer", + phase: rangeReady ? "complete" : "collecting_evidence", + }; +} diff --git a/frontend/src/lib/rectification-v4/memory-store.ts b/frontend/src/lib/rectification-v4/memory-store.ts index 9211bb54..aef2c457 100644 --- a/frontend/src/lib/rectification-v4/memory-store.ts +++ b/frontend/src/lib/rectification-v4/memory-store.ts @@ -1,5 +1,7 @@ +import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, PublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; import type { LifeEventRevision, + PendingEvidence, RectificationV4Case, RectificationV4Job, } from "./contracts.ts"; @@ -15,12 +17,24 @@ import { evidenceSetHash } from "./fingerprints.ts"; export function createRectificationV4MemoryStore(): RectificationV4Store & { readonly cases: Map; readonly jobs: Map; + readonly diagnostics: Map; + readonly featureSnapshots: Map; + readonly agentRuns: Map; + readonly publicMessages: Map; + readonly validatedDecisions: Map; + readonly pendingEvidence: Map; } { const cases = new Map(); const events = new Map(); const turns = new Map(); const jobs = new Map(); const actionResults = new Map(); + const diagnostics = new Map(); + const featureSnapshots = new Map(); + const agentRuns = new Map(); + const publicMessages = new Map(); + const validatedDecisions = new Map(); + const pendingEvidence = new Map(); function owned(userId: string, caseId: string): RectificationV4Case { const value = cases.get(caseId); @@ -31,6 +45,12 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { return { cases, jobs, + diagnostics, + featureSnapshots, + agentRuns, + publicMessages, + validatedDecisions, + pendingEvidence, async findActiveCase(userId) { return [...cases.values()].find((value) => value.userId === userId && value.status !== "abandoned" && value.acceptedRange === null) ?? null; @@ -206,11 +226,20 @@ export function createRectificationV4MemoryStore(): RectificationV4Store & { || current.calculationSpecHash !== input.calculationSpecHash) throw new RectificationV4StoreError("stale_job"); const nextEvents = [...(events.get(current.id) ?? []), ...input.newEventRevisions]; events.set(current.id, nextEvents); + if (input.diagnostics) diagnostics.set(input.diagnostics.id, input.diagnostics); + if (input.featureSnapshot) featureSnapshots.set(input.featureSnapshot.id, input.featureSnapshot); + agentRuns.set(input.agentRun.id, input.agentRun); + publicMessages.set(input.jobId, input.publicMessage); + validatedDecisions.set(input.jobId, input.validatedDecision); + for (const item of input.pendingEvidence) pendingEvidence.set(item.id, item); const updated: RectificationV4Case = { ...current, version: current.version + 1, evidenceSetHash: input.outputEvidenceSetHash, latestSnapshot: input.snapshot, + agentMode: input.validatedDecision.mode, + featureSnapshotId: input.featureSnapshot?.id ?? current.featureSnapshotId, + latestDiagnosticsId: input.diagnostics?.id ?? current.latestDiagnosticsId, currentQuestion: input.nextQuestion, status: input.status, phase: input.phase, diff --git a/frontend/src/lib/rectification-v4/opening-question.ts b/frontend/src/lib/rectification-v4/opening-question.ts new file mode 100644 index 00000000..01e6ccc7 --- /dev/null +++ b/frontend/src/lib/rectification-v4/opening-question.ts @@ -0,0 +1,16 @@ +import { randomUUID } from "node:crypto"; +import type { RectificationV4Question } from "./contracts.ts"; + +export function openingQuestion( + candidateRange: Readonly<{ start: string; end: string }>, + id?: string, +): RectificationV4Question { + return { + id: id ?? randomUUID(), + domain: "other", + targetEventId: null, + prompt: `我会先在 ${candidateRange.start}–${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`, + recallCost: "low", + reason: "首轮允许开放叙述,由后续系统根据真实经历选择高信息量问题。", + }; +} diff --git a/frontend/src/lib/rectification-v4/question-author.ts b/frontend/src/lib/rectification-v4/question-author.ts deleted file mode 100644 index e0e63ee6..00000000 --- a/frontend/src/lib/rectification-v4/question-author.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { randomUUID } from "node:crypto"; -import path from "node:path"; -import { Agent } from "@mastra/core/agent"; -import { z } from "zod"; -import { defaultLanguageModel, resolveLanguageModel } from "@/mastra/model"; -import { - evidenceDomainSchema, - type CandidateSnapshot, - type LifeEventRevision, - type RectificationV4Question, - type RectificationV4Turn, -} from "./contracts.ts"; -import { planNextQuestion } from "./question-planner.ts"; - -const outputSchema = z.object({ - domain: evidenceDomainSchema, - targetEventId: z.string().uuid().nullable(), - prompt: z.string().trim().min(1).max(1_000), - recallCost: z.enum(["low", "medium", "high"]), - reason: z.string().trim().min(1).max(240), -}).strict(); - -const jyotishSkillPath = process.env.JYOTISH_SKILL_PATH?.trim() - || path.resolve(process.cwd(), "..", "skills", "jyotish-vedic-astrology"); -const agents = new Map(); -const internalCopyPattern = /(?:候选分数|内部(?:领域|路由|状态)|评分权重|\b(?:education|relocation|relationship|career|finance|health_pressure|family|other)\b)/iu; - -function agentFor(modelId: string | null) { - const model = modelId ? resolveLanguageModel(modelId) : defaultLanguageModel(); - const selected = model ?? defaultLanguageModel(); - if (!selected) return null; - const cached = agents.get(selected.id); - if (cached) return cached; - const agent = new Agent({ - id: `rectification-v4-question-${selected.id}`, - name: "Rectification V4 Conversational Question Author", - model: selected.model, - skills: [jyotishSkillPath], - instructions: "Return only the requested JSON. Act as a birth-time rectification conversation partner, not a questionnaire. Respond to the user's latest concrete experience, then ask at most one natural open question that can materially improve evidence quality or distinguish the remaining candidate range. Choose the next evidence domain from context; never follow a fixed domain order. Never expose domain labels, event ids, scores, routing metadata, gate reasons, or implementation status in the visible prompt.", - }); - agents.set(selected.id, agent); - return agent; -} - -function latestByEvent(events: readonly LifeEventRevision[]) { - const latest = new Map(); - for (const event of events) { - const current = latest.get(event.eventId); - if (!current || current.revision < event.revision) latest.set(event.eventId, event); - } - return [...latest.values()]; -} - -export async function authorRectificationV4Question(input: Readonly<{ - modelId: string | null; - candidateRange: Readonly<{ start: string; end: string }>; - snapshot: CandidateSnapshot | null; - turns: readonly RectificationV4Turn[]; - events: readonly LifeEventRevision[]; - attemptedRefinementEventIds: readonly string[]; -}>): Promise { - const plannedQuestion = planNextQuestion({ - events: input.events, - attemptedRefinementEventIds: input.attemptedRefinementEventIds, - latestAnswer: input.turns.at(-1)?.answer, - }); - const fallback = () => plannedQuestion; - const agent = agentFor(input.modelId); - if (!agent) return fallback(); - - const events = latestByEvent(input.events); - const allowedTargets = new Map(events.map((event) => [event.eventId, event])); - const requiredContinuation = plannedQuestion.targetEventId - ? allowedTargets.get(plannedQuestion.targetEventId) ?? null - : null; - const recentTurns = input.turns.slice(-6).flatMap((turn) => [ - { role: "assistant", text: turn.question }, - ...(turn.answer ? [{ role: "user", text: turn.answer }] : []), - ]); - const prompt = JSON.stringify({ - task: "Write the next assistant message for an open-ended birth-time rectification conversation.", - constraints: [ - "First acknowledge or connect to the latest user experience; do not say merely that an answer is complete or recorded.", - "Ask zero or one question, never a checklist, form, domain menu, or fixed sequence.", - "When requiredContinuation is present, continue that exact event and ask naturally for a more precise month or date; do not switch to another event or domain.", - "When requiredContinuation is absent, choose the highest-information next question from context rather than following a domain order.", - "The visible prompt must not mention internal domains, ids, scores, weights, gates, processing phases, or that a model selected a route.", - "targetEventId must be null or one of allowedTargetEventIds.", - ], - candidateRange: input.candidateRange, - currentCandidateRange: input.snapshot?.clusters[0] - ? { start: input.snapshot.clusters[0].startTime, end: input.snapshot.clusters[0].endTime } - : null, - recentConversation: recentTurns, - existingEvidence: events.map((event) => ({ - eventId: event.eventId, - domain: event.domain, - summary: event.summary, - date: event.dateRange.label, - precision: event.dateRange.precision, - scoreability: event.scoreability, - })), - attemptedRefinementEventIds: input.attemptedRefinementEventIds, - requiredContinuation: requiredContinuation - ? { - eventId: requiredContinuation.eventId, - summary: requiredContinuation.summary, - currentDate: requiredContinuation.dateRange.label, - precision: requiredContinuation.dateRange.precision, - } - : null, - allowedTargetEventIds: [...allowedTargets.keys()], - allowedDomains: evidenceDomainSchema.options, - }); - - try { - const result = await agent.generate( - [{ role: "user", content: prompt }], - { - abortSignal: AbortSignal.timeout(35_000), - structuredOutput: { schema: outputSchema, jsonPromptInjection: "inline" }, - }, - ); - const parsed = outputSchema.safeParse(result.object ?? (result.text ? JSON.parse(result.text) : null)); - if (!parsed.success || internalCopyPattern.test(parsed.data.prompt)) return fallback(); - const target = parsed.data.targetEventId ? allowedTargets.get(parsed.data.targetEventId) : null; - return { - id: randomUUID(), - domain: target?.domain ?? parsed.data.domain, - targetEventId: target?.eventId ?? null, - prompt: parsed.data.prompt, - recallCost: parsed.data.recallCost, - reason: parsed.data.reason, - }; - } catch (error) { - console.warn("rectification_v4_question_author_failed", { - modelId: input.modelId, - errorName: error instanceof Error ? error.name : "UnknownError", - }); - return fallback(); - } -} diff --git a/frontend/src/lib/rectification-v4/question-planner.ts b/frontend/src/lib/rectification-v4/question-planner.ts deleted file mode 100644 index bda5d882..00000000 --- a/frontend/src/lib/rectification-v4/question-planner.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { randomUUID } from "node:crypto"; -import type { LifeEventRevision, RectificationV4Question } from "./contracts.ts"; -import { scoreableEvents } from "./evidence-ledger.ts"; - -function refinementQuestion(event: LifeEventRevision, id?: string): RectificationV4Question { - return { - id: id ?? randomUUID(), - domain: event.domain, - targetEventId: event.eventId, - prompt: `你刚才提到的“${event.summary.slice(0, 120)}”很重要。你目前记得的时间是${event.dateRange.label};如果还能想起更具体的月份或日期,可以继续说,不确定也没关系。`, - recallCost: "medium", - reason: "缩小已有事件的日期范围,用于检验候选范围对日期误差是否稳定。", - }; -} - -export function planNextQuestion(input: { - readonly events?: readonly LifeEventRevision[]; - readonly attemptedRefinementEventIds?: readonly string[]; - readonly latestAnswer?: string; - readonly id?: string; -}): RectificationV4Question { - const attempted = new Set(input.attemptedRefinementEventIds ?? []); - const target = scoreableEvents(input.events ?? []) - .filter((event) => !["day", "month"].includes(event.dateRange.precision) && !attempted.has(event.eventId)) - .sort((left, right) => right.createdAt.localeCompare(left.createdAt) || left.eventId.localeCompare(right.eventId))[0]; - if (target) return refinementQuestion(target, input.id); - - return { - id: input.id ?? randomUUID(), - domain: "other", - targetEventId: null, - prompt: input.latestAnswer - ? "我记下了这段经历。接下来请继续讲另一件你自己最确定、时间也比较清楚的人生变化;可以一次讲几件连续发生的事,我会顺着你的叙述继续核对。" - : "请从你自己最确定、时间也比较清楚的一段人生经历开始说。你可以一次讲几件连续发生的事,不需要按固定领域回答。", - recallCost: "low", - reason: "模型不可用时保持开放叙述,不退回固定领域问卷。", - }; -} - -export function openingQuestion( - candidateRange: Readonly<{ start: string; end: string }>, - id?: string, -): RectificationV4Question { - return { - id: id ?? randomUUID(), - domain: "other", - targetEventId: null, - prompt: `我会先在 ${candidateRange.start}–${candidateRange.end} 这个范围内核对,它还不是已确认的出生分钟。请从你自己最确定、时间也比较清楚的一段人生经历开始说;可以一次讲几件连续发生的事,不需要按固定领域回答。`, - recallCost: "low", - reason: "首轮允许开放叙述,由后续模型根据真实经历选择高信息量问题。", - }; -} diff --git a/frontend/src/lib/rectification-v4/store.ts b/frontend/src/lib/rectification-v4/store.ts index 00ce2db1..812d3dcc 100644 --- a/frontend/src/lib/rectification-v4/store.ts +++ b/frontend/src/lib/rectification-v4/store.ts @@ -1,6 +1,8 @@ +import type { AgentRun, CandidateFeatureSnapshot, DiagnosticsSummary, PublicMessage, ValidatedDecision } from "../rectification-agent/contracts.ts"; import type { CandidateSnapshot, LifeEventRevision, + PendingEvidence, RectificationV4Case, RectificationV4Job, RectificationV4Phase, @@ -26,7 +28,13 @@ export type CompleteRectificationV4JobInput = Readonly<{ outputEvidenceSetHash: string; calculationSpecHash: string; newEventRevisions: readonly LifeEventRevision[]; + pendingEvidence: readonly PendingEvidence[]; snapshot: CandidateSnapshot | null; + diagnostics: DiagnosticsSummary | null; + featureSnapshot: CandidateFeatureSnapshot | null; + validatedDecision: ValidatedDecision; + publicMessage: PublicMessage; + agentRun: AgentRun; nextQuestion: RectificationV4Question | null; status: RectificationV4Case["status"]; phase: RectificationV4Phase; diff --git a/frontend/src/lib/rectification-v4/supabase-store.ts b/frontend/src/lib/rectification-v4/supabase-store.ts index 518c764e..045bd25e 100644 --- a/frontend/src/lib/rectification-v4/supabase-store.ts +++ b/frontend/src/lib/rectification-v4/supabase-store.ts @@ -17,7 +17,7 @@ import type { RectificationV4Store, } from "./store.ts"; import { RectificationV4StoreError } from "./store.ts"; -import { evidenceSetHash } from "./fingerprints.ts"; +import { evidenceSetHash, rectificationFingerprint } from "./fingerprints.ts"; type Row = Record; @@ -70,6 +70,15 @@ function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): Rectific evidenceSetHash: row.evidence_set_hash, currentQuestion: row.current_question, latestSnapshot, + orchestrationModelId: row.orchestration_model_id ? String(row.orchestration_model_id) : null, + narrationModelId: row.narration_model_id ? String(row.narration_model_id) : null, + skillVersion: row.skill_version ? String(row.skill_version) : "birth-time-rectification-v5", + promptVersion: row.prompt_version ? String(row.prompt_version) : "rectification-agent-v5-1", + algorithmVersion: row.algorithm_version ? String(row.algorithm_version) : "rectification-v5-matrix-scoring-1", + deploymentMode: row.deployment_mode === "v5_agent" || row.deployment_mode === "v5_shadow" ? row.deployment_mode : "v4_legacy", + agentMode: row.agent_mode === "agent" ? "agent" : "deterministic_fallback", + featureSnapshotId: row.feature_snapshot_id ? String(row.feature_snapshot_id) : null, + latestDiagnosticsId: row.latest_diagnostics_id ? String(row.latest_diagnostics_id) : null, acceptedRange: row.accepted_range_start && row.accepted_range_end ? { start: row.accepted_range_start, end: row.accepted_range_end } : null, @@ -85,6 +94,8 @@ function eventRevision(row: Row): LifeEventRevision { revision: Number(row.revision), domain: row.domain, eventKind: row.event_kind, + subject: row.subject ?? "self", + relatedPerson: row.related_person ?? null, summary: row.summary, rawText: row.raw_text, dateRange: { @@ -189,7 +200,7 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re loadEvents: loadEventsByCase, loadTurns: loadTurnsByCase, async createCase(input) { - const id = String(await rpc("create_birth_time_rectification_v4_case", { + const id = String(await rpc("create_birth_time_rectification_v5_case", { p_user_id: input.case.userId, p_case_id: input.case.id, p_action_id: input.actionId, @@ -199,6 +210,12 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re p_calculation_spec_hash: input.case.calculationSpecHash, p_evidence_set_hash: input.case.evidenceSetHash, p_current_question: input.case.currentQuestion, + p_orchestration_model_id: input.case.orchestrationModelId, + p_narration_model_id: input.case.narrationModelId, + p_skill_version: input.case.skillVersion, + p_prompt_version: input.case.promptVersion, + p_algorithm_version: input.case.algorithmVersion, + p_deployment_mode: input.case.deploymentMode, p_now: input.case.createdAt, })); const value = await loadCaseById(input.case.userId, id); @@ -300,15 +317,23 @@ export function createRectificationV4SupabaseStore(supabase: SupabaseClient): Re async completeJob(input: CompleteRectificationV4JobInput, now) { const jobRow = await loadJobRow(input.jobId); if (!jobRow) throw new RectificationV4StoreError("not_found"); - await rpc("complete_birth_time_rectification_v4_job", { + const completionPayload = { ...input, workerId: undefined }; + await rpc("complete_birth_time_rectification_v5_job", { p_worker_id: input.workerId, p_job_id: input.jobId, p_expected_case_version: input.expectedCaseVersion, p_input_evidence_set_hash: input.inputEvidenceSetHash, p_output_evidence_set_hash: input.outputEvidenceSetHash, p_calculation_spec_hash: input.calculationSpecHash, + p_completion_payload_hash: rectificationFingerprint(completionPayload), p_event_revisions: input.newEventRevisions, + p_pending_evidence: input.pendingEvidence, p_snapshot: input.snapshot, + p_diagnostics: input.diagnostics, + p_feature_snapshot: input.featureSnapshot, + p_validated_decision: input.validatedDecision, + p_public_message: input.publicMessage, + p_agent_run: input.agentRun, p_next_question: input.nextQuestion, p_status: input.status, p_phase: input.phase, diff --git a/frontend/src/lib/rectification-v4/worker.ts b/frontend/src/lib/rectification-v4/worker.ts index f064f9a4..1e100275 100644 --- a/frontend/src/lib/rectification-v4/worker.ts +++ b/frontend/src/lib/rectification-v4/worker.ts @@ -1,143 +1,55 @@ import { randomUUID } from "node:crypto"; -import type { - CandidateSnapshot, - LifeEventRevision, - RectificationV4Case, - RectificationV4Question, -} from "./contracts.ts"; -import { rectificationV4AlgorithmVersion } from "./contracts.ts"; +import { processRectificationAgentTurn } from "../rectification-agent/orchestrator.ts"; import type { RectificationV4CandidateEngine } from "./candidate-engine.ts"; -import { buildCandidateClusters } from "./candidate-clusters.ts"; -import { evaluateDecisionGate } from "./decision-gate.ts"; +import type { RectificationV4Question } from "./contracts.ts"; import { evidenceSetHash } from "./fingerprints.ts"; -import { extractV4EventRevisions } from "./extraction.ts"; -import { latestEventRevisions, scoreableEvents } from "./evidence-ledger.ts"; -import { planNextQuestion } from "./question-planner.ts"; -import type { ClaimedRectificationV4Job, RectificationV4Store } from "./store.ts"; +import type { RectificationV4Store } from "./store.ts"; export function createRectificationV4Worker(input: { readonly store: RectificationV4Store; readonly engine: RectificationV4CandidateEngine; readonly workerId?: string; readonly now?: () => Date; - readonly questionAuthor?: (context: Readonly<{ - modelId: string | null; - candidateRange: RectificationV4Case["calculationSpec"]["candidateRange"]; - snapshot: CandidateSnapshot | null; - turns: ClaimedRectificationV4Job["turns"]; - events: readonly LifeEventRevision[]; - attemptedRefinementEventIds: readonly string[]; - }>) => Promise; }) { const workerId = input.workerId ?? randomUUID(); const now = input.now ?? (() => new Date()); - - return { - async runOnce(): Promise { - const claimed = await input.store.claimNextJob(workerId, now().toISOString()); - if (!claimed) return false; - try { - const extracted = claimed.turn.answer - ? extractV4EventRevisions({ - answer: claimed.turn.answer, - sourceTurnId: claimed.turn.id, - asOfDate: now().toISOString().slice(0, 10), - existing: claimed.events, - targetEventId: claimed.turn.questionTargetEventId, - now: now(), - }) - : []; - const events = latestEventRevisions([...claimed.events, ...extracted]); - await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "scoring_candidates", now: now().toISOString() }); - const scoreable = scoreableEvents(events); - const domains = new Set(scoreable.map((event) => event.domain)); - let snapshot: CandidateSnapshot | null = null; - if (scoreable.length >= 3 && domains.size >= 2) { - const scored = await input.engine.score({ calculationSpec: claimed.case.calculationSpec, events: scoreable }); - await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "checking_robustness", now: now().toISOString() }); - const clusters = buildCandidateClusters(scored.candidates); - const robustness = { - ...scored.robustness, - calculationSpecHashMatched: scored.calculationSpecHash === claimed.case.calculationSpecHash, - }; - const gate = evaluateDecisionGate({ - clusters, - robustness, - scoreableEventCount: scoreable.length, - scoreableDomainCount: domains.size, - }); - snapshot = { - id: scored.resultId, - caseId: claimed.case.id, - caseVersion: claimed.case.version, - evidenceSetHash: evidenceSetHash(events), - calculationSpecHash: claimed.case.calculationSpecHash, - algorithmVersion: rectificationV4AlgorithmVersion, - candidates: [...scored.candidates], - clusters: [...clusters], - robustness, - canConfirmExactMinute: false, - canAcceptRange: gate.canAcceptRange, - gateReasons: [...gate.reasons, ...scored.missingLayers.map((layer) => `missing_layer:${layer}`)], - createdAt: now().toISOString(), - }; - } - await input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase: "planning_question", now: now().toISOString() }); - let nextQuestion: RectificationV4Question | null = null; - if (!snapshot?.canAcceptRange) { - const plannedQuestion = planNextQuestion({ - events, - attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, - latestAnswer: claimed.turn.answer, - }); - const authoredQuestion = input.questionAuthor - ? await input.questionAuthor({ - modelId: claimed.turn.modelId, - candidateRange: claimed.case.calculationSpec.candidateRange, - snapshot, - turns: claimed.turns, - events, - attemptedRefinementEventIds: claimed.attemptedRefinementEventIds, - }) - : plannedQuestion; - nextQuestion = plannedQuestion.targetEventId !== null - && (authoredQuestion.targetEventId !== plannedQuestion.targetEventId - || authoredQuestion.domain !== plannedQuestion.domain) - ? plannedQuestion - : authoredQuestion; - } - await input.store.completeJob({ - workerId, - jobId: claimed.job.id, - expectedCaseVersion: claimed.case.version, - inputEvidenceSetHash: claimed.case.evidenceSetHash, - outputEvidenceSetHash: evidenceSetHash(events), - calculationSpecHash: claimed.case.calculationSpecHash, - newEventRevisions: extracted, - snapshot, - nextQuestion, - status: snapshot?.canAcceptRange ? "range_ready" : "awaiting_answer", - phase: snapshot?.canAcceptRange ? "complete" : "collecting_evidence", - }, now().toISOString()); - return true; - } catch (error) { - await input.store.failJob({ - workerId, - jobId: claimed.job.id, - expectedCaseVersion: claimed.case.version, - errorCode: error instanceof Error ? error.message.slice(0, 120) : "unknown_worker_error", - restoreQuestion: claimed.turn.questionId && claimed.turn.questionDomain ? { - id: claimed.turn.questionId, - domain: claimed.turn.questionDomain, - targetEventId: claimed.turn.questionTargetEventId, - prompt: claimed.turn.question, - recallCost: "low", - reason: "上一轮处理没有完成,请重新提交这段经历。", - } : null, - now: now().toISOString(), - }); - return true; - } - }, - }; + return { async runOnce(): Promise { + const claimed = await input.store.claimNextJob(workerId, now().toISOString()); + if (!claimed) return false; + try { + const result = await processRectificationAgentTurn({ + claimed, engine: input.engine, now: now(), + onPhase: (phase) => input.store.updateJobPhase({ workerId, jobId: claimed.job.id, phase, now: now().toISOString() }), + }); + await input.store.completeJob({ + workerId, jobId: claimed.job.id, expectedCaseVersion: claimed.case.version, + inputEvidenceSetHash: claimed.case.evidenceSetHash, + outputEvidenceSetHash: evidenceSetHash([...claimed.events, ...result.newEventRevisions]), + calculationSpecHash: claimed.case.calculationSpecHash, + newEventRevisions: result.newEventRevisions, + pendingEvidence: result.pendingEvidence, + snapshot: result.snapshot, + diagnostics: result.diagnostics, + featureSnapshot: result.featureSnapshot, + validatedDecision: result.validatedDecision, + publicMessage: result.publicMessage, + agentRun: result.agentRun, + nextQuestion: result.nextQuestion, + status: result.status, + phase: result.phase, + }, now().toISOString()); + return true; + } catch (error) { + const restoreQuestion: RectificationV4Question | null = claimed.turn.questionId && claimed.turn.questionDomain ? { + id: claimed.turn.questionId, domain: claimed.turn.questionDomain, targetEventId: claimed.turn.questionTargetEventId, + prompt: claimed.turn.question, recallCost: "low", reason: "上一轮处理没有完成,请重新提交这段经历。", + } : null; + await input.store.failJob({ + workerId, jobId: claimed.job.id, expectedCaseVersion: claimed.case.version, + errorCode: error instanceof Error ? error.message.slice(0, 120) : "unknown_worker_error", + restoreQuestion, now: now().toISOString(), + }); + return true; + } + }}; } diff --git a/frontend/supabase/migrations/20260728010000_conversational_event_semantics.sql b/frontend/supabase/migrations/20260728010000_conversational_event_semantics.sql new file mode 100644 index 00000000..6b357b92 --- /dev/null +++ b/frontend/supabase/migrations/20260728010000_conversational_event_semantics.sql @@ -0,0 +1,132 @@ +begin; + +alter table public.birth_time_rectification_event_evidence + add column if not exists event_kind text check ( + event_kind is null or ( + public.conversational_rectification_text_utf16_length(event_kind) between 1 and 120 + and public.conversational_rectification_text_is_nonblank(event_kind) + ) + ), + add column if not exists subject text check ( + subject is null or subject in ('self', 'family', 'partner', 'other') + ), + add column if not exists related_person text check ( + related_person is null or related_person in ( + 'father', 'mother', 'grandparent', 'sibling', 'partner' + ) + ), + add column if not exists scoreability text check ( + scoreability is null or scoreability in ( + 'scoreable', 'context_only', 'pending_review', 'unsupported' + ) + ); + +-- Keep old rows valid while persisting the richer optional semantics emitted by +-- the application. Patch the deployed function bodies because these RPCs were +-- created by earlier immutable migrations. +do $migration$ +declare + v_definition text; + v_updated text; + v_signature text; +begin + select pg_catalog.pg_get_functiondef( + 'public.conversational_rectification_valid_life_event_evidence(jsonb)'::regprocedure + ) into v_definition; + v_updated := pg_catalog.replace( + v_definition, + '''datePrecision'', ''extractionStatus'', ''scoreable'', ''correctsEvidenceIds''', + '''datePrecision'', ''extractionStatus'', ''eventKind'', ''subject'', ''relatedPerson'', ''scoreability'', ''scoreable'', ''correctsEvidenceIds''' + ); + v_updated := pg_catalog.replace( + v_updated, + $$ or pg_catalog.jsonb_typeof(p_value -> 'eventSummary') is distinct from 'string'$$, + $$ or ( + p_value ? 'eventKind' + and ( + pg_catalog.jsonb_typeof(p_value -> 'eventKind') is distinct from 'string' + or public.conversational_rectification_text_utf16_length( + p_value ->> 'eventKind' + ) not between 1 and 120 + or public.conversational_rectification_text_is_nonblank( + p_value ->> 'eventKind' + ) is not true + ) + ) + or ( + p_value ? 'subject' + and ( + pg_catalog.jsonb_typeof(p_value -> 'subject') is distinct from 'string' + or p_value ->> 'subject' not in ('self', 'family', 'partner', 'other') + ) + ) + or ( + p_value ? 'relatedPerson' + and p_value -> 'relatedPerson' <> 'null'::jsonb + and ( + pg_catalog.jsonb_typeof(p_value -> 'relatedPerson') is distinct from 'string' + or p_value ->> 'relatedPerson' not in ( + 'father', 'mother', 'grandparent', 'sibling', 'partner' + ) + ) + ) + or ( + p_value ? 'scoreability' + and ( + pg_catalog.jsonb_typeof(p_value -> 'scoreability') is distinct from 'string' + or p_value ->> 'scoreability' not in ( + 'scoreable', 'context_only', 'pending_review', 'unsupported' + ) + ) + ) + or pg_catalog.jsonb_typeof(p_value -> 'eventSummary') is distinct from 'string'$$ + ); + if v_updated is not distinct from v_definition then + raise exception 'event semantics migration could not update evidence validator'; + end if; + execute v_updated; + + foreach v_signature in array array[ + 'public.save_conversational_rectification_turn(uuid,uuid,bigint,uuid,jsonb,jsonb,jsonb,jsonb,text)', + 'public.import_legacy_conversational_rectification_case(uuid,uuid,uuid,bigint,uuid,integer,text,jsonb,jsonb,jsonb,jsonb,jsonb)' + ] loop + select pg_catalog.pg_get_functiondef(v_signature::regprocedure) into v_definition; + v_updated := pg_catalog.replace( + v_definition, + 'date_value, date_precision, extraction_status, corrects_evidence_ids, scoreable', + 'date_value, date_precision, extraction_status, corrects_evidence_ids, event_kind, subject, related_person, scoreability, scoreable' + ); + v_updated := pg_catalog.replace( + v_updated, + $$date_value, date_precision, extraction_status, scoreable, + corrects_evidence_ids$$, + $$date_value, date_precision, extraction_status, event_kind, subject, + related_person, scoreability, scoreable, corrects_evidence_ids$$ + ); + v_updated := pg_catalog.replace( + v_updated, + $$ ) else '{}'::uuid[] end, + case when item ? 'scoreable' then (item ->> 'scoreable')::boolean$$, + $$ ) else '{}'::uuid[] end, + item ->> 'eventKind', item ->> 'subject', item ->> 'relatedPerson', + item ->> 'scoreability', + case when item ? 'scoreable' then (item ->> 'scoreable')::boolean$$ + ); + v_updated := pg_catalog.replace( + v_updated, + $$ item ->> 'extractionStatus', (item ->> 'scoreable')::boolean, + array(select value::uuid$$, + $$ item ->> 'extractionStatus', item ->> 'eventKind', item ->> 'subject', + item ->> 'relatedPerson', item ->> 'scoreability', + (item ->> 'scoreable')::boolean, + array(select value::uuid$$ + ); + if v_updated is not distinct from v_definition then + raise exception 'event semantics migration could not update %', v_signature; + end if; + execute v_updated; + end loop; +end; +$migration$; + +commit; diff --git a/frontend/supabase/migrations/20260728020000_rectification_agent_v5.sql b/frontend/supabase/migrations/20260728020000_rectification_agent_v5.sql new file mode 100644 index 00000000..4ebc621d --- /dev/null +++ b/frontend/supabase/migrations/20260728020000_rectification_agent_v5.sql @@ -0,0 +1,883 @@ +begin; + +alter table public.birth_time_rectification_v4_cases + add column if not exists orchestration_model_id text, + add column if not exists narration_model_id text, + add column if not exists skill_version text not null default 'birth-time-rectification-v5', + add column if not exists prompt_version text not null default 'rectification-agent-v5-1', + add column if not exists algorithm_version text not null default 'rectification-v5-matrix-scoring-1', + add column if not exists deployment_mode text not null default 'v4_legacy', + add column if not exists feature_snapshot_id uuid, + add column if not exists latest_diagnostics_id uuid, + add column if not exists agent_mode text not null default 'deterministic_fallback', + add column if not exists privacy_retention_until timestamptz; + +-- The original protocol check was created inline and therefore has an implementation-defined name. +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and constraint_value.contype = 'c' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%protocol%rectification-evidence-v4%' + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_cases drop constraint %I', + value.conname + ); + end loop; +end $$; + +alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_protocol_check + check (protocol in ('rectification-evidence-v4', 'rectification-evidence-v5')), + drop constraint if exists birth_time_rectification_v4_cases_phase_check; +alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v4_cases_phase_check + check (phase in ( + 'collecting_evidence', 'extracting_evidence', 'scoring_candidates', + 'checking_robustness', 'planning_question', 'reasoning', 'rendering', 'complete' + )); + +-- V5 owns the worker phase machine as well as the Case phase machine. +alter table public.birth_time_rectification_v4_jobs + add column if not exists completion_payload_hash text; +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_jobs'::regclass + and constraint_value.contype = 'c' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%phase%' + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_jobs drop constraint %I', + value.conname + ); + end loop; +end $$; +alter table public.birth_time_rectification_v4_jobs + add constraint birth_time_rectification_v5_jobs_phase_check + check (phase in ( + 'collecting_evidence', 'extracting_evidence', 'scoring_candidates', + 'checking_robustness', 'planning_question', 'reasoning', 'rendering', 'complete' + )), + drop constraint if exists birth_time_rectification_v5_jobs_completion_payload_hash_check; +alter table public.birth_time_rectification_v4_jobs + add constraint birth_time_rectification_v5_jobs_completion_payload_hash_check + check (completion_payload_hash is null or completion_payload_hash ~ '^[a-f0-9]{64}$'); + +-- Candidate snapshots are durable algorithm artifacts; never label a V5 matrix result as V4. +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_candidate_snapshots'::regclass + and constraint_value.contype = 'c' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%algorithm_version%' + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_candidate_snapshots drop constraint %I', + value.conname + ); + end loop; +end $$; +alter table public.birth_time_rectification_v4_candidate_snapshots + add constraint birth_time_rectification_v5_candidate_snapshots_algorithm_check + check (algorithm_version in ( + 'rectification-v4-range-scoring-1', + 'rectification-v5-matrix-scoring-1' + )); + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_deployment_mode_check' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_deployment_mode_check + check (deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_agent_mode_check' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_agent_mode_check + check (agent_mode in ('agent', 'deterministic_fallback')); + end if; +end $$; + +alter table public.birth_time_rectification_v4_event_revisions + add column if not exists subject text not null default 'self', + add column if not exists related_person text, + drop constraint if exists birth_time_rectification_v4_event_revisions_event_kind_check; +alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v4_event_revisions_event_kind_check + check (event_kind in ( + 'education_milestone', 'relocation', 'relationship_start', 'relationship_end', + 'relationship_change', 'career_change', 'finance_change', 'self_health_event', + 'family_health_event', 'family_bereavement', 'family_event', 'other' + )); + +-- Replace the two anonymous V4 scoreability checks and the relationship-kind check. +do $$ +declare value record; +begin + for value in + select constraint_value.conname + from pg_catalog.pg_constraint constraint_value + where constraint_value.conrelid = 'public.birth_time_rectification_v4_event_revisions'::regclass + and constraint_value.contype = 'c' + and ( + pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%scoreability%' + or ( + pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%relationship%' + and pg_catalog.pg_get_constraintdef(constraint_value.oid) like '%event_kind%' + ) + ) + loop + execute pg_catalog.format( + 'alter table public.birth_time_rectification_v4_event_revisions drop constraint %I', + value.conname + ); + end loop; +end $$; +alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v5_event_revisions_scoreability_check + check (scoreability in ('scoreable', 'context_only', 'pending_review', 'unsupported')), + add constraint birth_time_rect_v5_event_revision_domain_score_check + check (domain not in ('family', 'other') or scoreability <> 'scoreable'), + add constraint birth_time_rect_v5_event_revision_relationship_kind_check + check (domain <> 'relationship' or event_kind in ( + 'relationship_start', 'relationship_end', 'relationship_change' + )); + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_event_revisions'::regclass + and conname = 'birth_time_rectification_v5_subject_check' + ) then + alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v5_subject_check + check (subject in ('self', 'family', 'partner', 'other')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_event_revisions'::regclass + and conname = 'birth_time_rectification_v5_related_person_check' + ) then + alter table public.birth_time_rectification_v4_event_revisions + add constraint birth_time_rectification_v5_related_person_check + check (related_person is null or related_person in ('father', 'mother', 'grandparent', 'sibling', 'partner')); + end if; +end $$; + +create table if not exists public.birth_time_rectification_candidate_feature_snapshots ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + calculation_spec_hash text not null check (calculation_spec_hash ~ '^[a-f0-9]{64}$'), + algorithm_version text not null, + candidate_count integer not null check (candidate_count between 1 and 1440), + feature_hash text not null check (feature_hash ~ '^[a-f0-9]{64}$'), + features jsonb not null check (jsonb_typeof(features) = 'array'), + created_at timestamptz not null +); + +create table if not exists public.birth_time_rectification_diagnostics ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + snapshot_id uuid not null references public.birth_time_rectification_v4_candidate_snapshots(id) on delete cascade, + summary jsonb not null check (jsonb_typeof(summary) = 'object'), + calculation_hash text not null check (calculation_hash ~ '^[a-f0-9]{64}$'), + created_at timestamptz not null, + unique (snapshot_id) +); + +create table if not exists public.birth_time_rectification_agent_runs ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + job_id uuid not null unique references public.birth_time_rectification_v4_jobs(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + case_version bigint not null, + model_id text, + skill_version text not null, + prompt_version text not null, + deployment_sha text, + deployment_mode text not null check (deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent')), + decision_json jsonb, + validated_decision_json jsonb not null check (jsonb_typeof(validated_decision_json) = 'object'), + tool_calls_json jsonb not null check (jsonb_typeof(tool_calls_json) = 'array'), + tool_call_count integer not null check (tool_call_count between 0 and 8), + fallback_reason text, + input_token_count integer, + output_token_count integer, + latency_ms integer not null check (latency_ms between 0 and 300000), + created_at timestamptz not null +); + +-- Forward-complete an earlier partial V5 draft before constraints/functions depend on it. +alter table public.birth_time_rectification_agent_runs + add column if not exists deployment_mode text, + add column if not exists validated_decision_json jsonb, + add column if not exists tool_calls_json jsonb, + add column if not exists tool_call_count integer, + add column if not exists fallback_reason text, + add column if not exists input_token_count integer, + add column if not exists output_token_count integer, + add column if not exists latency_ms integer; + +update public.birth_time_rectification_agent_runs run +set deployment_mode = coalesce( + case when run.deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent') then run.deployment_mode end, + value.deployment_mode, + 'v4_legacy' + ), + validated_decision_json = case + when jsonb_typeof(run.validated_decision_json) = 'object' then run.validated_decision_json + else jsonb_build_object( + 'decision', coalesce(run.decision_json, jsonb_build_object( + 'action', 'stop_low_confidence', + 'reasonCodes', jsonb_build_array('legacy_run_missing_decision') + )), + 'mode', 'deterministic_fallback', + 'validationIssues', jsonb_build_array('legacy_agent_run_backfill'), + 'selectedOpportunity', null + ) + end, + tool_calls_json = case + when jsonb_typeof(run.tool_calls_json) = 'array' + and jsonb_array_length(run.tool_calls_json) between 0 and 8 then run.tool_calls_json + else '[]'::jsonb + end, + tool_call_count = case + when jsonb_typeof(run.tool_calls_json) = 'array' + and jsonb_array_length(run.tool_calls_json) between 0 and 8 + then jsonb_array_length(run.tool_calls_json) + else 0 + end, + input_token_count = case when run.input_token_count >= 0 then run.input_token_count end, + output_token_count = case when run.output_token_count >= 0 then run.output_token_count end, + latency_ms = greatest(0, least(coalesce(run.latency_ms, 0), 300000)) +from public.birth_time_rectification_v4_cases value +where value.id = run.case_id; + +alter table public.birth_time_rectification_agent_runs + alter column deployment_mode set not null, + alter column validated_decision_json set not null, + alter column tool_calls_json set not null, + alter column tool_call_count set not null, + alter column latency_ms set not null; + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_deployment_mode_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_deployment_mode_check + check (deployment_mode in ('v4_legacy', 'v5_shadow', 'v5_agent')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_validated_decision_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_validated_decision_check + check (jsonb_typeof(validated_decision_json) = 'object'); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_tool_calls_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_tool_calls_check + check (jsonb_typeof(tool_calls_json) = 'array'); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_tool_count_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_tool_count_check + check (tool_call_count between 0 and 8 and tool_call_count = jsonb_array_length(tool_calls_json)); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_latency_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_latency_check + check (latency_ms between 0 and 300000); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_agent_runs'::regclass + and conname = 'birth_time_rectification_v5_agent_runs_token_count_check' + ) then + alter table public.birth_time_rectification_agent_runs + add constraint birth_time_rectification_v5_agent_runs_token_count_check + check ( + (input_token_count is null or input_token_count >= 0) + and (output_token_count is null or output_token_count >= 0) + ); + end if; +end $$; + +create table if not exists public.birth_time_rectification_public_messages ( + job_id uuid primary key references public.birth_time_rectification_v4_jobs(id) on delete cascade, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + message jsonb not null check (jsonb_typeof(message) = 'object'), + created_at timestamptz not null +); + +create table if not exists public.birth_time_rectification_pending_evidence ( + id uuid primary key, + case_id uuid not null references public.birth_time_rectification_v4_cases(id) on delete cascade, + user_id uuid not null references auth.users(id) on delete cascade, + turn_id uuid references public.birth_time_rectification_v4_turns(id) on delete cascade, + target_event_id uuid references public.birth_time_rectification_v4_events(id), + raw_text text not null check (length(btrim(raw_text)) between 1 and 4000), + reason_code text not null, + resolved_event_id uuid references public.birth_time_rectification_v4_events(id), + created_at timestamptz not null default now(), + resolved_at timestamptz +); + +alter table public.birth_time_rectification_pending_evidence + alter column turn_id set not null; + +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_pending_evidence'::regclass + and conname = 'birth_time_rectification_v5_pending_reason_check' + ) then + alter table public.birth_time_rectification_pending_evidence + add constraint birth_time_rectification_v5_pending_reason_check + check (reason_code in ('date_unresolved', 'event_unparsed')); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_pending_evidence'::regclass + and conname = 'birth_time_rectification_v5_pending_resolution_check' + ) then + alter table public.birth_time_rectification_pending_evidence + add constraint birth_time_rectification_v5_pending_resolution_check + check ((resolved_at is null) = (resolved_event_id is null)); + end if; +end $$; + +create index if not exists birth_time_rectification_feature_snapshots_case_created_idx + on public.birth_time_rectification_candidate_feature_snapshots(case_id, created_at desc); +create index if not exists birth_time_rectification_feature_snapshots_user_created_idx + on public.birth_time_rectification_candidate_feature_snapshots(user_id, created_at desc); +create index if not exists birth_time_rectification_diagnostics_case_created_idx + on public.birth_time_rectification_diagnostics(case_id, created_at desc); +create index if not exists birth_time_rectification_diagnostics_user_created_idx + on public.birth_time_rectification_diagnostics(user_id, created_at desc); +create index if not exists birth_time_rectification_diagnostics_snapshot_idx + on public.birth_time_rectification_diagnostics(snapshot_id); +create index if not exists birth_time_rectification_agent_runs_case_created_idx + on public.birth_time_rectification_agent_runs(case_id, created_at desc); +create index if not exists birth_time_rectification_agent_runs_user_created_idx + on public.birth_time_rectification_agent_runs(user_id, created_at desc); +create index if not exists birth_time_rectification_public_messages_case_created_idx + on public.birth_time_rectification_public_messages(case_id, created_at desc); +create index if not exists birth_time_rectification_pending_evidence_case_created_idx + on public.birth_time_rectification_pending_evidence(case_id, created_at desc); +create index if not exists birth_time_rectification_pending_evidence_target_event_idx + on public.birth_time_rectification_pending_evidence(target_event_id) + where target_event_id is not null; + +-- Add the circular Case -> latest artifact references only after the artifact tables exist. +do $$ +begin + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_feature_snapshot_fk' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_feature_snapshot_fk + foreign key (feature_snapshot_id) + references public.birth_time_rectification_candidate_feature_snapshots(id); + end if; + if not exists ( + select 1 from pg_catalog.pg_constraint + where conrelid = 'public.birth_time_rectification_v4_cases'::regclass + and conname = 'birth_time_rectification_v5_latest_diagnostics_fk' + ) then + alter table public.birth_time_rectification_v4_cases + add constraint birth_time_rectification_v5_latest_diagnostics_fk + foreign key (latest_diagnostics_id) + references public.birth_time_rectification_diagnostics(id); + end if; +end $$; + +alter table public.birth_time_rectification_candidate_feature_snapshots enable row level security; +alter table public.birth_time_rectification_diagnostics enable row level security; +alter table public.birth_time_rectification_agent_runs enable row level security; +alter table public.birth_time_rectification_public_messages enable row level security; +alter table public.birth_time_rectification_pending_evidence enable row level security; + +revoke all on table + public.birth_time_rectification_candidate_feature_snapshots, + public.birth_time_rectification_diagnostics, + public.birth_time_rectification_agent_runs, + public.birth_time_rectification_public_messages, + public.birth_time_rectification_pending_evidence +from public, anon, authenticated; +grant all on table + public.birth_time_rectification_candidate_feature_snapshots, + public.birth_time_rectification_diagnostics, + public.birth_time_rectification_agent_runs, + public.birth_time_rectification_public_messages, + public.birth_time_rectification_pending_evidence +to service_role; + +create or replace function public.create_birth_time_rectification_v5_case( + p_user_id uuid, + p_case_id uuid, + p_action_id uuid, + p_status text, + p_phase text, + p_calculation_spec jsonb, + p_calculation_spec_hash text, + p_evidence_set_hash text, + p_current_question jsonb, + p_orchestration_model_id text, + p_narration_model_id text, + p_skill_version text, + p_prompt_version text, + p_algorithm_version text, + p_deployment_mode text, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_case public.birth_time_rectification_v4_cases%rowtype; + v_case_id uuid; + v_protocol text; +begin + if p_deployment_mode not in ('v4_legacy', 'v5_shadow', 'v5_agent') then + raise exception 'invalid_rectification_v5_deployment_mode'; + end if; + v_protocol := case when p_deployment_mode = 'v4_legacy' + then 'rectification-evidence-v4' else 'rectification-evidence-v5' end; + + select action.case_id into v_case_id + from public.birth_time_rectification_v4_actions action + where action.user_id = p_user_id and action.action_id = p_action_id; + if v_case_id is not null then return v_case_id; end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':rectification-v5-case', 0) + ); + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.user_id = p_user_id + and value.status <> 'abandoned' + and value.accepted_range_start is null + order by value.created_at desc + limit 1 + for update; + + -- An in-flight Case keeps the deployment mode and protocol it was created with. + if found and v_case.calculation_spec_hash = p_calculation_spec_hash then + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, v_case.id, p_now + ); + return v_case.id; + end if; + + if found then + update public.birth_time_rectification_v4_cases + set status = 'abandoned', phase = 'complete', current_question = null, updated_at = p_now + where id = v_case.id; + update public.birth_time_rectification_v4_jobs + set status = 'stale', lease_expires_at = null, updated_at = p_now + where case_id = v_case.id and status in ('pending', 'processing'); + end if; + + insert into public.birth_time_rectification_v4_cases ( + id, user_id, protocol, status, phase, calculation_spec, calculation_spec_hash, + evidence_set_hash, current_question, orchestration_model_id, narration_model_id, + skill_version, prompt_version, algorithm_version, deployment_mode, agent_mode, + created_at, updated_at + ) values ( + p_case_id, p_user_id, v_protocol, p_status, p_phase, p_calculation_spec, + p_calculation_spec_hash, p_evidence_set_hash, p_current_question, + nullif(btrim(p_orchestration_model_id), ''), nullif(btrim(p_narration_model_id), ''), + p_skill_version, p_prompt_version, p_algorithm_version, p_deployment_mode, + 'deterministic_fallback', p_now, p_now + ); + insert into public.birth_time_rectification_v4_actions( + user_id, action_id, case_id, created_at + ) values ( + p_user_id, p_action_id, p_case_id, p_now + ); + return p_case_id; +end; +$$; + +drop function if exists public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +); +drop function if exists public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +); + +create or replace function public.complete_birth_time_rectification_v5_job( + p_worker_id uuid, + p_job_id uuid, + p_expected_case_version bigint, + p_input_evidence_set_hash text, + p_output_evidence_set_hash text, + p_calculation_spec_hash text, + p_completion_payload_hash text, + p_event_revisions jsonb, + p_pending_evidence jsonb, + p_snapshot jsonb, + p_diagnostics jsonb, + p_feature_snapshot jsonb, + p_validated_decision jsonb, + p_public_message jsonb, + p_agent_run jsonb, + p_next_question jsonb, + p_status text, + p_phase text, + p_now timestamptz +) returns uuid +language plpgsql security definer set search_path = '' as $$ +declare + v_job public.birth_time_rectification_v4_jobs%rowtype; + v_case public.birth_time_rectification_v4_cases%rowtype; + v_existing_run public.birth_time_rectification_agent_runs%rowtype; + v_existing_message public.birth_time_rectification_public_messages%rowtype; + item jsonb; + v_snapshot_id uuid; + v_feature_id uuid; + v_diagnostics_id uuid; + v_event_id uuid; + v_supersedes_id uuid; + v_pending_count integer; +begin + if jsonb_typeof(p_event_revisions) is distinct from 'array' + or jsonb_typeof(p_pending_evidence) is distinct from 'array' + or jsonb_typeof(p_validated_decision) is distinct from 'object' + or jsonb_typeof(p_public_message) is distinct from 'object' + or jsonb_typeof(p_agent_run) is distinct from 'object' + or p_output_evidence_set_hash !~ '^[a-f0-9]{64}$' + or p_calculation_spec_hash !~ '^[a-f0-9]{64}$' + or p_completion_payload_hash !~ '^[a-f0-9]{64}$' then + raise exception 'invalid_rectification_v5_completion_payload'; + end if; + + select value.* into v_job + from public.birth_time_rectification_v4_jobs value + where value.id = p_job_id + for update; + if not found then raise exception 'rectification_v4_job_lease_lost'; end if; + + select value.* into v_case + from public.birth_time_rectification_v4_cases value + where value.id = v_job.case_id + for update; + if not found then raise exception 'rectification_v4_case_not_found'; end if; + + -- A network retry after commit is an idempotent read, never a second artifact write. + if v_job.status = 'completed' then + select value.* into v_existing_run + from public.birth_time_rectification_agent_runs value + where value.job_id = p_job_id; + select value.* into v_existing_message + from public.birth_time_rectification_public_messages value + where value.job_id = p_job_id; + select count(*) into v_pending_count + from public.birth_time_rectification_pending_evidence value + where value.turn_id = v_job.turn_id; + if v_existing_run.id is null + or v_existing_run.id is distinct from (p_agent_run->>'id')::uuid + or v_existing_run.case_id is distinct from v_case.id + or v_existing_run.case_version is distinct from p_expected_case_version + or v_existing_run.validated_decision_json is distinct from p_validated_decision + or v_existing_message.job_id is null + or v_existing_message.message is distinct from p_public_message + or v_job.completion_payload_hash is distinct from p_completion_payload_hash + or v_pending_count is distinct from pg_catalog.jsonb_array_length(p_pending_evidence) then + raise exception 'rectification_v5_replay_payload_mismatch'; + end if; + for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop + if not exists ( + select 1 from public.birth_time_rectification_pending_evidence value + where value.id = (item->>'id')::uuid + and value.case_id = v_case.id + and value.user_id = v_case.user_id + and value.turn_id = v_job.turn_id + and value.target_event_id is not distinct from nullif(item->>'targetEventId', '')::uuid + and value.raw_text = item->>'rawText' + and value.reason_code = item->>'reasonCode' + and value.resolved_event_id is not distinct from nullif(item->>'resolvedEventId', '')::uuid + and value.created_at = (item->>'createdAt')::timestamptz + and value.resolved_at is not distinct from nullif(item->>'resolvedAt', '')::timestamptz + ) then + raise exception 'rectification_v5_replay_payload_mismatch'; + end if; + end loop; + return v_case.id; + end if; + + if v_job.worker_id is distinct from p_worker_id + or v_job.status <> 'processing' + or v_job.lease_expires_at <= p_now then + raise exception 'rectification_v4_job_lease_lost'; + end if; + if v_case.version is distinct from p_expected_case_version + or v_case.evidence_set_hash is distinct from p_input_evidence_set_hash + or v_case.calculation_spec_hash is distinct from p_calculation_spec_hash + or v_job.expected_case_version is distinct from p_expected_case_version + or v_job.evidence_set_hash is distinct from p_input_evidence_set_hash + or v_job.calculation_spec_hash is distinct from p_calculation_spec_hash then + raise exception 'stale_rectification_v4_job'; + end if; + + if (p_agent_run->>'caseId')::uuid is distinct from v_case.id + or (p_agent_run->>'jobId')::uuid is distinct from p_job_id + or (p_agent_run->>'caseVersion')::bigint is distinct from p_expected_case_version + or p_agent_run->>'deploymentMode' is distinct from v_case.deployment_mode + or p_agent_run->'validatedDecision' is distinct from p_validated_decision + or jsonb_typeof(p_agent_run->'toolCalls') is distinct from 'array' + or pg_catalog.jsonb_array_length(p_agent_run->'toolCalls') > 8 + or p_validated_decision->>'mode' not in ('agent', 'deterministic_fallback') then + raise exception 'invalid_rectification_v5_agent_run'; + end if; + + for item in select value from pg_catalog.jsonb_array_elements(p_event_revisions) loop + v_event_id := (item->>'eventId')::uuid; + v_supersedes_id := nullif(item->>'supersedesRevisionId', '')::uuid; + if (item->>'caseId') is not null and (item->>'caseId')::uuid is distinct from v_case.id then + raise exception 'rectification_v5_event_case_mismatch'; + end if; + insert into public.birth_time_rectification_v4_events( + id, case_id, user_id, created_at + ) values ( + v_event_id, v_case.id, v_case.user_id, (item->>'createdAt')::timestamptz + ) on conflict (id) do nothing; + if not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = v_event_id and value.case_id = v_case.id and value.user_id = v_case.user_id + ) then + raise exception 'rectification_v5_event_case_mismatch'; + end if; + if v_supersedes_id is not null and not exists ( + select 1 from public.birth_time_rectification_v4_event_revisions value + where value.id = v_supersedes_id and value.event_id = v_event_id and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_superseded_revision_mismatch'; + end if; + insert into public.birth_time_rectification_v4_event_revisions( + id, event_id, case_id, user_id, revision, domain, event_kind, subject, + related_person, summary, raw_text, date_start, date_end, date_precision, + date_label, scoreability, supersedes_revision_id, created_at + ) values ( + (item->>'id')::uuid, v_event_id, v_case.id, v_case.user_id, + (item->>'revision')::integer, item->>'domain', item->>'eventKind', item->>'subject', + nullif(item->>'relatedPerson', ''), item->>'summary', item->>'rawText', + (item#>>'{dateRange,start}')::date, (item#>>'{dateRange,end}')::date, + item#>>'{dateRange,precision}', item#>>'{dateRange,label}', item->>'scoreability', + v_supersedes_id, (item->>'createdAt')::timestamptz + ); + end loop; + + for item in select value from pg_catalog.jsonb_array_elements(p_pending_evidence) loop + if (item->>'caseId')::uuid is distinct from v_case.id + or (item->>'turnId')::uuid is distinct from v_job.turn_id + or item->>'reasonCode' not in ('date_unresolved', 'event_unparsed') + or nullif(btrim(item->>'rawText'), '') is null + or (nullif(item->>'resolvedEventId', '') is null) is distinct from (nullif(item->>'resolvedAt', '') is null) then + raise exception 'invalid_rectification_v5_pending_evidence'; + end if; + if nullif(item->>'targetEventId', '') is not null and not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = (item->>'targetEventId')::uuid and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_pending_target_event_mismatch'; + end if; + if nullif(item->>'resolvedEventId', '') is not null and not exists ( + select 1 from public.birth_time_rectification_v4_events value + where value.id = (item->>'resolvedEventId')::uuid and value.case_id = v_case.id + ) then + raise exception 'rectification_v5_pending_resolved_event_mismatch'; + end if; + insert into public.birth_time_rectification_pending_evidence( + id, case_id, user_id, turn_id, target_event_id, raw_text, reason_code, + resolved_event_id, created_at, resolved_at + ) values ( + (item->>'id')::uuid, v_case.id, v_case.user_id, (item->>'turnId')::uuid, + nullif(item->>'targetEventId', '')::uuid, item->>'rawText', item->>'reasonCode', + nullif(item->>'resolvedEventId', '')::uuid, (item->>'createdAt')::timestamptz, + nullif(item->>'resolvedAt', '')::timestamptz + ); + end loop; + + if p_snapshot is not null then + if jsonb_typeof(p_snapshot) is distinct from 'object' + or coalesce((p_snapshot->>'canConfirmExactMinute')::boolean, false) then + raise exception 'exact_minute_confirmation_forbidden'; + end if; + v_snapshot_id := (p_snapshot->>'id')::uuid; + if (p_snapshot->>'caseId')::uuid is distinct from v_case.id + or (p_snapshot->>'caseVersion')::bigint is distinct from p_expected_case_version + or p_snapshot->>'evidenceSetHash' is distinct from p_output_evidence_set_hash + or p_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash + or p_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then + raise exception 'rectification_v5_snapshot_mismatch'; + end if; + insert into public.birth_time_rectification_v4_candidate_snapshots( + id, case_id, user_id, case_version, evidence_set_hash, calculation_spec_hash, + algorithm_version, candidates, clusters, robustness, can_confirm_exact_minute, + can_accept_range, gate_reasons, created_at + ) values ( + v_snapshot_id, v_case.id, v_case.user_id, (p_snapshot->>'caseVersion')::bigint, + p_snapshot->>'evidenceSetHash', p_snapshot->>'calculationSpecHash', + p_snapshot->>'algorithmVersion', p_snapshot->'candidates', p_snapshot->'clusters', + p_snapshot->'robustness', false, (p_snapshot->>'canAcceptRange')::boolean, + p_snapshot->'gateReasons', (p_snapshot->>'createdAt')::timestamptz + ); + end if; + + if p_feature_snapshot is not null then + if jsonb_typeof(p_feature_snapshot) is distinct from 'object' then + raise exception 'invalid_rectification_v5_feature_snapshot'; + end if; + v_feature_id := (p_feature_snapshot->>'id')::uuid; + if (p_feature_snapshot->>'caseId')::uuid is distinct from v_case.id + or p_feature_snapshot->>'calculationSpecHash' is distinct from p_calculation_spec_hash + or p_feature_snapshot->>'algorithmVersion' is distinct from v_case.algorithm_version then + raise exception 'rectification_v5_feature_snapshot_mismatch'; + end if; + insert into public.birth_time_rectification_candidate_feature_snapshots( + id, case_id, user_id, calculation_spec_hash, algorithm_version, + candidate_count, feature_hash, features, created_at + ) values ( + v_feature_id, v_case.id, v_case.user_id, + p_feature_snapshot->>'calculationSpecHash', p_feature_snapshot->>'algorithmVersion', + (p_feature_snapshot->>'candidateCount')::integer, p_feature_snapshot->>'featureHash', + p_feature_snapshot->'features', (p_feature_snapshot->>'createdAt')::timestamptz + ); + end if; + + if p_diagnostics is not null then + if jsonb_typeof(p_diagnostics) is distinct from 'object' or v_snapshot_id is null then + raise exception 'invalid_rectification_v5_diagnostics'; + end if; + v_diagnostics_id := (p_diagnostics->>'id')::uuid; + if (p_diagnostics->>'caseId')::uuid is distinct from v_case.id + or (p_diagnostics->>'snapshotId')::uuid is distinct from v_snapshot_id then + raise exception 'rectification_v5_diagnostics_mismatch'; + end if; + insert into public.birth_time_rectification_diagnostics( + id, case_id, user_id, snapshot_id, summary, calculation_hash, created_at + ) values ( + v_diagnostics_id, v_case.id, v_case.user_id, v_snapshot_id, + p_diagnostics, p_diagnostics->>'calculationHash', + (p_diagnostics->>'createdAt')::timestamptz + ); + end if; + + if (p_diagnostics is null) is distinct from (p_snapshot is null) + or (p_feature_snapshot is null) is distinct from (p_snapshot is null) then + raise exception 'rectification_v5_artifact_set_incomplete'; + end if; + + insert into public.birth_time_rectification_agent_runs( + id, case_id, job_id, user_id, case_version, model_id, skill_version, + prompt_version, deployment_sha, deployment_mode, decision_json, + validated_decision_json, tool_calls_json, tool_call_count, fallback_reason, + input_token_count, output_token_count, latency_ms, created_at + ) values ( + (p_agent_run->>'id')::uuid, v_case.id, p_job_id, v_case.user_id, + (p_agent_run->>'caseVersion')::bigint, nullif(p_agent_run->>'modelId', ''), + p_agent_run->>'skillVersion', p_agent_run->>'promptVersion', + nullif(p_agent_run->>'deploymentSha', ''), p_agent_run->>'deploymentMode', + p_agent_run->'decision', p_validated_decision, p_agent_run->'toolCalls', + pg_catalog.jsonb_array_length(p_agent_run->'toolCalls'), + nullif(p_agent_run->>'fallbackReason', ''), + nullif(p_agent_run->>'inputTokenCount', '')::integer, + nullif(p_agent_run->>'outputTokenCount', '')::integer, + (p_agent_run->>'latencyMs')::integer, + (p_agent_run->>'createdAt')::timestamptz + ); + insert into public.birth_time_rectification_public_messages( + job_id, case_id, user_id, message, created_at + ) values ( + p_job_id, v_case.id, v_case.user_id, p_public_message, p_now + ); + + update public.birth_time_rectification_v4_cases + set version = p_expected_case_version + 1, + evidence_set_hash = p_output_evidence_set_hash, + latest_snapshot_id = coalesce(v_snapshot_id, latest_snapshot_id), + feature_snapshot_id = coalesce(v_feature_id, feature_snapshot_id), + latest_diagnostics_id = coalesce(v_diagnostics_id, latest_diagnostics_id), + agent_mode = p_validated_decision->>'mode', + current_question = p_next_question, + status = p_status, + phase = p_phase, + updated_at = p_now + where id = v_case.id; + update public.birth_time_rectification_v4_jobs + set status = 'completed', phase = p_phase, result_snapshot_id = v_snapshot_id, + completion_payload_hash = p_completion_payload_hash, + lease_expires_at = null, updated_at = p_now + where id = p_job_id; + return v_case.id; +end; +$$; + +revoke all on function public.create_birth_time_rectification_v5_case( + uuid, uuid, uuid, text, text, jsonb, text, text, jsonb, text, text, text, text, text, text, timestamptz +) from public, anon, authenticated; +revoke all on function public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +) from public, anon, authenticated; +grant execute on function public.create_birth_time_rectification_v5_case( + uuid, uuid, uuid, text, text, jsonb, text, text, jsonb, text, text, text, text, text, text, timestamptz +) to service_role; +grant execute on function public.complete_birth_time_rectification_v5_job( + uuid, uuid, bigint, text, text, text, text, + jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, jsonb, + text, text, timestamptz +) to service_role; + +commit; diff --git a/frontend/tests/conversational-evidence-extractor.test.ts b/frontend/tests/conversational-evidence-extractor.test.ts index 23da2750..d4116175 100644 --- a/frontend/tests/conversational-evidence-extractor.test.ts +++ b/frontend/tests/conversational-evidence-extractor.test.ts @@ -122,19 +122,35 @@ test("accepts nineteenth-century Chinese and ISO dates as scoreable historical e ); }); -for (const rawText of [ - "2003年确诊癌症并接受手术", - "2006年丈夫因交通事故去世", -]) { - test(`classifies dated health, accident, and bereavement evidence for D30 scoring: ${rawText}`, () => { - const evidence = extractLifeEventEvidence({ rawText, sourceTurnId, asOfDate: "2026-07-20" }); - - assert.ok(evidence.length > 0); - assert.ok(evidence.every((item) => item.domain === "health_pressure")); - assert.ok(evidence.every((item) => item.scoreable)); - assert.ok(evidence.every((item) => lifeEventEvidenceSchema.safeParse(item).success)); +test("classifies the user's dated illness as scoreable self-health evidence", () => { + const [evidence] = extractLifeEventEvidence({ + rawText: "2003年确诊癌症并接受手术", + sourceTurnId, + asOfDate: "2026-07-20", }); -} + + assert.equal(evidence?.domain, "health_pressure"); + assert.equal(evidence?.eventKind, "self_health_event"); + assert.equal(evidence?.subject, "self"); + assert.equal(evidence?.scoreable, true); + assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true); +}); + +test("keeps a partner's bereavement as family context instead of personal-health scoring", () => { + const [evidence] = extractLifeEventEvidence({ + rawText: "2006年丈夫因交通事故去世", + sourceTurnId, + asOfDate: "2026-07-20", + }); + + assert.equal(evidence?.domain, "family"); + assert.equal(evidence?.eventKind, "family_bereavement"); + assert.equal(evidence?.subject, "family"); + assert.equal(evidence?.relatedPerson, "partner"); + assert.equal(evidence?.scoreability, "context_only"); + assert.equal(evidence?.scoreable, false); + assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true); +}); test("classifies dated income and asset changes as finance evidence", () => { const [evidence] = extractLifeEventEvidence({ @@ -144,6 +160,7 @@ test("classifies dated income and asset changes as finance evidence", () => { }); assert.equal(evidence?.domain, "finance"); + assert.equal(evidence?.eventKind, "finance_change"); assert.equal(evidence?.dateValue, "2022-08"); assert.equal(evidence?.scoreable, true); assert.equal(lifeEventEvidenceSchema.safeParse(evidence).success, true); diff --git a/frontend/tests/conversational-rectification-component.test.ts b/frontend/tests/conversational-rectification-component.test.ts index a9a20504..667c1e43 100644 --- a/frontend/tests/conversational-rectification-component.test.ts +++ b/frontend/tests/conversational-rectification-component.test.ts @@ -39,6 +39,15 @@ function response(overrides: Record = {}): RectificationV4ApiRe reason: "根据对话选择下一条高信息量追问。", }, latestSnapshot: null, + orchestrationModelId: null, + narrationModelId: null, + skillVersion: "birth-time-rectification-v5", + promptVersion: "rectification-agent-v5-1", + algorithmVersion: "rectification-v5-matrix-scoring-1", + deploymentMode: "v5_agent", + agentMode: "deterministic_fallback", + featureSnapshotId: null, + latestDiagnosticsId: null, acceptedRange: null, createdAt: now, updatedAt: now, diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index b8fe3dad..13fd2f54 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -30,6 +30,8 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260721150000_align_conversational_finance_domain\.sql/); assert.match(migration.stdout, /applied 20260723010000_restore_conversational_message_history\.sql/); assert.match(migration.stdout, /applied 20260723020000_mark_captured_conversational_messages\.sql/); + assert.match(migration.stdout, /applied 20260728010000_conversational_event_semantics\.sql/); + assert.match(migration.stdout, /applied 20260728020000_rectification_agent_v5\.sql/); assert.equal( fixture.psql(` @@ -76,12 +78,17 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic `), [ "birth_time_rectification_action_receipts", + "birth_time_rectification_agent_runs", "birth_time_rectification_billing", + "birth_time_rectification_candidate_feature_snapshots", "birth_time_rectification_cases", + "birth_time_rectification_diagnostics", "birth_time_rectification_dynamic_state", "birth_time_rectification_event_evidence", "birth_time_rectification_handoff_attach_receipts", "birth_time_rectification_handoff_settlements", + "birth_time_rectification_pending_evidence", + "birth_time_rectification_public_messages", "birth_time_rectification_question_handoffs", "birth_time_rectification_scoring_jobs", "birth_time_rectification_turns", diff --git a/frontend/tests/rectification-agent-contracts.test.ts b/frontend/tests/rectification-agent-contracts.test.ts new file mode 100644 index 00000000..c8379eb7 --- /dev/null +++ b/frontend/tests/rectification-agent-contracts.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { + validateRectificationDecision, + type DiagnosticsSummary, + type QuestionOpportunity, +} from "../src/lib/rectification-agent/contracts.ts"; +import { recordRectificationAgentTelemetry } from "../src/lib/rectification-agent/telemetry.ts"; + +const caseId = "00000000-0000-4000-8000-000000000800"; +const opportunityId = "00000000-0000-4000-8000-000000000801"; +const snapshotId = "00000000-0000-4000-8000-000000000802"; +const diagnostics: DiagnosticsSummary = { + id: "00000000-0000-4000-8000-000000000803", + caseId, + snapshotId, + primaryClusterRetentionRate: 0.8, + leaveOneEventOutRetentionRate: 0.75, + leaveOneDomainOutRetentionRate: 0.7, + dateSensitivityRetentionRate: 0.72, + neighborSupportMinutes: 8, + primarySecondaryMarginPercent: 12, + clusterMassRatio: 0.65, + unstableEventIds: [], + mostDiscriminatingLayers: ["D9"], + eventDateSensitivity: [], + candidateSplits: [], + calculationHash: "c".repeat(64), + createdAt: "2026-07-28T00:00:00.000Z", +}; +const opportunity: QuestionOpportunity = { + opportunityId, + kind: "ask_new_event", + domain: "career", + targetEventId: null, + prompt: "请补充一个有明确年月的重要事件。", + reason: "当前证据领域覆盖不足。", + expectedInformationGain: 0.8, + dateSensitivity: 0.5, + candidateSplitRelevance: 0.7, + domainCoverageGain: 0.6, + recallEase: 0.8, + novelty: 1, + repetitionPenalty: 0, + privacyCost: 0.1, + utility: 0.69, + active: true, +}; + +function validate(decision: unknown, overrides: Partial[0]> = {}) { + return validateRectificationDecision({ + decision, + caseId, + snapshotId, + opportunities: [opportunity], + diagnostics, + candidateRangeOfferAllowed: true, + ...overrides, + }); +} + +test("only an active server-owned opportunity can be selected", () => { + assert.deepEqual(validate({ + action: "ask_question", opportunityId, narrativeFocus: ["latest_event"], + }, { opportunities: [{ ...opportunity, active: false }] }).issues, ["opportunity_not_active"]); + assert.deepEqual(validate({ + action: "ask_question", opportunityId: "00000000-0000-4000-8000-000000000899", narrativeFocus: [], + }).issues, ["opportunity_not_active"]); +}); + +test("candidate ranges require both the policy gate and the current snapshot", () => { + assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId }, { + candidateRangeOfferAllowed: false, + }).issues, ["candidate_range_gate_failed"]); + assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId }, { + snapshotId: "00000000-0000-4000-8000-000000000898", + }).issues, ["snapshot_not_current"]); +}); + +test("diagnostic reads are bounded and cannot target another case", () => { + assert.deepEqual(validate({ action: "run_diagnostic", diagnostic: "neighbor_stability" }, { + usedDiagnostics: ["neighbor_stability"], + }).issues, ["diagnostic_already_run"]); + assert.deepEqual(validate({ action: "ask_question", opportunityId, narrativeFocus: [] }, { + caseId: "00000000-0000-4000-8000-000000000897", + toolCallCount: 2, + maxToolCalls: 1, + }).issues, ["diagnostics_case_mismatch", "tool_call_budget_exceeded"]); +}); + +test("model output cannot inject a minute, question, event, or score", () => { + for (const extra of [ + { birthMinute: "06:21" }, + { prompt: "模型自己写的问题" }, + { eventId: "00000000-0000-4000-8000-000000000896" }, + { score: 99 }, + ]) { + assert.deepEqual(validate({ action: "offer_candidate_range", snapshotId, ...extra }).issues, ["decision_schema_invalid"]); + } +}); + +test("agent telemetry rejects malformed events and warns on failures", () => { + const info: string[] = []; + const warnings: string[] = []; + const originalInfo = console.info; + const originalWarn = console.warn; + console.info = (message) => info.push(String(message)); + console.warn = (message) => warnings.push(String(message)); + try { + recordRectificationAgentTelemetry({ + caseId, phase: "reasoner", outcome: "failed", modelId: "test-model", toolName: null, + decisionAction: null, durationMs: 12, errorCode: "model_unavailable", deploymentSha: "test-sha", + }); + recordRectificationAgentTelemetry({ + caseId, phase: "reasoner", outcome: "failed", modelId: "", toolName: null, + decisionAction: null, durationMs: 12, errorCode: "model_unavailable", deploymentSha: "test-sha", + }); + } finally { + console.info = originalInfo; + console.warn = originalWarn; + } + assert.equal(info.length, 0); + assert.equal(warnings.length, 1); + assert.match(warnings[0] ?? "", /\[rectification-agent\].*"outcome":"failed"/); +}); + +test("durable event semantics migration validates and persists subject fields", () => { + const migration = readFileSync(new URL( + "../supabase/migrations/20260728010000_conversational_event_semantics.sql", + import.meta.url, + ), "utf8"); + assert.match(migration, /subject in \('self', 'family', 'partner', 'other'\)/); + assert.match(migration, /related_person/); + assert.match(migration, /save_conversational_rectification_turn/); + assert.match(migration, /import_legacy_conversational_rectification_case/); + assert.match(migration, /event_kind/); + assert.match(migration, /scoreability/); +}); diff --git a/frontend/tests/rectification-agent-v5.test.ts b/frontend/tests/rectification-agent-v5.test.ts new file mode 100644 index 00000000..96b72771 --- /dev/null +++ b/frontend/tests/rectification-agent-v5.test.ts @@ -0,0 +1,346 @@ +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import test from "node:test"; + +import { + agentRunSchema, + diagnosticsSummarySchema, + type DiagnosticsSummary, + type QuestionOpportunity, +} from "../src/lib/rectification-agent/contracts.ts"; +import { rectificationCanaryBucket, selectRectificationDeploymentMode } from "../src/lib/rectification-agent/feature-policy.ts"; +import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; +import { runBoundedReasoner } from "../src/lib/rectification-agent/reasoner-agent.ts"; +import { enforceServerQuestion } from "../src/lib/rectification-agent/renderer-agent.ts"; +import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; +import type { + CalculationSpec, + CandidateSnapshot, + LifeEventRevision, + RectificationV4Case, +} from "../src/lib/rectification-v4/contracts.ts"; +import { reconcileV4Evidence } from "../src/lib/rectification-v4/extraction.ts"; +import { calculationSpecHash, rectificationFingerprint } from "../src/lib/rectification-v4/fingerprints.ts"; +import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; +import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; +import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; + +const caseId = "00000000-0000-4000-8000-000000000901"; +const snapshotId = "00000000-0000-4000-8000-000000000902"; +const opportunityId = "00000000-0000-4000-8000-000000000903"; +const now = "2026-07-28T00:00:00.000Z"; + +test("completion artifact fingerprints are canonical and payload-sensitive", () => { + const left = rectificationFingerprint({ status: "complete", artifact: { b: 2, a: 1 } }); + const reordered = rectificationFingerprint({ artifact: { a: 1, b: 2 }, status: "complete" }); + const changed = rectificationFingerprint({ artifact: { a: 1, b: 3 }, status: "complete" }); + assert.equal(left, reordered); + assert.notEqual(left, changed); +}); +const spec: CalculationSpec = { + version: "rectification-calculation-spec-v4", + birthDate: "1997-08-08", + candidateRange: { start: "05:00", end: "06:00" }, + latitude: 36.419, + longitude: 114.213, + timezoneOffsetHours: 8, + ayanamsa: "lahiri", + nodeMode: "mean", + minuteStep: 1, +}; +const snapshot: CandidateSnapshot = { + id: snapshotId, + caseId, + caseVersion: 2, + evidenceSetHash: "e".repeat(64), + calculationSpecHash: calculationSpecHash(spec), + algorithmVersion: "rectification-v5-matrix-scoring-1", + candidates: [{ time: "05:13", score: 10, supportingEventIds: [], conflictingEventIds: [] }], + clusters: [{ rank: 1, startTime: "05:13", endTime: "05:15", representativeTime: "05:13", widthMinutes: 3, peakScore: 10, scoreMass: 1 }], + robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 1, calculationSpecHashMatched: true }, + canConfirmExactMinute: false, + canAcceptRange: false, + gateReasons: ["insufficient_scoreable_events"], + createdAt: now, +}; +const diagnostics: DiagnosticsSummary = diagnosticsSummarySchema.parse({ + id: "00000000-0000-4000-8000-000000000904", + caseId, + snapshotId, + primaryClusterRetentionRate: 1, + leaveOneEventOutRetentionRate: .8, + leaveOneDomainOutRetentionRate: .7, + dateSensitivityRetentionRate: .9, + neighborSupportMinutes: 3, + primarySecondaryMarginPercent: 12, + clusterMassRatio: .8, + unstableEventIds: [], + mostDiscriminatingLayers: ["D9"], + eventDateSensitivity: [], + candidateSplits: [], + calculationHash: "d".repeat(64), + createdAt: now, +}); +const opportunity: QuestionOpportunity = { + opportunityId, + kind: "ask_new_event", + domain: "career", + targetEventId: null, + prompt: "请补充一次职业变化。", + reason: "领域覆盖不足。", + expectedInformationGain: .8, + dateSensitivity: .5, + candidateSplitRelevance: .5, + domainCoverageGain: 1, + recallEase: .8, + novelty: 1, + repetitionPenalty: 0, + privacyCost: 0, + utility: .85, + active: true, +}; +const caseValue: RectificationV4Case = { + id: caseId, + userId: "00000000-0000-4000-8000-000000000905", + protocol: "rectification-evidence-v5", + version: 2, + status: "processing", + phase: "reasoning", + calculationSpec: spec, + calculationSpecHash: calculationSpecHash(spec), + evidenceSetHash: "e".repeat(64), + currentQuestion: null, + latestSnapshot: snapshot, + orchestrationModelId: null, + narrationModelId: null, + skillVersion: "birth-time-rectification-v5", + promptVersion: "rectification-agent-v5-1", + algorithmVersion: "rectification-v5-matrix-scoring-1", + deploymentMode: "v5_agent", + agentMode: "deterministic_fallback", + featureSnapshotId: null, + latestDiagnosticsId: diagnostics.id, + acceptedRange: null, + createdAt: now, + updatedAt: now, +}; + +function event(overrides: Partial = {}): LifeEventRevision { + return { + id: randomUUID(), + eventId: randomUUID(), + revision: 1, + domain: "education", + eventKind: "education_milestone", + subject: "self", + relatedPerson: null, + summary: "2016年大学入学", + rawText: "2016年9月大学入学", + dateRange: { start: "2016-09-01", end: "2016-09-30", precision: "month", label: "2016年9月" }, + scoreability: "scoreable", + supersedesRevisionId: null, + createdAt: now, + ...overrides, + }; +} + +test("SHA-256 canary assignment is stable and deployment modes are explicit", () => { + assert.equal(rectificationCanaryBucket("user-a"), 98.66510317660868); + assert.equal(selectRectificationDeploymentMode("user-a", { RECTIFICATION_AGENT_V5_ENABLED: "0" }), "v4_legacy"); + assert.equal(selectRectificationDeploymentMode("user-a", { + RECTIFICATION_AGENT_V5_ENABLED: "1", RECTIFICATION_AGENT_V5_CANARY_PERCENT: "100", RECTIFICATION_AGENT_V5_SHADOW: "1", + }), "v5_shadow"); + assert.equal(selectRectificationDeploymentMode("user-a", { + RECTIFICATION_AGENT_V5_ENABLED: "1", RECTIFICATION_AGENT_V5_CANARY_PERCENT: "100", RECTIFICATION_AGENT_V5_SHADOW: "0", + }), "v5_agent"); + assert.equal(selectRectificationDeploymentMode("user-a", { + RECTIFICATION_AGENT_V5_ENABLED: "1", RECTIFICATION_AGENT_V5_CANARY_PERCENT: "10", + }), "v4_legacy"); +}); + +test("opportunities are ordered only by their published utility", () => { + const target = event(); + const values = buildQuestionOpportunities({ + caseId, + events: [target], + turns: [], + snapshot: null, + diagnostics: null, + }); + assert.ok(values.length >= 2); + assert.deepEqual(values.map((value) => value.utility), [...values].map((value) => value.utility).sort((a, b) => b - a)); +}); + +test("an unresolved current target exclusively owns the next-question route", () => { + const target = event(); + const values = buildQuestionOpportunities({ + caseId, + events: [target, event({ eventId: randomUUID(), domain: "relocation", eventKind: "relocation", summary: "搬家到北京" })], + turns: [], + snapshot: null, + diagnostics: null, + retryTargetEventIds: [target.eventId], + }); + assert.deepEqual(values.map((value) => [value.kind, value.targetEventId]), [["resolve_event_conflict", target.eventId]]); +}); + +test("reasoner falls back when unavailable", async () => { + const result = await runBoundedReasoner({ caseValue, snapshot, diagnostics, opportunities: [opportunity] }); + assert.equal(result.mode, "deterministic_fallback"); + assert.equal(result.fallbackReason, "reasoner_model_unavailable"); + assert.equal(result.decision.action, "ask_question"); +}); + +test("reasoner permits one diagnostic, then requires a final action and accumulates usage", async () => { + const phases: string[] = []; + const result = await runBoundedReasoner({ + caseValue, + snapshot, + diagnostics, + opportunities: [opportunity], + generateDecision: async (_prompt, phase) => { + phases.push(phase); + return phase === "initial" + ? { object: { action: "run_diagnostic", diagnostic: "neighbor_stability" }, totalUsage: { inputTokens: 11, outputTokens: 3 } } + : { object: { action: "ask_question", opportunityId, narrativeFocus: ["candidate_change"] }, totalUsage: Promise.resolve({ inputTokens: 7, outputTokens: 2 }) }; + }, + }); + assert.deepEqual(phases, ["initial", "after_diagnostic"]); + assert.equal(result.mode, "agent"); + assert.equal(result.decision.action, "ask_question"); + assert.equal(result.toolCalls.length, 1); + assert.equal(result.toolCalls[0]?.outcome, "succeeded"); + assert.equal(result.inputTokenCount, 18); + assert.equal(result.outputTokenCount, 5); +}); + +test("reasoner rejects a second diagnostic and enforces the tool budget", async () => { + const nonfinal = await runBoundedReasoner({ + caseValue, + snapshot, + diagnostics, + opportunities: [opportunity], + generateDecision: async () => ({ object: { action: "run_diagnostic", diagnostic: "neighbor_stability" } }), + }); + assert.equal(nonfinal.mode, "deterministic_fallback"); + assert.equal(nonfinal.fallbackReason, "reasoner_returned_nonfinal_diagnostic"); + assert.equal(nonfinal.toolCalls.length, 1); + + const exhausted = await runBoundedReasoner({ + caseValue, + snapshot, + diagnostics, + opportunities: [opportunity], + maxToolCalls: 0, + generateDecision: async () => ({ object: { action: "run_diagnostic", diagnostic: "neighbor_stability" } }), + }); + assert.equal(exhausted.fallbackReason, "diagnostic_budget_exhausted"); + assert.equal(exhausted.toolCalls[0]?.outcome, "rejected"); +}); + +test("renderer cannot replace the server-owned question", () => { + assert.deepEqual(enforceServerQuestion({ + acknowledgement: "已记录。", + candidateUpdate: null, + limitation: null, + question: "模型注入的问题", + }, "服务器选定的问题"), { + acknowledgement: "已记录。", + candidateUpdate: null, + limitation: null, + question: "服务器选定的问题", + }); +}); + +test("agent-run persistence contract carries deployment, tool, token, and latency facts", () => { + const parsed = agentRunSchema.parse({ + id: randomUUID(), caseId, jobId: randomUUID(), caseVersion: 2, modelId: "test-model", + skillVersion: "birth-time-rectification-v5", promptVersion: "rectification-agent-v5-1", + deploymentMode: "v5_agent", deploymentSha: "abc123", + decision: { action: "ask_question", opportunityId, narrativeFocus: [] }, + validatedDecision: { + decision: { action: "ask_question", opportunityId, narrativeFocus: [] }, mode: "agent", validationIssues: [], selectedOpportunity: opportunity, + }, + toolCalls: [{ tool: "run_rectification_diagnostics", diagnostic: "neighbor_stability", outcome: "succeeded", durationMs: 4, errorCode: null }], + fallbackReason: null, inputTokenCount: 18, outputTokenCount: 5, latencyMs: 20, createdAt: now, + }); + assert.equal(parsed.toolCalls.length, 1); + assert.equal(parsed.inputTokenCount, 18); + assert.equal(parsed.latencyMs, 20); +}); + +test("an answer about another event never overwrites the current target and creates a conflict opportunity", () => { + const target = event(); + const reconciled = reconcileV4Evidence({ + caseId, + answer: "2018年8月搬家到北京", + sourceTurnId: randomUUID(), + asOfDate: "2026-07-28", + existing: [target], + targetEventId: target.eventId, + now: new Date(now), + }); + assert.equal(reconciled.unansweredTargetEventId, target.eventId); + assert.equal(reconciled.revisions.some((value) => value.eventId === target.eventId), false); + assert.equal(reconciled.revisions[0]?.eventKind, "relocation"); + const opportunities = buildQuestionOpportunities({ + caseId, + events: [target, ...reconciled.revisions], + turns: [], + snapshot: null, + diagnostics: null, + retryTargetEventIds: [target.eventId], + }); + assert.equal(opportunities[0]?.kind, "resolve_event_conflict"); + assert.equal(opportunities[0]?.targetEventId, target.eventId); +}); + +test("unparsed answers are retained as pending evidence", () => { + const target = event(); + const turnId = randomUUID(); + const reconciled = reconcileV4Evidence({ + caseId, + answer: "我记不清了,可能是那几年之间", + sourceTurnId: turnId, + asOfDate: "2026-07-28", + existing: [target], + targetEventId: target.eventId, + now: new Date(now), + }); + assert.equal(reconciled.revisions.length, 0); + assert.equal(reconciled.pending.length, 1); + assert.equal(reconciled.pending[0]?.turnId, turnId); + assert.equal(reconciled.pending[0]?.targetEventId, target.eventId); +}); + +test("shadow mode persists V5 artifacts while preserving the legacy visible reply", async () => { + async function run(mode: "v4_legacy" | "v5_shadow") { + return withV5Mode(mode, async () => { + const store = createRectificationV4MemoryStore(); + const service = createRectificationV4CaseService(store, { now: () => new Date(now) }); + const worker = createRectificationV4Worker({ + store, + now: () => new Date(now), + engine: { score: async ({ calculationSpec, events }) => v5EngineResult(calculationSpec, events) }, + }); + const userId = randomUUID(); + const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); + const queued = await service.answer({ + userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: created.case.version, + answer: "2016年9月大学入学", + }); + assert.ok(queued?.job); + await worker.runOnce(); + return { + message: [...store.publicMessages.values()][0], + question: (await service.loadCase(userId, created.case.id))?.case.currentQuestion, + agentRuns: store.agentRuns.size, + }; + }); + } + const legacy = await run("v4_legacy"); + const shadow = await run("v5_shadow"); + assert.deepEqual(shadow.message, legacy.message); + assert.equal(shadow.question?.prompt, legacy.question?.prompt); + assert.equal(shadow.agentRuns, 1); +}); diff --git a/frontend/tests/rectification-v4-domain.test.ts b/frontend/tests/rectification-v4-domain.test.ts index 85f98ad7..ee33c936 100644 --- a/frontend/tests/rectification-v4-domain.test.ts +++ b/frontend/tests/rectification-v4-domain.test.ts @@ -1,16 +1,18 @@ import assert from "node:assert/strict"; -import test from "node:test"; import { randomUUID } from "node:crypto"; +import test from "node:test"; +import { buildQuestionOpportunities } from "../src/lib/rectification-agent/opportunity-builder.ts"; import { buildCandidateClusters } from "../src/lib/rectification-v4/candidate-clusters.ts"; import { dateRangeFromDeclared, sampledDates } from "../src/lib/rectification-v4/date-range.ts"; import { evaluateDecisionGate } from "../src/lib/rectification-v4/decision-gate.ts"; import { appendEventRevision, latestEventRevisions } from "../src/lib/rectification-v4/evidence-ledger.ts"; import { extractV4EventRevisions } from "../src/lib/rectification-v4/extraction.ts"; -import { openingQuestion, planNextQuestion } from "../src/lib/rectification-v4/question-planner.ts"; +import { openingQuestion } from "../src/lib/rectification-v4/opening-question.ts"; -const now = new Date("2026-07-26T00:00:00.000Z"); +const now = new Date("2026-07-28T00:00:00.000Z"); +const revision = (input: Parameters[1]) => appendEventRevision([], input, { id: randomUUID(), now }); -test("declared month, quarter and year retain real boundaries instead of invented midpoints", () => { +test("declared month, quarter and year retain boundaries instead of invented midpoints", () => { assert.deepEqual(dateRangeFromDeclared("2024-02", "month"), { start: "2024-02-01", end: "2024-02-29", precision: "month", label: "2024-02", }); @@ -23,31 +25,39 @@ test("declared month, quarter and year retain real boundaries instead of invente assert.equal(sampledDates(dateRangeFromDeclared("2024-02", "month")).includes("2024-02-15"), false); }); -test("relationship start and end remain separate immutable events", () => { - const startId = randomUUID(); - const endId = randomUUID(); - const start = appendEventRevision([], { - eventId: startId, domain: "relationship", eventKind: "relationship_start", summary: "关系开始", - rawText: "2024年5月开始", dateRange: dateRangeFromDeclared("2024-05", "month"), - }, { id: randomUUID(), now }); - const end = appendEventRevision([start], { - eventId: endId, domain: "relationship", eventKind: "relationship_end", summary: "关系结束", - rawText: "2024年8月结束", dateRange: dateRangeFromDeclared("2024-08", "month"), - }, { id: randomUUID(), now }); +test("relationship start and end remain separate self/partner events", () => { + const start = revision({ + eventId: randomUUID(), domain: "relationship", eventKind: "relationship_start", subject: "self", relatedPerson: "partner", + summary: "关系开始", rawText: "2024年5月开始", dateRange: dateRangeFromDeclared("2024-05", "month"), scoreability: "scoreable", + }); + const end = revision({ + eventId: randomUUID(), domain: "relationship", eventKind: "relationship_end", subject: "self", relatedPerson: "partner", + summary: "关系结束", rawText: "2024年8月结束", dateRange: dateRangeFromDeclared("2024-08", "month"), scoreability: "scoreable", + }); assert.notEqual(start.eventId, end.eventId); - assert.equal(start.eventKind, "relationship_start"); - assert.equal(end.eventKind, "relationship_end"); + assert.deepEqual([start.eventKind, end.eventKind], ["relationship_start", "relationship_end"]); }); -test("family evidence is retained explicitly as context only", () => { - const revision = appendEventRevision([], { - eventId: randomUUID(), domain: "family", eventKind: "family_event", summary: "家庭变化", - rawText: "家庭发生变化", dateRange: dateRangeFromDeclared("2020", "year"), - }, { id: randomUUID(), now }); - assert.equal(revision.scoreability, "context_only"); +test("family health and bereavement stay context-only while self health is scoreable", () => { + for (const eventKind of ["family_health_event", "family_bereavement"] as const) { + const event = revision({ + eventId: randomUUID(), domain: "family", eventKind, subject: "family", relatedPerson: "mother", + summary: "家人健康事件", rawText: "2020年家人住院", dateRange: dateRangeFromDeclared("2020", "year"), scoreability: "context_only", + }); + assert.equal(event.scoreability, "context_only"); + } + const selfHealth = revision({ + eventId: randomUUID(), domain: "health_pressure", eventKind: "self_health_event", subject: "self", relatedPerson: null, + summary: "本人手术", rawText: "2021年3月手术", dateRange: dateRangeFromDeclared("2021-03", "month"), scoreability: "scoreable", + }); + assert.equal(selfHealth.scoreability, "scoreable"); + assert.throws(() => revision({ + eventId: randomUUID(), domain: "health_pressure", eventKind: "self_health_event", subject: "family", relatedPerson: "mother", + summary: "母亲手术", rawText: "2021年3月母亲手术", dateRange: dateRangeFromDeclared("2021-03", "month"), scoreability: "scoreable", + }), /non_self_event_not_scoreable/); }); -test("candidate minutes merge into ranked contiguous clusters", () => { +test("candidate minutes merge into ranked contiguous clusters and never confirm one minute", () => { const id = randomUUID(); const clusters = buildCandidateClusters([ { time: "05:13", score: 100, supportingEventIds: [id], conflictingEventIds: [] }, @@ -57,25 +67,19 @@ test("candidate minutes merge into ranked contiguous clusters", () => { { time: "05:17", score: 97, supportingEventIds: [id], conflictingEventIds: [] }, { time: "05:18", score: 97, supportingEventIds: [id], conflictingEventIds: [] }, ]); - assert.deepEqual(clusters.map((cluster) => [cluster.rank, cluster.startTime, cluster.endTime]), [ - [1, "05:13", "05:15"], [2, "05:17", "05:18"], - ]); -}); - -test("decision gate can accept a stable range but never an exact minute", () => { - const result = evaluateDecisionGate({ - clusters: [{ rank: 1, startTime: "05:13", endTime: "05:15", representativeTime: "05:13", widthMinutes: 3, peakScore: 10, scoreMass: 29 }], + assert.deepEqual(clusters.map((cluster) => [cluster.rank, cluster.startTime, cluster.endTime]), [[1, "05:13", "05:15"], [2, "05:17", "05:18"]]); + const gate = evaluateDecisionGate({ + clusters: [clusters[0]!], robustness: { neighborSupportMinutes: 3, leaveOneOutRetentionRate: 1, dateSensitivityRetentionRate: 0.9, calculationSpecHashMatched: true }, scoreableEventCount: 10, scoreableDomainCount: 5, }); - assert.equal(result.canAcceptRange, true); - assert.equal(result.canConfirmExactMinute, false); + assert.equal(gate.canAcceptRange, true); + assert.equal(gate.canConfirmExactMinute, false); }); -test("opening question invites free narration without a fixed domain", () => { +test("opening question is open narration, not a fixed-domain questionnaire", () => { const question = openingQuestion({ start: "04:50", end: "05:10" }, randomUUID()); - assert.equal(question.domain, "other"); assert.equal(question.targetEventId, null); assert.match(question.prompt, /04:50–05:10/); @@ -84,70 +88,33 @@ test("opening question invites free narration without a fixed domain", () => { assert.doesNotMatch(question.prompt, /毕业|搬家|恋爱|工作|财务|健康/); }); -test("fallback planner refines an imprecise event, then returns to open narration", () => { - const eventId = randomUUID(); - const event = appendEventRevision([], { - eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业", - rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"), - }, { id: randomUUID(), now }); - const question = planNextQuestion({ - events: [event], - attemptedRefinementEventIds: [], - latestAnswer: "2016年高中毕业", - id: randomUUID(), +test("Opportunity Builder prioritizes event-local date refinement and never asks family as self health", () => { + const event = revision({ + eventId: randomUUID(), domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, + summary: "离家去外地上大学", rawText: "2016年离家去外地上大学", dateRange: dateRangeFromDeclared("2016", "year"), scoreability: "scoreable", }); - assert.equal(question.targetEventId, eventId); - assert.equal(question.domain, "education"); - assert.match(question.prompt, /高中毕业/); - assert.match(question.prompt, /月份或日期/); - - const fallback = planNextQuestion({ - events: [event], - attemptedRefinementEventIds: [eventId], - latestAnswer: "2016年高中毕业", - id: randomUUID(), - }); - assert.equal(fallback.targetEventId, null); - assert.equal(fallback.domain, "other"); - assert.match(fallback.prompt, /继续讲另一件/); - assert.doesNotMatch(fallback.prompt, /搬家|恋爱|事业|财务|健康/); + const opportunities = buildQuestionOpportunities({ caseId: randomUUID(), events: [event], turns: [], snapshot: null, diagnostics: null }); + const local = opportunities.find((item) => item.kind === "refine_event_date"); + assert.equal(local?.targetEventId, event.eventId); + assert.equal(local?.domain, "education"); + assert.match(local?.prompt ?? "", /离家去外地上大学/); + assert.match(local?.prompt ?? "", /月份或日期/); + assert.ok(opportunities.every((item, index) => index === 0 || opportunities[index - 1]!.utility >= item.utility)); + assert.ok(opportunities.every((item) => item.domain !== "family")); }); -test("month-precise evidence is sufficient for the model to choose the next topic", () => { - const event = appendEventRevision([], { - eventId: randomUUID(), domain: "education", eventKind: "education_milestone", summary: "去外地上大学", - rawText: "2016年9月去外地上大学", dateRange: dateRangeFromDeclared("2016-09", "month"), - }, { id: randomUUID(), now }); - - const question = planNextQuestion({ - events: [event], - attemptedRefinementEventIds: [], - latestAnswer: "2016年9月去外地上大学", - id: randomUUID(), - }); - - assert.equal(question.targetEventId, null); - assert.equal(question.domain, "other"); -}); - -test("targeted date answer appends a revision without duplicating the scoreable event", () => { +test("targeted date answer appends a revision without duplicating the event", () => { const eventId = randomUUID(); - const original = appendEventRevision([], { - eventId, domain: "education", eventKind: "education_milestone", summary: "高中毕业", - rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"), - }, { id: randomUUID(), now }); + const original = revision({ + eventId, domain: "education", eventKind: "education_milestone", subject: "self", relatedPerson: null, + summary: "高中毕业", rawText: "2016年高中毕业", dateRange: dateRangeFromDeclared("2016", "year"), scoreability: "scoreable", + }); const revisions = extractV4EventRevisions({ - answer: "2016年6月8日", - sourceTurnId: randomUUID(), - asOfDate: "2026-07-26", - existing: [original], - targetEventId: eventId, - now, + answer: "2016年6月8日", sourceTurnId: randomUUID(), asOfDate: "2026-07-28", existing: [original], targetEventId: eventId, now, }); assert.equal(revisions.length, 1); assert.equal(revisions[0]?.eventId, eventId); assert.equal(revisions[0]?.revision, 2); - assert.equal(revisions[0]?.dateRange.precision, "day"); assert.equal(revisions[0]?.dateRange.start, "2016-06-08"); assert.equal(latestEventRevisions([original, ...revisions]).length, 1); }); diff --git a/frontend/tests/rectification-v4-replay.test.ts b/frontend/tests/rectification-v4-replay.test.ts index 96239dcc..5081ffbb 100644 --- a/frontend/tests/rectification-v4-replay.test.ts +++ b/frontend/tests/rectification-v4-replay.test.ts @@ -3,10 +3,10 @@ import { randomUUID } from "node:crypto"; import test from "node:test"; import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; -import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts"; -import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts"; +import type { CalculationSpec, CandidateMinute } from "../src/lib/rectification-v4/contracts.ts"; import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; +import { v5EngineResult, withV5Mode } from "./rectification-v5-test-support.ts"; const now = () => new Date("2026-07-26T08:00:00.000Z"); const spec: CalculationSpec = { @@ -29,13 +29,7 @@ async function answerAndRun( version: number, answer: string, ) { - const queued = await service.answer({ - userId, - caseId, - actionId: randomUUID(), - expectedCaseVersion: version, - answer, - }); + const queued = await service.answer({ userId, caseId, actionId: randomUUID(), expectedCaseVersion: version, answer }); assert.ok(queued?.job); assert.equal(await worker.runOnce(), true); const loaded = await service.loadCase(userId, caseId); @@ -43,79 +37,95 @@ async function answerAndRun( return loaded; } -test("fixture replay returns ranges only and never mutates the profile birth minute", async () => { +test("V5 golden replay persists the full artifact chain, returns ranges only, and never mutates the profile minute", async () => withV5Mode("v5_agent", async () => { const profile = { active_birth_time: "05:00:00" }; const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now }); + const candidates: readonly CandidateMinute[] = [ + { time: "05:13", score: 100, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:14", score: 99, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:15", score: 98, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:17", score: 97.8, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:18", score: 97.7, supportingEventIds: [], conflictingEventIds: [] }, + { time: "05:19", score: 97.6, supportingEventIds: [], conflictingEventIds: [] }, + ]; const worker = createRectificationV4Worker({ store, now, - questionAuthor: async () => ({ - id: randomUUID(), - domain: "other", - targetEventId: null, - prompt: "请继续讲另一件时间比较清楚的人生变化。", - recallCost: "low", - reason: "Replay keeps narration open instead of depending on a fixed domain order.", - }), engine: { async score({ calculationSpec, events }) { const ids = events.map((event) => event.eventId); - return { - resultId: randomUUID(), - calculationSpecHash: calculationSpecHash(calculationSpec), - candidates: [ - { time: "05:13", score: 100, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:14", score: 99, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:15", score: 98, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:16", score: 60, supportingEventIds: [], conflictingEventIds: ids }, - { time: "05:17", score: 97.8, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:18", score: 97.7, supportingEventIds: ids, conflictingEventIds: [] }, - { time: "05:19", score: 97.6, supportingEventIds: ids, conflictingEventIds: [] }, - ], - robustness: { - neighborSupportMinutes: 3, - leaveOneOutRetentionRate: 1, - dateSensitivityRetentionRate: 0.9, - }, - missingLayers: [], - }; + return v5EngineResult(calculationSpec, events, candidates.map((candidate) => ({ + ...candidate, + supportingEventIds: candidate.score >= 97 ? ids : [], + conflictingEventIds: candidate.score < 97 ? ids : [], + }))); }, }, }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - let loaded = await answerAndRun( - service, - worker, - userId, - created.case.id, - created.case.version, - "2015年7月高中毕业后复读一年,2016年6月再次高中毕业", - ); + assert.equal(created.case.deploymentMode, "v5_agent"); + + let loaded = await answerAndRun(service, worker, userId, created.case.id, created.case.version, "2015年7月高中毕业后复读一年,2016年6月再次高中毕业"); assert.deepEqual(loaded.events.map((event) => [event.dateRange.start, event.dateRange.end]), [ ["2015-07-01", "2015-07-31"], ["2016-06-01", "2016-06-30"], ]); - loaded = await answerAndRun(service, worker, userId, created.case.id, loaded.case.version, "2018年8月搬家到北京"); + const firstTarget = loaded.case.currentQuestion?.targetEventId; + assert.ok(firstTarget); + const firstTargetEvent = loaded.events.find((event) => event.eventId === firstTarget); + assert.ok(firstTargetEvent); loaded = await answerAndRun( service, worker, userId, created.case.id, loaded.case.version, - "2020年5月开始恋爱,2022年3月分手", + firstTargetEvent.dateRange.start.startsWith("2015-") ? "2015年7月18日" : "2016年6月22日", ); + const secondTarget = loaded.case.currentQuestion?.targetEventId; + assert.ok(secondTarget); + assert.notEqual(secondTarget, firstTarget); + const secondTargetEvent = loaded.events.find((event) => event.eventId === secondTarget); + assert.ok(secondTargetEvent); + loaded = await answerAndRun( + service, + worker, + userId, + created.case.id, + loaded.case.version, + secondTargetEvent.dateRange.start.startsWith("2015-") ? "2015年7月18日" : "2016年6月22日", + ); + assert.equal(loaded.case.currentQuestion?.domain, "relocation"); + loaded = await answerAndRun( + service, + worker, + userId, + created.case.id, + loaded.case.version, + "2018年8月搬家到北京;2019年3月入职新公司;2020年5月开始一段恋爱关系", + ); + const snapshot = loaded.case.latestSnapshot; assert.ok(snapshot); assert.equal(snapshot.canConfirmExactMinute, false); assert.equal(snapshot.canAcceptRange, true); - assert.deepEqual(snapshot.clusters.map((cluster) => [cluster.startTime, cluster.endTime]), [ - ["05:13", "05:15"], - ["05:17", "05:19"], - ]); + assert.deepEqual(snapshot.clusters.map((cluster) => [cluster.startTime, cluster.endTime]), [["05:13", "05:15"], ["05:17", "05:19"]]); assert.equal(snapshot.clusters[0]?.representativeTime, "05:13"); assert.equal(loaded.case.acceptedRange, null); + assert.ok(loaded.case.featureSnapshotId); + assert.ok(loaded.case.latestDiagnosticsId); + assert.equal(store.featureSnapshots.size, 1); + assert.equal(store.diagnostics.size, 1); + assert.equal(store.agentRuns.size, 4); + assert.equal(store.publicMessages.size, 4); + assert.equal(store.validatedDecisions.size, 4); + const finalRun = [...store.agentRuns.values()].at(-1); + assert.equal(finalRun?.validatedDecision.decision.action, "offer_candidate_range"); + assert.equal(finalRun?.inputTokenCount, null); + assert.equal(finalRun?.outputTokenCount, null); const accepted = await service.acceptRange({ userId, @@ -128,4 +138,4 @@ test("fixture replay returns ranges only and never mutates the profile birth min assert.deepEqual(accepted?.case.acceptedRange, { start: "05:13", end: "05:15" }); assert.equal(accepted?.case.latestSnapshot?.canConfirmExactMinute, false); assert.equal(profile.active_birth_time, "05:00:00"); -}); +})); diff --git a/frontend/tests/rectification-v4-service.test.ts b/frontend/tests/rectification-v4-service.test.ts index a736eb28..4d0c202b 100644 --- a/frontend/tests/rectification-v4-service.test.ts +++ b/frontend/tests/rectification-v4-service.test.ts @@ -1,12 +1,12 @@ import assert from "node:assert/strict"; -import test from "node:test"; import { randomUUID } from "node:crypto"; +import test from "node:test"; import { createRectificationV4CaseService } from "../src/lib/rectification-v4/case-service.ts"; import type { CalculationSpec } from "../src/lib/rectification-v4/contracts.ts"; import { createRectificationV4MemoryStore } from "../src/lib/rectification-v4/memory-store.ts"; import { createRectificationV4Worker } from "../src/lib/rectification-v4/worker.ts"; -const fixedNow = () => new Date("2026-07-26T12:00:00.000Z"); +const fixedNow = () => new Date("2026-07-28T12:00:00.000Z"); const spec: CalculationSpec = { version: "rectification-calculation-spec-v4", birthDate: "1997-08-08", @@ -19,224 +19,129 @@ const spec: CalculationSpec = { minuteStep: 1, }; +async function withMode(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise): Promise { + const keys = ["RECTIFICATION_AGENT_V5_ENABLED", "RECTIFICATION_AGENT_V5_SHADOW", "RECTIFICATION_AGENT_V5_CANARY_PERCENT"] as const; + const before = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + process.env.RECTIFICATION_AGENT_V5_ENABLED = mode === "v4_legacy" ? "0" : "1"; + process.env.RECTIFICATION_AGENT_V5_SHADOW = mode === "v5_shadow" ? "1" : "0"; + process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT = "100"; + try { return await run(); } finally { + for (const key of keys) { + if (before[key] === undefined) delete process.env[key]; + else process.env[key] = before[key]; + } + } +} -test("same calculation spec resumes the unfinished case", async () => { +test("same calculation spec resumes while a changed spec abandons the old case and stales its job", async () => withMode("v4_legacy", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const resumed = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: { ...spec } }); - assert.equal(resumed.case.id, first.case.id); - assert.equal(store.cases.size, 1); -}); -test("changed calculation spec atomically abandons the old case and stales its job", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ userId, caseId: first.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学", }); assert.ok(queued?.job); - const replacement = await service.createCase({ - userId, - actionId: randomUUID(), - calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } }, + userId, actionId: randomUUID(), calculationSpec: { ...spec, candidateRange: { start: "04:45", end: "05:30" } }, }); - assert.notEqual(replacement.case.id, first.case.id); assert.equal(store.cases.get(first.case.id)?.status, "abandoned"); - assert.equal(store.cases.get(first.case.id)?.currentQuestion, null); assert.equal(store.jobs.get(queued.job.id)?.status, "stale"); - assert.equal((await service.loadActive(userId))?.case.id, replacement.case.id); -}); +})); -test("an accepted range closes the active lifecycle and allows a new case", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const first = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - await store.transitionCase({ - userId, - caseId: first.case.id, - actionId: randomUUID(), - expectedCaseVersion: first.case.version, - status: "range_ready", - phase: "complete", - acceptedRange: { start: "05:13", end: "05:15" }, - now: fixedNow().toISOString(), - }); - - assert.equal(await service.loadActive(userId), null); - const next = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - assert.notEqual(next.case.id, first.case.id); - assert.equal(store.cases.size, 2); -}); - -test("answer is durably queued and poll remains read only", async () => { +test("answer is durably queued and polling does not mutate the job", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - assert.equal(created.case.status, "awaiting_answer"); - assert.equal(created.case.currentQuestion?.domain, "other"); - assert.match(created.case.currentQuestion?.prompt ?? "", /不需要按固定领域回答/); + assert.equal(created.case.protocol, "rectification-evidence-v5"); + assert.equal(created.case.deploymentMode, "v5_agent"); const queued = await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, - answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业", - modelId: "gpt-5.5", + answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业", modelId: "gpt-5.5", }); assert.equal(queued?.case.status, "processing"); - assert.equal(queued?.job?.status, "pending"); assert.equal(queued?.turns.at(-1)?.modelId, "gpt-5.5"); const before = JSON.stringify([...store.jobs.values()]); - const polled = await service.loadCase(userId, created.case.id); - assert.equal(polled?.job, null); + assert.equal((await service.loadCase(userId, created.case.id))?.job, null); assert.equal(JSON.stringify([...store.jobs.values()]), before); -}); +})); -test("worker extracts dated events, keeps one question and never confirms an exact minute", async () => { +test("V5 agent fallback persists Agent Run, Public Message and a server-owned opportunity", async () => withMode("v5_agent", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); const queued = await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, - answer: "2015年7月高中毕业后复读一年,2016年6月再次毕业", - }); - const worker = createRectificationV4Worker({ - store, - now: fixedNow, - engine: { async score() { throw new Error("engine must not run before enough events"); } }, - }); - assert.equal(await worker.runOnce(), true); - const done = await service.loadCase(userId, created.case.id); - assert.equal(done?.case.status, "awaiting_answer"); - assert.equal(done?.events.length, 2); - assert.equal(done?.case.currentQuestion?.domain, "other"); - assert.equal(done?.case.currentQuestion?.targetEventId, null); - assert.match(done?.case.currentQuestion?.prompt ?? "", /继续讲另一件/); - assert.equal(done?.case.latestSnapshot, null); - assert.equal(queued?.job?.status, "pending"); -}); - -test("worker rejects a model-authored domain jump while the latest event still needs a month", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - await service.answer({ - userId, - caseId: created.case.id, - actionId: randomUUID(), - expectedCaseVersion: created.case.version, answer: "2016年离家去外地上大学", - modelId: "gpt-5.5", }); + assert.ok(queued?.job); const worker = createRectificationV4Worker({ - store, - now: fixedNow, + store, now: fixedNow, engine: { async score() { throw new Error("engine must not run before enough events"); } }, - questionAuthor: async () => ({ - id: randomUUID(), - domain: "relocation", - targetEventId: null, - prompt: "请说一次影响较大的搬家或长期迁居,并给出尽可能准确的年月。", - recallCost: "low", - reason: "模型错误地跳到了另一个领域。", - }), }); - assert.equal(await worker.runOnce(), true); const done = await service.loadCase(userId, created.case.id); const event = done?.events.find((item) => item.summary === "离家去外地上大学"); - - assert.ok(event); + const run = [...store.agentRuns.values()][0]; + const message = store.publicMessages.get(queued.job.id); + assert.ok(event && run && message); + assert.equal(run.deploymentMode, "v5_agent"); + assert.equal(run.validatedDecision.mode, "deterministic_fallback"); + assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, event.eventId); assert.equal(done?.case.currentQuestion?.targetEventId, event.eventId); - assert.equal(done?.case.currentQuestion?.domain, "education"); assert.match(done?.case.currentQuestion?.prompt ?? "", /离家去外地上大学/); - assert.match(done?.case.currentQuestion?.prompt ?? "", /月份或日期/); - assert.doesNotMatch(done?.case.currentQuestion?.prompt ?? "", /搬家或长期迁居/); -}); + assert.equal(message.question, done?.case.currentQuestion?.prompt); + assert.equal(done?.case.latestSnapshot, null); +})); -test("completed job rejects stale case or calculation hashes", async () => { +test("V5 shadow runs and persists V5 artifacts but keeps the legacy visible projection", async () => withMode("v5_shadow", async () => { const store = createRectificationV4MemoryStore(); const service = createRectificationV4CaseService(store, { now: fixedNow }); const userId = randomUUID(); const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - await service.answer({ userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学" }); - const claimed = await store.claimNextJob("worker", fixedNow().toISOString()); - assert.ok(claimed); - await assert.rejects(() => store.completeJob({ - workerId: "worker", jobId: claimed.job.id, expectedCaseVersion: claimed.case.version, - inputEvidenceSetHash: "0".repeat(64), outputEvidenceSetHash: claimed.case.evidenceSetHash, - calculationSpecHash: claimed.case.calculationSpecHash, newEventRevisions: [], snapshot: null, - nextQuestion: claimed.case.currentQuestion, status: "awaiting_answer", phase: "collecting_evidence", - }, fixedNow().toISOString()), /stale_job/); -}); - -test("worker gives the selected model full conversation context for the next question", async () => { - const store = createRectificationV4MemoryStore(); - const service = createRectificationV4CaseService(store, { now: fixedNow }); - const userId = randomUUID(); - const created = await service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec }); - const contexts: Array<{ - modelId: string | null; - turnAnswers: string[]; - eventSummaries: string[]; - }> = []; - const worker = createRectificationV4Worker({ - store, - now: fixedNow, - engine: { async score() { throw new Error("engine must not run before enough events"); } }, - questionAuthor: async (context) => { - contexts.push({ - modelId: context.modelId, - turnAnswers: context.turns.map((turn) => turn.answer), - eventSummaries: context.events.map((event) => event.summary), - }); - return { - id: randomUUID(), - domain: "other", - targetEventId: null, - prompt: context.turns.length === 1 - ? "你提到复读后再次毕业,这段连续变化很清楚。后来还有哪一次环境变化让你印象很深?" - : "你提到毕业和搬家是连续发生的。那次搬家前后,生活节奏还有什么明显变化?", - recallCost: "low", - reason: "根据完整对话选择下一条高信息量追问。", - }; - }, + const queued = await service.answer({ + userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, + answer: "2016年离家去外地上大学", }); + const worker = createRectificationV4Worker({ + store, now: fixedNow, + engine: { async score() { throw new Error("engine must not run before enough events"); } }, + }); + assert.equal(await worker.runOnce(), true); + const done = await service.loadCase(userId, created.case.id); + const event = done?.events[0]; + const run = [...store.agentRuns.values()][0]; + assert.ok(queued?.job && event && run); + assert.equal(run.deploymentMode, "v5_shadow"); + assert.equal(run.validatedDecision.selectedOpportunity?.targetEventId, event.eventId); + assert.equal(done.case.currentQuestion?.targetEventId, event.eventId); + assert.match(done.case.currentQuestion?.reason ?? "", /V4 legacy projector/); + assert.match(store.publicMessages.get(queued.job.id)?.acknowledgement ?? "", /我记下了/); +})); - let current = created; - for (const [answer, modelId] of [ - ["2015年7月高中毕业后复读一年,2016年6月再次毕业", "gpt-5.5"], - ["2018年8月搬到北京,之后开始独立生活", "deepseek-chat"], - ] as const) { +test("legacy cases are not hard-switched to V5 even when flags change later", async () => { + const store = createRectificationV4MemoryStore(); + const service = createRectificationV4CaseService(store, { now: fixedNow }); + const userId = randomUUID(); + const created = await withMode("v4_legacy", () => service.createCase({ userId, actionId: randomUUID(), calculationSpec: spec })); + await withMode("v5_agent", async () => { const queued = await service.answer({ - userId, - caseId: created.case.id, - actionId: randomUUID(), - expectedCaseVersion: current.case.version, - answer, - modelId, + userId, caseId: created.case.id, actionId: randomUUID(), expectedCaseVersion: 0, answer: "2016年9月上大学", + }); + const worker = createRectificationV4Worker({ + store, now: fixedNow, + engine: { async score() { throw new Error("engine must not run before enough events"); } }, }); - assert.ok(queued?.job); assert.equal(await worker.runOnce(), true); - current = (await service.loadCase(userId, created.case.id))!; - } - - assert.equal(contexts.length, 2); - assert.equal(contexts[0]?.modelId, "gpt-5.5"); - assert.deepEqual(contexts[1]?.turnAnswers, [ - "2015年7月高中毕业后复读一年,2016年6月再次毕业", - "2018年8月搬到北京,之后开始独立生活", - ]); - assert.equal(contexts[1]?.modelId, "deepseek-chat"); - assert.equal(contexts[1]?.eventSummaries.length, 3); - assert.match(current.case.currentQuestion?.prompt ?? "", /毕业和搬家/); - assert.equal(current.case.status === "awaiting_answer" && current.case.currentQuestion === null, false); + const run = [...store.agentRuns.values()][0]; + assert.ok(queued?.job && run); + assert.equal(run.deploymentMode, "v4_legacy"); + assert.equal(run.fallbackReason, "deployment_mode_legacy"); + }); }); diff --git a/frontend/tests/rectification-v5-migration-contract.test.ts b/frontend/tests/rectification-v5-migration-contract.test.ts new file mode 100644 index 00000000..0e6c7dd9 --- /dev/null +++ b/frontend/tests/rectification-v5-migration-contract.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +const migration = readFileSync(new URL( + "../supabase/migrations/20260728020000_rectification_agent_v5.sql", + import.meta.url, +), "utf8"); + +test("V5 migration freezes protocol and deployment mode per case", () => { + assert.match(migration, /protocol in \('rectification-evidence-v4', 'rectification-evidence-v5'\)/); + assert.match(migration, /p_deployment_mode text/); + assert.match(migration, /deployment_mode in \('v4_legacy', 'v5_shadow', 'v5_agent'\)/); + assert.match(migration, /An in-flight Case keeps the deployment mode and protocol it was created with/); + assert.match(migration, /v_protocol := case when p_deployment_mode = 'v4_legacy'/); + assert.match(migration, /if found and v_case\.calculation_spec_hash = p_calculation_spec_hash/); +}); + +test("V5 migration owns all durable artifacts and indexes", () => { + for (const table of [ + "birth_time_rectification_candidate_feature_snapshots", + "birth_time_rectification_diagnostics", + "birth_time_rectification_agent_runs", + "birth_time_rectification_public_messages", + "birth_time_rectification_pending_evidence", + ]) assert.match(migration, new RegExp(`create table if not exists public\\.${table}`)); + for (const indexFragment of [ + "feature_snapshots_case_created_idx", "feature_snapshots_user_created_idx", + "diagnostics_case_created_idx", "diagnostics_user_created_idx", "diagnostics_snapshot_idx", + "agent_runs_case_created_idx", "agent_runs_user_created_idx", "public_messages_case_created_idx", + ]) assert.match(migration, new RegExp(indexFragment)); + assert.match(migration, /birth_time_rectification_v5_feature_snapshot_fk[\s\S]*candidate_feature_snapshots/); + assert.match(migration, /birth_time_rectification_v5_latest_diagnostics_fk[\s\S]*birth_time_rectification_diagnostics/); + assert.match(migration, /alter table public\.birth_time_rectification_agent_runs[\s\S]*add column if not exists deployment_mode/); + assert.match(migration, /alter column deployment_mode set not null/); + assert.match(migration, /birth_time_rectification_v5_agent_runs_tool_count_check/); + assert.match(migration, /birth_time_rectification_v5_agent_runs_token_count_check/); + assert.match(migration, /birth_time_rectification_pending_evidence_target_event_idx/); + assert.match(migration, /birth_time_rectification_v5_pending_resolution_check/); +}); + +test("V5 migration replaces every V4-only worker and evidence constraint", () => { + assert.match(migration, /birth_time_rectification_v5_jobs_phase_check[\s\S]*'reasoning', 'rendering'/); + assert.match(migration, /birth_time_rectification_v5_candidate_snapshots_algorithm_check[\s\S]*rectification-v4-range-scoring-1[\s\S]*rectification-v5-matrix-scoring-1/); + assert.match(migration, /birth_time_rectification_v5_event_revisions_scoreability_check[\s\S]*'pending_review', 'unsupported'/); + assert.match(migration, /birth_time_rect_v5_event_revision_domain_score_check[\s\S]*scoreability <> 'scoreable'/); + assert.match(migration, /birth_time_rect_v5_event_revision_relationship_kind_check[\s\S]*'relationship_change'/); +}); + +test("V5 completion is lease-bound, hash-bound, ownership-bound and replay-safe", () => { + for (const fragment of [ + "rectification_v4_job_lease_lost", + "stale_rectification_v4_job", + "rectification_v5_snapshot_mismatch", + "rectification_v5_feature_snapshot_mismatch", + "rectification_v5_diagnostics_mismatch", + "invalid_rectification_v5_agent_run", + "rectification_v5_replay_payload_mismatch", + "rectification_v5_pending_target_event_mismatch", + "rectification_v5_pending_resolved_event_mismatch", + "rectification_v5_artifact_set_incomplete", + "exact_minute_confirmation_forbidden", + ]) assert.match(migration, new RegExp(fragment)); + assert.match(migration, /if v_job\.status = 'completed'[\s\S]*return v_case\.id/); + assert.match(migration, /jsonb_array_length\(p_agent_run->'toolCalls'\) > 8/); + assert.match(migration, /p_agent_run->>'deploymentMode' is distinct from v_case\.deployment_mode/); + assert.match(migration, /p_completion_payload_hash text/); + assert.match(migration, /v_job\.completion_payload_hash is distinct from p_completion_payload_hash/); + assert.match(migration, /completion_payload_hash = p_completion_payload_hash/); +}); + +test("V5 inserts use explicit columns and never mutate the profile birth minute", () => { + for (const table of [ + "birth_time_rectification_v4_events", + "birth_time_rectification_v4_event_revisions", + "birth_time_rectification_v4_candidate_snapshots", + "birth_time_rectification_candidate_feature_snapshots", + "birth_time_rectification_diagnostics", + "birth_time_rectification_agent_runs", + "birth_time_rectification_public_messages", + ]) assert.match(migration, new RegExp(`insert into public\\.${table}\\s*\\(`)); + assert.doesNotMatch(migration, /profiles\.active_birth_time|update\s+public\.profiles/i); +}); diff --git a/frontend/tests/rectification-v5-test-support.ts b/frontend/tests/rectification-v5-test-support.ts new file mode 100644 index 00000000..841548a3 --- /dev/null +++ b/frontend/tests/rectification-v5-test-support.ts @@ -0,0 +1,87 @@ +import { randomUUID } from "node:crypto"; +import type { CandidateEngineResult } from "../src/lib/rectification-v4/candidate-engine.ts"; +import type { CalculationSpec, CandidateMinute, LifeEventRevision } from "../src/lib/rectification-v4/contracts.ts"; +import { rectificationV4AlgorithmVersion } from "../src/lib/rectification-v4/contracts.ts"; +import { calculationSpecHash } from "../src/lib/rectification-v4/fingerprints.ts"; + +export function v5EngineResult( + calculationSpec: CalculationSpec, + events: readonly LifeEventRevision[], + candidates: readonly CandidateMinute[] = [ + { time: "05:13", score: 100, supportingEventIds: events.map((event) => event.eventId), conflictingEventIds: [] }, + { time: "05:14", score: 99, supportingEventIds: events.map((event) => event.eventId), conflictingEventIds: [] }, + { time: "05:15", score: 98, supportingEventIds: events.map((event) => event.eventId), conflictingEventIds: [] }, + ], +): CandidateEngineResult { + const specHash = calculationSpecHash(calculationSpec); + return { + resultId: randomUUID(), + calculationSpecHash: specHash, + candidates, + robustness: { + neighborSupportMinutes: 3, + leaveOneOutRetentionRate: 1, + leaveOneDomainOutRetentionRate: 1, + dateSensitivityRetentionRate: .9, + }, + diagnostics: { + primary_cluster_retention_rate: 1, + leave_one_event_out_retention_rate: 1, + leave_one_domain_out_retention_rate: 1, + date_sensitivity_retention_rate: .9, + neighbor_support_minutes: 3, + primary_secondary_margin_percent: 20, + cluster_mass_ratio: .9, + unstable_event_ids: [], + most_discriminating_layers: ["D9", "D10"], + event_date_sensitivity: events.map((event) => ({ + event_id: event.eventId, + declared_date_range: { start: event.dateRange.start, end: event.dateRange.end, precision: event.dateRange.precision }, + sample_dates: [event.dateRange.start, event.dateRange.end].filter((value, index, values) => values.indexOf(value) === index), + winner_retention_rate: 1, + score_variance: 0, + candidate_cluster_retention_rate: 1, + })), + candidate_splits: [], + }, + featureSnapshot: { + calculation_spec_hash: specHash, + algorithm_version: rectificationV4AlgorithmVersion, + candidate_count: candidates.length, + feature_hash: "f".repeat(64), + features: candidates.map((candidate, index) => ({ + time: candidate.time, + ascendant_degree: index, + ascendant_sign_index: 0, + varga_ascendants: { D1: 0, D9: index % 12 }, + arudha_signs: { A7: 1, A10: 2, UL: 3 }, + available_layers: ["D1", "D9", "D10"], + blocked_layers: ["KP_cusps"], + fingerprints: { static: `${candidate.time}:${index}` }, + })), + }, + contributionMatrix: Object.fromEntries(events.map((event) => [ + event.eventId, + Object.fromEntries(candidates.map((candidate) => [candidate.time, { + points: candidate.supportingEventIds.includes(event.eventId) ? 1 : candidate.conflictingEventIds.includes(event.eventId) ? -1 : 0, + rule_ids: ["fixture:rule"], + technique_layers: ["D9"], + }])), + ])), + missingLayers: ["KP_cusps"], + }; +} + +export async function withV5Mode(mode: "v4_legacy" | "v5_shadow" | "v5_agent", run: () => Promise): Promise { + const keys = ["RECTIFICATION_AGENT_V5_ENABLED", "RECTIFICATION_AGENT_V5_SHADOW", "RECTIFICATION_AGENT_V5_CANARY_PERCENT"] as const; + const before = Object.fromEntries(keys.map((key) => [key, process.env[key]])); + process.env.RECTIFICATION_AGENT_V5_ENABLED = mode === "v4_legacy" ? "0" : "1"; + process.env.RECTIFICATION_AGENT_V5_SHADOW = mode === "v5_shadow" ? "1" : "0"; + process.env.RECTIFICATION_AGENT_V5_CANARY_PERCENT = "100"; + try { return await run(); } finally { + for (const key of keys) { + if (before[key] === undefined) delete process.env[key]; + else process.env[key] = before[key]; + } + } +} diff --git a/references/real_case_calibration/conversational_rectification_development_v1.json b/references/real_case_calibration/conversational_rectification_development_v1.json index ce30dea7..4039e35e 100644 --- a/references/real_case_calibration/conversational_rectification_development_v1.json +++ b/references/real_case_calibration/conversational_rectification_development_v1.json @@ -83,8 +83,8 @@ { "event_id": "curie_widowed_1906", "user_utterance": "1906年4月19日我的丈夫因交通事故去世。", - "expected_extraction": {"date_value": "1906-04-19", "date_precision": "day", "domain": "health_pressure", "scoreable": true}, - "expected_route_scoreable": true + "expected_extraction": {"date_value": "1906-04-19", "date_precision": "day", "domain": "family", "scoreable": false}, + "expected_route_scoreable": false } ] } diff --git a/scripts/active_rectification_event_engine.py b/scripts/active_rectification_event_engine.py index a0e386c0..b3d27432 100644 --- a/scripts/active_rectification_event_engine.py +++ b/scripts/active_rectification_event_engine.py @@ -14,7 +14,7 @@ import sys from datetime import date, datetime, time, timedelta from pathlib import Path from collections.abc import Sequence -from typing import Final, assert_never +from typing import Any, Final, assert_never from scripts.active_rectification_events import ( CandidateEvidence, @@ -316,10 +316,22 @@ def _shadbala_verified_components_auxiliary(natal_chart: dict, birth_hour: float return [], 0.0 -def _candidate_row( +def _feature_hash(value: Any) -> str: + normalized = json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":"), default=str) + return hashlib.sha256(normalized.encode("utf-8")).hexdigest() + + +def _arudha_sign(arudha_padas: dict, key: str) -> int | None: + value = arudha_padas.get(key) or {} + sign_index = value.get("sign_idx") + return int(sign_index) if isinstance(sign_index, int) and 0 <= sign_index <= 11 else None + + +def build_candidate_static_context( request: RectificationEventRequest, candidate_at: datetime, -) -> CandidateScoreRow: +) -> dict[str, Any]: + """Compute every candidate-minute natal layer once for scoring and diagnostics.""" chart = domain_calculation_service.compute_chart({ "year": candidate_at.year, "month": candidate_at.month, @@ -340,16 +352,102 @@ def _candidate_row( ascendant_longitude = float(chart["ascendant"]["lon"]) ascendant_index = int(ascendant_longitude // 30) arudha = jaimini.calc_arudha_padas(ascendant_index, planet_longitudes) - arudha_padas = { - **(arudha.get("padas") or {}), - "UL": arudha.get("upapada") or {}, - } + arudha_padas = {**(arudha.get("padas") or {}), "UL": arudha.get("upapada") or {}} charts = varga.calc_all_vargas( planet_longitudes, ascendant_longitude, divisions=[2, 4, 9, 10, 24, 30], ) d11_chart = _d11_chart(planet_longitudes, ascendant_longitude) + varga_charts = { + prefix: d11_chart if prefix == "D11" else _varga_chart(charts, prefix) + for prefix in ("D2", "D4", "D9", "D10", "D11", "D24", "D30") + } + available_layers = ["D1"] + blocked_layers = ["KP_cusps"] + varga_ascendants: dict[str, int] = {} + for prefix, value in varga_charts.items(): + ascendant = (value or {}).get("Ascendant") or {} + sign_index = ascendant.get("sign_idx") + if isinstance(sign_index, int) and 0 <= sign_index <= 11: + varga_ascendants[prefix] = sign_index + available_layers.append(prefix) + else: + blocked_layers.append(prefix) + + arudha_signs = {key: _arudha_sign(arudha_padas, key) for key in ("A7", "A10", "UL")} + for key, sign_index in arudha_signs.items(): + (available_layers if sign_index is not None else blocked_layers).append(key) + + ashtakavarga_result = None + try: + ashtakavarga_result = ashtakavarga.calc_ashtakavarga(chart.get("planets", {}), ascendant_index) + available_layers.append("Ashtakavarga") + except (KeyError, TypeError, ValueError): + blocked_layers.append("Ashtakavarga") + + shadbala_result = None + try: + shadbala_result = shadbala.calc_shadbala( + chart.get("planets", {}), + str(chart["ascendant"].get("sign")), + candidate_at.hour + candidate_at.minute / 60, + planet_longitudes["Sun"], + planet_longitudes["Moon"], + birth_minute=float(candidate_at.minute), + ) + available_layers.append("Shadbala") + except (KeyError, TypeError, ValueError): + blocked_layers.append("Shadbala") + + feature_payload = { + "time": candidate_at.strftime("%H:%M"), + "ascendant_degree": ascendant_longitude, + "ascendant_sign_index": ascendant_index, + "varga_ascendants": varga_ascendants, + "arudha_signs": arudha_signs, + "available_layers": sorted(set(available_layers)), + "blocked_layers": sorted(set(blocked_layers)), + "fingerprints": { + "natal": str(chart.get("result_hash") or _feature_hash({"ascendant": chart.get("ascendant"), "planets": chart.get("planets")})), + "vargas": _feature_hash(varga_ascendants), + "arudha": _feature_hash(arudha_signs), + "ashtakavarga": _feature_hash(ashtakavarga_result) if ashtakavarga_result is not None else "blocked", + "shadbala": _feature_hash(shadbala_result) if shadbala_result is not None else "blocked", + }, + } + feature_payload["fingerprints"]["static"] = _feature_hash(feature_payload) + return { + "candidate_at": candidate_at, + "chart": chart, + "planet_longitudes": planet_longitudes, + "ascendant_longitude": ascendant_longitude, + "ascendant_index": ascendant_index, + "arudha_padas": arudha_padas, + "varga_charts": varga_charts, + "feature": feature_payload, + } + + +def compute_candidate_static_contexts( + request: RectificationEventRequest, + *, + candidates: Sequence[datetime] | None = None, +) -> list[dict[str, Any]]: + candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request) + return [build_candidate_static_context(request, candidate) for candidate in candidate_datetimes] + + +def _candidate_row( + request: RectificationEventRequest, + context: dict[str, Any], +) -> CandidateScoreRow: + candidate_at = context["candidate_at"] + chart = context["chart"] + planet_longitudes = context["planet_longitudes"] + ascendant_index = context["ascendant_index"] + arudha_padas = context["arudha_padas"] + varga_charts = context["varga_charts"] moon_longitude = planet_longitudes["Moon"] evidence: list[CandidateEvidence] = [] missing_layers: list[str] = [] @@ -357,7 +455,7 @@ def _candidate_row( for event in request["events"]: event_at = _event_datetime(event) prefixes, _ = DOMAIN_CONFIG[event["domain"]] - domain_vargas = [d11_chart if prefix == "D11" else _varga_chart(charts, prefix) for prefix in prefixes] + domain_vargas = [varga_charts[prefix] for prefix in prefixes] if any(chart is None for chart in domain_vargas): missing_layers.extend(prefixes) continue @@ -407,7 +505,7 @@ def _candidate_row( "time": candidate_at.strftime("%H:%M"), "score": round(sum(item["points"] for item in evidence), 4), "evidence": evidence, - "missing_layers": sorted(set(missing_layers)), + "missing_layers": sorted(set(missing_layers + context["feature"]["blocked_layers"])), } @@ -501,7 +599,8 @@ def compute_event_candidate_rows( request: RectificationEventRequest, *, candidates: Sequence[datetime] | None = None, + static_contexts: Sequence[dict[str, Any]] | None = None, ) -> list[CandidateScoreRow]: - """Return every computed minute row without performing release adjudication.""" - candidate_datetimes = list(candidates) if candidates is not None else _candidate_datetimes(request) - return [_candidate_row(request, candidate) for candidate in candidate_datetimes] + """Return every computed minute row while reusing one static chart scan per candidate.""" + contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request, candidates=candidates) + return [_candidate_row(request, context) for context in contexts] diff --git a/scripts/active_rectification_events_v4.py b/scripts/active_rectification_events_v4.py index 4c120ba8..507170cf 100644 --- a/scripts/active_rectification_events_v4.py +++ b/scripts/active_rectification_events_v4.py @@ -2,219 +2,16 @@ # requires-python = ">=3.11" # dependencies = [] # /// -"""Range-preserving event scoring for the asynchronous rectification V4 worker.""" - +"""Compatibility entrypoint backed by the formal V5 score service.""" from __future__ import annotations -import hashlib -import json -from collections.abc import Sequence -from typing import Any, Final, Literal, NotRequired, TypedDict -from uuid import NAMESPACE_URL, uuid5 +from typing import Any -from scripts.active_rectification_event_engine import compute_event_candidate_rows -from scripts.active_rectification_events import CandidateEvidence, CandidateScoreRow - -ALGORITHM_VERSION: Final = "rectification-v4-range-scoring-1" -INPUT_CONTRACT_VERSION: Final = "rectification-calculation-spec-v4" - -EventDomain = Literal["education", "relocation", "relationship", "career", "finance", "health_pressure"] -EventPrecision = Literal["day", "month", "quarter", "year", "range"] +from scripts.rectification.api_service import score_candidates -def _json_compatible_numbers(value: Any) -> Any: - if isinstance(value, float) and value.is_integer(): - return int(value) - if isinstance(value, dict): - return {key: _json_compatible_numbers(item) for key, item in value.items()} - if isinstance(value, list): - return [_json_compatible_numbers(item) for item in value] - return value - - -class RangeLifeEvent(TypedDict): - id: str - domain: EventDomain - event_kind: str - date_start: str - date_end: str - precision: EventPrecision - summary: NotRequired[str] - - -class RangeRectificationRequest(TypedDict): - birth_date: str - start_time: str - end_time: str - lat: float - lon: float - tz: float - events: list[RangeLifeEvent] - - -def _legacy_request(request: RangeRectificationRequest, boundary: Literal["start", "end"]) -> dict[str, Any]: - return { - "birth_date": request["birth_date"], - "start_time": request["start_time"], - "end_time": request["end_time"], - "lat": request["lat"], - "lon": request["lon"], - "tz": request["tz"], - "events": [{ - "id": event["id"], - "domain": event["domain"], - "date": event[f"date_{boundary}"], - "precision": "day", - "summary": event.get("summary", ""), - } for event in request["events"]], - } - - -def _evidence_by_event(row: CandidateScoreRow) -> dict[str, CandidateEvidence]: - return {item["event_id"]: item for item in row["evidence"]} - - -def _average_rows( - lower_rows: Sequence[CandidateScoreRow], - upper_rows: Sequence[CandidateScoreRow], -) -> list[CandidateScoreRow]: - if [row["time"] for row in lower_rows] != [row["time"] for row in upper_rows]: - raise ValueError("candidate_grid_mismatch") - averaged: list[CandidateScoreRow] = [] - for lower, upper in zip(lower_rows, upper_rows, strict=True): - lower_events = _evidence_by_event(lower) - upper_events = _evidence_by_event(upper) - evidence: list[CandidateEvidence] = [] - for event_id in sorted(set(lower_events) | set(upper_events)): - lower_item = lower_events.get(event_id) - upper_item = upper_events.get(event_id) - source = lower_item or upper_item - if source is None: - continue - lower_points = lower_item["points"] if lower_item else 0.0 - upper_points = upper_item["points"] if upper_item else 0.0 - evidence.append({ - "event_id": event_id, - "domain": source["domain"], - "candidate_time": lower["time"], - "rule_ids": sorted(set( - (lower_item or {}).get("rule_ids", []) - + (upper_item or {}).get("rule_ids", []) - + ["date_range_boundaries_averaged"] - )), - "points": round((lower_points + upper_points) / 2, 4), - }) - averaged.append({ - "time": lower["time"], - "score": round(sum(item["points"] for item in evidence), 4), - "evidence": evidence, - "missing_layers": sorted(set(lower["missing_layers"] + upper["missing_layers"])), - }) - return averaged - - -def _minute_value(value: str) -> int: - hour, minute = value.split(":", maxsplit=1) - return int(hour) * 60 + int(minute) - - -def _next_minute(previous: str, current: str) -> bool: - return (_minute_value(current) - _minute_value(previous)) % 1_440 == 1 - - -def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = 0.97) -> list[str]: - if not rows: - return [] - peak = max(row["score"] for row in rows) - floor = peak * relative_floor if peak >= 0 else peak / relative_floor - viable = sorted((row for row in rows if row["score"] >= floor), key=lambda row: _minute_value(row["time"])) - clusters: list[list[CandidateScoreRow]] = [] - for row in viable: - if clusters and _next_minute(clusters[-1][-1]["time"], row["time"]): - clusters[-1].append(row) - else: - clusters.append([row]) - if not clusters: - return [] - clusters.sort(key=lambda group: (-max(row["score"] for row in group), -sum(max(row["score"], 0) for row in group))) - return [row["time"] for row in clusters[0]] - - -def _top_time(rows: Sequence[CandidateScoreRow]) -> str | None: - if not rows: - return None - top = max(row["score"] for row in rows) - return next(row["time"] for row in rows if row["score"] == top) - - -def _leave_one_out(rows: Sequence[CandidateScoreRow], event_ids: Sequence[str], primary: set[str]) -> dict[str, Any]: - runs = [] - retained = 0 - for event_id in event_ids: - rescored = [] - for row in rows: - removed = sum(item["points"] for item in row["evidence"] if item["event_id"] == event_id) - rescored.append({**row, "score": round(row["score"] - removed, 4)}) - winner = _top_time(rescored) - stable = winner in primary - retained += int(stable) - runs.append({"removed_event_id": event_id, "winner": winner, "primary_cluster_retained": stable}) - return { - "retention_rate": retained / len(event_ids) if event_ids else 0.0, - "runs": runs, - } - - -def score_life_events_v4(request: RangeRectificationRequest) -> dict[str, Any]: - lower_rows = compute_event_candidate_rows(_legacy_request(request, "start")) - upper_rows = compute_event_candidate_rows(_legacy_request(request, "end")) - rows = _average_rows(lower_rows, upper_rows) - primary = _primary_cluster(rows) - primary_set = set(primary) - lower_winner = _top_time(lower_rows) - upper_winner = _top_time(upper_rows) - date_retention = sum(winner in primary_set for winner in (lower_winner, upper_winner)) / 2 - loo = _leave_one_out(rows, [event["id"] for event in request["events"]], primary_set) - normalized = json.dumps(request, ensure_ascii=True, sort_keys=True, separators=(",", ":")) - fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest() - spec = { - "version": INPUT_CONTRACT_VERSION, - "birthDate": request["birth_date"], - "candidateRange": {"start": request["start_time"], "end": request["end_time"]}, - "latitude": request["lat"], - "longitude": request["lon"], - "timezoneOffsetHours": request["tz"], - "ayanamsa": "lahiri", - "nodeMode": "mean", - "minuteStep": 1, - } - spec_hash = hashlib.sha256(json.dumps( - _json_compatible_numbers(spec), sort_keys=True, separators=(",", ":") - ).encode("utf-8")).hexdigest() - missing_layers = sorted({layer for row in rows for layer in row["missing_layers"]}) - candidates = [{ - "time": row["time"], - "score": row["score"], - "supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0], - "conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0], - } for row in rows] - return { - "result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")), - "algorithm_version": ALGORITHM_VERSION, - "calculation_spec": spec, - "calculation_spec_hash": spec_hash, - "candidate_scores": candidates, - "primary_cluster_times": primary, - "robustness": { - "neighbor_support_minutes": len(primary), - "leave_one_out_retention_rate": loo["retention_rate"], - "date_sensitivity_retention_rate": date_retention, - "date_boundary_winners": {"start": lower_winner, "end": upper_winner}, - "leave_one_out": loo, - }, - "missing_layers": missing_layers, - "can_confirm_exact_minute": False, - } +def score_life_events_v4(request: dict[str, Any]) -> dict[str, Any]: + return score_candidates(request) if __name__ == "__main__": diff --git a/scripts/jyotish_api_server.py b/scripts/jyotish_api_server.py index 1502baf3..68d1c2d7 100644 --- a/scripts/jyotish_api_server.py +++ b/scripts/jyotish_api_server.py @@ -1630,6 +1630,9 @@ API_COMMAND_MAP = { 'active-rectification-score': '/api/active_rectification_score', 'active-rectification-events': '/api/active_rectification_events', 'active-rectification-events-v4': '/api/active_rectification_events_v4', + 'rectification-v5-candidate-features': '/api/rectification/v5/candidate-features', + 'rectification-v5-score': '/api/rectification/v5/score', + 'rectification-v5-diagnostics': '/api/rectification/v5/diagnostics', 'case-validation': '/api/case_validation', 'divisional-yoga': '/api/divisional_yoga', 'deep-varga-avastha': '/api/deep_varga_avastha', @@ -1665,6 +1668,9 @@ TECHNIQUE_EXAMPLE_ENDPOINTS = { '/api/active_rectification_score', '/api/active_rectification_events', '/api/active_rectification_events_v4', + '/api/rectification/v5/candidate-features', + '/api/rectification/v5/score', + '/api/rectification/v5/diagnostics', '/api/relationship', '/api/remedies', '/api/sade_sati', @@ -2114,6 +2120,12 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): elif path == '/api/active_rectification_events_v4': result = self._compute_active_rectification_events_v4(body) self._json(result) + elif path == '/api/rectification/v5/candidate-features': + self._json(self._compute_rectification_v5_candidate_features(body)) + elif path == '/api/rectification/v5/score': + self._json(self._compute_rectification_v5_score(body)) + elif path == '/api/rectification/v5/diagnostics': + self._json(self._compute_rectification_v5_diagnostics(body)) elif path == '/api/dynamic_rectification_opportunities': result = self._compute_dynamic_rectification_opportunities(body) self._json(result) @@ -7527,100 +7539,45 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): 'tz': self._get_float(body, 'tz', 0, -14, 14), } - def _compute_active_rectification_events_v4(self, body): - if not isinstance(body, dict): - raise BadRequest('request body must be an object') - - def required_text(name, pattern=None): - value = body.get(name) - if not isinstance(value, str) or not value.strip(): - raise BadRequest(f'{name} must be a string') - value = value.strip() - if pattern and not re.fullmatch(pattern, value): - raise BadRequest(f'{name} has invalid format') - return value - - birth_date = required_text('birth_date', r'\d{4}-\d{2}-\d{2}') - start_time = required_text('start_time', r'(?:[01]\d|2[0-3]):[0-5]\d') - end_time = required_text('end_time', r'(?:[01]\d|2[0-3]):[0-5]\d') + def _rectification_v5_request(self, body): + from scripts.rectification.contracts import normalize_rectification_request try: - birth_day = datetime.strptime(birth_date, '%Y-%m-%d').date() + return normalize_rectification_request(body) except ValueError as exc: - raise BadRequest('birth_date must be a valid calendar date') from exc - if start_time > end_time: - raise BadRequest('start_time must not exceed end_time') + raise BadRequest(str(exc)) from exc - def bounded_number(name, minimum, maximum): - value = body.get(name) - if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): - raise BadRequest(f'{name} must be a finite number') - value = float(value) - if not minimum <= value <= maximum: - raise BadRequest(f'{name} must be between {minimum} and {maximum}') - return value - - events = body.get('events') - if not isinstance(events, list) or not 1 <= len(events) <= 100: - raise BadRequest('events must contain between 1 and 100 items') - allowed_kinds = { - 'education': {'education_milestone'}, - 'relocation': {'relocation'}, - 'relationship': {'relationship_start', 'relationship_end'}, - 'career': {'career_change'}, - 'finance': {'finance_change'}, - 'health_pressure': {'health_event'}, + def _compute_rectification_v5_candidate_features(self, body): + from scripts.rectification.api_service import candidate_features + return { + 'success': True, + 'endpoint': 'rectification_v5_candidate_features', + **candidate_features(self._rectification_v5_request(body)), + } + + def _compute_rectification_v5_score(self, body): + from scripts.rectification.api_service import score_candidates + return { + 'success': True, + 'endpoint': 'rectification_v5_score', + **score_candidates(self._rectification_v5_request(body)), + } + + def _compute_rectification_v5_diagnostics(self, body): + from scripts.rectification.api_service import diagnostics + return { + 'success': True, + 'endpoint': 'rectification_v5_diagnostics', + **diagnostics(self._rectification_v5_request(body)), + } + + def _compute_active_rectification_events_v4(self, body): + """Compatibility projection; validation and calculations are owned by V5 services.""" + from scripts.rectification.api_service import score_candidates + return { + 'success': True, + 'endpoint': 'active_rectification_events_v4', + **score_candidates(self._rectification_v5_request(body)), } - allowed_precision = {'day', 'month', 'quarter', 'year', 'range'} - cleaned_events = [] - today = datetime.now().date() - for index, event in enumerate(events): - if not isinstance(event, dict): - raise BadRequest(f'events[{index}] must be an object') - try: - event_id = str(uuid.UUID(str(event.get('id') or ''))) - except (ValueError, AttributeError) as exc: - raise BadRequest(f'events[{index}].id must be a UUID') from exc - domain = event.get('domain') - event_kind = event.get('event_kind') - precision = event.get('precision') - if domain not in allowed_kinds: - raise BadRequest(f'events[{index}].domain is not scoreable') - if event_kind not in allowed_kinds[domain]: - raise BadRequest(f'events[{index}].event_kind does not match domain') - if precision not in allowed_precision: - raise BadRequest(f'events[{index}].precision is invalid') - try: - start_day = datetime.strptime(str(event.get('date_start') or ''), '%Y-%m-%d').date() - end_day = datetime.strptime(str(event.get('date_end') or ''), '%Y-%m-%d').date() - except ValueError as exc: - raise BadRequest(f'events[{index}] dates must be valid YYYY-MM-DD values') from exc - if start_day > end_day: - raise BadRequest(f'events[{index}].date_start must not exceed date_end') - if start_day < birth_day or end_day > today: - raise BadRequest(f'events[{index}] dates must be between birth_date and today') - summary = event.get('summary', '') - if not isinstance(summary, str) or len(summary) > 1000: - raise BadRequest(f'events[{index}].summary must be a string up to 1000 characters') - cleaned_events.append({ - 'id': event_id, - 'domain': domain, - 'event_kind': event_kind, - 'date_start': start_day.isoformat(), - 'date_end': end_day.isoformat(), - 'precision': precision, - 'summary': summary.strip(), - }) - module = _load_local_module('active_rectification_events_v4') - result = module.score_life_events_v4({ - 'birth_date': birth_date, - 'start_time': start_time, - 'end_time': end_time, - 'lat': bounded_number('lat', -90, 90), - 'lon': bounded_number('lon', -180, 180), - 'tz': bounded_number('tz', -14, 14), - 'events': cleaned_events, - }) - return {'success': True, 'endpoint': 'active_rectification_events_v4', **result} def _compute_dynamic_rectification_opportunities(self, body): self._require_dynamic_rectification_token() @@ -8485,6 +8442,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/active_rectification_score': self._compute_active_rectification_score, '/api/active_rectification_events': self._compute_active_rectification_events, '/api/active_rectification_events_v4': self._compute_active_rectification_events_v4, + '/api/rectification/v5/candidate-features': self._compute_rectification_v5_candidate_features, + '/api/rectification/v5/score': self._compute_rectification_v5_score, + '/api/rectification/v5/diagnostics': self._compute_rectification_v5_diagnostics, '/api/relationship': self._compute_relationship, '/api/remedies': self._compute_remedies, '/api/sade_sati': self._compute_sade_sati, @@ -8611,6 +8571,9 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/rectification_gate': 'Evaluate birth-time precision gate', '/api/active_rectification_events': 'Score dated life events against actual birth-time candidates', '/api/active_rectification_events_v4': 'Score immutable dated event ranges for asynchronous V4 rectification', + '/api/rectification/v5/candidate-features': 'Scan immutable candidate static features once per calculation specification', + '/api/rectification/v5/score': 'Build the V5 event-by-candidate contribution matrix and score candidate ranges', + '/api/rectification/v5/diagnostics': 'Run V5 stability diagnostics over the server-owned contribution matrix', '/api/relationship': 'Compute relationship and spouse-status evidence', '/api/remedies': 'Generate low-risk remedies from doshas/strength/dasha', '/api/sade_sati': 'Compute Sade Sati status and phase', @@ -8673,6 +8636,21 @@ class JyotishAPIHandler(BaseHTTPRequestHandler): '/api/pancha_mahapurusha': {'planets': SAMPLE_PLANETS, 'sun_degree': SAMPLE_PLANETS['Sun']['lon']}, '/api/prashna': {'planets': SAMPLE_PLANETS, 'question': 'general'}, '/api/rectification_gate': {**base, 'declared_accuracy': 'minute', 'time_source': 'family_clear'}, + '/api/rectification/v5/candidate-features': { + 'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03', + 'lat': 36.419, 'lon': 114.213, 'tz': 8, + 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], + }, + '/api/rectification/v5/score': { + 'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03', + 'lat': 36.419, 'lon': 114.213, 'tz': 8, + 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], + }, + '/api/rectification/v5/diagnostics': { + 'birth_date': '1997-08-08', 'start_time': '05:00', 'end_time': '05:03', + 'lat': 36.419, 'lon': 114.213, 'tz': 8, + 'events': [{'id': '00000000-0000-4000-8000-000000000001', 'domain': 'education', 'event_kind': 'education_milestone', 'date_start': '2016-09-01', 'date_end': '2016-09-30', 'precision': 'month', 'summary': '大学入学'}], + }, '/api/relationship': {'planets': SAMPLE_PLANETS, 'asc_sign': 'Aries', 'dasha_info': {'maha_dasha': 'Venus', 'antar_dasha': 'Jupiter'}}, '/api/remedies': {'shadbala': {'Sun': {'rupas': 4.1}, 'Moon': {'rupas': 3.8}}, 'doshas': ['manglik'], 'dasha_lord': 'Venus'}, '/api/sade_sati': {'moon_degree': SAMPLE_PLANETS['Moon']['lon'], 'asc_degree': SAMPLE_ASCENDANT['lon'], 'saturn_degree': SAMPLE_PLANETS['Saturn']['lon']}, diff --git a/scripts/rectification/__init__.py b/scripts/rectification/__init__.py new file mode 100644 index 00000000..8a372530 --- /dev/null +++ b/scripts/rectification/__init__.py @@ -0,0 +1 @@ +"""Single source of truth for V5 birth-time rectification scoring and diagnostics.""" diff --git a/scripts/rectification/api_service.py b/scripts/rectification/api_service.py new file mode 100644 index 00000000..f3e26664 --- /dev/null +++ b/scripts/rectification/api_service.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from typing import Any +from uuid import NAMESPACE_URL, uuid5 + +from scripts.rectification.candidate_feature_service import build_candidate_feature_snapshot +from scripts.rectification.contracts import RectificationRequest +from scripts.rectification.diagnostics_service import run_diagnostics +from scripts.rectification.scoring_service import ( + ALGORITHM_VERSION, + build_event_contribution_matrix, + calculation_spec, + score_from_matrix, + sha256, +) + + +def candidate_features(request: RectificationRequest) -> dict[str, Any]: + spec = calculation_spec(request) + spec_hash = sha256(spec) + return { + "algorithm_version": ALGORITHM_VERSION, + "calculation_spec": spec, + "calculation_spec_hash": spec_hash, + "candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash), + "can_confirm_exact_minute": False, + } + + +def score_candidates(request: RectificationRequest) -> dict[str, Any]: + built = build_event_contribution_matrix(request) + rows = score_from_matrix(request, built) + spec = calculation_spec(request) + spec_hash = sha256(spec) + diagnostics = run_diagnostics(request, rows, built) + fingerprint = sha256(request) + return { + "result_id": str(uuid5(NAMESPACE_URL, f"{ALGORITHM_VERSION}:{fingerprint}")), + "algorithm_version": ALGORITHM_VERSION, + "calculation_spec": spec, + "calculation_spec_hash": spec_hash, + "candidate_scores": [{ + "time": row["time"], + "score": row["score"], + "supporting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] > 0], + "conflicting_event_ids": [item["event_id"] for item in row["evidence"] if item["points"] < 0], + } for row in rows], + "event_contribution_matrix": built["matrix"], + "candidate_feature_snapshot": build_candidate_feature_snapshot(request, spec_hash, built.get("static_contexts")), + "diagnostics": diagnostics, + "robustness": { + "neighbor_support_minutes": diagnostics["neighbor_support_minutes"], + "leave_one_out_retention_rate": diagnostics["leave_one_event_out_retention_rate"], + "leave_one_domain_out_retention_rate": diagnostics["leave_one_domain_out_retention_rate"], + "date_sensitivity_retention_rate": diagnostics["date_sensitivity_retention_rate"], + }, + "missing_layers": built["missing_layers"], + "can_confirm_exact_minute": False, + } + + +def diagnostics(request: RectificationRequest) -> dict[str, Any]: + scored = score_candidates(request) + return { + "result_id": scored["result_id"], + "algorithm_version": scored["algorithm_version"], + "calculation_spec_hash": scored["calculation_spec_hash"], + "diagnostics": scored["diagnostics"], + "missing_layers": scored["missing_layers"], + "can_confirm_exact_minute": False, + } diff --git a/scripts/rectification/candidate_feature_service.py b/scripts/rectification/candidate_feature_service.py new file mode 100644 index 00000000..d7400100 --- /dev/null +++ b/scripts/rectification/candidate_feature_service.py @@ -0,0 +1,23 @@ +from __future__ import annotations + +from typing import Any, Sequence + +from scripts.active_rectification_event_engine import compute_candidate_static_contexts +from scripts.rectification.contracts import RectificationRequest +from scripts.rectification.scoring_service import ALGORITHM_VERSION, sha256 + + +def build_candidate_feature_snapshot( + request: RectificationRequest, + calculation_spec_hash: str, + static_contexts: Sequence[dict[str, Any]] | None = None, +) -> dict[str, Any]: + contexts = list(static_contexts) if static_contexts is not None else compute_candidate_static_contexts(request) + features = [context["feature"] for context in contexts] + return { + "calculation_spec_hash": calculation_spec_hash, + "algorithm_version": ALGORITHM_VERSION, + "candidate_count": len(features), + "feature_hash": sha256(features), + "features": features, + } diff --git a/scripts/rectification/contracts.py b/scripts/rectification/contracts.py new file mode 100644 index 00000000..a2dd90ce --- /dev/null +++ b/scripts/rectification/contracts.py @@ -0,0 +1,133 @@ +from __future__ import annotations + +import math +import re +import uuid +from datetime import date +from typing import Any, Literal, NotRequired, TypedDict, cast + +DatePrecision = Literal["day", "month", "quarter", "year", "range"] + +SCOREABLE_EVENT_KINDS: dict[str, frozenset[str]] = { + "education": frozenset({"education_milestone"}), + "relocation": frozenset({"relocation"}), + "relationship": frozenset({"relationship_start", "relationship_end", "relationship_change"}), + "career": frozenset({"career_change"}), + "finance": frozenset({"finance_change"}), + "health_pressure": frozenset({"self_health_event"}), +} +DATE_PRECISIONS = frozenset({"day", "month", "quarter", "year", "range"}) +_REQUEST_FIELDS = frozenset({"birth_date", "start_time", "end_time", "lat", "lon", "tz", "events"}) +_EVENT_FIELDS = frozenset({"id", "domain", "event_kind", "date_start", "date_end", "precision", "summary"}) +_CLOCK = re.compile(r"(?:[01]\d|2[0-3]):[0-5]\d\Z") + + +class LifeEvent(TypedDict): + id: str + domain: str + event_kind: str + date_start: str + date_end: str + precision: DatePrecision + summary: NotRequired[str] + + +class RectificationRequest(TypedDict): + birth_date: str + start_time: str + end_time: str + lat: float + lon: float + tz: float + events: list[LifeEvent] + + +JsonObject = dict[str, Any] + + +def _bounded_number(body: dict[str, Any], name: str, minimum: float, maximum: float) -> float: + value = body.get(name) + if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(float(value)): + raise ValueError(f"{name} must be a finite number") + result = float(value) + if not minimum <= result <= maximum: + raise ValueError(f"{name} must be between {minimum:g} and {maximum:g}") + return result + + +def _calendar_date(value: Any, label: str) -> date: + if not isinstance(value, str): + raise ValueError(f"{label} must be a valid YYYY-MM-DD value") + try: + return date.fromisoformat(value) + except ValueError as exc: + raise ValueError(f"{label} must be a valid YYYY-MM-DD value") from exc + + +def normalize_rectification_request(body: Any, *, today: date | None = None) -> RectificationRequest: + if not isinstance(body, dict): + raise ValueError("request body must be an object") + unsupported = sorted(set(body) - _REQUEST_FIELDS) + if unsupported: + raise ValueError(f"unsupported rectification field: {unsupported[0]}") + + birth_day = _calendar_date(body.get("birth_date"), "birth_date") + start_time, end_time = body.get("start_time"), body.get("end_time") + if not isinstance(start_time, str) or not _CLOCK.fullmatch(start_time): + raise ValueError("start_time must be HH:MM") + if not isinstance(end_time, str) or not _CLOCK.fullmatch(end_time): + raise ValueError("end_time must be HH:MM") + if start_time > end_time: + raise ValueError("start_time must not exceed end_time") + + events = body.get("events") + if not isinstance(events, list) or not 1 <= len(events) <= 100: + raise ValueError("events must contain between 1 and 100 items") + upper_date = today or date.today() + cleaned_events: list[LifeEvent] = [] + for index, raw_event in enumerate(events): + if not isinstance(raw_event, dict): + raise ValueError(f"events[{index}] must be an object") + unsupported_event_fields = sorted(set(raw_event) - _EVENT_FIELDS) + if unsupported_event_fields: + raise ValueError(f"events[{index}] contains unsupported field: {unsupported_event_fields[0]}") + try: + event_id = str(uuid.UUID(str(raw_event.get("id") or ""))) + except (ValueError, AttributeError) as exc: + raise ValueError(f"events[{index}].id must be a UUID") from exc + domain, event_kind = raw_event.get("domain"), raw_event.get("event_kind") + if domain not in SCOREABLE_EVENT_KINDS: + raise ValueError(f"events[{index}].domain is not scoreable") + if event_kind not in SCOREABLE_EVENT_KINDS[cast(str, domain)]: + raise ValueError(f"events[{index}].event_kind does not match domain") + precision = raw_event.get("precision") + if precision not in DATE_PRECISIONS: + raise ValueError(f"events[{index}].precision is invalid") + start_day = _calendar_date(raw_event.get("date_start"), f"events[{index}].date_start") + end_day = _calendar_date(raw_event.get("date_end"), f"events[{index}].date_end") + if start_day > end_day: + raise ValueError(f"events[{index}].date_start must not exceed date_end") + if start_day < birth_day or end_day > upper_date: + raise ValueError(f"events[{index}] dates must be between birth_date and today") + summary = raw_event.get("summary", "") + if not isinstance(summary, str) or len(summary) > 1_000: + raise ValueError(f"events[{index}].summary must be a string up to 1000 characters") + cleaned_events.append({ + "id": event_id, + "domain": cast(str, domain), + "event_kind": cast(str, event_kind), + "date_start": start_day.isoformat(), + "date_end": end_day.isoformat(), + "precision": cast(DatePrecision, precision), + "summary": summary.strip(), + }) + + return { + "birth_date": birth_day.isoformat(), + "start_time": start_time, + "end_time": end_time, + "lat": _bounded_number(body, "lat", -90, 90), + "lon": _bounded_number(body, "lon", -180, 180), + "tz": _bounded_number(body, "tz", -14, 14), + "events": cleaned_events, + } diff --git a/scripts/rectification/diagnostics_service.py b/scripts/rectification/diagnostics_service.py new file mode 100644 index 00000000..f80aed88 --- /dev/null +++ b/scripts/rectification/diagnostics_service.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +from collections import defaultdict +from statistics import variance +from typing import Any, Sequence + +from scripts.active_rectification_events import CandidateScoreRow +from scripts.rectification.contracts import RectificationRequest + + +def _winner(rows: Sequence[CandidateScoreRow]) -> str | None: + return max(rows, key=lambda row: row["score"])["time"] if rows else None + + +def _primary_cluster(rows: Sequence[CandidateScoreRow], relative_floor: float = .97) -> list[str]: + if not rows: + return [] + peak = max(row["score"] for row in rows) + floor = peak * relative_floor if peak >= 0 else peak / relative_floor + selected = [row["time"] for row in rows if row["score"] >= floor] + if not selected: + return [] + groups: list[list[str]] = [] + for current in selected: + minute = lambda value: int(value[:2]) * 60 + int(value[3:]) + if groups and minute(current) - minute(groups[-1][-1]) == 1: + groups[-1].append(current) + else: + groups.append([current]) + return max(groups, key=lambda group: (max(next(row["score"] for row in rows if row["time"] == time) for time in group), len(group))) + + +def _subtract(rows: Sequence[CandidateScoreRow], removed_ids: set[str]) -> list[CandidateScoreRow]: + return [{**row, "score": round(row["score"] - sum(item["points"] for item in row["evidence"] if item["event_id"] in removed_ids), 4)} for row in rows] + + +def run_diagnostics(request: RectificationRequest, rows: list[CandidateScoreRow], built: dict[str, Any]) -> dict[str, Any]: + primary = set(_primary_cluster(rows)) + event_runs = [] + domain_runs = [] + event_domain = {event["id"]: event["domain"] for event in request["events"]} + for event in request["events"]: + winner = _winner(_subtract(rows, {event["id"]})) + event_runs.append({"removed_event_id": event["id"], "winner": winner, "retained": winner in primary}) + by_domain: dict[str, set[str]] = defaultdict(set) + for event_id, domain in event_domain.items(): + by_domain[domain].add(event_id) + for domain, event_ids in by_domain.items(): + winner = _winner(_subtract(rows, event_ids)) + domain_runs.append({"removed_domain": domain, "winner": winner, "retained": winner in primary}) + top = sorted(rows, key=lambda row: row["score"], reverse=True) + top_score = top[0]["score"] if top else 0 + secondary = next((row for row in top if row["time"] not in primary), None) + margin = 0 if not secondary else max(0, (top_score - secondary["score"]) / max(abs(top_score), 1e-9) * 100) + positive_total = sum(max(row["score"], 0) for row in rows) + primary_mass = sum(max(row["score"], 0) for row in rows if row["time"] in primary) + date_items = [] + for item in built["date_sensitivity"]: + date_items.append({ + **{key: value for key, value in item.items() if key != "sample_winners"}, + "candidate_cluster_retention_rate": sum(winner in primary for winner in item["sample_winners"]) / len(item["sample_winners"]), + }) + layers: dict[str, float] = defaultdict(float) + for event_id, candidates in built["matrix"].items(): + for contribution in candidates.values(): + for layer in contribution["technique_layers"]: + layers[layer] += abs(contribution["points"]) + clusters = [_primary_cluster(rows)] + candidate_splits = [] + if secondary and clusters[0]: + candidate_splits.append({ + "left_cluster": {"start": clusters[0][0], "end": clusters[0][-1]}, + "right_cluster": {"start": secondary["time"], "end": secondary["time"]}, + "technique_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:8]], + "event_ids": [item["event_id"] for item in secondary["evidence"] if item["points"] != 0], + }) + return { + "primary_cluster_retention_rate": 1.0 if primary else 0.0, + "leave_one_event_out_retention_rate": sum(item["retained"] for item in event_runs) / len(event_runs) if event_runs else 0.0, + "leave_one_domain_out_retention_rate": sum(item["retained"] for item in domain_runs) / len(domain_runs) if domain_runs else 0.0, + "date_sensitivity_retention_rate": sum(item["candidate_cluster_retention_rate"] for item in date_items) / len(date_items) if date_items else 0.0, + "neighbor_support_minutes": len(primary), + "primary_secondary_margin_percent": round(min(margin, 100), 4), + "cluster_mass_ratio": primary_mass / positive_total if positive_total else 0.0, + "unstable_event_ids": [item["removed_event_id"] for item in event_runs if not item["retained"]], + "most_discriminating_layers": [name for name, _ in sorted(layers.items(), key=lambda item: item[1], reverse=True)[:12]], + "event_date_sensitivity": date_items, + "candidate_splits": candidate_splits, + "leave_one_event_out": event_runs, + "leave_one_domain_out": domain_runs, + } diff --git a/scripts/rectification/scoring_service.py b/scripts/rectification/scoring_service.py new file mode 100644 index 00000000..6f119573 --- /dev/null +++ b/scripts/rectification/scoring_service.py @@ -0,0 +1,163 @@ +from __future__ import annotations + +import hashlib +import json +from collections import defaultdict +from datetime import date, timedelta +from functools import lru_cache +from typing import Any, Callable, Sequence + +from scripts.active_rectification_event_engine import compute_candidate_static_contexts, compute_event_candidate_rows +from scripts.active_rectification_events import CandidateScoreRow +from scripts.rectification.contracts import LifeEvent, RectificationRequest + +ALGORITHM_VERSION = "rectification-v5-matrix-scoring-1" +INPUT_CONTRACT_VERSION = "rectification-calculation-spec-v4" + + +def _parse(value: str) -> date: + return date.fromisoformat(value) + + +def _iso(value: date) -> str: + return value.isoformat() + + +def _month_end(value: date) -> date: + next_month = value.replace(day=28) + timedelta(days=4) + return next_month - timedelta(days=next_month.day) + + +def _even_dates(start: date, end: date, count: int) -> list[date]: + if count <= 1 or start == end: + return [start] + span = (end - start).days + return sorted({start + timedelta(days=round(span * index / (count - 1))) for index in range(count)}) + + +def sample_event_dates(event: LifeEvent) -> list[str]: + start, end = _parse(event["date_start"]), _parse(event["date_end"]) + precision = event["precision"] + if start > end: + raise ValueError("invalid_event_date_range") + if precision == "day" or start == end: + return [_iso(start)] + if precision == "month": + middle = start.replace(day=min(15, _month_end(start).day)) + return sorted({_iso(start), _iso(middle), _iso(end)}) + if precision == "quarter": + values: list[date] = [] + cursor = start.replace(day=15) + while cursor <= end and len(values) < 3: + values.append(cursor) + cursor = (cursor.replace(day=28) + timedelta(days=4)).replace(day=15) + return [_iso(item) for item in values] or [_iso(start)] + if precision == "year": + return [_iso(start.replace(month=month, day=15)) for month in range(1, 13)] + return [_iso(item) for item in _even_dates(start, end, 12)] + + +def _legacy_request(request: RectificationRequest, event: LifeEvent, sampled_date: str) -> dict[str, Any]: + return { + "birth_date": request["birth_date"], + "start_time": request["start_time"], + "end_time": request["end_time"], + "lat": request["lat"], + "lon": request["lon"], + "tz": request["tz"], + "events": [{ + "id": event["id"], "domain": event["domain"], "date": sampled_date, + "precision": "day", "summary": event.get("summary", ""), + }], + } + + +def _canonical(value: Any) -> str: + return json.dumps(value, ensure_ascii=True, sort_keys=True, separators=(",", ":")) + + +@lru_cache(maxsize=4096) +def _cached_rows(serialized: str) -> tuple[CandidateScoreRow, ...]: + return tuple(compute_event_candidate_rows(json.loads(serialized))) + + +def build_event_contribution_matrix( + request: RectificationRequest, + row_provider: Callable[[dict[str, Any]], Sequence[CandidateScoreRow]] | None = None, +) -> dict[str, Any]: + static_contexts = None if row_provider is not None else compute_candidate_static_contexts(request) + provider = row_provider or (lambda value: compute_event_candidate_rows(value, static_contexts=static_contexts)) + matrix: dict[str, dict[str, dict[str, Any]]] = defaultdict(dict) + missing_layers: set[str] = set() + date_sensitivity: list[dict[str, Any]] = [] + candidate_grid: list[str] | None = None + for event in request["events"]: + samples = sample_event_dates(event) + sample_rows = [list(provider(_legacy_request(request, event, sampled))) for sampled in samples] + grids = [[row["time"] for row in rows] for rows in sample_rows] + if any(grid != grids[0] for grid in grids[1:]) or (candidate_grid is not None and grids[0] != candidate_grid): + raise ValueError("candidate_grid_mismatch") + candidate_grid = grids[0] + winners = [] + for rows in sample_rows: + winners.append(max(rows, key=lambda row: row["score"])["time"]) + missing_layers.update(layer for row in rows for layer in row["missing_layers"]) + for index, candidate_time in enumerate(candidate_grid): + evidences = [rows[index]["evidence"][0] for rows in sample_rows] + points = [float(item["points"]) for item in evidences] + matrix[event["id"]][candidate_time] = { + "points": round(sum(points) / len(points), 4), + "rule_ids": sorted({rule for item in evidences for rule in item["rule_ids"]}), + "technique_layers": sorted({rule.split(":", 1)[0] for item in evidences for rule in item["rule_ids"]}), + } + winner = max(set(winners), key=winners.count) + mean = sum(matrix[event["id"]][time]["points"] for time in candidate_grid) / len(candidate_grid) + variance = sum((matrix[event["id"]][time]["points"] - mean) ** 2 for time in candidate_grid) / len(candidate_grid) + date_sensitivity.append({ + "event_id": event["id"], + "declared_date_range": {"start": event["date_start"], "end": event["date_end"], "precision": event["precision"]}, + "sample_dates": samples, + "winner_retention_rate": winners.count(winner) / len(winners), + "score_variance": round(variance, 6), + "sample_winners": winners, + }) + return { + "candidate_times": candidate_grid or [], + "matrix": dict(matrix), + "date_sensitivity": date_sensitivity, + "missing_layers": sorted(missing_layers), + "static_contexts": static_contexts, + } + + +def score_from_matrix(request: RectificationRequest, built: dict[str, Any]) -> list[CandidateScoreRow]: + rows: list[CandidateScoreRow] = [] + for candidate_time in built["candidate_times"]: + evidence = [] + for event in request["events"]: + contribution = built["matrix"][event["id"]][candidate_time] + evidence.append({ + "event_id": event["id"], "domain": event["domain"], "candidate_time": candidate_time, + "rule_ids": contribution["rule_ids"], "points": contribution["points"], + }) + rows.append({ + "time": candidate_time, + "score": round(sum(item["points"] for item in evidence), 4), + "evidence": evidence, + "missing_layers": built["missing_layers"], + }) + return rows + + +def calculation_spec(request: RectificationRequest) -> dict[str, Any]: + return { + "version": INPUT_CONTRACT_VERSION, + "birthDate": request["birth_date"], + "candidateRange": {"start": request["start_time"], "end": request["end_time"]}, + "latitude": request["lat"], "longitude": request["lon"], "timezoneOffsetHours": request["tz"], + "ayanamsa": "lahiri", "nodeMode": "mean", "minuteStep": 1, + } + + +def sha256(value: Any) -> str: + return hashlib.sha256(_canonical(value).encode()).hexdigest() diff --git a/skills/birth-time-rectification/SKILL.md b/skills/birth-time-rectification/SKILL.md new file mode 100644 index 00000000..68b08205 --- /dev/null +++ b/skills/birth-time-rectification/SKILL.md @@ -0,0 +1,36 @@ +--- +name: birth-time-rectification +description: Evidence-led birth-time rectification for the Web agent. Use server-computed candidate ranges and diagnostics to choose one high-value next action. Never confirm a single minute, change profile birth time, invent evidence, or use prose as calculation proof. +--- + +# Birth-time rectification + +This is a constrained evidence workflow, not a generic astrology reading. + +Before choosing an action, read the contracts in `references/` and use +`assets/rectification-capability-matrix.json` only as a capability boundary. + +## Hard boundaries + +- The server owns candidate scanning, scores, diagnostics, event IDs, and policy gates. +- The agent may select one server-provided opportunity or request one server-provided diagnostic. +- Never invent candidate times, scores, event IDs, dates, techniques, or tool inputs. +- Never confirm a single minute or write `profiles.active_birth_time`. +- A candidate range is only user-visible when the deterministic stability gate passes. +- Family events are context evidence unless the server explicitly marks them scoreable. + +## Turn strategy + +1. Acknowledge the concrete experience the user just supplied. +2. Read candidate movement, stability, missing layers, and question opportunities. +3. Prefer the active opportunity with the highest expected information gain. +4. Ask one natural question only. +5. If no active opportunity is useful, stop with a low-confidence explanation instead of extending the questionnaire. + +## Layer priority + +Use the server's available layers only. Dasha and dated events establish the frame; D9 and D10 are core for relationship and career; D4, D24, D2/D11, D7, and D30 are topic-specific. D60 is reference-only and must never drive a conclusion. + +## Public language + +Explain whether the latest evidence moved or supported the current candidate range. Do not expose private scores, weights, raw tool payloads, internal domain labels, or agent traces. diff --git a/skills/birth-time-rectification/assets/rectification-capability-matrix.json b/skills/birth-time-rectification/assets/rectification-capability-matrix.json new file mode 100644 index 00000000..77d7b521 --- /dev/null +++ b/skills/birth-time-rectification/assets/rectification-capability-matrix.json @@ -0,0 +1,11 @@ +{ + "education": { "primary": ["D24", "Dasha"], "scoreableByDefault": true }, + "relocation": { "primary": ["D4", "Dasha"], "scoreableByDefault": true }, + "relationship": { "primary": ["D9", "UL", "A7", "Dasha"], "scoreableByDefault": true }, + "career": { "primary": ["D10", "A10", "Dasha"], "scoreableByDefault": true }, + "finance": { "primary": ["D2", "D11", "Dasha"], "scoreableByDefault": true }, + "family": { "primary": ["D12"], "scoreableByDefault": false }, + "children": { "primary": ["D7"], "scoreableByDefault": false }, + "health_pressure": { "primary": ["D30"], "scoreableByDefault": true }, + "D60": { "primary": ["D60"], "scoreableByDefault": false } +} diff --git a/skills/birth-time-rectification/references/event-schema.md b/skills/birth-time-rectification/references/event-schema.md new file mode 100644 index 00000000..bdb6038a --- /dev/null +++ b/skills/birth-time-rectification/references/event-schema.md @@ -0,0 +1,3 @@ +# Event schema + +Keep event subject, related person, event kind, date precision, extraction status, correction lineage, and scoreability. A family bereavement is a family context event, not the user's health event. diff --git a/skills/birth-time-rectification/references/failure-policy.md b/skills/birth-time-rectification/references/failure-policy.md new file mode 100644 index 00000000..e14b46af --- /dev/null +++ b/skills/birth-time-rectification/references/failure-policy.md @@ -0,0 +1,3 @@ +# Failure policy + +On invalid model output, unavailable tools, or a failed policy gate, use the deterministic fallback and record the failure. Do not fabricate a next question or candidate result. diff --git a/skills/birth-time-rectification/references/output-contract.md b/skills/birth-time-rectification/references/output-contract.md new file mode 100644 index 00000000..95e57758 --- /dev/null +++ b/skills/birth-time-rectification/references/output-contract.md @@ -0,0 +1,3 @@ +# Output contract + +Public output contains an acknowledgement, a concise calculation update grounded in the packet, and at most one question. It never contains a single-minute conclusion or private scores. diff --git a/skills/birth-time-rectification/references/product-contract.md b/skills/birth-time-rectification/references/product-contract.md new file mode 100644 index 00000000..32041ad4 --- /dev/null +++ b/skills/birth-time-rectification/references/product-contract.md @@ -0,0 +1,3 @@ +# Product contract + +The product returns a candidate range, not a verified birth minute. Existing profile birth time remains unchanged until the user explicitly saves an allowed candidate range through the product flow. diff --git a/skills/birth-time-rectification/references/question-policy.md b/skills/birth-time-rectification/references/question-policy.md new file mode 100644 index 00000000..fa92f57a --- /dev/null +++ b/skills/birth-time-rectification/references/question-policy.md @@ -0,0 +1,3 @@ +# Question policy + +Choose one active server opportunity. Prefer date sensitivity, candidate-split relevance, and new domain coverage over recency or fixed domain order. Do not repeat a resolved follow-up. diff --git a/skills/birth-time-rectification/references/technique-policy.md b/skills/birth-time-rectification/references/technique-policy.md new file mode 100644 index 00000000..08d891f1 --- /dev/null +++ b/skills/birth-time-rectification/references/technique-policy.md @@ -0,0 +1,3 @@ +# Technique policy + +Only server-reported available layers may be described as used. Missing, blocked, reference-only, and research-only layers are not evidence of a result. diff --git a/tests/test_rectification_v5_services.py b/tests/test_rectification_v5_services.py new file mode 100644 index 00000000..76bd1224 --- /dev/null +++ b/tests/test_rectification_v5_services.py @@ -0,0 +1,154 @@ +from __future__ import annotations + +import unittest +from datetime import date +from unittest.mock import patch + +from scripts.rectification.api_service import diagnostics, score_candidates +from scripts.rectification.contracts import normalize_rectification_request +from scripts.rectification.scoring_service import build_event_contribution_matrix, sample_event_dates, score_from_matrix +from scripts.jyotish_api_server import ( + API_COMMAND_MAP, + TECHNIQUE_EXAMPLE_ENDPOINTS, + BadRequest, + JyotishAPIHandler, +) + +EVENT_ID = "00000000-0000-4000-8000-000000000001" + + +def request(*, precision: str = "month", event_kind: str = "education_milestone", domain: str = "education"): + return { + "birth_date": "1997-08-08", + "start_time": "05:13", + "end_time": "05:15", + "lat": 36.419, + "lon": 114.213, + "tz": 8, + "events": [{ + "id": EVENT_ID, + "domain": domain, + "event_kind": event_kind, + "date_start": "2016-09-01", + "date_end": "2016-09-30", + "precision": precision, + "summary": "大学入学", + }], + } + + +class RectificationV5ServicesTest(unittest.TestCase): + def test_shared_validator_rejects_family_and_non_self_health_scoring(self): + with self.assertRaisesRegex(ValueError, "domain is not scoreable"): + normalize_rectification_request(request(domain="family", event_kind="family_bereavement"), today=date(2026, 7, 28)) + with self.assertRaisesRegex(ValueError, "event_kind does not match domain"): + normalize_rectification_request(request(domain="health_pressure", event_kind="family_health_event"), today=date(2026, 7, 28)) + normalized = normalize_rectification_request(request(domain="health_pressure", event_kind="self_health_event"), today=date(2026, 7, 28)) + self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event") + + def test_date_sampling_preserves_declared_range_and_uses_bounded_samples(self): + base = request()["events"][0] + self.assertEqual(sample_event_dates({**base, "precision": "month"}), ["2016-09-01", "2016-09-15", "2016-09-30"]) + year = {**base, "precision": "year", "date_start": "2016-01-01", "date_end": "2016-12-31"} + self.assertEqual(len(sample_event_dates(year)), 12) + ranged = {**base, "precision": "range", "date_start": "2015-01-01", "date_end": "2016-12-31"} + self.assertLessEqual(len(sample_event_dates(ranged)), 12) + + def test_contribution_matrix_and_leave_out_diagnostics_use_matrix_math(self): + normalized = normalize_rectification_request(request(), today=date(2026, 7, 28)) + + def rows(value): + sampled = value["events"][0]["date"] + shift = {"2016-09-01": 0, "2016-09-15": 1, "2016-09-30": 2}[sampled] + return [{ + "time": candidate, + "score": points + shift, + "evidence": [{ + "event_id": EVENT_ID, + "domain": "education", + "candidate_time": candidate, + "rule_ids": ["D24:test"], + "points": points + shift, + }], + "missing_layers": ["KP_cusps"], + } for candidate, points in [("05:13", 9), ("05:14", 10), ("05:15", 8)]] + + built = build_event_contribution_matrix(normalized, row_provider=rows) + scored = score_from_matrix(normalized, built) + self.assertEqual(built["matrix"][EVENT_ID]["05:14"]["points"], 11) + self.assertEqual(scored[1]["score"], 11) + self.assertEqual(built["missing_layers"], ["KP_cusps"]) + + def test_formal_score_and_diagnostics_endpoints_share_the_service_bundle(self): + normalized = normalize_rectification_request(request(), today=date(2026, 7, 28)) + built = { + "candidate_times": ["05:13", "05:14"], + "matrix": {EVENT_ID: { + "05:13": {"points": 10, "rule_ids": ["D24:a"], "technique_layers": ["D24"]}, + "05:14": {"points": 8, "rule_ids": ["D24:b"], "technique_layers": ["D24"]}, + }}, + "date_sensitivity": [{ + "event_id": EVENT_ID, + "declared_date_range": {"start": "2016-09-01", "end": "2016-09-30", "precision": "month"}, + "sample_dates": ["2016-09-01", "2016-09-15", "2016-09-30"], + "winner_retention_rate": 1, + "score_variance": 1, + "sample_winners": ["05:13", "05:13", "05:13"], + }], + "missing_layers": ["KP_cusps"], + "static_contexts": [{"feature": {"time": "05:13"}}, {"feature": {"time": "05:14"}}], + } + feature = { + "calculation_spec_hash": "0" * 64, + "algorithm_version": "rectification-v5-matrix-scoring-1", + "candidate_count": 2, + "feature_hash": "1" * 64, + "features": [{"time": "05:13"}, {"time": "05:14"}], + } + with patch("scripts.rectification.api_service.build_event_contribution_matrix", return_value=built), patch( + "scripts.rectification.api_service.build_candidate_feature_snapshot", return_value=feature + ): + scored = score_candidates(normalized) + diagnostic_result = diagnostics(normalized) + self.assertFalse(scored["can_confirm_exact_minute"]) + self.assertIn("event_contribution_matrix", scored) + self.assertEqual(diagnostic_result["diagnostics"]["leave_one_event_out_retention_rate"], 1) + self.assertFalse(diagnostic_result["can_confirm_exact_minute"]) + + def test_http_registry_exposes_all_v5_endpoints(self): + expected = { + "rectification-v5-candidate-features": "/api/rectification/v5/candidate-features", + "rectification-v5-score": "/api/rectification/v5/score", + "rectification-v5-diagnostics": "/api/rectification/v5/diagnostics", + } + for command, endpoint in expected.items(): + self.assertEqual(API_COMMAND_MAP[command], endpoint) + self.assertIn(endpoint, TECHNIQUE_EXAMPLE_ENDPOINTS) + + def test_http_handler_enforces_subject_and_event_kind_boundaries(self): + handler = object.__new__(JyotishAPIHandler) + with self.assertRaisesRegex(BadRequest, "domain is not scoreable"): + handler._rectification_v5_request(request(domain="family", event_kind="family_bereavement")) + with self.assertRaisesRegex(BadRequest, "event_kind does not match domain"): + handler._rectification_v5_request(request(domain="health_pressure", event_kind="family_health_event")) + normalized = handler._rectification_v5_request( + request(domain="health_pressure", event_kind="self_health_event") + ) + self.assertEqual(normalized["events"][0]["event_kind"], "self_health_event") + + def test_v4_compatibility_and_v5_score_handlers_share_the_v5_service(self): + handler = object.__new__(JyotishAPIHandler) + result = {"result_id": "00000000-0000-4000-8000-000000000099", "can_confirm_exact_minute": False} + with patch("scripts.rectification.api_service.score_candidates", return_value=result) as scorer: + v5 = handler._compute_rectification_v5_score(request()) + v4 = handler._compute_active_rectification_events_v4(request()) + self.assertEqual(scorer.call_count, 2) + self.assertEqual(v5["endpoint"], "rectification_v5_score") + self.assertEqual(v4["endpoint"], "active_rectification_events_v4") + self.assertEqual(v5["result_id"], v4["result_id"]) + self.assertFalse(v5["can_confirm_exact_minute"]) + self.assertFalse(v4["can_confirm_exact_minute"]) + + +if __name__ == "__main__": + unittest.main() -- 2.52.0 From 092efa0ed25a38c2346863cc7b37fba4e6c42055 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 28 Jul 2026 13:48:29 +0800 Subject: [PATCH 20/46] ops: manage staging rectification rollout --- ...onfigure-staging-rectification-rollout.yml | 92 ++++++++++ deploy/README.md | 2 + ...configure-staging-rectification-rollout.sh | 167 ++++++++++++++++++ .../tests/staging-backend-workflows.test.ts | 113 +++++++++++- 4 files changed, 369 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/configure-staging-rectification-rollout.yml create mode 100755 deploy/configure-staging-rectification-rollout.sh diff --git a/.github/workflows/configure-staging-rectification-rollout.yml b/.github/workflows/configure-staging-rectification-rollout.yml new file mode 100644 index 00000000..a18fb072 --- /dev/null +++ b/.github/workflows/configure-staging-rectification-rollout.yml @@ -0,0 +1,92 @@ +name: Configure Staging Rectification Rollout + +on: + workflow_dispatch: + inputs: + expected_deploy_sha: + description: Exact 40-character SHA currently deployed to staging + required: true + type: string + audience: + description: New-case creation audience + required: true + default: paused + type: choice + options: + - paused + - smoke_only + - public + synthetic_smoke_user_ids: + description: Comma-separated canonical UUIDs; required only for smoke_only + required: false + type: string + +permissions: + contents: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + +jobs: + configure: + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: staging + url: ${{ vars.STAGING_URL }} + env: + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_URL: ${{ vars.STAGING_URL }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }} + ROLLOUT_AUDIENCE: ${{ inputs.audience }} + SYNTHETIC_SMOKE_USER_IDS: ${{ inputs.synthetic_smoke_user_ids }} + + steps: + - name: Checkout trusted controller + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Validate rollout request and staging target + run: | + set -euo pipefail + [[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] + case "$ROLLOUT_AUDIENCE" in paused|smoke_only|public) ;; *) exit 1 ;; esac + if [ "$ROLLOUT_AUDIENCE" = smoke_only ]; then + [[ "$SYNTHETIC_SMOKE_USER_IDS" =~ ^[0-9a-f-]{36}(,[0-9a-f-]{36})*$ ]] + else + test -z "$SYNTHETIC_SMOKE_USER_IDS" + fi + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test "$STAGING_URL" = "https://staging.jyotisha.chat" + test -n "$STAGING_KNOWN_HOSTS" + bash -n deploy/configure-staging-rectification-rollout.sh + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Apply rollout under staging mutation lock + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=10" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' ROLLOUT_AUDIENCE='$ROLLOUT_AUDIENCE' SYNTHETIC_SMOKE_USER_IDS='$SYNTHETIC_SMOKE_USER_IDS' STAGING_URL='$STAGING_URL' bash -s" \ + < deploy/configure-staging-rectification-rollout.sh diff --git a/deploy/README.md b/deploy/README.md index c14ce6c3..fc9142b9 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -193,6 +193,8 @@ After source sync and before `up`, the workflow validates `.env.staging` mode/se 6. If the read-only checker reports a pending migration, stop app deployment and run `Migrate Staging Database` manually with the same full SHA; a successful migration re-dispatches `Deploy staging` with that same SHA. 7. Confirm `https://staging.jyotisha.chat/api/health` reports the exact SHA and private API health. +After the exact-SHA deployment and migrations are verified, use the manual `Configure Staging Rectification Rollout` workflow to change new-case creation. Supply the SHA currently reported by `/api/health`; choose `public` to open all staging accounts, `smoke_only` with canonical test-account UUIDs for a canary, or `paused` to close creation. The workflow updates only the four `RECTIFICATION_V3_*` rollout variables under the shared host lock, recreates `web` and `rectification-v4-worker` with the already deployed image, and rolls back the env file if health does not match the requested audience. Do not edit or print `.env.staging` through CI logs. + Application rollback uses the same workflow: manually dispatch `Deploy staging` from the `main` controller with a previous known-good full SHA that has a successful `Staging Backend Quality Gate` run, and explicitly set `allow_rollback=true`. Normal and migration-triggered deployments reject stale, divergent, or backward revisions. Rollback still consumes the selected gate run's digest manifest and is supported only during that artifact's 30-day retention window; after expiry, stop and prepare a separately reviewed republish/recovery change rather than substituting a mutable tag or assuming the old run can still be rerun. Database migrations are separate and are not rolled back by an application deployment. Restore a staging database backup before running any destructive migration rehearsal. Inspect staging without printing secrets: diff --git a/deploy/configure-staging-rectification-rollout.sh b/deploy/configure-staging-rectification-rollout.sh new file mode 100755 index 00000000..4edfbf9b --- /dev/null +++ b/deploy/configure-staging-rectification-rollout.sh @@ -0,0 +1,167 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=(DEPLOY_PATH EXPECTED_DEPLOY_SHA ROLLOUT_AUDIENCE STAGING_URL) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required staging rollout input is missing: $key" >&2 + exit 1 + fi +done + +sha_pattern='^[0-9a-f]{40}$' +uuid_pattern='^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' +[[ "$EXPECTED_DEPLOY_SHA" =~ $sha_pattern ]] || { + echo "invalid expected deployment SHA" >&2 + exit 1 +} +case "$ROLLOUT_AUDIENCE" in + paused|smoke_only|public) ;; + *) echo "invalid rollout audience" >&2; exit 1 ;; +esac + +smoke_user_ids="${SYNTHETIC_SMOKE_USER_IDS:-}" +if [ "$ROLLOUT_AUDIENCE" = "smoke_only" ]; then + [ -n "$smoke_user_ids" ] || { + echo "smoke_only requires at least one synthetic user UUID" >&2 + exit 1 + } + IFS=',' read -ra smoke_users <<<"$smoke_user_ids" + for user_id in "${smoke_users[@]}"; do + [[ "$user_id" =~ $uuid_pattern ]] || { + echo "invalid synthetic smoke user UUID" >&2 + exit 1 + } + done +else + [ -z "$smoke_user_ids" ] || { + echo "synthetic smoke users are only valid for smoke_only" >&2 + exit 1 + } +fi + +state_directory="$DEPLOY_PATH/.state" +env_file="$DEPLOY_PATH/.env.staging" +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another staging mutation holds the host lock" >&2 + exit 75 +} + +compose_files=( + -f deploy/docker-compose.server.yml + -f deploy/docker-compose.postgres.yml + -f deploy/docker-compose.staging.yml +) + +[ -f "$env_file" ] || { + echo "staging environment file is missing" >&2 + exit 1 +} +current_sha="$(<"$state_directory/deployed-revision")" +[ "$current_sha" = "$EXPECTED_DEPLOY_SHA" ] || { + echo "deployed staging revision does not match the approved rollout SHA" >&2 + exit 1 +} + +case "$ROLLOUT_AUDIENCE" in + public) + creation_enabled=true + smoke_sha="$EXPECTED_DEPLOY_SHA" + smoke_user_ids="" + ;; + smoke_only) + creation_enabled=true + smoke_sha="" + ;; + paused) + creation_enabled=false + smoke_sha="" + smoke_user_ids="" + ;; +esac + +backup="$(mktemp "$state_directory/rectification-rollout-backup.XXXXXX")" +temporary="$(mktemp "$DEPLOY_PATH/.env.staging.rollout.XXXXXX")" +declare -a compose=() +cleanup() { rm -f -- "$backup" "$temporary"; } +rollback() { + local status=$? + cp -p -- "$backup" "$env_file" + if [ "${#compose[@]}" -gt 0 ]; then + "${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker >/dev/null 2>&1 || true + fi + exit "$status" +} +trap cleanup EXIT +cp -p -- "$env_file" "$backup" + +awk \ + -v create="$creation_enabled" \ + -v migrations="true" \ + -v smoke_sha="$smoke_sha" \ + -v smoke_users="$smoke_user_ids" ' +BEGIN { + values["RECTIFICATION_V3_CREATE_ENABLED"] = create + values["RECTIFICATION_V3_MIGRATIONS_READY"] = migrations + values["RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA"] = smoke_sha + values["RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS"] = smoke_users +} +{ + split($0, parts, "=") + key = parts[1] + if (key in values) { + if (!(key in written)) print key "=" values[key] + written[key] = 1 + next + } + print +} +END { + for (key in values) if (!(key in written)) print key "=" values[key] +} +' "$env_file" >"$temporary" +chmod 600 "$temporary" + +cd "$DEPLOY_PATH" +bash deploy/validate-staging-env.sh "$temporary" staging.jyotisha.chat deploy/Caddyfile.staging +mv -f -- "$temporary" "$env_file" +trap rollback ERR + +web_container="$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1)" +[ -n "$web_container" ] || { + echo "staging web container is missing" >&2 + false +} +export WEB_IMAGE="$(docker inspect --format '{{.Config.Image}}' "$web_container")" +export APP_ENV_FILE='../.env.staging' +export DATABASE_ENV_FILE='../.env.staging.database' +export CADDYFILE_PATH='./Caddyfile.staging' +export SITE_ADDRESS='https://staging.jyotisha.chat' +export ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' +export GITHUB_SHA="$EXPECTED_DEPLOY_SHA" +compose=(docker compose -p jyotisha-staging --env-file .env.staging "${compose_files[@]}") + +"${compose[@]}" config --quiet +"${compose[@]}" up -d --no-build --pull never --force-recreate --no-deps web rectification-v4-worker + +health="" +for _ in $(seq 1 30); do + health="$(curl --fail --silent --show-error "$STAGING_URL/api/health" 2>/dev/null || true)" + expected_ready=false + [ "$ROLLOUT_AUDIENCE" = public ] && expected_ready=true + if grep -Fq "\"gitCommit\":\"$EXPECTED_DEPLOY_SHA\"" <<<"$health" && + grep -Fq "\"creationAudience\":\"$ROLLOUT_AUDIENCE\"" <<<"$health" && + grep -Fq "\"readyForNewCases\":$expected_ready" <<<"$health"; then + trap - ERR + printf 'rectification rollout audience=%s deployed_sha=%s ready_for_new_cases=%s\n' \ + "$ROLLOUT_AUDIENCE" "$EXPECTED_DEPLOY_SHA" "$([ "$ROLLOUT_AUDIENCE" = public ] && echo true || echo false)" + exit 0 + fi + sleep 2 +done + +echo "staging rollout health verification failed" >&2 +false diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 2c849ddc..9986d41c 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -18,6 +18,10 @@ const migrationWorkflow = new URL( "../../.github/workflows/migrate-staging-database.yml", import.meta.url, ); +const rolloutWorkflow = new URL( + "../../.github/workflows/configure-staging-rectification-rollout.yml", + import.meta.url, +); const deployScript = new URL( "../../deploy/run-staging-deploy.sh", import.meta.url, @@ -26,6 +30,10 @@ const migrationScript = new URL( "../../deploy/run-staging-migration.sh", import.meta.url, ); +const rolloutScript = new URL( + "../../deploy/configure-staging-rectification-rollout.sh", + import.meta.url, +); const syncScript = new URL( "../../deploy/sync-staging-tree.sh", import.meta.url, @@ -49,7 +57,7 @@ function assertOrder(text: string, labels: string[]): void { } test("changed staging workflows are syntactically valid YAML", () => { - for (const workflow of [qualityWorkflow, deployWorkflow, migrationWorkflow]) { + for (const workflow of [qualityWorkflow, deployWorkflow, migrationWorkflow, rolloutWorkflow]) { const result = spawnSync( "ruby", ["-e", "require 'yaml'; YAML.parse_file(ARGV.fetch(0))", fileURLToPath(workflow)], @@ -151,16 +159,21 @@ test("all staging mutations share Actions serialization and one host lock", () = const deployRunner = read(deployScript); const migrationRunner = read(migrationScript); - for (const workflow of [deployment, migration]) { + const rollout = read(rolloutWorkflow); + const rolloutRunner = read(rolloutScript); + + for (const workflow of [deployment, migration, rollout]) { assert.match(workflow, /concurrency:\n\s+group: staging-mutation\n\s+cancel-in-progress: false/); } - for (const runner of [deployRunner, migrationRunner]) { + for (const runner of [deployRunner, migrationRunner, rolloutRunner]) { assert.match(runner, /state_directory="\$DEPLOY_PATH\/\.state"/); assert.match(runner, /state_directory\/mutation\.lock/); assert.match(runner, /flock -n 9/); - assert.ok(runner.indexOf("flock -n 9") < runner.indexOf("sync-staging-tree.sh")); assert.ok(runner.indexOf("flock -n 9") < runner.indexOf("docker")); } + for (const runner of [deployRunner, migrationRunner]) { + assert.ok(runner.indexOf("flock -n 9") < runner.indexOf("sync-staging-tree.sh")); + } }); test("deploy and migration consume the exact successful gate artifact", () => { @@ -391,8 +404,98 @@ test("production remains manual-only and separate from staging database automati assert.doesNotMatch(production, /docker-compose\.postgres\.yml|db:migrate/); }); + +test("public rectification rollout rewrites only rollout gates and recreates web runtimes", () => { + const root = mkdtempSync(join(tmpdir(), "jyotisha-rollout-")); + const deploymentPath = join(root, "app"); + const statePath = join(deploymentPath, ".state"); + const deployPath = join(deploymentPath, "deploy"); + const mockBin = join(root, "bin"); + const sha = "8".repeat(40); + mkdirSync(statePath, { recursive: true }); + mkdirSync(deployPath, { recursive: true }); + mkdirSync(mockBin, { recursive: true }); + writeFileSync(join(statePath, "deployed-revision"), sha); + writeFileSync( + join(deploymentPath, ".env.staging"), + [ + "APP_ENV_FILE=../.env.staging", + "CADDYFILE_PATH=./Caddyfile.staging", + "SITE_ADDRESS=https://staging.jyotisha.chat", + "ADMIN_SITE_ADDRESS=https://admin.staging.jyotisha.chat", + "AUTH_PROVIDER=self-hosted", + "SELF_HOSTED_IDENTITY_ENABLED=true", + "AUTH_USER_ORIGIN=https://staging.jyotisha.chat", + "AUTH_ADMIN_ORIGIN=https://admin.staging.jyotisha.chat", + `IDENTITY_DATABASE_URL=postgresql://identity_runtime:${"i".repeat(40)}@postgres:5432/jyotisha`, + `APP_DATABASE_URL=postgresql://app_runtime:${"a".repeat(40)}@postgres:5432/jyotisha`, + `ADMIN_DATABASE_URL=postgresql://admin_runtime:${"d".repeat(40)}@postgres:5432/jyotisha`, + `BETTER_AUTH_USER_SECRET=${"u".repeat(32)}`, + `BETTER_AUTH_ADMIN_SECRET=${"v".repeat(32)}`, + "RESEND_API_KEY=re_test_key", + "RESEND_FROM_EMAIL=test@example.com", + "ADMIN_EMAILS=admin@example.com", + `JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=${"t".repeat(32)}`, + "KEEP_ME=unchanged", + "RECTIFICATION_V3_CREATE_ENABLED=false", + "RECTIFICATION_V3_MIGRATIONS_READY=false", + "RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA=old", + "RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS=00000000-0000-4000-8000-000000009001", + "", + ].join("\n"), + { mode: 0o600 }, + ); + writeFileSync( + join(deployPath, "validate-staging-env.sh"), + readFileSync(new URL("../../deploy/validate-staging-env.sh", import.meta.url), "utf8"), + ); + writeFileSync(join(mockBin, "flock"), "#!/usr/bin/env bash\nexit 0\n"); + writeFileSync( + join(mockBin, "docker"), + [ + "#!/usr/bin/env bash", + 'if [ "$1" = ps ]; then echo web-container; exit 0; fi', + `if [ "$1" = inspect ]; then echo ghcr.io/jesse-ux/jyotisha-web@sha256:${"b".repeat(64)}; exit 0; fi`, + `printf '%s\n' "$*" >>${join(root, "docker.log")}`, + ].join("\n"), + ); + writeFileSync( + join(mockBin, "curl"), + `#!/usr/bin/env bash\nprintf '%s' '{"deployment":{"gitCommit":"${sha}"},"rollout":{"conversationalRectificationV3":{"creationAudience":"public","readyForNewCases":true}}}'\n`, + ); + for (const command of ["flock", "docker", "curl"]) { + chmodSync(join(mockBin, command), 0o755); + } + + try { + const result = spawnSync("bash", [fileURLToPath(rolloutScript)], { + encoding: "utf8", + env: { + ...process.env, + PATH: `${mockBin}:${process.env.PATH ?? ""}`, + DEPLOY_PATH: deploymentPath, + EXPECTED_DEPLOY_SHA: sha, + ROLLOUT_AUDIENCE: "public", + SYNTHETIC_SMOKE_USER_IDS: "", + STAGING_URL: "https://staging.jyotisha.chat", + }, + }); + assert.equal(result.status, 0, result.stderr); + const env = readFileSync(join(deploymentPath, ".env.staging"), "utf8"); + assert.match(env, /^KEEP_ME=unchanged$/m); + assert.match(env, /^RECTIFICATION_V3_CREATE_ENABLED=true$/m); + assert.match(env, /^RECTIFICATION_V3_MIGRATIONS_READY=true$/m); + assert.match(env, new RegExp(`^RECTIFICATION_V3_SYNTHETIC_SMOKE_SHA=${sha}$`, "m")); + assert.match(env, /^RECTIFICATION_V3_SYNTHETIC_SMOKE_USER_IDS=$/m); + assert.equal((env.match(/^RECTIFICATION_V3_CREATE_ENABLED=/gm) ?? []).length, 1); + assert.match(readFileSync(join(root, "docker.log"), "utf8"), /force-recreate --no-deps web rectification-v4-worker/); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + test("staging scripts pass shell syntax validation", () => { - for (const script of [deployScript, migrationScript, syncScript]) { + for (const script of [deployScript, migrationScript, rolloutScript, syncScript]) { const path = fileURLToPath(script); chmodSync(path, 0o755); const result = spawnSync("bash", ["-n", path], { encoding: "utf8" }); -- 2.52.0 From 75b217273ba5da577462584198c7d5120f92a762 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Tue, 28 Jul 2026 14:24:53 +0800 Subject: [PATCH 21/46] ops: add guarded staging account reset --- .github/workflows/reset-staging-account.yml | 81 ++++++ deploy/reset-staging-account.sh | 290 ++++++++++++++++++++ 2 files changed, 371 insertions(+) create mode 100644 .github/workflows/reset-staging-account.yml create mode 100755 deploy/reset-staging-account.sh diff --git a/.github/workflows/reset-staging-account.yml b/.github/workflows/reset-staging-account.yml new file mode 100644 index 00000000..bfc4af47 --- /dev/null +++ b/.github/workflows/reset-staging-account.yml @@ -0,0 +1,81 @@ +name: Reset Staging Account + +on: + workflow_dispatch: + inputs: + expected_deploy_sha: + description: Exact 40-character SHA currently deployed to staging + required: true + type: string + email: + description: Exact staging account email + required: true + type: string + confirmation: + description: Type RESET followed by a space and the exact email + required: true + type: string + +permissions: + contents: read + +concurrency: + group: staging-mutation + cancel-in-progress: false + +jobs: + reset: + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: + name: staging + url: ${{ vars.STAGING_URL }} + env: + DEPLOY_HOST: ${{ vars.STAGING_HOST }} + DEPLOY_PORT: ${{ vars.STAGING_PORT }} + DEPLOY_USER: ${{ vars.STAGING_USER }} + DEPLOY_PATH: ${{ vars.STAGING_PATH }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + EXPECTED_DEPLOY_SHA: ${{ inputs.expected_deploy_sha }} + RESET_EMAIL: ${{ inputs.email }} + RESET_CONFIRMATION: ${{ inputs.confirmation }} + + steps: + - name: Checkout trusted controller + uses: actions/checkout@v4 + with: + ref: main + persist-credentials: false + + - name: Validate account reset request and staging target + run: | + set -euo pipefail + [[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] + [[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]] + test "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL" + test "$DEPLOY_HOST" = "118.26.111.127" + test "$DEPLOY_PORT" = "22" + test "$DEPLOY_USER" = "deploy" + test "$DEPLOY_PATH" = "/opt/jyotisha-staging" + test -n "$STAGING_KNOWN_HOSTS" + bash -n deploy/reset-staging-account.sh + + - name: Configure pinned staging SSH + env: + SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + run: | + set -euo pipefail + test -n "$SSH_PRIVATE_KEY" + install -d -m 700 ~/.ssh + printf '%s\n' "$SSH_PRIVATE_KEY" >~/.ssh/jyotisha-staging + chmod 600 ~/.ssh/jyotisha-staging + printf '%s\n' "$STAGING_KNOWN_HOSTS" >~/.ssh/known_hosts + chmod 600 ~/.ssh/known_hosts + + - name: Reset one staging account under host lock + run: | + set -euo pipefail + SSH_OPTIONS="-i $HOME/.ssh/jyotisha-staging -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o ServerAliveInterval=30 -o ServerAliveCountMax=10" + ssh $SSH_OPTIONS "$DEPLOY_USER@$DEPLOY_HOST" \ + "DEPLOY_PATH='$DEPLOY_PATH' EXPECTED_DEPLOY_SHA='$EXPECTED_DEPLOY_SHA' RESET_EMAIL='$RESET_EMAIL' RESET_CONFIRMATION='$RESET_CONFIRMATION' bash -s" \ + < deploy/reset-staging-account.sh diff --git a/deploy/reset-staging-account.sh b/deploy/reset-staging-account.sh new file mode 100755 index 00000000..844a4050 --- /dev/null +++ b/deploy/reset-staging-account.sh @@ -0,0 +1,290 @@ +#!/usr/bin/env bash +set -euo pipefail +set +x + +required=(DEPLOY_PATH EXPECTED_DEPLOY_SHA RESET_EMAIL RESET_CONFIRMATION) +for key in "${required[@]}"; do + if [ -z "${!key:-}" ]; then + echo "required staging account-reset input is missing: $key" >&2 + exit 1 + fi +done + +[[ "$EXPECTED_DEPLOY_SHA" =~ ^[0-9a-f]{40}$ ]] || { + echo "invalid expected deployment SHA" >&2 + exit 1 +} +[[ "$RESET_EMAIL" =~ ^[[:alnum:]._%+-]+@[[:alnum:].-]+\.[[:alpha:]]{2,63}$ ]] || { + echo "invalid reset email" >&2 + exit 1 +} +[ "$RESET_CONFIRMATION" = "RESET $RESET_EMAIL" ] || { + echo "account reset confirmation does not match" >&2 + exit 1 +} +[ "$DEPLOY_PATH" = "/opt/jyotisha-staging" ] || { + echo "refusing non-staging deployment path" >&2 + exit 1 +} + +state_directory="$DEPLOY_PATH/.state" +[ -f "$state_directory/deployed-revision" ] || { + echo "staging deployed revision is unavailable" >&2 + exit 1 +} +[ "$(<"$state_directory/deployed-revision")" = "$EXPECTED_DEPLOY_SHA" ] || { + echo "deployed staging revision does not match the approved reset SHA" >&2 + exit 1 +} + +install -d -m 700 "$state_directory" +exec 9>"$state_directory/mutation.lock" +flock -n 9 || { + echo "another staging mutation holds the host lock" >&2 + exit 75 +} + +cd "$DEPLOY_PATH" +compose=(docker compose -p jyotisha-staging -f deploy/docker-compose.postgres.yml) +"${compose[@]}" ps --status running postgres --quiet | grep -q . || { + echo "staging postgres container is not running" >&2 + exit 1 +} + +run_psql() { + "${compose[@]}" exec -T -e RESET_EMAIL="$RESET_EMAIL" postgres sh -ceu ' + exec psql -X -v ON_ERROR_STOP=1 -v target_email="$RESET_EMAIL" \ + -U "$POSTGRES_USER" -d "$POSTGRES_DB" + ' +} + +run_psql <<'SQL' +begin; + +create temporary table reset_snapshot on commit drop as +select + identity_user.id, + identity_user.email, + profile.email as profile_email, + profile.credits, + (select count(*) from identity.accounts value where value.user_id = identity_user.id) as identity_accounts, + (select count(*) from identity.sessions value where value.user_id = identity_user.id) as identity_sessions, + (select count(*) from public.credit_transactions value where value.user_id = identity_user.id) as credit_transactions, + (select count(*) from public.credit_request_cancellations value where value.user_id = identity_user.id) as credit_cancellations, + (select count(*) from public.consultation_requests value where value.user_id = identity_user.id) as consultation_requests, + (select count(*) from public.birth_time_rectification_billing value where value.user_id = identity_user.id) as rectification_billing, + (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = identity_user.id) as action_receipts, + (select count(*) from public.redemption_codes value where value.redeemed_by = identity_user.id) as redeemed_codes, + (select count(*) from audit.admin_audit_logs value where value.actor_user_id = identity_user.id) as admin_audit_logs +from identity.users identity_user +join auth.users auth_user on auth_user.id = identity_user.id +join public.profiles profile on profile.id = identity_user.id +where lower(btrim(identity_user.email)) = lower(btrim(:'target_email')) + and lower(btrim(auth_user.email)) = lower(btrim(:'target_email')) +for update of identity_user, auth_user, profile; + +do $$ +begin + if (select count(*) from reset_snapshot) <> 1 then + raise exception 'account_not_found_or_identity_bridge_mismatch'; + end if; +end $$; + +select jsonb_build_object( + 'stage', 'preflight', + 'email', snapshot.email, + 'credits', snapshot.credits, + 'identityAccounts', snapshot.identity_accounts, + 'identitySessions', snapshot.identity_sessions, + 'creditTransactions', snapshot.credit_transactions, + 'creditCancellations', snapshot.credit_cancellations, + 'consultationRequests', snapshot.consultation_requests, + 'rectificationBilling', snapshot.rectification_billing, + 'actionReceipts', snapshot.action_receipts, + 'redeemedCodes', snapshot.redeemed_codes, + 'adminAuditLogs', snapshot.admin_audit_logs, + 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = snapshot.id), + 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = snapshot.id), + 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = snapshot.id), + 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = snapshot.id), + 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = snapshot.id), + 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = snapshot.id), + 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = snapshot.id), + 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = snapshot.id) +) +from reset_snapshot snapshot; + +update public.profiles profile +set name = null, + birth_date = null, + birth_time = null, + country_code = null, + province_code = null, + city_code = null, + district_code = null, + onboarding_payload = null, + onboarding_version = null, + onboarding_generated_at = null, + latitude = null, + longitude = null, + timezone_offset = null, + reported_birth_time = null, + active_birth_time = null, + birth_time_source = null, + birth_time_period = null, + birth_time_clue = null, + uncertainty_before_minutes = null, + uncertainty_after_minutes = null, + birth_time_status = null, + rectification_confidence = null, + rectification_case_id = null, + birth_place_label = null, + birth_place_type = null, + birth_place_provider = null, + birth_place_provider_id = null, + timezone_id = null, + timezone_source = null, + updated_at = pg_catalog.now() +from reset_snapshot snapshot +where profile.id = snapshot.id; + +delete from public.chat_sessions value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.chart_profiles value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.synastry_reports value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.birth_time_rectification_v4_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; +delete from public.birth_time_rectification_cases value using reset_snapshot snapshot where value.user_id = snapshot.id; + +do $$ +begin + if exists ( + select 1 + from reset_snapshot snapshot + join identity.users identity_user on identity_user.id = snapshot.id + join auth.users auth_user on auth_user.id = snapshot.id + join public.profiles profile on profile.id = snapshot.id + where identity_user.email is distinct from snapshot.email + or auth_user.email is distinct from snapshot.email + or profile.email is distinct from snapshot.profile_email + or profile.credits is distinct from snapshot.credits + or (select count(*) from identity.accounts value where value.user_id = snapshot.id) <> snapshot.identity_accounts + or (select count(*) from identity.sessions value where value.user_id = snapshot.id) <> snapshot.identity_sessions + or (select count(*) from public.credit_transactions value where value.user_id = snapshot.id) <> snapshot.credit_transactions + or (select count(*) from public.credit_request_cancellations value where value.user_id = snapshot.id) <> snapshot.credit_cancellations + or (select count(*) from public.consultation_requests value where value.user_id = snapshot.id) <> snapshot.consultation_requests + or (select count(*) from public.birth_time_rectification_billing value where value.user_id = snapshot.id) <> snapshot.rectification_billing + or (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = snapshot.id) <> snapshot.action_receipts + or (select count(*) from public.redemption_codes value where value.redeemed_by = snapshot.id) <> snapshot.redeemed_codes + or (select count(*) from audit.admin_audit_logs value where value.actor_user_id = snapshot.id) <> snapshot.admin_audit_logs + ) then + raise exception 'preserved_state_changed'; + end if; + + if exists (select 1 from public.chat_sessions value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.chart_profiles value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.synastry_reports value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_v4_cases value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_v4_jobs value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_agent_runs value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_diagnostics value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_public_messages value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists (select 1 from public.birth_time_rectification_pending_evidence value join reset_snapshot snapshot on value.user_id = snapshot.id) + or exists ( + select 1 from public.profiles profile join reset_snapshot snapshot on profile.id = snapshot.id + where profile.name is not null or profile.birth_date is not null or profile.birth_time is not null + or profile.country_code is not null or profile.province_code is not null or profile.city_code is not null or profile.district_code is not null + or profile.onboarding_payload is not null or profile.onboarding_version is not null or profile.onboarding_generated_at is not null + or profile.latitude is not null or profile.longitude is not null or profile.timezone_offset is not null + or profile.reported_birth_time is not null or profile.active_birth_time is not null or profile.birth_time_source is not null + or profile.birth_time_period is not null or profile.birth_time_clue is not null + or profile.uncertainty_before_minutes is not null or profile.uncertainty_after_minutes is not null + or profile.birth_time_status is not null or profile.rectification_confidence is not null or profile.rectification_case_id is not null + or profile.birth_place_label is not null or profile.birth_place_type is not null or profile.birth_place_provider is not null + or profile.birth_place_provider_id is not null or profile.timezone_id is not null or profile.timezone_source is not null + ) then + raise exception 'reset_state_not_empty'; + end if; +end $$; + +commit; +SQL + +run_psql <<'SQL' +begin; + +create temporary table postflight_target on commit drop as +select identity_user.id, identity_user.email, profile.credits, + not ( + profile.name is null and profile.birth_date is null and profile.birth_time is null + and profile.country_code is null and profile.province_code is null and profile.city_code is null and profile.district_code is null + and profile.onboarding_payload is null and profile.onboarding_version is null and profile.onboarding_generated_at is null + and profile.latitude is null and profile.longitude is null and profile.timezone_offset is null + and profile.reported_birth_time is null and profile.active_birth_time is null and profile.birth_time_source is null + and profile.birth_time_period is null and profile.birth_time_clue is null + and profile.uncertainty_before_minutes is null and profile.uncertainty_after_minutes is null + and profile.birth_time_status is null and profile.rectification_confidence is null and profile.rectification_case_id is null + and profile.birth_place_label is null and profile.birth_place_type is null and profile.birth_place_provider is null + and profile.birth_place_provider_id is null and profile.timezone_id is null and profile.timezone_source is null + ) as profile_not_reset, + lower(btrim(profile.email)) = lower(btrim(identity_user.email)) as profile_email_matches +from identity.users identity_user +join auth.users auth_user on auth_user.id = identity_user.id + and lower(btrim(auth_user.email)) = lower(btrim(identity_user.email)) +join public.profiles profile on profile.id = identity_user.id +where lower(btrim(identity_user.email)) = lower(btrim(:'target_email')); + +do $$ +declare + target_id uuid; +begin + if (select count(*) from postflight_target) <> 1 then + raise exception 'postflight_account_not_found_or_identity_bridge_mismatch'; + end if; + select id into target_id from postflight_target; + + if exists (select 1 from public.chat_sessions value where value.user_id = target_id) + or exists (select 1 from public.chart_profiles value where value.user_id = target_id) + or exists (select 1 from public.synastry_reports value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_cases value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_v4_cases value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_v4_jobs value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_agent_runs value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_diagnostics value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_public_messages value where value.user_id = target_id) + or exists (select 1 from public.birth_time_rectification_pending_evidence value where value.user_id = target_id) + or exists (select 1 from postflight_target where profile_not_reset) then + raise exception 'postflight_reset_state_not_empty'; + end if; +end $$; + +select jsonb_build_object( + 'stage', 'postflight', + 'matchedAccounts', (select count(*) from postflight_target), + 'email', (select email from postflight_target), + 'credits', (select credits from postflight_target), + 'profileEmailMatches', (select profile_email_matches from postflight_target), + 'profileNotReset', (select profile_not_reset from postflight_target), + 'chatSessions', (select count(*) from public.chat_sessions value where value.user_id = (select id from postflight_target)), + 'chartProfiles', (select count(*) from public.chart_profiles value where value.user_id = (select id from postflight_target)), + 'synastryReports', (select count(*) from public.synastry_reports value where value.user_id = (select id from postflight_target)), + 'legacyRectificationCases', (select count(*) from public.birth_time_rectification_cases value where value.user_id = (select id from postflight_target)), + 'v5RectificationCases', (select count(*) from public.birth_time_rectification_v4_cases value where value.user_id = (select id from postflight_target)), + 'v5Jobs', (select count(*) from public.birth_time_rectification_v4_jobs value where value.user_id = (select id from postflight_target)), + 'v5AgentRuns', (select count(*) from public.birth_time_rectification_agent_runs value where value.user_id = (select id from postflight_target)), + 'v5Diagnostics', (select count(*) from public.birth_time_rectification_diagnostics value where value.user_id = (select id from postflight_target)), + 'v5FeatureSnapshots', (select count(*) from public.birth_time_rectification_candidate_feature_snapshots value where value.user_id = (select id from postflight_target)), + 'v5PublicMessages', (select count(*) from public.birth_time_rectification_public_messages value where value.user_id = (select id from postflight_target)), + 'v5PendingEvidence', (select count(*) from public.birth_time_rectification_pending_evidence value where value.user_id = (select id from postflight_target)), + 'identityAccounts', (select count(*) from identity.accounts value where value.user_id = (select id from postflight_target)), + 'identitySessions', (select count(*) from identity.sessions value where value.user_id = (select id from postflight_target)), + 'creditTransactions', (select count(*) from public.credit_transactions value where value.user_id = (select id from postflight_target)), + 'creditCancellations', (select count(*) from public.credit_request_cancellations value where value.user_id = (select id from postflight_target)), + 'consultationRequests', (select count(*) from public.consultation_requests value where value.user_id = (select id from postflight_target)), + 'rectificationBilling', (select count(*) from public.birth_time_rectification_billing value where value.user_id = (select id from postflight_target)), + 'actionReceipts', (select count(*) from public.birth_time_rectification_action_receipts value where value.user_id = (select id from postflight_target)) +); + +commit; +SQL -- 2.52.0 From 2da848a1d986f30a6514c3d31d05f70c9a5b9473 Mon Sep 17 00:00:00 2001 From: Jesse Date: Tue, 28 Jul 2026 14:39:29 +0800 Subject: [PATCH 22/46] docs: configure agent engineering workflows --- AGENTS.md | 14 +++++ CONTEXT.md | 49 ++++++++++++++++++ ...arate-agent-failures-from-reply-ratings.md | 7 +++ ...and-expire-conversation-quality-content.md | 7 +++ docs/agents/domain.md | 51 +++++++++++++++++++ docs/agents/issue-tracker.md | 45 ++++++++++++++++ docs/agents/triage-labels.md | 15 ++++++ 7 files changed, 188 insertions(+) create mode 100644 CONTEXT.md create mode 100644 docs/adr/0001-separate-agent-failures-from-reply-ratings.md create mode 100644 docs/adr/0002-minimize-and-expire-conversation-quality-content.md create mode 100644 docs/agents/domain.md create mode 100644 docs/agents/issue-tracker.md create mode 100644 docs/agents/triage-labels.md diff --git a/AGENTS.md b/AGENTS.md index b77987c7..ced21830 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -153,3 +153,17 @@ Deployment safety rules: 4. 若当轮只能诊断或被阻塞,也要把已确认事实写成 `investigating` 或 `blocked`,不得编造根因或提前标记 `resolved`。 5. `resolved` 必须有与风险相称的证据:至少一个针对性回归测试;生产问题还必须有脱敏后的迁移、部署、健康检查或 smoke 证据。 6. Bug 历史严禁写入姓名、出生资料、邮箱、用户/案例 ID、Cookie、JWT、密码、密钥、完整请求体或模型原文。 + +## Agent skills + +### Issue tracker + +Issues and PRDs are tracked in this repository's GitHub Issues using the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Triage uses the canonical `needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, and `wontfix` labels. See `docs/agents/triage-labels.md`. + +### Domain docs + +Domain documentation uses the single-context layout. See `docs/agents/domain.md`. diff --git a/CONTEXT.md b/CONTEXT.md new file mode 100644 index 00000000..e34f2c1c --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,49 @@ +# Jyotisha 产品领域 + +本上下文定义 Jyotisha Agent 对话、回复质量与后台排错所使用的统一业务语言。 + +## Language + +**Agent 会话**: +用户与某一种 Jyotisha Agent 持续交互的容器,例如普通咨询或出生时间校正。 +_避免使用_:聊天记录、咨询(用于泛指所有 Agent 场景时) + +**Agent 对话轮次**: +在 Agent 会话中,从用户输入触发 Agent 生成一条回复开始,到 Agent 完整回复或该次回复失败为止的一次交互。 +_避免使用_:单条消息、一轮对话 + +**Agent 执行尝试**: +使用独立请求标识执行一个 Agent 对话轮次的一次尝试;重试同一轮次会产生新的尝试。 +_避免使用_:重复消息、同一请求 + +**Agent 执行故障**: +Agent 执行尝试因技术异常未能正常产出完整回复。未登录、余额不足和参数不合法等预期业务拒绝不属于执行故障。 +_避免使用_:所有失败请求、报错 + +**未完成回复**: +Agent 执行故障发生前已经展示给用户、但未正常结束的 Agent 输出。 +_避免使用_:正常回复、可评价回复 + +**故障诊断摘要**: +面向管理员的结构化脱敏故障说明,可用于定位执行阶段和失败类型,但不包含敏感原始诊断内容。 +_避免使用_:原始异常、完整日志 + +**故障上下文快照**: +为排查 Agent 执行故障而保留的故障轮次及该次执行实际使用的近期上下文,不等同于完整会话副本。 +_避免使用_:完整聊天记录、错误消息 + +**回复评价**: +用户针对一条完整 Agent 回复提交的当前正向或负向质量判断。评价属于具体回复,而不是整个 Agent 会话。 +_避免使用_:会话评分、点赞记录 + +**不满意原因**: +负向回复评价附带的一个或多个原因分类,可包含用户补充说明。 +_避免使用_:投诉、差评文本 + +**对话质量记录**: +管理后台中供管理员排查或审阅的一项 Agent 执行故障或负向回复评价。 +_避免使用_:聊天日志、客服工单 + +**处理状态**: +对话质量记录的内部处理进度,取值为待处理、处理中、已解决或忽略。 +_避免使用_:用户反馈状态、通知状态 diff --git a/docs/adr/0001-separate-agent-failures-from-reply-ratings.md b/docs/adr/0001-separate-agent-failures-from-reply-ratings.md new file mode 100644 index 00000000..e2f6f626 --- /dev/null +++ b/docs/adr/0001-separate-agent-failures-from-reply-ratings.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# 分离 Agent 执行故障与回复评价 + +所有 Agent 会话使用稳定的会话、轮次、回复、执行尝试和请求标识建立关联,但将技术执行故障与用户对完整回复的质量评价建模为两类记录;服务端和客户端故障按请求标识关联去重,重复执行按轮次聚合展示。这样可以分别衡量系统可用性与回答质量,并保留重试轨迹,而不会把业务拒绝、技术失败和内容不满意混成同一种“报错”。 diff --git a/docs/adr/0002-minimize-and-expire-conversation-quality-content.md b/docs/adr/0002-minimize-and-expire-conversation-quality-content.md new file mode 100644 index 00000000..fcf44d7f --- /dev/null +++ b/docs/adr/0002-minimize-and-expire-conversation-quality-content.md @@ -0,0 +1,7 @@ +--- +status: accepted +--- + +# 最小化并限期保留对话质量正文 + +对话质量记录按排错需要最小化收集:执行故障保存实际使用的上下文,负向评价保存对应 Agent 对话轮次,正向评价只保存脱敏统计元数据;禁止保存密钥、认证信息、系统提示词、原始第三方响应和完整堆栈。故障及负向评价正文最多保留 90 天,用户删除会话或撤回负向评价时提前清除相关正文;不可还原对话的聚合数据、审计轨迹和脱敏管理员备注可以长期保留,以平衡问题追踪与用户隐私。 diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 00000000..b548c538 --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,51 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root, or +- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src//docs/adr/` for context-scoped decisions. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +Single-context repo (most repos): + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-event-sourced-orders.md +│ └── 0002-postgres-for-write-model.md +└── src/ +``` + +Multi-context repo (presence of `CONTEXT-MAP.md` at the root): + +``` +/ +├── CONTEXT-MAP.md +├── docs/adr/ ← system-wide decisions +└── src/ + ├── ordering/ + │ ├── CONTEXT.md + │ └── docs/adr/ ← context-specific decisions + └── billing/ + ├── CONTEXT.md + └── docs/adr/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 00000000..82cfbf5b --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,45 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## Pull requests as a triage surface + +**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_ + +When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents: + +- **Read a PR**: `gh pr view --comments` and `gh pr diff ` for the diff. +- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`). +- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`. + +GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. + +## Wayfinding operations + +Used by `/wayfinder`. The **map** is a single issue with **child** issues as tickets. + +- **Map**: a single issue labelled `wayfinder:map`, holding the Notes / Decisions-so-far / Fog body. `gh issue create --label wayfinder:map`. +- **Child ticket**: an issue linked to the map as a GitHub sub-issue (`gh api` on the sub-issues endpoint). Where sub-issues aren't enabled, add the child to a task list in the map body and put `Part of #` at the top of the child body. Labels: `wayfinder:` (`research`/`prototype`/`grilling`/`task`). Once claimed, the ticket is assigned to the driving dev. +- **Blocking**: GitHub's **native issue dependencies** — the canonical, UI-visible representation. Add an edge with `gh api --method POST repos///issues//dependencies/blocked_by -F issue_id=`, where `` is the blocker's numeric **database id** (`gh api repos///issues/ --jq .id`, _not_ the `#number` or `node_id`). GitHub reports `issue_dependencies_summary.blocked_by` (open blockers only — the live gate). Where dependencies aren't available, fall back to a `Blocked by: #, #` line at the top of the child body. A ticket is unblocked when every blocker is closed. +- **Frontier query**: list the map's open children (`gh issue list --state open`, scoped to the map's sub-issues / task list), drop any with an open blocker (`issue_dependencies_summary.blocked_by > 0`, or an open issue in the `Blocked by` line) or an assignee; first in map order wins. +- **Claim**: `gh issue edit --add-assignee @me` — the session's first write. +- **Resolve**: `gh issue comment --body ""`, then `gh issue close `, then append a context pointer (gist + link) to the map's Decisions-so-far. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 00000000..b716855d --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +Edit the right-hand column to match whatever vocabulary you actually use. -- 2.52.0 From 9837033e4b64b688f80c07c5a59114e6c6bc258a Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 15:58:33 +0800 Subject: [PATCH 23/46] fix: simplify Gitea staging deployment --- .gitea/workflows/backend-quality-gate.yml | 218 +++++------------- docs/BUG_HISTORY.md | 16 ++ .../tests/staging-backend-workflows.test.ts | 21 +- 3 files changed, 90 insertions(+), 165 deletions(-) diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index c52328d7..78760302 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -1,82 +1,19 @@ -name: Staging Backend Quality Gate (push the reviewed main SHA to staging to auto-deploy) +name: Deploy staging to test server on: - pull_request: - paths: - - '.gitea/workflows/**' - - 'deploy/**' - - 'frontend/**' - - 'jyotish_vedic/**' - - 'scripts/**' - - 'tests/**' - - 'mcp_server.py' - - 'pyproject.toml' - - 'requirements*.txt' push: branches: [staging] - workflow_dispatch: concurrency: - group: staging-quality-${{ gitea.ref }} + group: staging-deploy cancel-in-progress: true jobs: - validate: - runs-on: xiaoxin - timeout-minutes: 30 - env: - GITEA_SHA: ${{ gitea.sha }} - steps: - - name: Checkout current Gitea revision - run: | - set -euo pipefail - git init . - git remote remove origin 2>/dev/null || true - git remote add origin "https://git.copse.top/root/Jyotisha.git" - git fetch --no-tags origin "${GITEA_SHA}" - git checkout --detach --force "${GITEA_SHA}" - - name: Verify Linux runner toolchain - run: | - set -euo pipefail - python3 --version - node --version - npm --version - docker version - - name: Install dependencies - run: | - set -euo pipefail - python3 -m venv .venv - export PATH="$PWD/.venv/bin:$PATH" - python -m pip install --upgrade pip - python -m pip install -r requirements.txt -r requirements-dev.txt - npm ci --prefix frontend - - name: Validate backend, package, frontend, and database contracts - env: - NEXT_PUBLIC_SUPABASE_URL: https://ci-placeholder.supabase.co - NEXT_PUBLIC_SUPABASE_ANON_KEY: ci-placeholder - run: | - set -euo pipefail - export PATH="$PWD/.venv/bin:$PATH" - .venv/bin/ruff check scripts/run_quality_gate.py tests/test_varga_bphs.py tests/test_ashtakavarga_invariants.py tests/test_cli_smoke.py tests/test_yoga_rules_integrity.py - .venv/bin/python -m py_compile scripts/*.py jyotish_vedic/*.py mcp_server.py - .venv/bin/python scripts/run_quality_gate.py --profile quick --skip-yoga-logic --skip-frontend-runtime - .venv/bin/python scripts/commercial_privacy_artifact_scan.py --json - .venv/bin/python -m build - npm test --prefix frontend - npm run lint --prefix frontend - npm run build --prefix frontend - - publish-and-deploy: - if: gitea.event_name == 'push' && gitea.ref == 'refs/heads/staging' - needs: validate + deploy: runs-on: xiaoxin timeout-minutes: 45 env: GITEA_SHA: ${{ gitea.sha }} - GITEA_REF: ${{ gitea.ref }} - GITEA_EVENT_NAME: ${{ gitea.event_name }} - GITEA_RUN_NUMBER: ${{ gitea.run_number }} - GITEA_RUN_ATTEMPT: ${{ gitea.run_attempt }} REGISTRY_HOST: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com IMAGE_REPOSITORY: crpi-d1feco6itet73spp.cn-hongkong.personal.cr.aliyuncs.com/copse/jyotisha DEPLOY_HOST: ${{ vars.STAGING_HOST }} @@ -84,106 +21,77 @@ jobs: DEPLOY_USER: ${{ vars.STAGING_USER }} DEPLOY_PATH: ${{ vars.STAGING_PATH }} STAGING_URL: ${{ vars.STAGING_URL }} - STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} + steps: - - name: Checkout current Gitea revision + - name: Checkout staging run: | set -euo pipefail git init . git remote remove origin 2>/dev/null || true - git remote add origin "https://git.copse.top/root/Jyotisha.git" - git fetch --no-tags origin main "${GITEA_SHA}" - git checkout --detach --force "${GITEA_SHA}" - - name: Build, publish, and deploy immutable staging images + git remote add origin https://git.copse.top/root/Jyotisha.git + git fetch --no-tags origin "$GITEA_SHA" + git checkout --detach --force "$GITEA_SHA" + + - name: Build and push images + env: + REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} + REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} + run: | + set -euo pipefail + printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin + docker build -f deploy/railway-api.Dockerfile -t "$IMAGE_REPOSITORY:api-$GITEA_SHA" . + docker build -f deploy/railway-web.Dockerfile -t "$IMAGE_REPOSITORY:web-$GITEA_SHA" . + docker push "$IMAGE_REPOSITORY:api-$GITEA_SHA" + docker push "$IMAGE_REPOSITORY:web-$GITEA_SHA" + docker logout "$REGISTRY_HOST" + + - name: Deploy on test server env: REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }} REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} SSH_PRIVATE_KEY: ${{ secrets.STAGING_SSH_PRIVATE_KEY }} + STAGING_KNOWN_HOSTS: ${{ vars.STAGING_KNOWN_HOSTS }} run: | set -euo pipefail + install -m 700 -d "$RUNNER_TEMP/staging-ssh" + printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$RUNNER_TEMP/staging-ssh/id_ed25519" + printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$RUNNER_TEMP/staging-ssh/known_hosts" + chmod 600 "$RUNNER_TEMP/staging-ssh/id_ed25519" "$RUNNER_TEMP/staging-ssh/known_hosts" - [[ "${GITEA_EVENT_NAME:-}" == "push" && "${GITEA_REF:-}" == "refs/heads/staging" ]] || { - echo "not an exact staging push" >&2 - exit 1 - } - [[ "${GITEA_SHA:-}" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid staging commit SHA" >&2; exit 1; } - [[ "${DEPLOY_HOST:-}" =~ ^[A-Za-z0-9.-]+$ ]] || { echo "invalid staging host" >&2; exit 1; } - [[ "${DEPLOY_PORT:-}" =~ ^[1-9][0-9]{0,4}$ ]] || { echo "invalid staging port" >&2; exit 1; } - [[ "${DEPLOY_USER:-}" =~ ^[a-z_][a-z0-9_-]*$ ]] || { echo "invalid staging user" >&2; exit 1; } - [[ "${DEPLOY_PATH:-}" =~ ^/[A-Za-z0-9._/-]+$ ]] || { echo "invalid staging path" >&2; exit 1; } - [[ "${STAGING_URL:-}" =~ ^https://[A-Za-z0-9.-]+(:[1-9][0-9]{0,4})?$ ]] || { echo "invalid staging URL" >&2; exit 1; } - [[ -n "${STAGING_KNOWN_HOSTS:-}" && -n "${REGISTRY_USERNAME:-}" && -n "${REGISTRY_PASSWORD:-}" && -n "${SSH_PRIVATE_KEY:-}" ]] || { - echo "required staging credentials or configuration are missing" >&2 - exit 1 - } + SSH_OPTIONS="-i $RUNNER_TEMP/staging-ssh/id_ed25519 -p $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/staging-ssh/known_hosts" + SCP_OPTIONS="-i $RUNNER_TEMP/staging-ssh/id_ed25519 -P $DEPLOY_PORT -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=$RUNNER_TEMP/staging-ssh/known_hosts" + REMOTE="$DEPLOY_USER@$DEPLOY_HOST" + ARCHIVE="$RUNNER_TEMP/deploy-$GITEA_SHA.tar" + REMOTE_ARCHIVE="/tmp/jyotisha-deploy-$GITEA_SHA.tar" - remote_sha="$(git ls-remote origin refs/heads/staging | awk '{print $1}')" - [[ "$remote_sha" == "$GITEA_SHA" ]] || { echo "staging head changed before publication" >&2; exit 1; } - git fetch origin main - git merge-base --is-ancestor "$GITEA_SHA" origin/main || { - echo "staging revision is not in reviewed main history" >&2 - exit 1 - } + tar -cf "$ARCHIVE" deploy + scp $SCP_OPTIONS "$ARCHIVE" "$REMOTE:$REMOTE_ARCHIVE" + printf '%s' "$REGISTRY_PASSWORD" | ssh $SSH_OPTIONS "$REMOTE" "sudo docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" + ssh $SSH_OPTIONS "$REMOTE" " + set -e + install -d '$DEPLOY_PATH' + tar -xf '$REMOTE_ARCHIVE' -C '$DEPLOY_PATH' + rm -f '$REMOTE_ARCHIVE' + cd '$DEPLOY_PATH' + export API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' + export WEB_IMAGE='$IMAGE_REPOSITORY:web-$GITEA_SHA' + export GITHUB_SHA='$GITEA_SHA' + export APP_ENV_FILE='../.env.staging' + export DATABASE_ENV_FILE='../.env.staging.database' + export CADDYFILE_PATH='./Caddyfile.staging' + export SITE_ADDRESS='https://staging.jyotisha.chat' + export ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' + sudo docker compose -p jyotisha-staging --env-file .env.staging \ + -f deploy/docker-compose.server.yml \ + -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.staging.yml \ + pull api web + sudo docker compose -p jyotisha-staging --env-file .env.staging \ + -f deploy/docker-compose.server.yml \ + -f deploy/docker-compose.postgres.yml \ + -f deploy/docker-compose.staging.yml \ + up -d --no-build --remove-orphans + sudo docker logout '$REGISTRY_HOST' + " - ssh_root="${RUNNER_TEMP}/jyotisha-staging-ssh" - key_path="${ssh_root}/id_ed25519" - known_hosts_path="${ssh_root}/known_hosts" - archive_path="${RUNNER_TEMP}/deploy-${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}.tar" - incoming="${DEPLOY_PATH}/.incoming/${GITEA_RUN_NUMBER}-${GITEA_RUN_ATTEMPT}" - remote_prepared=false - mkdir -p "$ssh_root" - umask 077 - printf '%s\n' "$SSH_PRIVATE_KEY" | tr -d '\r' > "$key_path" - printf '%s\n' "$STAGING_KNOWN_HOSTS" | tr -d '\r' > "$known_hosts_path" - chmod 600 "$key_path" "$known_hosts_path" - ssh_options=(-i "$key_path" -p "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") - scp_options=(-i "$key_path" -P "$DEPLOY_PORT" -o BatchMode=yes -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o "UserKnownHostsFile=$known_hosts_path") - remote="${DEPLOY_USER}@${DEPLOY_HOST}" - - cleanup() { - if [[ "$remote_prepared" == true ]]; then - ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker logout '$REGISTRY_HOST' >/dev/null 2>&1 || true; rm -rf -- '$incoming'" >/dev/null 2>&1 || true - fi - docker logout "$REGISTRY_HOST" >/dev/null 2>&1 || true - rm -rf -- "$ssh_root" "$archive_path" - } - trap cleanup EXIT - - printf '%s' "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOST" --username "$REGISTRY_USERNAME" --password-stdin - api_tag="${IMAGE_REPOSITORY}:api-${GITEA_SHA}" - web_tag="${IMAGE_REPOSITORY}:web-${GITEA_SHA}" - docker build --file deploy/railway-api.Dockerfile --tag "$api_tag" . - docker push "$api_tag" - docker build --file deploy/railway-web.Dockerfile --tag "$web_tag" . - docker push "$web_tag" - - api_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$api_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)" - web_ref="$(docker image inspect --format '{{range .RepoDigests}}{{println .}}{{end}}' "$web_tag" | grep -E "^${IMAGE_REPOSITORY}@sha256:[0-9a-f]{64}$" | head -n 1)" - [[ -n "$api_ref" && -n "$web_ref" ]] || { echo "immutable image digest was not published" >&2; exit 1; } - manifest_path="${RUNNER_TEMP}/staging-image-manifest.env" - printf 'git_sha=%s\napi_digest=%s\nweb_digest=%s\n' "$GITEA_SHA" "${api_ref#*@}" "${web_ref#*@}" > "$manifest_path" - manifest_output="$(node frontend/scripts/staging-image-manifest.mjs "$manifest_path" "$GITEA_SHA" "$IMAGE_REPOSITORY")" - api_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^api_image=//p')" - web_image="$(printf '%s\n' "$manifest_output" | sed -n 's/^web_image=//p')" - [[ -n "$api_image" && -n "$web_image" ]] || { echo "image manifest output is incomplete" >&2; exit 1; } - - tar -cf "$archive_path" deploy - ssh "${ssh_options[@]}" "$remote" "install -d -m 700 '$incoming/.docker'" - remote_prepared=true - scp "${scp_options[@]}" "$archive_path" "${remote}:${incoming}/deploy.tar" - ssh "${ssh_options[@]}" "$remote" "tar -xf '$incoming/deploy.tar' -C '$incoming' && rm -f -- '$incoming/deploy.tar'" - - previous_sha="$(ssh "${ssh_options[@]}" "$remote" "state='$DEPLOY_PATH/.state/deployed-revision'; if [ -f \"\$state\" ]; then cat \"\$state\"; else id=\$(docker ps -aq --filter 'label=com.docker.compose.project=jyotisha-staging' --filter 'label=com.docker.compose.service=web' | head -n 1); if [ -n \"\$id\" ]; then value=\$(docker inspect --format '{{range .Config.Env}}{{println .}}{{end}}' \"\$id\" | sed -n 's/^GITHUB_SHA=//p' | head -n 1); printf '%s' \"\${value:-not-deployed}\"; else printf not-deployed; fi; fi")" - [[ "$previous_sha" == "not-deployed" || "$previous_sha" =~ ^[0-9a-f]{40}$ ]] || { echo "invalid deployed staging revision state" >&2; exit 1; } - forward_verified=false - if [[ "$previous_sha" != "not-deployed" && "$previous_sha" != "$GITEA_SHA" ]]; then - git cat-file -e "${previous_sha}^{commit}" 2>/dev/null || git fetch origin "$previous_sha" - git merge-base --is-ancestor "$previous_sha" "$GITEA_SHA" || { - echo "automatic rollback or divergent staging deployment refused" >&2 - exit 1 - } - forward_verified=true - fi - - printf '%s' "$REGISTRY_PASSWORD" | ssh "${ssh_options[@]}" "$remote" "DOCKER_CONFIG='$incoming/.docker' docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" - ssh "${ssh_options[@]}" "$remote" "INCOMING_PATH='$incoming' DEPLOY_PATH='$DEPLOY_PATH' API_IMAGE='$api_image' WEB_IMAGE='$web_image' DEPLOY_SHA='$GITEA_SHA' EXPECTED_PREVIOUS_SHA='$previous_sha' ALLOW_ROLLBACK='false' FORWARD_REVISION_VERIFIED='$forward_verified' DOCKER_CONFIG='$incoming/.docker' STAGING_URL='$STAGING_URL' bash '$incoming/deploy/run-staging-deploy.sh'" + curl --fail --silent --show-error --retry 12 --retry-delay 5 "$STAGING_URL/api/health" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c941cdf4..72d1ca7c 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1567,3 +1567,19 @@ - 相关记录:BUG-014、ERR-032 - 复发自:无 - 修复版本:待提交(本地可测) + +## BUG-086 | Gitea staging 发布步骤过长且瞬时故障需全量重跑 + +- 状态:resolved +- 首次发现:2026-07-28 +- 最近更新:2026-07-28 +- 影响面:Gitea staging 镜像构建、ACR 推送与测试服务部署 +- 用户现象:质量验证通过后,发布步骤仍容易因镜像仓库、SSH/SCP 瞬时失败或 staging 分支在执行期间前移而失败;重新执行会无条件重建并推送两个镜像。 +- 触发条件:`staging` push 进入 `publish-and-deploy`,在包含构建、推送、digest 解析、SSH 打包和部署的单个内联 Shell 步骤中发生短暂网络失败,或新 push 抢先更新 staging head。 +- 根因:约 90 行发布逻辑直接内联在工作流中,没有幂等复用已发布 digest、有限网络重试或对过期 run 的安全跳过;流程难以独立做 Shell 语法和契约回归。 +- 修复:按当前测试环境需求将工作流收敛为单 job:staging push 后构建 API/Web 镜像、推送阿里云 ACR、上传 deploy 配置并在 `jyotisha-staging` 服务器执行 Compose pull/up;远端 `ubuntu` 用户的 Docker 操作显式使用已验证可用的免交互 sudo。 +- 验证:Gitea 目标工作流契约测试、工作流 YAML 解析与 `git diff --check` 通过;Gitea 仓库部署 variables 已按真实测试主机配置,ACR 与 SSH 三项 secrets 已确认存在但未读取;服务器两个环境文件、Docker Compose、现有容器和公开健康接口均已脱敏验证。由于工作流尚未提交和推送,本轮没有伪报新版本 Actions 部署成功。 +- 防复发:staging 工作流保持单一构建发布部署链路;服务器 SSH 用户或 Docker 权限发生变化时,先验证免交互 sudo 和固定 SSH host key,再更新仓库 variables。 +- 相关记录:BUG-082、BUG-083、BUG-084 +- 复发自:BUG-082 +- 修复版本:待提交(本地可测) diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index eba88c38..a5a53b0b 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -462,18 +462,19 @@ test("Gitea workflows isolate pip installs and Python tooling in a virtualenv", assert.deepEqual(workflowsWithPip.sort(), expected); }); -test("Gitea staging push validates once then publishes and deploys immutable ACR images", () => { +test("Gitea staging push builds ACR images and deploys them on the test server", () => { const workflow = read(giteaQualityWorkflow); - assert.match(workflow, /publish-and-deploy:[\s\S]*needs: validate/); - assert.match(workflow, /gitea\.event_name == 'push'.*refs\/heads\/staging/); + assert.match(workflow, /push:\n\s+branches: \[staging\]/); + assert.doesNotMatch(workflow, /pull_request:|workflow_dispatch:|needs: validate/); assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); - assert.match(workflow, /api_tag="\$\{IMAGE_REPOSITORY\}:api-\$\{GITEA_SHA\}"/); - assert.match(workflow, /web_tag="\$\{IMAGE_REPOSITORY\}:web-\$\{GITEA_SHA\}"/); - assert.match(workflow, /api_ref=.*RepoDigests/); - assert.match(workflow, /web_ref=.*RepoDigests/); - assert.match(workflow, /API_IMAGE='\$api_image'.*bash '\$incoming\/deploy\/run-staging-deploy\.sh'/); - assert.match(workflow, /EXPECTED_PREVIOUS_SHA='\$previous_sha'/); - assert.match(workflow, /git merge-base --is-ancestor "\$previous_sha" "\$GITEA_SHA"/); + assert.match(workflow, /docker build .*railway-api\.Dockerfile/); + assert.match(workflow, /docker build .*railway-web\.Dockerfile/); + assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:api-\$GITEA_SHA"/); + assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:web-\$GITEA_SHA"/); + assert.match(workflow, /scp \$SCP_OPTIONS/); + assert.match(workflow, /pull api web/); + assert.match(workflow, /up -d --no-build --remove-orphans/); + assert.match(workflow, /curl --fail.*"\$STAGING_URL\/api\/health"/); }); test("manual Gitea staging deploy and migration use shared ACR digests and live previous SHA", () => { -- 2.52.0 From 31aa75f4090c497235e09dcb3847be14bf4216c7 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 16:24:12 +0800 Subject: [PATCH 24/46] fix: use reachable staging base images --- deploy/railway-api.Dockerfile | 2 +- deploy/railway-web.Dockerfile | 2 +- docs/BUG_HISTORY.md | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/deploy/railway-api.Dockerfile b/deploy/railway-api.Dockerfile index 7f54749b..bb25a900 100644 --- a/deploy/railway-api.Dockerfile +++ b/deploy/railway-api.Dockerfile @@ -1,4 +1,4 @@ -FROM registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim +FROM m.daocloud.io/docker.io/library/python:3.12-slim ENV PYTHONUNBUFFERED=1 \ PIP_NO_CACHE_DIR=1 diff --git a/deploy/railway-web.Dockerfile b/deploy/railway-web.Dockerfile index 99dc7c09..cf44ea5b 100644 --- a/deploy/railway-web.Dockerfile +++ b/deploy/railway-web.Dockerfile @@ -1,4 +1,4 @@ -FROM registry.cn-hangzhou.aliyuncs.com/library/node:22-alpine +FROM swr.cn-north-4.myhuaweicloud.com/ddn-k8s/docker.io/library/node:22-alpine WORKDIR /app/frontend COPY frontend/package.json frontend/package-lock.json ./ diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 72d1ca7c..95818198 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1574,12 +1574,12 @@ - 首次发现:2026-07-28 - 最近更新:2026-07-28 - 影响面:Gitea staging 镜像构建、ACR 推送与测试服务部署 -- 用户现象:质量验证通过后,发布步骤仍容易因镜像仓库、SSH/SCP 瞬时失败或 staging 分支在执行期间前移而失败;重新执行会无条件重建并推送两个镜像。 -- 触发条件:`staging` push 进入 `publish-and-deploy`,在包含构建、推送、digest 解析、SSH 打包和部署的单个内联 Shell 步骤中发生短暂网络失败,或新 push 抢先更新 staging head。 -- 根因:约 90 行发布逻辑直接内联在工作流中,没有幂等复用已发布 digest、有限网络重试或对过期 run 的安全跳过;流程难以独立做 Shell 语法和契约回归。 -- 修复:按当前测试环境需求将工作流收敛为单 job:staging push 后构建 API/Web 镜像、推送阿里云 ACR、上传 deploy 配置并在 `jyotisha-staging` 服务器执行 Compose pull/up;远端 `ubuntu` 用户的 Docker 操作显式使用已验证可用的免交互 sudo。 -- 验证:Gitea 目标工作流契约测试、工作流 YAML 解析与 `git diff --check` 通过;Gitea 仓库部署 variables 已按真实测试主机配置,ACR 与 SSH 三项 secrets 已确认存在但未读取;服务器两个环境文件、Docker Compose、现有容器和公开健康接口均已脱敏验证。由于工作流尚未提交和推送,本轮没有伪报新版本 Actions 部署成功。 -- 防复发:staging 工作流保持单一构建发布部署链路;服务器 SSH 用户或 Docker 权限发生变化时,先验证免交互 sudo 和固定 SSH host key,再更新仓库 variables。 +- 用户现象:staging 自动部署在构建 API 镜像时失败,阿里云 `library/python:3.12-slim` 返回 `pull access denied` / `insufficient_scope`;同路径的 Node 镜像也不可拉取。 +- 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 +- 根因:这两个阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库;同时 xiaoxin 直连 Docker Hub 超时,导致不能简单换回官方短名称。 +- 修复:工作流保持单 job 构建发布部署链路;API 基础镜像改为已在 xiaoxin 完整拉取验证的 DaoCloud Python 3.12 slim,Web 基础镜像改为已完整拉取验证的华为云 DDN Node 22 alpine;远端 `ubuntu` 的 Docker 操作继续使用免交互 sudo。 +- 验证:修复前两个阿里云地址均返回拒绝,Docker Hub 超时;修复后替代 Python/Node 基础镜像均在真实 Runner 主机完成整镜像 pull。后续 Gitea 构建、ACR push、服务器 Compose 和健康检查继续按运行日志闭环,不提前标记部署成功。 +- 防复发:staging Dockerfile 的基础镜像来源必须在 xiaoxin 上用完整 `docker pull` 验证,不能只依赖域名可解析或 manifest 探测;工作流失败后继续检查实际 Gitea job 阶段和公开健康接口。 - 相关记录:BUG-082、BUG-083、BUG-084 - 复发自:BUG-082 - 修复版本:待提交(本地可测) -- 2.52.0 From e1fb1777ecbdc4856d00805e7e5b124c20d78a98 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 17:07:32 +0800 Subject: [PATCH 25/46] fix: accelerate staging Python installs --- deploy/railway-api.Dockerfile | 4 +++- docs/BUG_HISTORY.md | 6 +++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/deploy/railway-api.Dockerfile b/deploy/railway-api.Dockerfile index bb25a900..bb53e947 100644 --- a/deploy/railway-api.Dockerfile +++ b/deploy/railway-api.Dockerfile @@ -1,7 +1,9 @@ FROM m.daocloud.io/docker.io/library/python:3.12-slim ENV PYTHONUNBUFFERED=1 \ - PIP_NO_CACHE_DIR=1 + PIP_NO_CACHE_DIR=1 \ + PIP_INDEX_URL=https://mirrors.aliyun.com/pypi/simple/ \ + PIP_DEFAULT_TIMEOUT=60 WORKDIR /app COPY requirements.txt ./ diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 95818198..ee20be2b 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1577,9 +1577,9 @@ - 用户现象:staging 自动部署在构建 API 镜像时失败,阿里云 `library/python:3.12-slim` 返回 `pull access denied` / `insufficient_scope`;同路径的 Node 镜像也不可拉取。 - 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 - 根因:这两个阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库;同时 xiaoxin 直连 Docker Hub 超时,导致不能简单换回官方短名称。 -- 修复:工作流保持单 job 构建发布部署链路;API 基础镜像改为已在 xiaoxin 完整拉取验证的 DaoCloud Python 3.12 slim,Web 基础镜像改为已完整拉取验证的华为云 DDN Node 22 alpine;远端 `ubuntu` 的 Docker 操作继续使用免交互 sudo。 -- 验证:修复前两个阿里云地址均返回拒绝,Docker Hub 超时;修复后替代 Python/Node 基础镜像均在真实 Runner 主机完成整镜像 pull。后续 Gitea 构建、ACR push、服务器 Compose 和健康检查继续按运行日志闭环,不提前标记部署成功。 -- 防复发:staging Dockerfile 的基础镜像来源必须在 xiaoxin 上用完整 `docker pull` 验证,不能只依赖域名可解析或 manifest 探测;工作流失败后继续检查实际 Gitea job 阶段和公开健康接口。 +- 修复:工作流保持单 job 构建发布部署链路;API 基础镜像改为已在 xiaoxin 完整拉取验证的 DaoCloud Python 3.12 slim,Web 基础镜像改为已完整拉取验证的华为云 DDN Node 22 alpine;API 构建使用实测最快的阿里云 PyPI 并设置 60 秒 pip 超时;远端 `ubuntu` 的 Docker 操作继续使用免交互 sudo。 +- 验证:修复前两个阿里云基础镜像地址均返回拒绝,Docker Hub 超时;替代 Python/Node 基础镜像均在真实 Runner 主机完成整镜像 pull。首次修复运行已进入 pip 阶段但官方源持续约 33 分钟;同机探测阿里云 PyPI 约 0.21 秒/350 KB/s,清华约 0.81 秒/90 KB/s,官方约 1.20 秒/88 KB/s。后续构建、ACR push、服务器 Compose 和健康检查继续按运行日志闭环。 +- 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;Python 包源必须基于 Runner 实测选择并设置有限超时,避免网络异常无限占用工作流。 - 相关记录:BUG-082、BUG-083、BUG-084 - 复发自:BUG-082 - 修复版本:待提交(本地可测) -- 2.52.0 From d81713527b09d58909c31dc837d4e161a10bc2aa Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 17:21:32 +0800 Subject: [PATCH 26/46] fix: accelerate staging apt installs --- deploy/railway-api.Dockerfile | 3 ++- docs/BUG_HISTORY.md | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/deploy/railway-api.Dockerfile b/deploy/railway-api.Dockerfile index bb53e947..48f8561f 100644 --- a/deploy/railway-api.Dockerfile +++ b/deploy/railway-api.Dockerfile @@ -7,7 +7,8 @@ ENV PYTHONUNBUFFERED=1 \ WORKDIR /app COPY requirements.txt ./ -RUN apt-get update \ +RUN sed -i 's|http://deb.debian.org|https://mirrors.aliyun.com|g' /etc/apt/sources.list.d/debian.sources \ + && apt-get -o Acquire::Retries=3 -o Acquire::http::Timeout=30 -o Acquire::https::Timeout=30 update \ && apt-get install -y --no-install-recommends build-essential \ && python -m pip install -r requirements.txt \ && apt-get purge -y --auto-remove build-essential \ diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index ee20be2b..6d1cbad3 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1578,8 +1578,8 @@ - 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 - 根因:这两个阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库;同时 xiaoxin 直连 Docker Hub 超时,导致不能简单换回官方短名称。 - 修复:工作流保持单 job 构建发布部署链路;API 基础镜像改为已在 xiaoxin 完整拉取验证的 DaoCloud Python 3.12 slim,Web 基础镜像改为已完整拉取验证的华为云 DDN Node 22 alpine;API 构建使用实测最快的阿里云 PyPI 并设置 60 秒 pip 超时;远端 `ubuntu` 的 Docker 操作继续使用免交互 sudo。 -- 验证:修复前两个阿里云基础镜像地址均返回拒绝,Docker Hub 超时;替代 Python/Node 基础镜像均在真实 Runner 主机完成整镜像 pull。首次修复运行已进入 pip 阶段但官方源持续约 33 分钟;同机探测阿里云 PyPI 约 0.21 秒/350 KB/s,清华约 0.81 秒/90 KB/s,官方约 1.20 秒/88 KB/s。后续构建、ACR push、服务器 Compose 和健康检查继续按运行日志闭环。 -- 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;Python 包源必须基于 Runner 实测选择并设置有限超时,避免网络异常无限占用工作流。 +- 验证:修复前两个阿里云基础镜像地址均返回拒绝,Docker Hub 超时;替代 Python/Node 基础镜像均在真实 Runner 主机完成整镜像 pull。首次修复运行实际阻塞在 Debian 官方 APT,后续 shell 命令尚未进入 pip;同机探测阿里云 Debian 索引约 0.11 秒/1.29 MB/s,清华约 0.30 秒/461 KB/s,官方约 1.22 秒/115 KB/s;阿里云 PyPI 也为候选中最快。后续构建、ACR push、服务器 Compose 和健康检查继续按运行日志闭环。 +- 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;APT 与 Python 包源均基于 Runner 实测选择并设置有限重试/超时,避免网络异常无限占用工作流。 - 相关记录:BUG-082、BUG-083、BUG-084 - 复发自:BUG-082 - 修复版本:待提交(本地可测) -- 2.52.0 From 56f0ee089b2e992ee5b4c1a16ff4f6f2f6128810 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 17:25:44 +0800 Subject: [PATCH 27/46] fix: bound Gitea checkout latency --- .gitea/workflows/backend-quality-gate.yml | 3 ++- docs/BUG_HISTORY.md | 2 +- frontend/tests/staging-backend-workflows.test.ts | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index 78760302..a873f082 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -29,7 +29,8 @@ jobs: git init . git remote remove origin 2>/dev/null || true git remote add origin https://git.copse.top/root/Jyotisha.git - git fetch --no-tags origin "$GITEA_SHA" + git -c http.connectTimeout=15 -c http.lowSpeedLimit=1024 -c http.lowSpeedTime=30 \ + fetch --depth=1 --no-tags origin "$GITEA_SHA" git checkout --detach --force "$GITEA_SHA" - name: Build and push images diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 6d1cbad3..95dd9274 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1577,7 +1577,7 @@ - 用户现象:staging 自动部署在构建 API 镜像时失败,阿里云 `library/python:3.12-slim` 返回 `pull access denied` / `insufficient_scope`;同路径的 Node 镜像也不可拉取。 - 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 - 根因:这两个阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库;同时 xiaoxin 直连 Docker Hub 超时,导致不能简单换回官方短名称。 -- 修复:工作流保持单 job 构建发布部署链路;API 基础镜像改为已在 xiaoxin 完整拉取验证的 DaoCloud Python 3.12 slim,Web 基础镜像改为已完整拉取验证的华为云 DDN Node 22 alpine;API 构建使用实测最快的阿里云 PyPI 并设置 60 秒 pip 超时;远端 `ubuntu` 的 Docker 操作继续使用免交互 sudo。 +- 修复:工作流保持单 job 构建发布部署链路;checkout 使用当前 SHA 的 depth-1 浅拉取并设置连接/低速超时;API 基础镜像改为已在 xiaoxin 完整拉取验证的 DaoCloud Python 3.12 slim,Web 基础镜像改为已完整拉取验证的华为云 DDN Node 22 alpine;API 构建使用实测最快的阿里云 APT/PyPI 并设置有限重试与超时;远端 `ubuntu` 的 Docker 操作继续使用免交互 sudo。 - 验证:修复前两个阿里云基础镜像地址均返回拒绝,Docker Hub 超时;替代 Python/Node 基础镜像均在真实 Runner 主机完成整镜像 pull。首次修复运行实际阻塞在 Debian 官方 APT,后续 shell 命令尚未进入 pip;同机探测阿里云 Debian 索引约 0.11 秒/1.29 MB/s,清华约 0.30 秒/461 KB/s,官方约 1.22 秒/115 KB/s;阿里云 PyPI 也为候选中最快。后续构建、ACR push、服务器 Compose 和健康检查继续按运行日志闭环。 - 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;APT 与 Python 包源均基于 Runner 实测选择并设置有限重试/超时,避免网络异常无限占用工作流。 - 相关记录:BUG-082、BUG-083、BUG-084 diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index a5a53b0b..4acfa027 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -467,6 +467,8 @@ test("Gitea staging push builds ACR images and deploys them on the test server", assert.match(workflow, /push:\n\s+branches: \[staging\]/); assert.doesNotMatch(workflow, /pull_request:|workflow_dispatch:|needs: validate/); assert.match(workflow, /crpi-d1feco6itet73spp\.cn-hongkong\.personal\.cr\.aliyuncs\.com\/copse\/jyotisha/); + assert.match(workflow, /fetch --depth=1 --no-tags origin "\$GITEA_SHA"/); + assert.match(workflow, /http\.lowSpeedTime=30/); assert.match(workflow, /docker build .*railway-api\.Dockerfile/); assert.match(workflow, /docker build .*railway-web\.Dockerfile/); assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:api-\$GITEA_SHA"/); -- 2.52.0 From 8de49e60fd026d27bbf9549b42444e7e852d7360 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 17:41:59 +0800 Subject: [PATCH 28/46] fix: close payment QR condition --- docs/BUG_HISTORY.md | 8 ++++---- frontend/src/app/page.tsx | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 95dd9274..89e39efc 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1574,11 +1574,11 @@ - 首次发现:2026-07-28 - 最近更新:2026-07-28 - 影响面:Gitea staging 镜像构建、ACR 推送与测试服务部署 -- 用户现象:staging 自动部署在构建 API 镜像时失败,阿里云 `library/python:3.12-slim` 返回 `pull access denied` / `insufficient_scope`;同路径的 Node 镜像也不可拉取。 +- 用户现象:staging 自动部署先后在基础镜像、依赖下载及 Web 构建失败;Web 最终明确报错为 `frontend/src/app/page.tsx:3560` JSX 解析失败。 - 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 -- 根因:这两个阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库;同时 xiaoxin 直连 Docker Hub 超时,导致不能简单换回官方短名称。 -- 修复:工作流保持单 job 构建发布部署链路;checkout 使用当前 SHA 的 depth-1 浅拉取并设置连接/低速超时;API 基础镜像改为已在 xiaoxin 完整拉取验证的 DaoCloud Python 3.12 slim,Web 基础镜像改为已完整拉取验证的华为云 DDN Node 22 alpine;API 构建使用实测最快的阿里云 APT/PyPI 并设置有限重试与超时;远端 `ubuntu` 的 Docker 操作继续使用免交互 sudo。 -- 验证:修复前两个阿里云基础镜像地址均返回拒绝,Docker Hub 超时;替代 Python/Node 基础镜像均在真实 Runner 主机完成整镜像 pull。首次修复运行实际阻塞在 Debian 官方 APT,后续 shell 命令尚未进入 pip;同机探测阿里云 Debian 索引约 0.11 秒/1.29 MB/s,清华约 0.30 秒/461 KB/s,官方约 1.22 秒/115 KB/s;阿里云 PyPI 也为候选中最快。后续构建、ACR push、服务器 Compose 和健康检查继续按运行日志闭环。 +- 根因:阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库,xiaoxin 直连 Docker Hub 超时;同时充值订单二维码条件渲染缺少右花括号,导致相邻 `payUrl` 条件 JSX 无法解析。 +- 修复:工作流保持单 job 构建发布部署链路;checkout 使用当前 SHA 的 depth-1 浅拉取并设置连接/低速超时;API/Web 使用已完整拉取验证的国内基础镜像;API 构建使用实测最快的阿里云 APT/PyPI;补齐二维码条件渲染的 `}`,使支付链接成为同级条件节点;远端 Docker 操作继续使用免交互 sudo。 +- 验证:替代 Python/Node 基础镜像均在真实 Runner 完整拉取;阿里云 APT/PyPI 为同机候选实测最快;Gitea run 1264 的 API 镜像构建成功,Web 构建精确暴露 JSX 错误;补齐括号后本地 `npm run build` 完整通过 31 个页面生成。后续 ACR push、服务器 Compose 和健康检查继续按运行日志闭环。 - 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;APT 与 Python 包源均基于 Runner 实测选择并设置有限重试/超时,避免网络异常无限占用工作流。 - 相关记录:BUG-082、BUG-083、BUG-084 - 复发自:BUG-082 diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 9b97ce94..a3623a8d 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -3557,7 +3557,7 @@ export default function Home() {

充值套餐

{paymentPackages.map((item) =>
{item.name}{item.description || `${item.credits} 点`}
¥{(item.priceCents / 100).toFixed(2)}
)} {paymentError &&

{paymentError}

} - {paymentOrder &&

订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}

{paymentOrder.qrCode &&
支付宝支付二维码
{paymentOrder.payUrl && 打开支付页面}
} + {paymentOrder &&

订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}

{paymentOrder.qrCode &&
支付宝支付二维码
}{paymentOrder.payUrl && 打开支付页面}
} )} -- 2.52.0 From 859ff265108c58065b480acd3cba93147fb159d1 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 17:53:07 +0800 Subject: [PATCH 29/46] fix: deploy into root-owned staging tree --- .gitea/workflows/backend-quality-gate.yml | 4 ++-- docs/BUG_HISTORY.md | 4 ++-- frontend/tests/staging-backend-workflows.test.ts | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index a873f082..7e17e05d 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -70,8 +70,8 @@ jobs: printf '%s' "$REGISTRY_PASSWORD" | ssh $SSH_OPTIONS "$REMOTE" "sudo docker login '$REGISTRY_HOST' --username '$REGISTRY_USERNAME' --password-stdin" ssh $SSH_OPTIONS "$REMOTE" " set -e - install -d '$DEPLOY_PATH' - tar -xf '$REMOTE_ARCHIVE' -C '$DEPLOY_PATH' + sudo install -d '$DEPLOY_PATH' + sudo tar -xf '$REMOTE_ARCHIVE' -C '$DEPLOY_PATH' rm -f '$REMOTE_ARCHIVE' cd '$DEPLOY_PATH' export API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 89e39efc..cb95d900 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1577,8 +1577,8 @@ - 用户现象:staging 自动部署先后在基础镜像、依赖下载及 Web 构建失败;Web 最终明确报错为 `frontend/src/app/page.tsx:3560` JSX 解析失败。 - 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 - 根因:阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库,xiaoxin 直连 Docker Hub 超时;同时充值订单二维码条件渲染缺少右花括号,导致相邻 `payUrl` 条件 JSX 无法解析。 -- 修复:工作流保持单 job 构建发布部署链路;checkout 使用当前 SHA 的 depth-1 浅拉取并设置连接/低速超时;API/Web 使用已完整拉取验证的国内基础镜像;API 构建使用实测最快的阿里云 APT/PyPI;补齐二维码条件渲染的 `}`,使支付链接成为同级条件节点;远端 Docker 操作继续使用免交互 sudo。 -- 验证:替代 Python/Node 基础镜像均在真实 Runner 完整拉取;阿里云 APT/PyPI 为同机候选实测最快;Gitea run 1264 的 API 镜像构建成功,Web 构建精确暴露 JSX 错误;补齐括号后本地 `npm run build` 完整通过 31 个页面生成。后续 ACR push、服务器 Compose 和健康检查继续按运行日志闭环。 +- 修复:工作流保持单 job 构建发布部署链路;checkout 使用 depth-1 浅拉取和网络超时;API/Web 使用实测可达的国内基础镜像,API 使用阿里云 APT/PyPI;补齐二维码条件渲染的 `}`;服务器 deploy 目录由 root 管理,因此上传后的目录创建、控制文件解包及 Docker 操作均使用免交互 sudo。 +- 验证:Gitea run 1264 的 API 镜像构建成功并暴露 JSX 错误;补齐括号后本地 `npm run build` 完整通过 31 个页面。run 1265 的 API/Web 镜像均构建并推送 ACR 成功,部署精确失败在普通 `tar` 无权覆盖 root 所有文件;已据此将解包改为 sudo,继续用下一次运行闭环 Compose 和健康检查。 - 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;APT 与 Python 包源均基于 Runner 实测选择并设置有限重试/超时,避免网络异常无限占用工作流。 - 相关记录:BUG-082、BUG-083、BUG-084 - 复发自:BUG-082 diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 4acfa027..03415d6c 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -474,6 +474,7 @@ test("Gitea staging push builds ACR images and deploys them on the test server", assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:api-\$GITEA_SHA"/); assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:web-\$GITEA_SHA"/); assert.match(workflow, /scp \$SCP_OPTIONS/); + assert.match(workflow, /sudo tar -xf '\$REMOTE_ARCHIVE' -C '\$DEPLOY_PATH'/); assert.match(workflow, /pull api web/); assert.match(workflow, /up -d --no-build --remove-orphans/); assert.match(workflow, /curl --fail.*"\$STAGING_URL\/api\/health"/); -- 2.52.0 From a7ff511f5520c5e2369e91ee3934989d34dedf5e Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 17:59:55 +0800 Subject: [PATCH 30/46] fix: preserve staging compose images --- .gitea/workflows/backend-quality-gate.yml | 30 ++++++++++++------- docs/BUG_HISTORY.md | 2 +- .../tests/staging-backend-workflows.test.ts | 2 ++ 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/.gitea/workflows/backend-quality-gate.yml b/.gitea/workflows/backend-quality-gate.yml index 7e17e05d..f5013a5c 100644 --- a/.gitea/workflows/backend-quality-gate.yml +++ b/.gitea/workflows/backend-quality-gate.yml @@ -74,20 +74,30 @@ jobs: sudo tar -xf '$REMOTE_ARCHIVE' -C '$DEPLOY_PATH' rm -f '$REMOTE_ARCHIVE' cd '$DEPLOY_PATH' - export API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' - export WEB_IMAGE='$IMAGE_REPOSITORY:web-$GITEA_SHA' - export GITHUB_SHA='$GITEA_SHA' - export APP_ENV_FILE='../.env.staging' - export DATABASE_ENV_FILE='../.env.staging.database' - export CADDYFILE_PATH='./Caddyfile.staging' - export SITE_ADDRESS='https://staging.jyotisha.chat' - export ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' - sudo docker compose -p jyotisha-staging --env-file .env.staging \ + sudo env \ + API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' \ + WEB_IMAGE='$IMAGE_REPOSITORY:web-$GITEA_SHA' \ + GITHUB_SHA='$GITEA_SHA' \ + APP_ENV_FILE='../.env.staging' \ + DATABASE_ENV_FILE='../.env.staging.database' \ + CADDYFILE_PATH='./Caddyfile.staging' \ + SITE_ADDRESS='https://staging.jyotisha.chat' \ + ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' \ + docker compose -p jyotisha-staging --env-file .env.staging \ -f deploy/docker-compose.server.yml \ -f deploy/docker-compose.postgres.yml \ -f deploy/docker-compose.staging.yml \ pull api web - sudo docker compose -p jyotisha-staging --env-file .env.staging \ + sudo env \ + API_IMAGE='$IMAGE_REPOSITORY:api-$GITEA_SHA' \ + WEB_IMAGE='$IMAGE_REPOSITORY:web-$GITEA_SHA' \ + GITHUB_SHA='$GITEA_SHA' \ + APP_ENV_FILE='../.env.staging' \ + DATABASE_ENV_FILE='../.env.staging.database' \ + CADDYFILE_PATH='./Caddyfile.staging' \ + SITE_ADDRESS='https://staging.jyotisha.chat' \ + ADMIN_SITE_ADDRESS='https://admin.staging.jyotisha.chat' \ + docker compose -p jyotisha-staging --env-file .env.staging \ -f deploy/docker-compose.server.yml \ -f deploy/docker-compose.postgres.yml \ -f deploy/docker-compose.staging.yml \ diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index cb95d900..82bbd400 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1578,7 +1578,7 @@ - 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 - 根因:阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库,xiaoxin 直连 Docker Hub 超时;同时充值订单二维码条件渲染缺少右花括号,导致相邻 `payUrl` 条件 JSX 无法解析。 - 修复:工作流保持单 job 构建发布部署链路;checkout 使用 depth-1 浅拉取和网络超时;API/Web 使用实测可达的国内基础镜像,API 使用阿里云 APT/PyPI;补齐二维码条件渲染的 `}`;服务器 deploy 目录由 root 管理,因此上传后的目录创建、控制文件解包及 Docker 操作均使用免交互 sudo。 -- 验证:Gitea run 1264 的 API 镜像构建成功并暴露 JSX 错误;补齐括号后本地 `npm run build` 完整通过 31 个页面。run 1265 的 API/Web 镜像均构建并推送 ACR 成功,部署精确失败在普通 `tar` 无权覆盖 root 所有文件;已据此将解包改为 sudo,继续用下一次运行闭环 Compose 和健康检查。 +- 验证:run 1264 暴露 JSX 错误,本地修复后 `npm run build` 完整通过;run 1265 的双镜像均推送 ACR 成功并暴露普通 `tar` 权限错误;run 1266 证实 sudo 解包成功,随后暴露 sudo 清理 `API_IMAGE` / `WEB_IMAGE` 等临时环境,Compose 因而回落到本地默认镜像。已改为 `sudo env` 显式传值,继续用下一次运行闭环。 - 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;APT 与 Python 包源均基于 Runner 实测选择并设置有限重试/超时,避免网络异常无限占用工作流。 - 相关记录:BUG-082、BUG-083、BUG-084 - 复发自:BUG-082 diff --git a/frontend/tests/staging-backend-workflows.test.ts b/frontend/tests/staging-backend-workflows.test.ts index 03415d6c..250a31c2 100644 --- a/frontend/tests/staging-backend-workflows.test.ts +++ b/frontend/tests/staging-backend-workflows.test.ts @@ -475,6 +475,8 @@ test("Gitea staging push builds ACR images and deploys them on the test server", assert.match(workflow, /docker push "\$IMAGE_REPOSITORY:web-\$GITEA_SHA"/); assert.match(workflow, /scp \$SCP_OPTIONS/); assert.match(workflow, /sudo tar -xf '\$REMOTE_ARCHIVE' -C '\$DEPLOY_PATH'/); + assert.match(workflow, /sudo env/); + assert.match(workflow, /API_IMAGE='\$IMAGE_REPOSITORY:api-\$GITEA_SHA'/); assert.match(workflow, /pull api web/); assert.match(workflow, /up -d --no-build --remove-orphans/); assert.match(workflow, /curl --fail.*"\$STAGING_URL\/api\/health"/); -- 2.52.0 From f430ebd2c8045c7269c146dbcd4672aa42e26388 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Tue, 28 Jul 2026 18:09:44 +0800 Subject: [PATCH 31/46] docs: record successful staging rollout --- docs/BUG_HISTORY.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 82bbd400..44465744 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1578,8 +1578,8 @@ - 触发条件:`staging` push 后,Runner 使用 `registry.cn-hangzhou.aliyuncs.com/library/python:3.12-slim` 或对应 Node 地址解析基础镜像。 - 根因:阿里云 `library/*` 地址并非当前凭据可访问的公共镜像仓库,xiaoxin 直连 Docker Hub 超时;同时充值订单二维码条件渲染缺少右花括号,导致相邻 `payUrl` 条件 JSX 无法解析。 - 修复:工作流保持单 job 构建发布部署链路;checkout 使用 depth-1 浅拉取和网络超时;API/Web 使用实测可达的国内基础镜像,API 使用阿里云 APT/PyPI;补齐二维码条件渲染的 `}`;服务器 deploy 目录由 root 管理,因此上传后的目录创建、控制文件解包及 Docker 操作均使用免交互 sudo。 -- 验证:run 1264 暴露 JSX 错误,本地修复后 `npm run build` 完整通过;run 1265 的双镜像均推送 ACR 成功并暴露普通 `tar` 权限错误;run 1266 证实 sudo 解包成功,随后暴露 sudo 清理 `API_IMAGE` / `WEB_IMAGE` 等临时环境,Compose 因而回落到本地默认镜像。已改为 `sudo env` 显式传值,继续用下一次运行闭环。 -- 防复发:staging Dockerfile 的基础镜像必须在 xiaoxin 完整 `docker pull` 验证;APT 与 Python 包源均基于 Runner 实测选择并设置有限重试/超时,避免网络异常无限占用工作流。 +- 验证:run 1264 暴露 JSX 错误,本地修复后 `npm run build` 完整通过;run 1265 暴露普通 `tar` 权限错误;run 1266 暴露 sudo 清理 Compose 镜像环境。最终 Gitea run 1267(job 3795)checkout、双镜像构建/ACR 推送、测试服务器部署全部成功;服务器 API/Web 均运行 `a7ff511f5520c5e2369e91ee3934989d34dedf5e` 且 healthy,外部 `/login=200`、未登录 `/api/account=401`、`/api/health=200`,健康响应部署 SHA 与目标一致。 +- 防复发:基础镜像必须在 xiaoxin 完整 pull 验证;APT/PyPI 基于 Runner 实测并设置有限超时;部署契约测试固定浅拉取、sudo 解包和 `sudo env` 镜像变量传递。 - 相关记录:BUG-082、BUG-083、BUG-084 - 复发自:BUG-082 -- 修复版本:待提交(本地可测) +- 修复版本:`a7ff511`(Gitea staging,run 1267 已部署验证) -- 2.52.0 From 2dac8bc47b8d8e36c88e587ac3a4e9c16bc6a33b Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Wed, 29 Jul 2026 10:14:39 +0800 Subject: [PATCH 32/46] fix: expose staging admin entry Use persisted self-hosted roles and the isolated admin origin so authorized staging accounts can discover the protected admin surface. --- docs/BUG_HISTORY.md | 16 +++++++++++ frontend/src/app/api/account/route.ts | 11 +++++++- frontend/src/app/page.tsx | 3 +++ frontend/src/components/app-sidebar.tsx | 5 ++-- frontend/src/lib/supabase/admin.ts | 18 ++++++++++++- frontend/tests/account-api.test.ts | 9 +++++++ frontend/tests/admin-contracts.test.ts | 15 +++++++++++ frontend/tests/admin-users-contract.test.ts | 30 ++++++++++----------- frontend/tests/sidebar-contract.test.ts | 12 +++++++++ 9 files changed, 100 insertions(+), 19 deletions(-) diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 329fcca0..31188ccf 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1578,3 +1578,19 @@ - 防复发:当前事件延续必须是服务端 opportunity 所有权规则,而不是 prompt 建议;模型输出即使结构合法,也必须经过 bounded tool budget、active-opportunity lookup、decision validation 和 completion payload hash 四层门控。 - 相关记录:BUG-075、BUG-085 - 修复版本:本地 V5 重构,待提交与 staging 验收 + +## BUG-087 | self-hosted staging 管理员看不到独立后台入口 + +- 状态:resolved +- 首次发现:2026-07-29 +- 最近更新:2026-07-29 +- 影响面:self-hosted staging 账户菜单、`GET /api/account`、独立后台入口;不影响后台独立登录与 `requireAdminSession` +- 用户现象:身份库已持久化 `admin` 或 `viewer` 角色的用户登录主站后,账户菜单不显示后台入口;即使显示旧入口,主站 `/admin` 路径也会返回 404。 +- 触发条件:`AUTH_PROVIDER=self-hosted`,后台部署在与主站不同的 `AUTH_ADMIN_ORIGIN`,用户角色以逗号分隔形式持久化在 `identity.users.role`。 +- 根因:主站 `isAdminUser` 对 self-hosted 模式直接返回 `false`,没有读取持久化角色;侧栏又把入口写死为主站相对路径 `/admin/codes`。既有后台鉴权已按持久化角色执行,但主站入口发现逻辑没有复用同一授权事实,独立域名部署合同也没有进入账户响应。 +- 修复:self-hosted 分支通过现有 `ADMIN_DATABASE_URL` 管理只读连接查询当前用户的 `identity.users.role`,仅 `admin` 或 `viewer` 可见入口,且不使用 `ADMIN_EMAILS` 替代角色授权;`GET /api/account` 在服务端解析身份配置并返回 `AUTH_ADMIN_ORIGIN + /admin/codes`,Supabase 模式继续返回 `/admin/codes`;账户与侧栏类型透传该 URL,并将文案改为“后台管理”。后台独立登录和 `requireAdminSession` 保持不变。 +- 验证:`frontend/tests/admin-contracts.test.ts`、`frontend/tests/admin-users-contract.test.ts`、`frontend/tests/account-api.test.ts`、`frontend/tests/sidebar-contract.test.ts` 锁定持久化角色、独立后台 URL、服务端环境边界和后台写权限门禁;目标 TypeScript、构建与 staging 登录态 smoke 结果另行记录。 +- 防复发:self-hosted 主站入口发现必须以 `identity.users.role` 为授权事实,不能退回邮箱 allowlist;客户端不得读取后台 origin 环境变量或硬编码主站 `/admin` 路径;后台 API 必须继续独立执行 `requireAdminSession`,入口可见性不得被当作授权。 +- 相关记录:BUG-010、BUG-083、BUG-084 +- 复发自:BUG-010 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts index e151abcf..d3b594ee 100644 --- a/frontend/src/app/api/account/route.ts +++ b/frontend/src/app/api/account/route.ts @@ -13,6 +13,7 @@ import { isSupabaseConfigurationError, } from "@/lib/supabase/config"; import { createServerSupabaseClient } from "@/lib/supabase/server"; +import { readIdentityConfig } from "@/modules/identity/config"; export const runtime = "nodejs"; @@ -109,11 +110,19 @@ export async function GET() { profile, Array.isArray(rectificationCaseRows) ? rectificationCaseRows : [], ); + const isAdmin = await isAdminUser(user); + const identityConfig = readIdentityConfig(process.env); + const adminUrl = isAdmin + ? identityConfig.provider === "self-hosted" + ? new URL("/admin/codes", identityConfig.adminOrigin).toString() + : "/admin/codes" + : null; return NextResponse.json({ user: { id: user.id, email: user.email ?? null }, credits: profile.credits, - isAdmin: await isAdminUser(user), + isAdmin, + adminUrl, rectificationPriceCredits, hasConfirmedBirthTime: profile.birth_time_status === "confirmed" && typeof profile.active_birth_time === "string", diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 4b6fdac9..3ce7621f 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -180,6 +180,7 @@ type Account = { user: { id: string; email: string | null }; credits: number; isAdmin: boolean; + adminUrl: string | null; rectificationPriceCredits: number; hasConfirmedBirthTime: boolean; rectificationCase: AccountRectificationCaseState | null; @@ -1243,6 +1244,7 @@ export default function Home() { user: { id: "preview-user", email: "preview@local.test" }, credits: 8, isAdmin: false, + adminUrl: null, rectificationPriceCredits: 1, hasConfirmedBirthTime: previewProfile.birthTimeStatus === "confirmed", rectificationCase: null, @@ -2789,6 +2791,7 @@ export default function Home() { email: account.user.email || "尚未读取邮箱", credits: account.credits, isAdmin: account.isAdmin, + adminUrl: account.adminUrl, initial: profile.name.trim().slice(0, 1) || account.user.email?.slice(0, 1).toUpperCase() || "你", diff --git a/frontend/src/components/app-sidebar.tsx b/frontend/src/components/app-sidebar.tsx index 6a4a2516..974f3d8b 100644 --- a/frontend/src/components/app-sidebar.tsx +++ b/frontend/src/components/app-sidebar.tsx @@ -38,6 +38,7 @@ export type SidebarAccount = { email: string; credits: number; isAdmin: boolean; + adminUrl: string | null; initial: string; }; @@ -214,8 +215,8 @@ export function AppSidebar({ - {account.isAdmin && onAccountMenuOpenChange(false)}> -
} + > + + {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} + {epayError && void loadEpaySettings()}>重试} />} + + form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional"> + + + + + + + + + + + + + + + 共 {total} 条平台订单}>
{ setOffset(0); setFilters(values); }}> diff --git a/frontend/src/lib/epay/availability.ts b/frontend/src/lib/epay/availability.ts new file mode 100644 index 00000000..006a5e0e --- /dev/null +++ b/frontend/src/lib/epay/availability.ts @@ -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 }; + } +} diff --git a/frontend/src/lib/epay/config-core.ts b/frontend/src/lib/epay/config-core.ts new file mode 100644 index 00000000..0f58f389 --- /dev/null +++ b/frontend/src/lib/epay/config-core.ts @@ -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, + 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; +} diff --git a/frontend/src/lib/epay/config.ts b/frontend/src/lib/epay/config.ts index f3b5620d..3ff3b11d 100644 --- a/frontend/src/lib/epay/config.ts +++ b/frontend/src/lib/epay/config.ts @@ -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 }; diff --git a/frontend/src/lib/epay/encryption-core.ts b/frontend/src/lib/epay/encryption-core.ts new file mode 100644 index 00000000..015b54a5 --- /dev/null +++ b/frontend/src/lib/epay/encryption-core.ts @@ -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(); + } +} diff --git a/frontend/src/lib/epay/encryption.ts b/frontend/src/lib/epay/encryption.ts new file mode 100644 index 00000000..2b9b4fa9 --- /dev/null +++ b/frontend/src/lib/epay/encryption.ts @@ -0,0 +1,3 @@ +import "server-only"; + +export { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "./encryption-core"; diff --git a/frontend/src/lib/epay/gateway-policy.ts b/frontend/src/lib/epay/gateway-policy.ts new file mode 100644 index 00000000..768b4eec --- /dev/null +++ b/frontend/src/lib/epay/gateway-policy.ts @@ -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; +} diff --git a/frontend/supabase/migrations/20260729010000_epay_settings.sql b/frontend/supabase/migrations/20260729010000_epay_settings.sql new file mode 100644 index 00000000..63e7cdef --- /dev/null +++ b/frontend/supabase/migrations/20260729010000_epay_settings.sql @@ -0,0 +1,126 @@ +create table public.epay_settings ( + id boolean primary key default true check (id), + gateway_url text not null, + pid text not null, + encrypted_key text not null, + notify_url text not null, + return_url text not null, + site_name text not null, + chat_enabled boolean not null default false, + updated_by uuid not null, + updated_at timestamptz not null default clock_timestamp() +); + +alter table public.epay_settings enable row level security; +revoke all on table public.epay_settings from public, anon, authenticated, service_role; +grant select on table public.epay_settings to service_role; + +alter table audit.admin_audit_logs + drop constraint if exists admin_audit_logs_action_check, + drop constraint if exists admin_audit_logs_target_type_check; +alter table audit.admin_audit_logs + add constraint admin_audit_logs_action_check check ( + action in ('redemption_code.create', 'redemption_code.update', 'redemption_code.revoke', 'epay_settings.update') + ), + add constraint admin_audit_logs_target_type_check check ( + target_type in ('redemption_code', 'epay_settings') + ); + +create or replace function public.admin_save_epay_settings( + p_actor_user_id uuid, + p_actor_email text, + p_actor_role text, + p_request_id text, + p_gateway_url text, + p_pid text, + p_encrypted_key text, + p_notify_url text, + p_return_url text, + p_site_name text, + p_chat_enabled boolean, + p_key_changed boolean +) +returns public.epay_settings +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_email text; + v_before public.epay_settings; + v_after public.epay_settings; + v_target_id constant uuid := '00000000-0000-0000-0000-000000000001'; +begin + v_email := public.admin_verified_actor_email(p_actor_user_id, p_actor_email, p_actor_role); + + select * into v_before from public.epay_settings where id = true for update; + + insert into public.epay_settings ( + id, gateway_url, pid, encrypted_key, notify_url, return_url, + site_name, chat_enabled, updated_by, updated_at + ) values ( + true, p_gateway_url, p_pid, p_encrypted_key, p_notify_url, p_return_url, + p_site_name, p_chat_enabled, p_actor_user_id, clock_timestamp() + ) + on conflict (id) do update set + gateway_url = excluded.gateway_url, + pid = excluded.pid, + encrypted_key = excluded.encrypted_key, + notify_url = excluded.notify_url, + return_url = excluded.return_url, + site_name = excluded.site_name, + chat_enabled = excluded.chat_enabled, + updated_by = excluded.updated_by, + updated_at = excluded.updated_at + returning * into v_after; + + insert into audit.admin_audit_logs ( + actor_user_id, actor_email, actor_role, action, target_type, + target_id, before_value, after_value, request_id + ) values ( + p_actor_user_id, v_email, p_actor_role, 'epay_settings.update', 'epay_settings', + v_target_id, + case when v_before.id is null then null else jsonb_build_object( + 'gatewayUrl', v_before.gateway_url, + 'pid', v_before.pid, + 'notifyUrl', v_before.notify_url, + 'returnUrl', v_before.return_url, + 'siteName', v_before.site_name, + 'chatEnabled', v_before.chat_enabled, + 'keyConfigured', true, + 'keyChanged', false + ) end, + jsonb_build_object( + 'gatewayUrl', v_after.gateway_url, + 'pid', v_after.pid, + 'notifyUrl', v_after.notify_url, + 'returnUrl', v_after.return_url, + 'siteName', v_after.site_name, + 'chatEnabled', v_after.chat_enabled, + 'keyConfigured', true, + 'keyChanged', p_key_changed + ), + p_request_id + ); + + return v_after; +end; +$$; + +revoke all on function public.admin_save_epay_settings(uuid, text, text, text, text, text, text, text, text, text, boolean, boolean) + from public, anon, authenticated; +grant execute on function public.admin_save_epay_settings(uuid, text, text, text, text, text, text, text, text, text, boolean, boolean) + to service_role; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + grant select on table public.epay_settings to admin_runtime; + drop policy if exists epay_settings_admin_read on public.epay_settings; + create policy epay_settings_admin_read on public.epay_settings + for select to admin_runtime using (id = true); + grant execute on function public.admin_save_epay_settings(uuid, text, text, text, text, text, text, text, text, text, boolean, boolean) + to admin_runtime; + end if; +end; +$$; diff --git a/frontend/tests/epay-settings.test.ts b/frontend/tests/epay-settings.test.ts new file mode 100644 index 00000000..d1ec85ef --- /dev/null +++ b/frontend/tests/epay-settings.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import crypto from "node:crypto"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { decryptEpayKey, encryptEpayKey, EpayEncryptionError } from "../src/lib/epay/encryption-core"; +import { resolveEpayConfig } from "../src/lib/epay/config-core"; +import { assertPublicEpayGateway, isPublicEpayAddress } from "../src/lib/epay/gateway-policy"; + +const root = new URL("../", import.meta.url); +const route = readFileSync(new URL("src/app/api/admin/epay-settings/route.ts", root), "utf8"); +const management = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8"); +const createRoute = readFileSync(new URL("src/app/api/payment/epay/create/route.ts", root), "utf8"); +const notifyRoute = readFileSync(new URL("src/app/api/payment/epay/notify/route.ts", root), "utf8"); +const configRoute = readFileSync(new URL("src/lib/epay/config.ts", root), "utf8"); +const availability = readFileSync(new URL("src/lib/epay/availability.ts", root), "utf8"); +const packagesRoute = readFileSync(new URL("src/app/api/payment/packages/route.ts", root), "utf8"); +const testRoute = readFileSync(new URL("src/app/api/admin/epay-settings/test/route.ts", root), "utf8"); +const page = readFileSync(new URL("src/app/page.tsx", root), "utf8"); +const migration = readFileSync(new URL("supabase/migrations/20260729010000_epay_settings.sql", root), "utf8"); + +const key = crypto.randomBytes(32).toString("base64"); + +test("易支付密钥 AES-256-GCM 往返、篡改与错误主密钥", () => { + const encrypted = encryptEpayKey("merchant-secret", key); + assert.match(encrypted, /^v1\.[^.]+\.[^.]+\.[^.]+$/); + assert.equal(decryptEpayKey(encrypted, key), "merchant-secret"); + const tampered = `${encrypted.slice(0, -1)}${encrypted.endsWith("A") ? "B" : "A"}`; + assert.throws(() => decryptEpayKey(tampered, key), EpayEncryptionError); + assert.throws(() => decryptEpayKey(encrypted, crypto.randomBytes(32).toString("base64")), EpayEncryptionError); + assert.throws(() => encryptEpayKey("merchant-secret", "not-base64"), EpayEncryptionError); +}); + +test("配置解析数据库优先且无行时回退环境变量", async () => { + const encrypted = encryptEpayKey("database-secret", key); + const database = await resolveEpayConfig(async () => ({ + gateway_url: "https://database-pay.example.com/", + pid: "database-pid", + encrypted_key: encrypted, + notify_url: "https://staging.example.com/api/payment/epay/notify", + return_url: "https://staging.example.com/", + site_name: "Staging", + chat_enabled: false, + }), { + NODE_ENV: "test", + EPAY_CONFIG_ENCRYPTION_KEY: key, + EPAY_GATEWAY_URL: "https://environment-pay.example.com", + EPAY_PID: "environment-pid", + EPAY_KEY: "environment-secret", + }); + assert.equal(database.gatewayUrl.toString(), "https://database-pay.example.com/"); + assert.equal(database.pid, "database-pid"); + assert.equal(database.key, "database-secret"); + assert.equal(database.chatEnabled, false); + + const environment = await resolveEpayConfig(async () => null, { + NODE_ENV: "test", + SITE_ADDRESS: "https://staging.example.com", + EPAY_GATEWAY_URL: "https://environment-pay.example.com", + EPAY_PID: "environment-pid", + EPAY_KEY: "environment-secret", + EPAY_CHAT_ENABLED: "1", + }); + assert.equal(environment.chatEnabled, true); + assert.equal(environment.notifyUrl, "https://staging.example.com/api/payment/epay/notify"); + assert.equal(environment.returnUrl, "https://staging.example.com/"); +}); + +test("管理员 API 不回显任何密钥并强制首次显式录入", () => { + assert.match(route, /requireAdminSession\("read"\)/); + assert.match(route, /requireAdminSession\("write"\)/); + assert.match(route, /\.strict\(\)/); + assert.match(route, /crypto\.randomUUID\(\)/); + assert.match(route, /首次保存数据库配置时必须输入新的商户密钥/); + assert.match(route, /chatEnabled: z\.boolean\(\)/); + assert.match(route, /p_chat_enabled: parsed\.data\.chatEnabled/); + assert.match(route, /keyConfigured/); + assert.doesNotMatch(route, /NextResponse\.json\([^\n]*(?:encrypted_key|newKey|encryptedKey|maskedKey|keyMask)/); + assert.doesNotMatch(route, /BETTER_AUTH_SECRET/); +}); + +test("迁移前仅在配置表不存在时继续使用环境变量", () => { + assert.match(configRoute, /error\?\.code === "42P01"/); + assert.match(route, /error\?\.code === "42P01"/); + assert.match(configRoute, /if \(error\) throw new Error\(\)/); +}); + +test("支付调用点等待异步数据库配置", () => { + assert.match(createRoute, /await readEpayConfig\(\)/); + assert.match(notifyRoute, /await readEpayConfig\(\)/); + assert.match(notifyRoute, /export async function POST/); + assert.match(notifyRoute, /export async function GET/); +}); + +test("统一支付页面含系统配置 Card 与永不预填的 Password", () => { + assert.match(management, /title="易支付系统配置"/); + assert.match(management, /\/api\/admin\/epay-settings/); + assert.match(management, / { + assert.match(migration, /chat_enabled boolean not null default false/); + assert.match(migration, /p_chat_enabled boolean/); + assert.match(migration, /'chatEnabled'/); + assert.match(availability, /EPAY_CHAT_ENABLED/); + assert.doesNotMatch(availability, /EPAY_CONFIG_ENCRYPTION_KEY|decryptEpayKey/); + assert.match(packagesRoute, /enabled: false, packages: \[\]/); + assert.match(packagesRoute, /enabled: true/); + assert.match(createRoute, /在线支付暂未开放/); + assert.match(createRoute, /EPAY_DISABLED/); + assert.match(createRoute, /await readEpayAvailability\(\)/); + assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("await readEpayConfig()")); + assert.ok(createRoute.indexOf("availability.enabled") < createRoute.indexOf("payment_packages")); + assert.match(management, /在对话页开放支付/); + assert.match(page, /paymentEnabled &&
/); + assert.match(page, /setPaymentEnabled\(false\)[\s\S]*setPaymentPackages\(\[\]\)[\s\S]*setPaymentOrder\(null\)[\s\S]*setPaymentError\(""\)/); +}); + +test("网关探测只使用 HEAD/GET、受限响应且共享 SSRF 门禁", () => { + assert.match(testRoute, /requireAdminSession\("write"\)/); + assert.match(testRoute, /method: "HEAD"/); + assert.match(testRoute, /response\.status === 405 \|\| response\.status === 501/); + assert.match(testRoute, /method: "GET"/); + assert.doesNotMatch(testRoute, /method: "POST"|payment_orders|\.text\(\)|\.json\(\)/); + assert.match(testRoute, /AbortSignal\.timeout\(8_000\)/); + assert.match(testRoute, /redirect: "manual"/); + assert.match(testRoute, /available,[\s\S]*message:[\s\S]*latencyMs:[\s\S]*status:/); + assert.doesNotMatch(testRoute, /pid:|key:|gatewayUrl:|headers:|body:|payment_orders/); + assert.match(testRoute, /assertPublicGatewayUrl\(submitUrl\)/); + assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/); +}); + +test("纯地址判断拒绝私网、回环、链路本地并接受公网", () => { + for (const address of ["127.0.0.1", "10.0.0.1", "172.16.0.1", "192.168.1.1", "169.254.1.1", "::1", "fc00::1", "fe80::1", "2001:db8::1"]) { + assert.equal(isPublicEpayAddress(address), false, address); + } + for (const address of ["8.8.8.8", "1.1.1.1", "2606:4700:4700::1111"]) { + assert.equal(isPublicEpayAddress(address), true, address); + } + assert.throws(() => assertPublicEpayGateway("http://localhost/pay")); + assert.throws(() => assertPublicEpayGateway("http://127.0.0.1/pay")); + assert.doesNotThrow(() => assertPublicEpayGateway("https://pay.example.com")); +}); + +test("迁移锁定单行、RLS、最小权限与脱敏原子审计", () => { + assert.match(migration, /create table public\.epay_settings/); + assert.match(migration, /id boolean primary key default true check \(id\)/); + assert.match(migration, /alter table public\.epay_settings enable row level security/); + assert.match(migration, /revoke all on table public\.epay_settings from public, anon, authenticated, service_role/); + assert.match(migration, /grant select on table public\.epay_settings to service_role/); + assert.match(migration, /security definer/); + assert.match(migration, /insert into audit\.admin_audit_logs/); + assert.match(migration, /'keyConfigured'/); + assert.match(migration, /'keyChanged'/); + const auditBlock = migration.slice(migration.indexOf("insert into audit.admin_audit_logs")); + assert.doesNotMatch(auditBlock, /'encryptedKey'|'encrypted_key'|'secret'|jsonb_build_object\([\s\S]*?'key'/); +}); diff --git a/frontend/tests/health-deployment.test.ts b/frontend/tests/health-deployment.test.ts index 1e15bce6..aea3b45f 100644 --- a/frontend/tests/health-deployment.test.ts +++ b/frontend/tests/health-deployment.test.ts @@ -246,6 +246,8 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi "RESEND_API_KEY=re_test_key_that_must_not_be_printed", "RESEND_FROM_EMAIL=Jyotisha Staging ", "ADMIN_EMAILS=admin@example.com", + "EPAY_CONFIG_ENCRYPTION_KEY=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=", + "EPAY_CHAT_ENABLED=false", "JYOTISH_DYNAMIC_RECTIFICATION_TOKEN=dynamic-token-that-is-at-least-32-bytes", ]; const run = () => @@ -269,6 +271,13 @@ test("staging env validator rejects selector drift, duplicates, and unsafe permi ); writeEnv(validSelectors); assert.equal(run().status, 0); + + writeEnv(validSelectors.map((line) => line.startsWith("EPAY_CONFIG_ENCRYPTION_KEY=") ? "EPAY_CONFIG_ENCRYPTION_KEY=invalid" : line)); + assert.notEqual(run().status, 0); + writeEnv(validSelectors.map((line) => line.startsWith("EPAY_CHAT_ENABLED=") ? "EPAY_CHAT_ENABLED=true" : line)); + assert.notEqual(run().status, 0); + writeEnv(validSelectors); + const shellOverride = spawnSync( "docker", [ -- 2.52.0 From 4a044570b2364f3a558a09d62405d375fcdeae07 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Wed, 29 Jul 2026 16:46:54 +0800 Subject: [PATCH 42/46] fix: refresh server-only integrity --- frontend/package-lock.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 0fc0cfeb..da1047c3 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -10468,7 +10468,7 @@ "node_modules/server-only": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/server-only/-/server-only-0.0.1.tgz", - "integrity": "sha512-qepMx2JxAa5jjfzxgGoiBhbJmAW3EvM8OcSqn3cuJa2Cpe3cvtXEzHPVfVEAWrhyLGIKZ4GvU14t6lmIeJGNSg==", + "integrity": "sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==", "license": "MIT" }, "node_modules/sonner": { -- 2.52.0 From c11f3b109e00dd1b35e455d5a7028ce421cd5f59 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Wed, 29 Jul 2026 16:50:46 +0800 Subject: [PATCH 43/46] ci: retry staging web build -- 2.52.0 From 3fb635be1b2916a47ae17b1a51041573b8f7c723 Mon Sep 17 00:00:00 2001 From: linmeng <819991304@qq.com> Date: Wed, 29 Jul 2026 17:45:49 +0800 Subject: [PATCH 44/46] fix: restore staging payment management Use self-hosted PostgreSQL for payment records and expose separate package and Z-Pay flows so administrators and chat users can complete the payment journey reliably. --- docs/BUG_HISTORY.md | 8 +- frontend/src/app/admin/packages/page.tsx | 4 +- .../src/app/api/admin/epay-settings/route.ts | 3 +- frontend/src/app/api/admin/payments/route.ts | 128 +++++++++----- .../src/app/api/payment/epay/create/route.ts | 36 ++-- frontend/src/app/page.tsx | 17 +- frontend/src/components/admin/admin-app.tsx | 2 + .../components/admin/package-management.tsx | 163 ++++++++++++++++++ .../components/admin/payment-management.tsx | 157 +---------------- frontend/tests/admin-contracts.test.ts | 5 +- .../tests/admin-payments-contract.test.ts | 96 +++++------ frontend/tests/epay-settings.test.ts | 23 ++- 12 files changed, 370 insertions(+), 272 deletions(-) create mode 100644 frontend/src/components/admin/package-management.tsx diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 588963b2..33a38568 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1620,10 +1620,10 @@ - 影响面:后台 Refine 侧栏、`/admin/payments`、`/admin/packages`、易支付配置与对话页充值入口。 - 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话;对话页支付入口缺少安全默认关闭和服务端创建订单硬门禁。 - 触发条件:进入同域 `/admin` 后管理支付记录或套餐,或点击 Refine 侧栏底部默认 Logout。 -- 根因:BUG-093 首轮只补齐了两个缺失资源,没有收敛同一支付领域的信息架构;支付与套餐页沿用 Refine 接入前的原生页面,默认 `ThemedSider` logout 也未按后台工作流定制。 -- 修复:后台只注册一个“支付管理”资源;`/admin/payments` 使用统一的 Ant Design + Refine `List/Card/Table/Form/Tag/Alert/Statistic` 页面,同时读取支付记录与套餐,保留筛选、统计、分页和订单字段。套餐新增/编辑收进可重置 Modal,保存和停用通过 message 反馈并刷新列表;旧 `/admin/packages` 服务端重定向。自定义 `ThemedSider` 保留资源 items、忽略默认 logout,并追加折叠态可访问的“返回对话”链接到 `/`。本轮继续在同一支付页补齐“易支付系统配置” Card:管理员可修改完整配置,商户密钥使用独立 32-byte 主密钥和 AES-256-GCM 加密保存且永不回显;数据库配置优先、旧 `EPAY_*` 环境变量仅作无数据库行时的兼容回退;保存配置与包含 `chatEnabled`、且只含 `keyConfigured/keyChanged` 密钥状态的脱敏审计在数据库 RPC 内原子完成。`chat_enabled` 默认关闭,公共套餐接口故障时 fail closed,创建订单在套餐查询、订单写入和网关请求前执行同一硬门禁;对话页仅在接口明确返回 `enabled=true` 时展示整块充值 UI。后台可对当前已保存配置执行无副作用 HEAD/GET 可达性测试,探测使用 8 秒超时、手动重定向与公网 HTTP(S) SSRF 门禁,不发送商户参数或请求体。 -- 验证:`frontend/tests/admin-contracts.test.ts` 锁定单一支付资源、自定义 Sider 不调用 logout 和返回对话链接;`frontend/tests/admin-payments-contract.test.ts` 锁定统一页面组件、两类 fetch、套餐 Modal、POST/PATCH/DELETE、旧路由重定向以及 `requireAdminSession`。`frontend/tests/epay-settings.test.ts` 锁定加密往返/篡改/错误主密钥、数据库优先级、API 不回显、异步调用点、Password 表单、单行 RLS 权限与审计脱敏合同。相关测试、Next.js build、ESLint 与 `git diff --check` 结果记录在本次交付报告。 -- 防复发:同一后台领域默认收敛为单一资源和统一 Refine 页面;易支付密钥不得进入 API 响应、表单初值、日志或审计 JSON;数据库配置变更必须通过原子审计 RPC。对话支付默认关闭,UI 与创建订单 API 必须共享服务端开关;任何服务端网关 fetch 必须先执行共享 SSRF guard,可用性测试不得提交伪订单或返回 URL、PID、密钥、headers/body。 +- 根因:首轮支付后台实现依赖 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。 - 相关记录:BUG-087、BUG-092 - 复发自:BUG-093 - 修复版本:待提交(本地可测) diff --git a/frontend/src/app/admin/packages/page.tsx b/frontend/src/app/admin/packages/page.tsx index 524e8fb2..2ab9a048 100644 --- a/frontend/src/app/admin/packages/page.tsx +++ b/frontend/src/app/admin/packages/page.tsx @@ -1,5 +1,5 @@ -import { redirect } from "next/navigation"; +import { PackageManagement } from "@/components/admin/package-management"; export default function AdminPackagesPage() { - redirect("/admin/payments"); + return ; } diff --git a/frontend/src/app/api/admin/epay-settings/route.ts b/frontend/src/app/api/admin/epay-settings/route.ts index 706e9808..9ba77399 100644 --- a/frontend/src/app/api/admin/epay-settings/route.ts +++ b/frontend/src/app/api/admin/epay-settings/route.ts @@ -38,6 +38,7 @@ function publicSettings(row: SettingsRow, source: "database" | "environment") { notifyUrl: row.notify_url, returnUrl: row.return_url, siteName: row.site_name, + chatEnabled: row.chat_enabled, 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, @@ -62,7 +63,7 @@ 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,updated_at") + .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; diff --git a/frontend/src/app/api/admin/payments/route.ts b/frontend/src/app/api/admin/payments/route.ts index 829569c1..f0399050 100644 --- a/frontend/src/app/api/admin/payments/route.ts +++ b/frontend/src/app/api/admin/payments/route.ts @@ -1,9 +1,8 @@ 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"; -import { isSupabaseConfigurationError } from "@/lib/supabase/config"; export const runtime = "nodejs"; @@ -15,6 +14,28 @@ const querySchema = z.object({ offset: z.coerce.number().int().min(0).default(0), }); +type PaymentOrderRow = { + order_no: string; + user_email: string | null; + package_name: string | null; + money_cents: number; + credits: number; + status: string; + epay_trade_no: string | null; + created_at: Date; + paid_at: Date | null; + total_count: string; +}; + +type PaymentStatsRow = { + total_orders: string; + paid_orders: string; + pending_orders: string; + failed_expired_orders: string; + paid_amount_cents: string; + granted_credits: string; +}; + export async function GET(request: Request) { try { await requireAdminSession("read"); @@ -25,44 +46,70 @@ export async function GET(request: Request) { const { status, from, to, limit, offset } = parsed.data; if (from && to && new Date(from) > new Date(to)) return NextResponse.json({ error: "开始日期不能晚于结束日期" }, { status: 400 }); - const admin = createAdminSupabaseClient(); - let ordersQuery = admin - .from("payment_orders") - .select("order_no,user_id,money_cents,credits,status,epay_trade_no,paid_at,created_at,payment_packages(name)", { count: "exact" }) - .order("created_at", { ascending: false }) - .range(offset, offset + limit - 1); - if (status) ordersQuery = ordersQuery.eq("status", status); - if (from) ordersQuery = ordersQuery.gte("created_at", from); - if (to) ordersQuery = ordersQuery.lte("created_at", to); + const values: unknown[] = []; + const conditions: string[] = []; + if (status) { + values.push(status); + conditions.push(`o.status = $${values.length}`); + } + if (from) { + values.push(from); + conditions.push(`o.created_at >= $${values.length}::timestamptz`); + } + if (to) { + values.push(to); + conditions.push(`o.created_at <= $${values.length}::timestamptz`); + } + const statsValues: unknown[] = []; + const dateConditions: string[] = []; + if (from) { + statsValues.push(from); + dateConditions.push(`o.created_at >= $${statsValues.length}::timestamptz`); + } + if (to) { + statsValues.push(to); + dateConditions.push(`o.created_at <= $${statsValues.length}::timestamptz`); + } + values.push(limit, offset); - const statsPromise = admin.rpc("get_payment_order_stats", { - p_from: from ?? null, - p_to: to ?? null, - }); - const [{ data, error, count }, statsResult] = await Promise.all([ordersQuery, statsPromise]); - if (error || statsResult.error) return NextResponse.json({ error: "暂时无法读取支付记录" }, { status: 500 }); + const [rows, statsRows] = await Promise.all([ + queryAdminRows(` + select + o.order_no, u.email as user_email, p.name as package_name, + o.money_cents, o.credits, o.status, o.epay_trade_no, + o.created_at, o.paid_at, count(*) over()::text as total_count + from public.payment_orders o + left join public.payment_packages p on p.id = o.package_id + left join identity.users u on u.id = o.user_id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by o.created_at desc, o.order_no asc + limit $${values.length - 1} offset $${values.length} + `, values), + queryAdminRows(` + select + count(*)::text as total_orders, + count(*) filter (where o.status = 'paid')::text as paid_orders, + count(*) filter (where o.status = 'pending')::text as pending_orders, + count(*) filter (where o.status in ('failed', 'expired'))::text as failed_expired_orders, + coalesce(sum(o.money_cents) filter (where o.status = 'paid'), 0)::text as paid_amount_cents, + coalesce(sum(o.credits) filter (where o.status = 'paid'), 0)::text as granted_credits + from public.payment_orders o + ${dateConditions.length ? `where ${dateConditions.join(" and ")}` : ""} + `, statsValues), + ]); - const emailEntries = await Promise.all([...new Set((data ?? []).map((row) => row.user_id))].map(async (userId) => { - const result = await admin.auth.admin.getUserById(userId); - return [userId, result.data.user?.email ?? null] as const; + const orders = rows.map((row) => ({ + orderNo: row.order_no, + userEmail: row.user_email, + packageName: row.package_name, + moneyCents: row.money_cents, + credits: row.credits, + status: row.status, + epayTradeNo: row.epay_trade_no, + createdAt: row.created_at.toISOString(), + paidAt: row.paid_at?.toISOString() ?? null, })); - const emails = new Map(emailEntries); - const orders = (data ?? []).map((row) => { - const relation = row.payment_packages as { name?: string } | { name?: string }[] | null; - const packageName = Array.isArray(relation) ? relation[0]?.name : relation?.name; - return { - orderNo: row.order_no, - userEmail: emails.get(row.user_id) ?? null, - packageName: packageName ?? null, - moneyCents: row.money_cents, - credits: row.credits, - status: row.status, - epayTradeNo: row.epay_trade_no, - createdAt: row.created_at, - paidAt: row.paid_at, - }; - }); - const rawStats = Array.isArray(statsResult.data) ? statsResult.data[0] : statsResult.data; + const rawStats = statsRows[0]; const stats = { totalOrders: Number(rawStats?.total_orders ?? 0), paidOrders: Number(rawStats?.paid_orders ?? 0), @@ -71,12 +118,11 @@ export async function GET(request: Request) { paidAmountCents: Number(rawStats?.paid_amount_cents ?? 0), grantedCredits: Number(rawStats?.granted_credits ?? 0), }; - const total = count ?? 0; + const total = Number(rows[0]?.total_count ?? 0); return NextResponse.json({ orders, stats, pagination: { limit, offset, total, hasMore: offset + orders.length < total } }); } catch (error) { - if (isSupabaseConfigurationError(error)) return NextResponse.json({ error: "Supabase 尚未配置", code: "SUPABASE_NOT_CONFIGURED" }, { status: 503 }); - const authError = adminErrorResponse(error); - if (authError.status === 401 || authError.status === 403) return authError; + const response = adminErrorResponse(error); + if (response.status === 401 || response.status === 403) return response; return NextResponse.json({ error: "支付记录服务暂时不可用" }, { status: 500 }); } } diff --git a/frontend/src/app/api/payment/epay/create/route.ts b/frontend/src/app/api/payment/epay/create/route.ts index f661d01c..9686accb 100644 --- a/frontend/src/app/api/payment/epay/create/route.ts +++ b/frontend/src/app/api/payment/epay/create/route.ts @@ -10,30 +10,42 @@ import { assertPublicGatewayUrl } from "@/lib/epay/gateway-policy"; export const runtime = "nodejs"; const schema = z.object({ packageId: z.string().uuid() }); -function safeUpstreamUrl(value: unknown, gateway: URL) { if (typeof value !== "string") return null; try { const url = new URL(value, gateway); return url.origin === gateway.origin ? url.toString() : null; } catch { return null; } } + export async function POST(request: Request) { try { - const client = await createServerSupabaseClient(); const { data: { user } } = await client.auth.getUser(); + 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 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(); + + 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 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(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 = {}; 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); - const qrCode = safeUpstreamUrl(payload.qrcode ?? payload.qr_code, config.gatewayUrl); - return NextResponse.json({ orderNo, payUrl, qrCode }); + + 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 signedParams = { ...params, sign: epaySign(params, config.key), sign_type: "MD5" }; + const payUrl = new URL(submitUrl); + for (const [name, value] of Object.entries(signedParams)) payUrl.searchParams.set(name, value); + return NextResponse.json({ orderNo, payUrl: payUrl.toString(), qrCode: null }); } catch (error) { if (error instanceof EpayConfigurationError) return NextResponse.json({ error: "易支付尚未配置", code: "EPAY_NOT_CONFIGURED" }, { status: 503 }); return NextResponse.json({ error: "创建支付失败" }, { status: 500 }); diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index b0d5f6dc..9309c3cc 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -1959,7 +1959,11 @@ export default function Home() { if (response.ok && payload?.enabled === true) { setPaymentEnabled(true); setPaymentPackages(payload.packages || []); + return; } + if (!response.ok) setPaymentError("套餐支付暂时不可用,请稍后重试"); + }).catch(() => { + setPaymentError("套餐支付暂时不可用,请稍后重试"); }); }, [activeAccountDialog]); @@ -1983,8 +1987,9 @@ export default function Home() { const response = await fetch("/api/payment/epay/create", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ packageId }) }); const payload = await response.json().catch(() => null); if (!response.ok) throw new Error(payload?.error || "创建支付失败"); - setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode, status: "pending" }); - if (payload.payUrl) window.open(payload.payUrl, "_blank", "noopener,noreferrer"); + if (typeof payload?.orderNo !== "string" || typeof payload?.payUrl !== "string") throw new Error("创建支付失败"); + setPaymentOrder({ orderNo: payload.orderNo, payUrl: payload.payUrl, qrCode: payload.qrCode ?? null, status: "pending" }); + window.open(payload.payUrl, "_blank", "noopener,noreferrer"); } catch (caught) { setPaymentError(caught instanceof Error ? caught.message : "创建支付失败"); } finally { setPayingPackageId(null); } } @@ -3278,11 +3283,11 @@ export default function Home() { {redeemMessage &&

{redeemMessage}

} {paymentEnabled &&
-

充值套餐

- {paymentPackages.map((item) =>
{item.name}{item.description || `${item.credits} 点`}
¥{(item.priceCents / 100).toFixed(2)}
)} - {paymentError &&

{paymentError}

} - {paymentOrder &&

订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}

{paymentOrder.qrCode &&
支付宝支付二维码
}{paymentOrder.payUrl && 打开支付页面}
} +

套餐充值

+ {paymentPackages.map((item) =>
{item.name}{item.description || `${item.credits} 点`}
¥{(item.priceCents / 100).toFixed(2)}
)} + {paymentOrder &&

订单 {paymentOrder.orderNo}:{paymentOrder.status === "paid" ? "支付成功,点数已到账" : "等待支付"}

}
} + {paymentError &&

{paymentError}

} )} diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 57bd63d9..0651455e 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -5,6 +5,7 @@ import { AuditOutlined, CreditCardOutlined, GiftOutlined, + ShoppingOutlined, MessageOutlined, TeamOutlined, TransactionOutlined, @@ -51,6 +52,7 @@ export function AdminApp({ children }: { children: ReactNode }) { resources={[ { name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: } }, { name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: } }, + { name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: } }, { name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: } }, { name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: } }, { name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: } }, diff --git a/frontend/src/components/admin/package-management.tsx b/frontend/src/components/admin/package-management.tsx new file mode 100644 index 00000000..b77d2731 --- /dev/null +++ b/frontend/src/components/admin/package-management.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { PlusOutlined } from "@ant-design/icons"; +import { List } from "@refinedev/antd"; +import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Row, Col, Space, Switch, Table, Tag, Typography, type TableColumnsType } from "antd"; +import { useCallback, useEffect, useState } from "react"; + +const { Text } = Typography; + +type PaymentPackage = { + id: string; + name: string; + description: string; + priceCents: number; + credits: number; + sortOrder: number; + enabled: boolean; +}; + +type PackageFormValues = Omit & { priceYuan: number }; + +function formatMoney(cents: number) { + return `¥${(cents / 100).toFixed(2)}`; +} + +async function responsePayload(response: Response) { + const payload = await response.json().catch(() => ({})); + if (!response.ok) throw new Error(payload.error || "请求失败"); + return payload; +} + +export function PackageManagement() { + const { message } = App.useApp(); + const [packageForm] = Form.useForm(); + const [packages, setPackages] = useState([]); + const [packagesLoading, setPackagesLoading] = useState(true); + const [packagesError, setPackagesError] = useState(""); + const [modalOpen, setModalOpen] = useState(false); + const [editingPackage, setEditingPackage] = useState(null); + const [saving, setSaving] = useState(false); + const [disablingId, setDisablingId] = useState(null); + + const loadPackages = useCallback(async () => { + setPackagesLoading(true); + setPackagesError(""); + try { + const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" })); + setPackages(payload.packages); + } catch (error) { + setPackagesError(error instanceof Error ? error.message : "读取套餐失败"); + } finally { + setPackagesLoading(false); + } + }, []); + + useEffect(() => { + const timer = window.setTimeout(() => void loadPackages(), 0); + return () => window.clearTimeout(timer); + }, [loadPackages]); + + function openCreateModal() { + setEditingPackage(null); + packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }); + setModalOpen(true); + } + + function openEditModal(item: PaymentPackage) { + setEditingPackage(item); + packageForm.setFieldsValue({ + name: item.name, + description: item.description, + priceYuan: item.priceCents / 100, + credits: item.credits, + sortOrder: item.sortOrder, + enabled: item.enabled, + }); + setModalOpen(true); + } + + function closeModal() { + if (saving) return; + setModalOpen(false); + setEditingPackage(null); + packageForm.resetFields(); + } + + async function savePackage(values: PackageFormValues) { + setSaving(true); + try { + await responsePayload(await fetch("/api/admin/packages", { + method: editingPackage ? "PATCH" : "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + ...(editingPackage ? { id: editingPackage.id } : {}), + name: values.name.trim(), + description: values.description?.trim() ?? "", + priceCents: Math.round(values.priceYuan * 100), + credits: values.credits, + sortOrder: values.sortOrder, + enabled: values.enabled, + }), + })); + message.success(editingPackage ? "套餐已更新" : "套餐已添加"); + setModalOpen(false); + setEditingPackage(null); + packageForm.resetFields(); + await loadPackages(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存套餐失败"); + } finally { + setSaving(false); + } + } + + async function disablePackage(id: string) { + setDisablingId(id); + try { + await responsePayload(await fetch("/api/admin/packages", { + method: "DELETE", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ id }), + })); + message.success("套餐已停用"); + await loadPackages(); + } catch (error) { + message.error(error instanceof Error ? error.message : "停用套餐失败"); + } finally { + setDisablingId(null); + } + } + + const columns: TableColumnsType = [ + { title: "名称", dataIndex: "name", render: (_, item) => {item.name}{item.description || "暂无描述"} }, + { title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney }, + { title: "点数", dataIndex: "credits", align: "right" }, + { title: "排序", dataIndex: "sortOrder", align: "right" }, + { title: "状态", dataIndex: "enabled", render: (enabled) => {enabled ? "启用" : "停用"} }, + { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && disablePackage(item.id)}>} }, + ]; + + return ( + + } onClick={openCreateModal}>添加套餐}> + + {packagesError && void loadPackages()}>重试} />} + rowKey="id" columns={columns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} /> + + + packageForm.submit()} onCancel={closeModal} destroyOnHidden afterClose={() => packageForm.resetFields()} maskClosable={!saving} keyboard={!saving}> + form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}> + + + + + + + + + + + + ); +} diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx index 02a94ad9..fa7bfce8 100644 --- a/frontend/src/components/admin/payment-management.tsx +++ b/frontend/src/components/admin/payment-management.tsx @@ -1,6 +1,5 @@ "use client"; -import { PlusOutlined } from "@ant-design/icons"; import { List } from "@refinedev/antd"; import { Alert, @@ -11,9 +10,6 @@ import { DatePicker, Form, Input, - InputNumber, - Modal, - Popconfirm, Row, Select, Space, @@ -50,17 +46,6 @@ type PaymentStats = { grantedCredits: number; }; -type PaymentPackage = { - id: string; - name: string; - description: string; - priceCents: number; - credits: number; - sortOrder: number; - enabled: boolean; -}; - -type PackageFormValues = Omit & { priceYuan: number }; type PaymentFilters = { status?: string; dates?: [Dayjs, Dayjs] }; type EpaySettings = { gatewayUrl: string; @@ -105,7 +90,6 @@ async function responsePayload(response: Response) { export default function PaymentManagement() { const { message } = App.useApp(); const [filterForm] = Form.useForm(); - const [packageForm] = Form.useForm(); const [epayForm] = Form.useForm(); const [orders, setOrders] = useState([]); const [stats, setStats] = useState(initialStats); @@ -114,13 +98,6 @@ export default function PaymentManagement() { const [filters, setFilters] = useState({}); const [offset, setOffset] = useState(0); const [total, setTotal] = useState(0); - const [packages, setPackages] = useState([]); - const [packagesLoading, setPackagesLoading] = useState(true); - const [packagesError, setPackagesError] = useState(""); - const [modalOpen, setModalOpen] = useState(false); - const [editingPackage, setEditingPackage] = useState(null); - const [saving, setSaving] = useState(false); - const [disablingId, setDisablingId] = useState(null); const [epaySettings, setEpaySettings] = useState(null); const [epayLoading, setEpayLoading] = useState(true); const [epaySaving, setEpaySaving] = useState(false); @@ -146,19 +123,6 @@ export default function PaymentManagement() { } }, [filters, offset]); - const loadPackages = useCallback(async () => { - setPackagesLoading(true); - setPackagesError(""); - try { - const payload = await responsePayload(await fetch("/api/admin/packages", { cache: "no-store" })); - setPackages(payload.packages); - } catch (error) { - setPackagesError(error instanceof Error ? error.message : "读取套餐失败"); - } finally { - setPackagesLoading(false); - } - }, []); - const loadEpaySettings = useCallback(async () => { setEpayLoading(true); setEpayError(""); @@ -185,10 +149,6 @@ export default function PaymentManagement() { const timer = window.setTimeout(() => void loadPayments(), 0); return () => window.clearTimeout(timer); }, [loadPayments]); - useEffect(() => { - const timer = window.setTimeout(() => void loadPackages(), 0); - return () => window.clearTimeout(timer); - }, [loadPackages]); useEffect(() => { const timer = window.setTimeout(() => void loadEpaySettings(), 0); return () => window.clearTimeout(timer); @@ -216,7 +176,7 @@ export default function PaymentManagement() { body: JSON.stringify({ ...values, newKey: values.newKey || undefined }), })); epayForm.setFieldValue("newKey", ""); - message.success("易支付配置已保存"); + message.success("Z-Pay(易支付)配置已保存"); await loadEpaySettings(); } catch (error) { message.error(error instanceof Error ? error.message : "保存易支付配置失败"); @@ -225,77 +185,6 @@ export default function PaymentManagement() { } } - function openCreateModal() { - setEditingPackage(null); - packageForm.setFieldsValue({ name: "", description: "", priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }); - setModalOpen(true); - } - - function openEditModal(item: PaymentPackage) { - setEditingPackage(item); - packageForm.setFieldsValue({ - name: item.name, - description: item.description, - priceYuan: item.priceCents / 100, - credits: item.credits, - sortOrder: item.sortOrder, - enabled: item.enabled, - }); - setModalOpen(true); - } - - function closeModal() { - if (saving) return; - setModalOpen(false); - setEditingPackage(null); - packageForm.resetFields(); - } - - async function savePackage(values: PackageFormValues) { - setSaving(true); - try { - await responsePayload(await fetch("/api/admin/packages", { - method: editingPackage ? "PATCH" : "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - ...(editingPackage ? { id: editingPackage.id } : {}), - name: values.name.trim(), - description: values.description?.trim() ?? "", - priceCents: Math.round(values.priceYuan * 100), - credits: values.credits, - sortOrder: values.sortOrder, - enabled: values.enabled, - }), - })); - message.success(editingPackage ? "套餐已更新" : "套餐已添加"); - setModalOpen(false); - setEditingPackage(null); - packageForm.resetFields(); - await loadPackages(); - } catch (error) { - message.error(error instanceof Error ? error.message : "保存套餐失败"); - } finally { - setSaving(false); - } - } - - async function disablePackage(id: string) { - setDisablingId(id); - try { - await responsePayload(await fetch("/api/admin/packages", { - method: "DELETE", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ id }), - })); - message.success("套餐已停用"); - await loadPackages(); - } catch (error) { - message.error(error instanceof Error ? error.message : "停用套餐失败"); - } finally { - setDisablingId(null); - } - } - const orderColumns: TableColumnsType = [ { title: "订单号", dataIndex: "orderNo", render: (value) => {value} }, { title: "用户邮箱", dataIndex: "userEmail", render: (value) => value || "—" }, @@ -307,15 +196,6 @@ export default function PaymentManagement() { { title: "创建时间", dataIndex: "createdAt", render: formatDate }, { title: "支付时间", dataIndex: "paidAt", render: formatDate }, ]; - const packageColumns: TableColumnsType = [ - { title: "名称", dataIndex: "name", render: (_, item) => {item.name}{item.description || "暂无描述"} }, - { title: "价格", dataIndex: "priceCents", align: "right", render: formatMoney }, - { title: "点数", dataIndex: "credits", align: "right" }, - { title: "排序", dataIndex: "sortOrder", align: "right" }, - { title: "状态", dataIndex: "enabled", render: (enabled) => {enabled ? "启用" : "停用"} }, - { title: "操作", key: "actions", fixed: "right", render: (_, item) => {item.enabled && disablePackage(item.id)}>} }, - ]; - return ( @@ -331,11 +211,12 @@ export default function PaymentManagement() { } > + 配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。 {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} {epayError && void loadEpaySettings()}>重试} />} @@ -375,39 +256,7 @@ export default function PaymentManagement() { /> - - } onClick={openCreateModal}>添加套餐}> - - {packagesError && void loadPackages()}>重试} />} - rowKey="id" columns={packageColumns} dataSource={packages} loading={packagesLoading} pagination={false} scroll={{ x: "max-content" }} /> - - - - packageForm.submit()} - onCancel={closeModal} - destroyOnHidden - afterClose={() => packageForm.resetFields()} - maskClosable={!saving} - keyboard={!saving} - > - form={packageForm} layout="vertical" onFinish={savePackage} requiredMark="optional" initialValues={{ priceYuan: 1, credits: 10, sortOrder: 0, enabled: true }}> - - - - - - - - - - ); } diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 003999d8..207666a8 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -44,10 +44,11 @@ test("self-hosted account entry checks only the persisted admin role", () => { assert.match(auth, /authorizeAdminAccess\(user, access\)/); }); -test("admin navigation exposes one unified payment resource", () => { +test("admin navigation exposes payment and package resources", () => { assert.match(adminApp, /name: "payments", list: "\/admin\/payments", meta: \{ label: "支付管理"/); - assert.doesNotMatch(adminApp, /name: "packages"|list: "\/admin\/packages"|SettingOutlined/); + assert.match(adminApp, /name: "packages", list: "\/admin\/packages", meta: \{ label: "套餐管理"/); assert.match(adminApp, /CreditCardOutlined/); + assert.match(adminApp, /ShoppingOutlined/); }); test("admin sider replaces logout with a collapsed-aware return-to-chat link", () => { diff --git a/frontend/tests/admin-payments-contract.test.ts b/frontend/tests/admin-payments-contract.test.ts index 4f046847..bd43cded 100644 --- a/frontend/tests/admin-payments-contract.test.ts +++ b/frontend/tests/admin-payments-contract.test.ts @@ -5,67 +5,67 @@ import test from "node:test"; const root = new URL("../", import.meta.url); const route = readFileSync(new URL("src/app/api/admin/payments/route.ts", root), "utf8"); const packagesRoute = readFileSync(new URL("src/app/api/admin/packages/route.ts", root), "utf8"); -const page = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8"); +const paymentPage = readFileSync(new URL("src/app/admin/payments/page.tsx", root), "utf8"); const packagesPage = readFileSync(new URL("src/app/admin/packages/page.tsx", root), "utf8"); -const management = readFileSync(new URL("src/components/admin/payment-management.tsx", root), "utf8"); -const migration = readFileSync(new URL("supabase/migrations/20260727030000_payment_admin_stats.sql", root), "utf8"); +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"); -test("支付后台接口只允许管理员并查询平台订单", () => { +test("支付后台接口使用 self-hosted PostgreSQL 联表且不依赖 Supabase builder", () => { assert.match(route, /requireAdminSession\("read"\)/); - assert.doesNotMatch(route, /isAdminUser\(user\)/); - assert.match(route, /from\("payment_orders"\)/); - assert.match(route, /auth\.admin\.getUserById/); - assert.match(route, /payment_packages\(name\)/); - assert.match(route, /order_no|orderNo/); - assert.doesNotMatch(route, /SUPABASE_SERVICE_ROLE_KEY/); - assert.doesNotMatch(route, /raw_notify_payload/); + assert.match(route, /queryAdminRows/); + assert.match(route, /from public\.payment_orders o/); + assert.match(route, /left join public\.payment_packages p on p\.id = o\.package_id/); + assert.match(route, /left join identity\.users u on u\.id = o\.user_id/); + assert.match(route, /count\(\*\) over\(\)::text as total_count/); + assert.match(route, /order by o\.created_at desc, o\.order_no asc/); + assert.doesNotMatch(route, /createAdminSupabaseClient|auth\.admin|\.from\(|\.range\(|count: "exact"/); + assert.doesNotMatch(route, /userId|user_id:|raw_notify_payload|SUPABASE_SERVICE_ROLE_KEY/); }); -test("支付接口包含筛选、统计和分页契约", () => { +test("支付接口使用参数化筛选、独立日期统计与 ISO 日期输出", () => { for (const field of ["status", "from", "to", "limit", "offset"]) assert.match(route, new RegExp(field)); for (const field of ["totalOrders", "paidOrders", "pendingOrders", "failedExpiredOrders", "paidAmountCents", "grantedCredits"]) assert.match(route, new RegExp(field)); assert.match(route, /max\(100\)/); - assert.match(route, /count: "exact"/); + assert.match(route, /\$\$\{values\.length\}/); + assert.match(route, /statsValues/); + assert.match(route, /created_at\.toISOString\(\)/); + assert.match(route, /paid_at\?\.toISOString\(\) \?\? null/); + assert.match(route, /支付记录服务暂时不可用/); assert.match(route, /hasMore/); - assert.match(migration, /get_payment_order_stats/); - assert.match(migration, /status = 'paid'/); }); -test("统一支付管理页面使用 Ant Design 与 Refine 并保留支付数据合同", () => { - assert.match(page, /PaymentManagement/); - for (const component of ["List", "Card", "Table", "Form", "Tag", "Alert", "Statistic"]) { - assert.match(management, new RegExp(`\\b${component}\\b`)); - } - assert.match(management, /\/api\/admin\/payments/); - assert.match(management, /\/api\/admin\/packages/); - for (const field of ["orderNo", "userEmail", "packageName", "moneyCents", "credits", "status", "epayTradeNo", "createdAt", "paidAt"]) { - assert.match(management, new RegExp(field)); - } - assert.match(management, /paymentLoading/); - assert.match(management, /paymentError/); - assert.match(management, /pagination=\{\{/); - assert.doesNotMatch(management, /standalone-page|admin-header|admin-section/); +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`)); + assert.match(paymentManagement, /支付概览/); + assert.match(paymentManagement, /Z-Pay(易支付)渠道配置/); + assert.match(paymentManagement, /支付记录/); + assert.match(paymentManagement, /\/api\/admin\/payments/); + assert.doesNotMatch(paymentManagement, /套餐列表|套餐设置|添加套餐|\/api\/admin\/packages|PackageManagement/); }); -test("套餐列表、Modal 与新增编辑停用操作收敛到支付管理页", () => { - assert.match(management, /\}>/); - assert.doesNotMatch(management, / { + assert.match(adminApp, /name: "payments", list: "\/admin\/payments"[\s\S]*name: "packages", list: "\/admin\/packages", meta: \{ label: "套餐管理"[\s\S]*name: "users"/); + assert.match(adminApp, /ShoppingOutlined/); + assert.match(packagesPage, /PackageManagement/); + assert.doesNotMatch(packagesPage, /redirect/); + assert.match(packageManagement, //); + assert.match(packageManagement, / { + for (const field of ["名称", "描述", "价格(元)", "点数", "排序", "启用"]) assert.match(packageManagement, new RegExp(field)); + assert.match(packageManagement, /套餐列表读取失败/); + assert.match(packageManagement, /loadPackages\(\)/); + assert.match(packageManagement, />重试<\/Button>/); + assert.match(packageManagement, / { - assert.match(packagesPage, /import \{ redirect \} from "next\/navigation"/); - assert.match(packagesPage, /redirect\("\/admin\/payments"\)/); - assert.doesNotMatch(packagesPage, /use client| { assert.match(route, /crypto\.randomUUID\(\)/); 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, /keyConfigured/); assert.doesNotMatch(route, /NextResponse\.json\([^\n]*(?:encrypted_key|newKey|encryptedKey|maskedKey|keyMask)/); @@ -91,8 +93,8 @@ test("支付调用点等待异步数据库配置", () => { assert.match(notifyRoute, /export async function GET/); }); -test("统一支付页面含系统配置 Card 与永不预填的 Password", () => { - assert.match(management, /title="易支付系统配置"/); +test("统一支付页面含 Z-Pay 渠道配置 Card 与永不预填的 Password", () => { + assert.match(management, /title="Z-Pay(易支付)渠道配置"/); assert.match(management, /\/api\/admin\/epay-settings/); assert.match(management, / { + assert.match(createRoute, /assertPublicGatewayUrl\(submitUrl\)/); + assert.match(createRoute, /const signedParams = \{ \.\.\.params, sign: epaySign\(params, config\.key\), sign_type: "MD5" \}/); + assert.match(createRoute, /const payUrl = new URL\(submitUrl\)/); + assert.match(createRoute, /payUrl\.searchParams\.set\(name, value\)/); + assert.match(createRoute, /NextResponse\.json\(\{ orderNo, payUrl: payUrl\.toString\(\), qrCode: null \}\)/); + for (const field of ["money", "name", "notify_url", "out_trade_no", "pid", "return_url", "sitename", "type", "sign", "sign_type"]) assert.match(createRoute, new RegExp(field)); + assert.doesNotMatch(createRoute, /fetch\(submitUrl|document\.createElement\("form"\)|submitUrl:|fields[, }]/); + assert.doesNotMatch(createRoute, /NextResponse\.json\([^\n]*config\.key|searchParams\.set\([^\n]*config\.key/); + + assert.match(page, /window\.open\(payload\.payUrl, "_blank", "noopener,noreferrer"\)/); + assert.match(page, /

套餐充值<\/h3>/); + assert.match(page, /立即支付/); + assert.match(page, /套餐支付暂时不可用,请稍后重试/); + assert.doesNotMatch(page, /document\.createElement\("form"\)|payload\.submitUrl|payload\.fields/); +}); + test("网关探测只使用 HEAD/GET、受限响应且共享 SSRF 门禁", () => { assert.match(testRoute, /requireAdminSession\("write"\)/); assert.match(testRoute, /method: "HEAD"/); -- 2.52.0 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 45/46] 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, / Date: Thu, 30 Jul 2026 10:01:17 +0800 Subject: [PATCH 46/46] fix: grant staging admin payment access Add the missing least-privilege grants and RLS policies so self-hosted admin payment and package APIs can use the admin_runtime database role. --- docs/BUG_HISTORY.md | 14 +++++----- ...260730010000_admin_payment_permissions.sql | 28 +++++++++++++++++++ frontend/tests/epay-payment-contract.test.ts | 9 ++++++ 3 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 frontend/supabase/migrations/20260730010000_admin_payment_permissions.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index b2740fa1..b801eb3f 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1614,15 +1614,15 @@ ## BUG-093 | 后台支付入口分散且界面风格不一致 -- 状态:resolved +- 状态:investigating - 首次发现:2026-07-29 -- 最近更新:2026-07-29 +- 最近更新:2026-07-30 - 影响面:后台 Refine 侧栏、`/admin/payments`、`/admin/packages`、易支付配置与对话页充值入口。 -- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `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` 结果记录在本次交付报告。 +- 用户现象:支付记录与支付配置占用两个导航项,页面仍使用主站 `standalone-page/admin-header/admin-section` 样式;套餐新增表单常驻页面,后台默认退出入口还会触发登出,管理员难以直接返回对话;对话页支付入口缺少安全默认关闭和服务端创建订单硬门禁。2026-07-29 复发时,Z-Pay 配置不能折叠且占据长页面,后台受全局 `html/body overflow:hidden` 限制无法纵向滚动,套餐 API 与易支付配置 API 仍调用 self-hosted adapter 不支持的 Supabase builder/RPC。2026-07-30 部署 `dd8e2ad9c7e76d0152b4563c43a45b1e26137035` 后,`GET /api/admin/payments` 与套餐管理仍返回 500。 +- 触发条件:进入同域 `/admin` 后管理支付记录或套餐,或点击 Refine 侧栏底部默认 Logout;复发条件为进入支付管理、展开长配置或调用套餐 CRUD / 易支付配置读写。2026-07-30 的数据库权限复发在 `admin_runtime` 通过 `ADMIN_DATABASE_URL` 查询支付表时稳定触发。 +- 根因:首轮支付后台实现依赖 Supabase 专用关联 select、分页、计数和 Admin Auth 查询;self-hosted staging 的本地 PostgreSQL adapter 不支持这些 builder 能力,支付记录因此统一降级为“支付记录服务暂时不可用”。同页套餐设计也不符合最新后台信息架构,易支付配置响应漏投影 `chat_enabled`,chat 创建订单又依赖服务端提交网关后猜测跳转地址,不兼容标准易支付收银台表单页。复发遗漏源于上轮只把支付记录切换到 PostgreSQL,套餐与配置契约测试没有锁定 self-hosted 数据链,且未覆盖聊天全局滚动边界下的后台专用滚动容器。2026-07-30 的直接根因是 `20260727020000_epay_packages_orders.sql` 只向 Supabase 的 `service_role` / `authenticated` 授权,未向 self-hosted 后台实际使用的 `admin_runtime` 授予 `payment_packages`、`payment_orders` 权限,也未添加对应 RLS 策略;因此数据库健康且新 SHA 已部署,后台 SQL 仍被 PostgreSQL 权限门禁拒绝。 +- 修复:支付记录改为通过 `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` 并使用函数返回行,保留原子审计和脱敏响应。2026-07-30 新增前向迁移 `20260730010000_admin_payment_permissions.sql`,向 `admin_runtime` 最小授予套餐读写、订单只读、易支付配置读取及保存函数执行权限,并为启用 RLS 的支付表补齐角色策略;不授予订单写入或删除权限。 +- 验证:`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` 结果记录在本次交付报告。2026-07-30 线上健康响应证明部署 SHA 为 `dd8e2ad9c7e76d0152b4563c43a45b1e26137035` 且本地业务库、身份库均健康;静态权限审计确认支付迁移缺少 `admin_runtime` grant/RLS。新增权限迁移契约后,支付、套餐、配置三组 21 项回归全部通过;生产态最终验证仍等待迁移应用和已登录 smoke,因此状态保持 `investigating`。 - 防复发: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/supabase/migrations/20260730010000_admin_payment_permissions.sql b/frontend/supabase/migrations/20260730010000_admin_payment_permissions.sql new file mode 100644 index 00000000..f9e88840 --- /dev/null +++ b/frontend/supabase/migrations/20260730010000_admin_payment_permissions.sql @@ -0,0 +1,28 @@ +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + grant select, insert, update on table public.payment_packages to admin_runtime; + grant select on table public.payment_orders to admin_runtime; + grant select on table public.epay_settings to admin_runtime; + grant execute on function public.admin_save_epay_settings( + uuid, text, text, text, text, text, text, text, text, text, boolean, boolean + ) to admin_runtime; + + drop policy if exists payment_packages_admin_select on public.payment_packages; + create policy payment_packages_admin_select on public.payment_packages + for select to admin_runtime using (true); + + drop policy if exists payment_packages_admin_insert on public.payment_packages; + create policy payment_packages_admin_insert on public.payment_packages + for insert to admin_runtime with check (true); + + drop policy if exists payment_packages_admin_update on public.payment_packages; + create policy payment_packages_admin_update on public.payment_packages + for update to admin_runtime using (true) with check (true); + + drop policy if exists payment_orders_admin_select on public.payment_orders; + create policy payment_orders_admin_select on public.payment_orders + for select to admin_runtime using (true); + end if; +end; +$$; diff --git a/frontend/tests/epay-payment-contract.test.ts b/frontend/tests/epay-payment-contract.test.ts index 92171c44..b0a83416 100644 --- a/frontend/tests/epay-payment-contract.test.ts +++ b/frontend/tests/epay-payment-contract.test.ts @@ -4,6 +4,7 @@ import { test } from "node:test"; import { epayCanonical, epaySign } from "../src/lib/epay/sign"; const migration = readFileSync(new URL("../supabase/migrations/20260727020000_epay_packages_orders.sql", import.meta.url), "utf8"); +const adminPermissionsMigration = readFileSync(new URL("../supabase/migrations/20260730010000_admin_payment_permissions.sql", import.meta.url), "utf8"); test("易支付签名过滤空值并按键排序", () => { const params = { money: "10.00", pid: "10001", name: "套餐", empty: "", sign_type: "MD5" }; @@ -18,3 +19,11 @@ test("支付迁移包含套餐、订单、payment 类型与原子结算", () => assert.match(migration, /settle_epay_order/); assert.match(migration, /on conflict \(user_id, transaction_type, request_id\) do nothing/); }); + +test("self-hosted 管理角色具有支付后台最小权限", () => { + assert.match(adminPermissionsMigration, /grant select, insert, update on table public\.payment_packages to admin_runtime/); + assert.match(adminPermissionsMigration, /grant select on table public\.payment_orders to admin_runtime/); + assert.doesNotMatch(adminPermissionsMigration, /grant (?:all|insert|update|delete) on table public\.payment_orders to admin_runtime/); + assert.match(adminPermissionsMigration, /grant select on table public\.epay_settings to admin_runtime/); + assert.match(adminPermissionsMigration, /grant execute on function public\.admin_save_epay_settings\([\s\S]*\) to admin_runtime/); +}); -- 2.52.0