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/45] 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", () => { 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/45] 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/); 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/45] 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") 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 12/45] 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() 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 13/45] 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", () => { 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 14/45] 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 - 修复版本:待提交(本地可测) 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 15/45] 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 - 修复版本:待提交(本地可测) 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 16/45] 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 - 修复版本:待提交(本地可测) 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 17/45] 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"/); 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 18/45] 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 && 打开支付页面}
} )} 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 19/45] 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"/); 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 20/45] 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"/); 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 21/45] 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 已部署验证) 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 22/45] 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)}> -