From 5622ab448a5a02509a060fd7d96cd24da4113c56 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 6 Aug 2026 19:49:55 +0800 Subject: [PATCH] feat(admin): add operations resources and audit UI --- .../src/app/admin/administrators/page.tsx | 2 + frontend/src/app/admin/audit-logs/page.tsx | 2 + frontend/src/app/admin/codes/page.tsx | 26 +- frontend/src/app/admin/consultations/page.tsx | 2 + .../app/admin/credit-transactions/page.tsx | 2 + frontend/src/app/admin/customers/page.tsx | 2 + frontend/src/app/admin/feature-flags/page.tsx | 2 + .../src/app/admin/model-releases/page.tsx | 2 + frontend/src/app/admin/models/page.tsx | 2 + frontend/src/app/admin/orders/page.tsx | 2 + frontend/src/app/admin/products/page.tsx | 2 + frontend/src/app/admin/roles/page.tsx | 2 + frontend/src/app/admin/security/page.tsx | 5 + frontend/src/app/admin/subscriptions/page.tsx | 2 + frontend/src/app/admin/usage/page.tsx | 2 + frontend/src/app/admin/users/page.tsx | 34 +- .../src/app/api/admin/audit-logs/route.ts | 4 +- .../src/app/api/admin/consultations/route.ts | 4 +- .../api/admin/credit-transactions/route.ts | 4 +- frontend/src/app/api/admin/customers/route.ts | 115 +++ .../src/app/api/admin/feature-flags/route.ts | 13 + frontend/src/app/api/admin/payments/route.ts | 4 +- frontend/src/app/api/admin/users/route.ts | 86 +- frontend/src/components/admin/admin-app.tsx | 32 +- .../admin/administrators-resource.tsx | 166 ++++ .../admin/billing-operations-resources.tsx | 572 ++++++++++++ .../src/components/admin/codes-resource.tsx | 332 +++++-- .../admin/feature-flags-management.tsx | 170 ++++ .../src/components/admin/mfa-security.tsx | 279 ++++++ .../src/components/admin/model-management.tsx | 408 +++++++++ .../components/admin/payment-management.tsx | 32 +- .../components/admin/product-management.tsx | 273 ++++++ .../components/admin/reason-action-modal.tsx | 253 ++++++ .../src/components/admin/resource-table.tsx | 6 +- .../src/components/admin/roles-resource.tsx | 39 + .../src/components/admin/users-resource.tsx | 82 +- frontend/src/lib/admin/providers.ts | 124 ++- ...0260806050000_operations_feature_flags.sql | 131 +++ ...0806060000_unified_rectification_usage.sql | 843 ++++++++++++++++++ 39 files changed, 3770 insertions(+), 293 deletions(-) create mode 100644 frontend/src/app/admin/administrators/page.tsx create mode 100644 frontend/src/app/admin/audit-logs/page.tsx create mode 100644 frontend/src/app/admin/consultations/page.tsx create mode 100644 frontend/src/app/admin/credit-transactions/page.tsx create mode 100644 frontend/src/app/admin/customers/page.tsx create mode 100644 frontend/src/app/admin/feature-flags/page.tsx create mode 100644 frontend/src/app/admin/model-releases/page.tsx create mode 100644 frontend/src/app/admin/models/page.tsx create mode 100644 frontend/src/app/admin/orders/page.tsx create mode 100644 frontend/src/app/admin/products/page.tsx create mode 100644 frontend/src/app/admin/roles/page.tsx create mode 100644 frontend/src/app/admin/security/page.tsx create mode 100644 frontend/src/app/admin/subscriptions/page.tsx create mode 100644 frontend/src/app/admin/usage/page.tsx create mode 100644 frontend/src/app/api/admin/customers/route.ts create mode 100644 frontend/src/app/api/admin/feature-flags/route.ts create mode 100644 frontend/src/components/admin/administrators-resource.tsx create mode 100644 frontend/src/components/admin/billing-operations-resources.tsx create mode 100644 frontend/src/components/admin/feature-flags-management.tsx create mode 100644 frontend/src/components/admin/mfa-security.tsx create mode 100644 frontend/src/components/admin/model-management.tsx create mode 100644 frontend/src/components/admin/product-management.tsx create mode 100644 frontend/src/components/admin/reason-action-modal.tsx create mode 100644 frontend/src/components/admin/roles-resource.tsx create mode 100644 frontend/supabase/migrations/20260806050000_operations_feature_flags.sql create mode 100644 frontend/supabase/migrations/20260806060000_unified_rectification_usage.sql diff --git a/frontend/src/app/admin/administrators/page.tsx b/frontend/src/app/admin/administrators/page.tsx new file mode 100644 index 00000000..4aa30bfd --- /dev/null +++ b/frontend/src/app/admin/administrators/page.tsx @@ -0,0 +1,2 @@ +import AdministratorsResource from "@/components/admin/administrators-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/audit-logs/page.tsx b/frontend/src/app/admin/audit-logs/page.tsx new file mode 100644 index 00000000..5207268f --- /dev/null +++ b/frontend/src/app/admin/audit-logs/page.tsx @@ -0,0 +1,2 @@ +import AuditLogsResource from "@/components/admin/audit-logs-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/codes/page.tsx b/frontend/src/app/admin/codes/page.tsx index d195b258..b2728963 100644 --- a/frontend/src/app/admin/codes/page.tsx +++ b/frontend/src/app/admin/codes/page.tsx @@ -1,27 +1,5 @@ -"use client"; - -import { useSearchParams } from "next/navigation"; - -import AuditLogsResource from "@/components/admin/audit-logs-resource"; import CodesResource from "@/components/admin/codes-resource"; -import ConsultationsResource from "@/components/admin/consultations-resource"; -import CreditTransactionsResource from "@/components/admin/credit-transactions-resource"; -import UsersResource from "@/components/admin/users-resource"; -const resourceComponents = { - codes: CodesResource, - users: UsersResource, - "credit-transactions": CreditTransactionsResource, - consultations: ConsultationsResource, - "audit-logs": AuditLogsResource, -} as const; - -export default function AdminResourcesPage() { - const requested = useSearchParams().get("resource") ?? "codes"; - const Resource = resourceComponents[ - requested in resourceComponents - ? requested as keyof typeof resourceComponents - : "codes" - ]; - return ; +export default function AdminCodesPage() { + return ; } diff --git a/frontend/src/app/admin/consultations/page.tsx b/frontend/src/app/admin/consultations/page.tsx new file mode 100644 index 00000000..e9482efc --- /dev/null +++ b/frontend/src/app/admin/consultations/page.tsx @@ -0,0 +1,2 @@ +import ConsultationsResource from "@/components/admin/consultations-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/credit-transactions/page.tsx b/frontend/src/app/admin/credit-transactions/page.tsx new file mode 100644 index 00000000..73ff20c5 --- /dev/null +++ b/frontend/src/app/admin/credit-transactions/page.tsx @@ -0,0 +1,2 @@ +import CreditTransactionsResource from "@/components/admin/credit-transactions-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/customers/page.tsx b/frontend/src/app/admin/customers/page.tsx new file mode 100644 index 00000000..603f806a --- /dev/null +++ b/frontend/src/app/admin/customers/page.tsx @@ -0,0 +1,2 @@ +import UsersResource from "@/components/admin/users-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/feature-flags/page.tsx b/frontend/src/app/admin/feature-flags/page.tsx new file mode 100644 index 00000000..4daf96ea --- /dev/null +++ b/frontend/src/app/admin/feature-flags/page.tsx @@ -0,0 +1,2 @@ +import FeatureFlagsManagement from "@/components/admin/feature-flags-management"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/model-releases/page.tsx b/frontend/src/app/admin/model-releases/page.tsx new file mode 100644 index 00000000..4aab3509 --- /dev/null +++ b/frontend/src/app/admin/model-releases/page.tsx @@ -0,0 +1,2 @@ +import { ModelReleasesResource } from "@/components/admin/billing-operations-resources"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/models/page.tsx b/frontend/src/app/admin/models/page.tsx new file mode 100644 index 00000000..9600f987 --- /dev/null +++ b/frontend/src/app/admin/models/page.tsx @@ -0,0 +1,2 @@ +import ModelManagement from "@/components/admin/model-management"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/orders/page.tsx b/frontend/src/app/admin/orders/page.tsx new file mode 100644 index 00000000..78c8b902 --- /dev/null +++ b/frontend/src/app/admin/orders/page.tsx @@ -0,0 +1,2 @@ +import { OrdersResource } from "@/components/admin/billing-operations-resources"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/products/page.tsx b/frontend/src/app/admin/products/page.tsx new file mode 100644 index 00000000..adc4278d --- /dev/null +++ b/frontend/src/app/admin/products/page.tsx @@ -0,0 +1,2 @@ +import ProductManagement from "@/components/admin/product-management"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/roles/page.tsx b/frontend/src/app/admin/roles/page.tsx new file mode 100644 index 00000000..4e05aa45 --- /dev/null +++ b/frontend/src/app/admin/roles/page.tsx @@ -0,0 +1,2 @@ +import RolesResource from "@/components/admin/roles-resource"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/security/page.tsx b/frontend/src/app/admin/security/page.tsx new file mode 100644 index 00000000..919c9faa --- /dev/null +++ b/frontend/src/app/admin/security/page.tsx @@ -0,0 +1,5 @@ +import MfaSecurity from "@/components/admin/mfa-security"; + +export default function Page() { + return ; +} diff --git a/frontend/src/app/admin/subscriptions/page.tsx b/frontend/src/app/admin/subscriptions/page.tsx new file mode 100644 index 00000000..7ae0e49d --- /dev/null +++ b/frontend/src/app/admin/subscriptions/page.tsx @@ -0,0 +1,2 @@ +import { SubscriptionsResource } from "@/components/admin/billing-operations-resources"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/usage/page.tsx b/frontend/src/app/admin/usage/page.tsx new file mode 100644 index 00000000..aa933d88 --- /dev/null +++ b/frontend/src/app/admin/usage/page.tsx @@ -0,0 +1,2 @@ +import { UsageResource } from "@/components/admin/billing-operations-resources"; +export default function Page() { return ; } diff --git a/frontend/src/app/admin/users/page.tsx b/frontend/src/app/admin/users/page.tsx index d57241a1..9b68a718 100644 --- a/frontend/src/app/admin/users/page.tsx +++ b/frontend/src/app/admin/users/page.tsx @@ -1,33 +1,5 @@ -"use client"; +import { redirect } from "next/navigation"; -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" }; - -async function loadUsers(): Promise { - 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 || "暂时无法读取管理员列表"); - return payload.users; -} - -export default function AdminUsersPage() { - const [users, setUsers] = useState([]); - const [email, setEmail] = useState(""); - const [error, setError] = useState(""); - useEffect(() => { void loadUsers().then(setUsers).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(""); setUsers(await loadUsers()); - } - 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; } - setUsers(await loadUsers()); - } - 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 && }
; +export default function LegacyUsersPage() { + redirect("/admin/customers"); } diff --git a/frontend/src/app/api/admin/audit-logs/route.ts b/frontend/src/app/api/admin/audit-logs/route.ts index ffdf00b1..2fa8d2e2 100644 --- a/frontend/src/app/api/admin/audit-logs/route.ts +++ b/frontend/src/app/api/admin/audit-logs/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { requireAdminSession } from "@/lib/admin/auth"; +import { requirePermission } from "@/lib/admin/auth"; import { pageOffset, queryAdminRows } from "@/lib/admin/database"; import { adminErrorResponse, @@ -39,7 +39,7 @@ export const DELETE = readonlyAdminMutation; export async function GET(request: Request) { try { - await requireAdminSession(); + await requirePermission("audit.read"); const parsed = parseListQuery(request); if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); const { page, pageSize, sort, order, q, status } = parsed.data; diff --git a/frontend/src/app/api/admin/consultations/route.ts b/frontend/src/app/api/admin/consultations/route.ts index 51ce4c53..7ac77346 100644 --- a/frontend/src/app/api/admin/consultations/route.ts +++ b/frontend/src/app/api/admin/consultations/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { requireAdminSession } from "@/lib/admin/auth"; +import { requirePermission } from "@/lib/admin/auth"; import { pageOffset, queryAdminRows } from "@/lib/admin/database"; import { adminErrorResponse, @@ -35,7 +35,7 @@ export const DELETE = readonlyAdminMutation; export async function GET(request: Request) { try { - await requireAdminSession(); + await requirePermission("billing.orders.read"); const parsed = parseListQuery(request); if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); const { page, pageSize, sort, order, q, status } = parsed.data; diff --git a/frontend/src/app/api/admin/credit-transactions/route.ts b/frontend/src/app/api/admin/credit-transactions/route.ts index adf86190..539d1992 100644 --- a/frontend/src/app/api/admin/credit-transactions/route.ts +++ b/frontend/src/app/api/admin/credit-transactions/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; -import { requireAdminSession } from "@/lib/admin/auth"; +import { requirePermission } from "@/lib/admin/auth"; import { pageOffset, queryAdminRows } from "@/lib/admin/database"; import { adminErrorResponse, @@ -40,7 +40,7 @@ export const DELETE = readonlyAdminMutation; export async function GET(request: Request) { try { - await requireAdminSession(); + await requirePermission("billing.orders.read"); const parsed = parseListQuery(request); if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); const { page, pageSize, sort, order, q, status } = parsed.data; diff --git a/frontend/src/app/api/admin/customers/route.ts b/frontend/src/app/api/admin/customers/route.ts new file mode 100644 index 00000000..4234fbbf --- /dev/null +++ b/frontend/src/app/api/admin/customers/route.ts @@ -0,0 +1,115 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; + +import { requirePermission } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { + adminErrorResponse, + invalidQueryResponse, + parseListQuery, + readonlyAdminMutation, + requestId, +} from "@/lib/admin/http"; + +export const runtime = "nodejs"; + +type UserRow = { + id: string; + email: string; + name: string | null; + role: string; + email_verified: boolean; + banned: boolean; + created_at: Date; + credits: number; + total_count: string; +}; + +type BirthDataRow = { + user_id: string; + birth_date: string | null; + birth_time_status: string | null; + birth_place_label: string | null; +}; + +const sortColumns = new Map([ + ["createdAt", "u.created_at"], + ["email", "u.email"], + ["credits", "p.credits"], + ["name", "u.name"], +]); +const userIdSchema = z.string().uuid(); + +export const POST = readonlyAdminMutation; +export const PUT = readonlyAdminMutation; +export const PATCH = readonlyAdminMutation; +export const DELETE = readonlyAdminMutation; + +async function revealCustomerBirthData(request: Request, revealUserId: string) { + const parsedUserId = userIdSchema.safeParse(revealUserId); + if (!parsedUserId.success) return invalidQueryResponse({ revealUserId: ["必须是有效用户 ID"] }); + const session = await requirePermission("admin.customers.birth_data.read"); + const rows = await queryAdminRows( + "select * from public.admin_read_customer_birth_data($1, $2::uuid[], $3)", + [session.user.id, [parsedUserId.data], requestId(request)], + ); + const row = rows[0]; + if (!row) return NextResponse.json({ error: "用户资料不存在" }, { status: 404 }); + return NextResponse.json({ + data: { + userId: row.user_id, + birthDate: row.birth_date, + birthTimeStatus: row.birth_time_status, + birthPlace: row.birth_place_label, + }, + }); +} + +export async function GET(request: Request) { + try { + const revealUserId = new URL(request.url).searchParams.get("revealUserId"); + if (revealUserId) return await revealCustomerBirthData(request, revealUserId); + + await requirePermission("admin.customers.read"); + const parsed = parseListQuery(request); + if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); + const { page, pageSize, sort, order, q } = parsed.data; + const values: unknown[] = []; + const conditions: string[] = []; + if (q) { + values.push(`%${q}%`); + conditions.push(`(u.email ilike $${values.length} or u.name ilike $${values.length})`); + } + values.push(pageSize, pageOffset(page, pageSize)); + const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "u.created_at"; + const rows = await queryAdminRows(` + select + u.id, u.email, u.name, u.role, u.email_verified, u.banned, + u.created_at, p.credits, count(*) over()::text as total_count + from identity.users u + join public.profiles p on p.id = u.id + ${conditions.length ? `where ${conditions.join(" and ")}` : ""} + order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, u.id asc + limit $${values.length - 1} offset $${values.length} + `, values); + return NextResponse.json({ + data: rows.map((row) => ({ + id: row.id, + email: row.email, + name: row.name, + role: row.role, + emailVerified: row.email_verified, + banned: row.banned, + createdAt: row.created_at.toISOString(), + credits: row.credits, + birthDate: null, + birthTimeStatus: null, + birthPlace: null, + birthDataMasked: true, + })), + total: Number(rows[0]?.total_count ?? 0), + }); + } catch (error) { + return adminErrorResponse(error); + } +} diff --git a/frontend/src/app/api/admin/feature-flags/route.ts b/frontend/src/app/api/admin/feature-flags/route.ts new file mode 100644 index 00000000..d5b709df --- /dev/null +++ b/frontend/src/app/api/admin/feature-flags/route.ts @@ -0,0 +1,13 @@ +import { NextResponse } from "next/server"; +import { z } from "zod"; +import { requirePermission } from "@/lib/admin/auth"; +import { pageOffset, queryAdminRows } from "@/lib/admin/database"; +import { adminErrorResponse, invalidQueryResponse, parseListQuery, requestId, requireAdminMutation, requireHighRiskAdminMutation } from "@/lib/admin/http"; +export const runtime="nodejs"; +const schema=z.discriminatedUnion("action",[ + z.object({action:z.literal("save"),id:z.string().uuid().nullable().optional(),flagKey:z.string().regex(/^[a-z][a-z0-9._-]{1,99}$/),enabled:z.boolean(),rolloutPercentage:z.number().int().min(0).max(100),config:z.record(z.string(),z.unknown()).default({}),expectedVersion:z.number().int().positive().nullable().optional(),reason:z.string().trim().min(1).max(500)}).strict(), + z.object({action:z.literal("publish"),id:z.string().uuid(),expectedVersion:z.number().int().positive(),reason:z.string().trim().min(1).max(500)}).strict(), +]); +type Row={id:string;flag_key:string;version:number;enabled:boolean;rollout_percentage:number;config:Record;status:string;created_at:Date;published_at:Date|null;total_count:string}; +export async function GET(request:Request){try{await requirePermission("admin.access");const p=parseListQuery(request);if(!p.success)return invalidQueryResponse(p.error.flatten());const q=p.data.q?`%${p.data.q}%`:null;const rows=await queryAdminRows(`select f.*,count(*) over()::text total_count from public.feature_flags f where ($1::text is null or f.flag_key ilike $1) and ($2::text is null or f.status=$2) order by f.created_at desc limit $3 offset $4`,[q,p.data.status??null,p.data.pageSize,pageOffset(p.data.page,p.data.pageSize)]);return NextResponse.json({data:rows.map(r=>({id:r.id,flagKey:r.flag_key,version:r.version,enabled:r.enabled,rolloutPercentage:r.rollout_percentage,config:r.config,status:r.status,createdAt:r.created_at.toISOString(),publishedAt:r.published_at?.toISOString()??null})),total:Number(rows[0]?.total_count??0)});}catch(e){return adminErrorResponse(e)}} +export async function POST(request:Request){try{const b=schema.safeParse(await request.json().catch(()=>null));if(!b.success)return invalidQueryResponse(b.error.flatten());const session=b.data.action==="publish"?await requireHighRiskAdminMutation(request,"ops.flags.write"):await requireAdminMutation(request,"ops.flags.write");const rid=requestId(request);if(b.data.action==="publish"){const rows=await queryAdminRows<{id:string}>("select public.admin_publish_feature_flag($1,$2,$3,$4,$5) id",[session.user.id,b.data.id,b.data.expectedVersion,b.data.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}const v=b.data;const rows=await queryAdminRows<{id:string}>("select public.admin_save_feature_flag($1,$2,$3,$4,$5,$6::jsonb,$7,$8,$9) id",[session.user.id,v.id??null,v.flagKey,v.enabled,v.rolloutPercentage,JSON.stringify(v.config),v.expectedVersion??null,v.reason,rid]);return NextResponse.json({data:{id:rows[0]!.id,requestId:rid}})}catch(e){return adminErrorResponse(e)}} diff --git a/frontend/src/app/api/admin/payments/route.ts b/frontend/src/app/api/admin/payments/route.ts index f0399050..8b7fead7 100644 --- a/frontend/src/app/api/admin/payments/route.ts +++ b/frontend/src/app/api/admin/payments/route.ts @@ -1,6 +1,6 @@ import { NextResponse } from "next/server"; import { z } from "zod"; -import { requireAdminSession } from "@/lib/admin/auth"; +import { requirePermission } from "@/lib/admin/auth"; import { queryAdminRows } from "@/lib/admin/database"; import { adminErrorResponse } from "@/lib/admin/http"; @@ -38,7 +38,7 @@ type PaymentStatsRow = { export async function GET(request: Request) { try { - await requireAdminSession("read"); + await requirePermission("billing.orders.read"); const url = new URL(request.url); const parsed = querySchema.safeParse(Object.fromEntries(url.searchParams)); diff --git a/frontend/src/app/api/admin/users/route.ts b/frontend/src/app/api/admin/users/route.ts index 79d28afc..f5a5737b 100644 --- a/frontend/src/app/api/admin/users/route.ts +++ b/frontend/src/app/api/admin/users/route.ts @@ -1,85 +1 @@ -import { NextResponse } from "next/server"; - -import { requireAdminSession } from "@/lib/admin/auth"; -import { pageOffset, queryAdminRows } from "@/lib/admin/database"; -import { - adminErrorResponse, - invalidQueryResponse, - parseListQuery, - readonlyAdminMutation, -} from "@/lib/admin/http"; - -export const runtime = "nodejs"; - -type UserRow = { - id: string; - email: string; - name: string | null; - role: string; - email_verified: boolean; - banned: boolean; - created_at: Date; - credits: number; - birth_date: string | null; - birth_time_status: string | null; - birth_place_label: string | null; - total_count: string; -}; - -const sortColumns = new Map([ - ["createdAt", "u.created_at"], - ["email", "u.email"], - ["credits", "p.credits"], - ["name", "u.name"], -]); - -export const POST = readonlyAdminMutation; -export const PUT = readonlyAdminMutation; -export const PATCH = readonlyAdminMutation; -export const DELETE = readonlyAdminMutation; - -export async function GET(request: Request) { - try { - await requireAdminSession(); - const parsed = parseListQuery(request); - if (!parsed.success) return invalidQueryResponse(parsed.error.flatten()); - const { page, pageSize, sort, order, q } = parsed.data; - const values: unknown[] = []; - const conditions: string[] = []; - if (q) { - values.push(`%${q}%`); - conditions.push(`(u.email ilike $${values.length} or u.name ilike $${values.length})`); - } - values.push(pageSize, pageOffset(page, pageSize)); - const sortColumn = sortColumns.get(sort ?? "createdAt") ?? "u.created_at"; - const rows = await queryAdminRows(` - select - u.id, u.email, u.name, u.role, u.email_verified, u.banned, - u.created_at, p.credits, p.birth_date, p.birth_time_status, - p.birth_place_label, count(*) over()::text as total_count - from identity.users u - join public.profiles p on p.id = u.id - ${conditions.length ? `where ${conditions.join(" and ")}` : ""} - order by ${sortColumn} ${order === "asc" ? "asc" : "desc"}, u.id asc - limit $${values.length - 1} offset $${values.length} - `, values); - return NextResponse.json({ - data: rows.map((row) => ({ - id: row.id, - email: row.email, - name: row.name, - role: row.role, - emailVerified: row.email_verified, - banned: row.banned, - createdAt: row.created_at.toISOString(), - credits: row.credits, - birthDate: row.birth_date, - birthTimeStatus: row.birth_time_status, - birthPlace: row.birth_place_label, - })), - total: Number(rows[0]?.total_count ?? 0), - }); - } catch (error) { - return adminErrorResponse(error); - } -} +export { DELETE, GET, PATCH, POST, PUT, runtime } from "../customers/route"; diff --git a/frontend/src/components/admin/admin-app.tsx b/frontend/src/components/admin/admin-app.tsx index 045daa24..301052e5 100644 --- a/frontend/src/components/admin/admin-app.tsx +++ b/frontend/src/components/admin/admin-app.tsx @@ -1,14 +1,20 @@ "use client"; import { + ApiOutlined, ArrowLeftOutlined, AuditOutlined, + ControlOutlined, CreditCardOutlined, + DatabaseOutlined, + ExperimentOutlined, GiftOutlined, - ShoppingOutlined, MessageOutlined, + SafetyCertificateOutlined, + ShoppingOutlined, TeamOutlined, TransactionOutlined, + UserOutlined, } from "@ant-design/icons"; import { Authenticated, Refine } from "@refinedev/core"; import { ErrorComponent, ThemedLayout, ThemedSider, useNotificationProvider } from "@refinedev/antd"; @@ -51,14 +57,22 @@ export function AdminApp({ children }: { children: ReactNode }) { accessControlProvider={adminAccessControlProvider} notificationProvider={notificationProvider} resources={[ - { name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: } }, - { name: "payments", list: "/admin/payments", meta: { label: "支付管理", icon: } }, - { name: "packages", list: "/admin/packages", meta: { label: "套餐管理", icon: } }, - { name: "users", list: "/admin/codes?resource=users", meta: { label: "用户资料", icon: } }, - { name: "credit-transactions", list: "/admin/codes?resource=credit-transactions", meta: { label: "积分流水", icon: } }, - { name: "consultations", list: "/admin/codes?resource=consultations", meta: { label: "咨询请求", icon: } }, - { name: "audit-logs", list: "/admin/codes?resource=audit-logs", meta: { label: "审计日志", icon: } }, - ]} + { name: "administrators", list: "/admin/administrators", meta: { label: "管理员", icon: } }, + { name: "roles", list: "/admin/roles", meta: { label: "角色权限", icon: } }, + { name: "customers", list: "/admin/customers", meta: { label: "用户资料", icon: } }, + { name: "products", list: "/admin/products", meta: { label: "商品权益", icon: } }, + { name: "subscriptions", list: "/admin/subscriptions", meta: { label: "订阅", icon: } }, + { name: "orders", list: "/admin/orders", meta: { label: "订单", icon: } }, + { name: "codes", list: "/admin/codes", meta: { label: "兑换码", icon: } }, + { name: "credit-transactions", list: "/admin/credit-transactions", meta: { label: "积分流水", icon: } }, + { name: "consultations", list: "/admin/consultations", meta: { label: "咨询请求", icon: } }, + { name: "usage", list: "/admin/usage", meta: { label: "用量与成本", icon: } }, + { name: "models", list: "/admin/models", meta: { label: "模型配置", icon: } }, + { name: "model-releases", list: "/admin/model-releases", meta: { label: "模型发布", icon: } }, + { name: "feature-flags", list: "/admin/feature-flags", meta: { label: "功能开关", icon: } }, + { name: "security", list: "/admin/security", meta: { label: "安全验证", icon: } }, + { name: "audit-logs", list: "/admin/audit-logs", meta: { label: "审计日志", icon: } }, + ]} options={{ syncWithLocation: true, warnWhenUnsavedChanges: true, diff --git a/frontend/src/components/admin/administrators-resource.tsx b/frontend/src/components/admin/administrators-resource.tsx new file mode 100644 index 00000000..0fb11732 --- /dev/null +++ b/frontend/src/components/admin/administrators-resource.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { PlusOutlined } from "@ant-design/icons"; +import { useGetIdentity } from "@refinedev/core"; +import { useTable } from "@refinedev/antd"; +import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typography } from "antd"; +import { useState } from "react"; + +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { ReasonActionModal } from "./reason-action-modal"; +import { formatAdminDate } from "./resource-table"; + +const roleOptions = [ + { value: "owner", label: "Owner" }, + { value: "model_admin", label: "Model Admin" }, + { value: "billing_admin", label: "Billing Admin" }, + { value: "operations", label: "Operations" }, + { value: "support", label: "Support" }, + { value: "auditor", label: "Auditor" }, +] as const; + +type RoleCode = (typeof roleOptions)[number]["value"]; + +interface Administrator { + id: string; + email: string; + name: string; + roles: RoleCode[]; + createdAt: string; +} + +type AssignmentForm = { email: string; roleCode: RoleCode }; +type PendingRoleAction = { + action: "assign" | "revoke"; + roleCode: RoleCode; + userId?: string; + email?: string; + label: string; +}; + +export default function AdministratorsResource() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const { tableProps, searchFormProps, tableQuery } = useTable({ + resource: "administrators", + syncWithLocation: true, + pagination: { pageSize: 20 }, + onSearch: ({ q }) => [{ field: "q", operator: "contains", value: q }], + }); + const [assignmentForm] = Form.useForm(); + const [assignmentOpen, setAssignmentOpen] = useState(false); + const [assignmentUser, setAssignmentUser] = useState(null); + const [pendingAction, setPendingAction] = useState(null); + const [saving, setSaving] = useState(false); + const canManage = Boolean(identity?.permissions.includes("admin.users.manage_roles")); + + function openAssignment(item?: Administrator) { + setAssignmentUser(item ?? null); + assignmentForm.setFieldsValue({ email: item?.email ?? "", roleCode: undefined }); + setAssignmentOpen(true); + } + + function prepareAssignment(values: AssignmentForm) { + const role = roleOptions.find((item) => item.value === values.roleCode)!; + setPendingAction({ + action: "assign", + roleCode: values.roleCode, + ...(assignmentUser ? { userId: assignmentUser.id } : { email: values.email.trim() }), + label: `${assignmentUser?.email ?? values.email.trim()} · ${role.label}`, + }); + } + + async function submitRoleAction(reason: string) { + if (!pendingAction) return; + setSaving(true); + try { + await adminRequestJson("/api/admin/administrators", { + method: pendingAction.action === "assign" ? "POST" : "DELETE", + body: JSON.stringify({ + userId: pendingAction.userId, + email: pendingAction.email, + roleCode: pendingAction.roleCode, + reason, + }), + }); + message.success(pendingAction.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销"); + setPendingAction(null); + setAssignmentOpen(false); + setAssignmentUser(null); + assignmentForm.resetFields(); + await tableQuery.refetch(); + } finally { + setSaving(false); + } + } + + return ( + } onClick={() => openAssignment()}>按邮箱分配角色 : RBAC} + > + + 六类系统角色可在此分配和撤销。每次变更都需要邮箱验证码与操作原因;最后一位 Owner 受服务端保护,不能被撤销。 + +
+ + + +
+ value || "—" }, + { + title: "角色", + dataIndex: "roles", + render: (roles: RoleCode[], item: Administrator) => {roles.map((role) => { + event.preventDefault(); + setPendingAction({ action: "revoke", roleCode: role, userId: item.id, label: `${item.email} · ${role}` }); + }} + >{role})}, + }, + { title: "加入时间", dataIndex: "createdAt", render: formatAdminDate }, + ...(canManage ? [{ title: "操作", fixed: "right" as const, render: (_: unknown, item: Administrator) => }] : []), + ]} + /> + assignmentForm.submit()} + onCancel={() => setAssignmentOpen(false)} + destroyOnHidden + > + form={assignmentForm} layout="vertical" onFinish={prepareAssignment}> + + + + +
+ form.submit()} + onCancel={() => setSelected(null)} + destroyOnHidden + > + + form={form} + layout="vertical" + onFinish={prepareExtend} + > + + + + + + setExtendDays(null)} + onSubmit={extend} + /> + setRevokeTarget(null)} + onSubmit={(reason) => revoke(revokeTarget!, reason)} + /> + + ); +} + +type Order = { + id: string; + orderNo: string; + userId: string; + email: string | null; + productCode: string | null; + productVersion: number | null; + moneyCents: number; + currency: string; + status: string; + grantType: string | null; + grantStatus: string; + grantError: string | null; + adjustmentVersion: number; + refundStatus: string; + refundAmountCents: number | null; + refundedAt: string | null; + paidAt: string | null; + createdAt: string; +}; + +export function OrdersResource() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const invalidate = useInvalidate(); + const [target, setTarget] = useState<{ + order: Order; + action: "retry_grant" | "compensate" | "record_refund"; + } | null>(null); + const [saving, setSaving] = useState(false); + const canAdjust = Boolean( + identity?.permissions.includes("billing.adjustments.write"), + ); + + async function adjust(reason: string) { + if (!target) return; + setSaving(true); + try { + const result = await adminRequestJson<{ + data: { actionSuccess: boolean }; + }>("/api/admin/orders", { + method: "POST", + headers: { "x-request-id": crypto.randomUUID() }, + body: JSON.stringify({ + id: target.order.id, + action: target.action, + expectedVersion: target.order.adjustmentVersion, + reason, + }), + }); + await invalidate({ resource: "orders", invalidates: ["list"] }); + if (target.action === "retry_grant" && !result.data.actionSuccess) { + message.warning("已执行重试,但权益发放仍失败,请查看最新错误"); + } else { + message.success( + target.action === "record_refund" + ? "已记录账务全额退款状态" + : "订单权益操作已完成", + ); + } + setTarget(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "订单操作失败"); + } finally { + setSaving(false); + } + } + + const columns: TableColumnsType = [ + { + title: "订单", + render: (_, item) => ( + + {item.orderNo} + {formatAdminDate(item.createdAt)} + + ), + }, + { title: "用户", render: (_, item) => item.email ?? item.userId }, + { + title: "商品", + render: (_, item) => + item.productCode + ? `${item.productCode} · v${item.productVersion}` + : "旧积分订单", + }, + { + title: "金额", + render: (_, item) => + `${item.currency} ${(item.moneyCents / 100).toFixed(2)}`, + }, + { + title: "支付", + dataIndex: "status", + render: (value) => ( + + {value} + + ), + }, + { + title: "权益发放", + render: (_, item) => ( + + + {item.grantStatus} + + {item.grantError && {item.grantError}} + + ), + }, + { + title: "退款账务", + render: (_, item) => + item.refundStatus === "recorded" ? ( + + 已记录 + {formatAdminDate(item.refundedAt)} + + ) : ( + "—" + ), + }, + { title: "支付时间", dataIndex: "paidAt", render: formatAdminDate }, + { + title: "领域动作", + fixed: "right", + render: (_, item) => canAdjust ? ( + + + + + + ) : null, + }, + ]; + const modalTitle = + target?.action === "retry_grant" + ? "重试失败的权益发放" + : target?.action === "compensate" + ? "人工补偿失败的积分权益" + : "仅记录账务全额退款(不会调用支付网关)"; + return ( + <> + + resource="orders" + title="支付订单与权益发放" + columns={columns} + statusOptions={[ + "pending", + "paid", + "failed", + "granted", + "refunded", + "recorded", + ].map((value) => ({ value, label: value }))} + /> + setTarget(null)} + onSubmit={adjust} + /> + + ); +} + +type Usage = { + id: string; + userId: string; + email: string | null; + requestId: string; + featureKey: string; + source: string; + requestedModelId: string | null; + actualModelId: string | null; + modelConfigVersion: number | null; + inputTokens: number; + outputTokens: number; + costMicrousd: number; + durationMs: number | null; + createdAt: string; +}; + +export function UsageResource() { + const columns: TableColumnsType = [ + { + title: "请求", + render: (_, item) => ( + + {item.requestId} + {item.email ?? item.userId} + + ), + }, + { title: "功能", dataIndex: "featureKey" }, + { + title: "资金来源", + dataIndex: "source", + render: (value) => ( + {value} + ), + }, + { + title: "模型", + render: (_, item) => ( + + {item.actualModelId ?? "—"} + + 请求 {item.requestedModelId ?? "—"} · v + {item.modelConfigVersion ?? "—"} + + + ), + }, + { + title: "Token", + render: (_, item) => + `${item.inputTokens.toLocaleString()} / ${item.outputTokens.toLocaleString()}`, + }, + { + title: "成本", + dataIndex: "costMicrousd", + render: (value: number) => `$${(value / 1_000_000).toFixed(6)}`, + }, + { + title: "耗时", + dataIndex: "durationMs", + render: (value) => (value == null ? "—" : `${value} ms`), + }, + { title: "时间", dataIndex: "createdAt", render: formatAdminDate }, + ]; + return ( + + resource="usage" + title="用量、Token 与成本" + columns={columns} + statusOptions={[ + "subscription", + "credits", + "complimentary", + "chat.standard", + "rectification", + ].map((value) => ({ value, label: value }))} + /> + ); +} + +type ModelRelease = { + id: string; + modelId: string; + fromVersion: number | null; + toVersion: number; + action: string; + actorEmail: string | null; + reason: string; + requestId: string; + createdAt: string; +}; + +export function ModelReleasesResource() { + const columns: TableColumnsType = [ + { title: "模型", dataIndex: "modelId" }, + { + title: "版本", + render: (_, item) => `${item.fromVersion ?? "—"} → ${item.toVersion}`, + }, + { + title: "动作", + dataIndex: "action", + render: (value) => ( + {value} + ), + }, + { + title: "管理员", + dataIndex: "actorEmail", + render: (value) => value ?? "—", + }, + { title: "原因", dataIndex: "reason" }, + { + title: "请求 ID", + dataIndex: "requestId", + render: (value) => {value}, + }, + { title: "时间", dataIndex: "createdAt", render: formatAdminDate }, + ]; + return ( + + resource="model-releases" + title="模型发布与回滚历史" + columns={columns} + statusOptions={["publish", "rollback"].map((value) => ({ + value, + label: value, + }))} + /> + ); +} diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx index 28e14515..e34a9f43 100644 --- a/frontend/src/components/admin/codes-resource.tsx +++ b/frontend/src/components/admin/codes-resource.tsx @@ -1,12 +1,33 @@ "use client"; -import { useCreate, useDelete, useGetIdentity, usePermissions, useUpdate } from "@refinedev/core"; -import { Button, DatePicker, Form, Input, InputNumber, Modal, Space, Tag, Typography, type TableColumnsType } from "antd"; +import { + useCreate, + useGetIdentity, + useInvalidate, + useUpdate, +} from "@refinedev/core"; +import { + App, + Button, + DatePicker, + Form, + Input, + InputNumber, + Modal, + Space, + Tag, + Typography, + type TableColumnsType, +} from "antd"; import dayjs from "dayjs"; import { useState } from "react"; -import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; -import type { AdminIdentity } from "@/lib/admin/providers"; +import { ReasonActionModal } from "@/components/admin/reason-action-modal"; +import { + formatAdminDate, + ResourceTable, +} from "@/components/admin/resource-table"; +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; type CodeRecord = { id: string; @@ -28,7 +49,10 @@ type CreateValues = { expiresAt?: ReturnType; note?: string; }; -type EditValues = { note?: string; expiresAt?: ReturnType | null }; +type EditValues = { + note?: string; + expiresAt?: ReturnType | null; +}; const statusColors: Record = { available: "green", @@ -38,90 +62,144 @@ const statusColors: Record = { }; export default function CodesPage() { - const { data: role } = usePermissions<"admin">({}); + const { message } = App.useApp(); + const invalidate = useInvalidate(); const { data: identity } = useGetIdentity(); - const { mutate: createCodes, mutation: createMutation } = useCreate<{ id: string; generated: CodeRecord[] }>(); - const { mutate: updateCode, mutation: updateMutation } = useUpdate(); - const { mutate: revokeCode, mutation: revokeMutation } = useDelete(); + const { mutateAsync: createCodes, mutation: createMutation } = useCreate<{ + id: string; + generated: CodeRecord[]; + }>(); + const { mutateAsync: updateCode, mutation: updateMutation } = + useUpdate(); const [createOpen, setCreateOpen] = useState(false); + const [pendingCreate, setPendingCreate] = useState(null); const [editRecord, setEditRecord] = useState(null); + const [pendingEdit, setPendingEdit] = useState(null); const [generated, setGenerated] = useState([]); + const [revokeRecord, setRevokeRecord] = useState(null); + const [revoking, setRevoking] = useState(false); const [createForm] = Form.useForm(); const [editForm] = Form.useForm(); - const writable = role === "admin"; + const writable = Boolean( + identity?.permissions.includes("billing.adjustments.write"), + ); - function submitCreate(values: CreateValues) { - createCodes({ + async function submitCreate(reason: string) { + if (!pendingCreate) return; + const result = await createCodes({ resource: "codes", values: { - credits: values.credits, - count: values.count, - expiresAt: values.expiresAt?.toISOString() ?? null, - note: values.note?.trim() || null, + credits: pendingCreate.credits, + count: pendingCreate.count, + expiresAt: pendingCreate.expiresAt?.toISOString() ?? null, + note: pendingCreate.note?.trim() || null, + reason, }, successNotification: false, - }, { - onSuccess(result) { - setGenerated(result.data.generated); - setCreateOpen(false); - createForm.resetFields(); - }, }); + setGenerated(result.data.generated); + setPendingCreate(null); + setCreateOpen(false); + createForm.resetFields(); } - function submitEdit(values: EditValues) { - if (!editRecord) return; - updateCode({ + async function submitEdit(reason: string) { + if (!editRecord || !pendingEdit) return; + await updateCode({ resource: "codes", id: editRecord.id, values: { - note: values.note?.trim() || null, - expiresAt: values.expiresAt?.toISOString() ?? null, + note: pendingEdit.note?.trim() || null, + expiresAt: pendingEdit.expiresAt?.toISOString() ?? null, + reason, }, - }, { onSuccess: () => setEditRecord(null) }); + }); + setPendingEdit(null); + setEditRecord(null); } - function confirmRevoke(record: CodeRecord) { - Modal.confirm({ - title: "撤销此兑换码?", - content: `${record.mask} 撤销后不可兑换,且不能恢复。`, - okText: "确认撤销", - okButtonProps: { danger: true }, - cancelText: "取消", - onOk: () => new Promise((resolve, reject) => { - revokeCode({ resource: "codes", id: record.id }, { - onSuccess: () => resolve(), - onError: () => reject(new Error("撤销失败")), - }); - }), - }); + async function revoke(record: CodeRecord, reason: string) { + setRevoking(true); + try { + await adminRequestJson(`/api/admin/codes/${record.id}`, { + method: "DELETE", + headers: { "x-request-id": crypto.randomUUID() }, + body: JSON.stringify({ reason }), + }); + await invalidate({ resource: "codes", invalidates: ["list"] }); + message.success("兑换码已撤销"); + setRevokeRecord(null); + } catch (error) { + message.error(error instanceof Error ? error.message : "撤销失败"); + } finally { + setRevoking(false); + } } const columns: TableColumnsType = [ { title: "兑换码", dataIndex: "mask" }, { title: "点数", dataIndex: "credits", sorter: true }, - { title: "状态", dataIndex: "status", sorter: true, render: (value) => {value} }, - { title: "到期时间", dataIndex: "expiresAt", sorter: true, render: formatAdminDate }, + { + title: "状态", + dataIndex: "status", + sorter: true, + render: (value) => ( + {value} + ), + }, + { + title: "到期时间", + dataIndex: "expiresAt", + sorter: true, + render: formatAdminDate, + }, { title: "备注", dataIndex: "note", render: (value) => value || "—" }, - { title: "兑换账户", dataIndex: "redeemedEmail", render: (value) => value || "—" }, + { + title: "兑换账户", + dataIndex: "redeemedEmail", + render: (value) => value || "—", + }, { title: "兑换时间", dataIndex: "redeemedAt", render: formatAdminDate }, { title: "撤销时间", dataIndex: "revokedAt", render: formatAdminDate }, - { title: "创建时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + { + title: "创建时间", + dataIndex: "createdAt", + sorter: true, + render: formatAdminDate, + }, { title: "操作", fixed: "right", - render: (_, record) => writable && record.status !== "redeemed" && record.status !== "revoked" ? ( - - - - - ) : "—", + render: (_, record) => + writable && + record.status !== "redeemed" && + record.status !== "revoked" ? ( + + + + + ) : ( + "—" + ), }, ]; @@ -129,7 +207,7 @@ export default function CodesPage() { <> resource="codes" - title={`兑换码${identity ? ` · ${identity.email} (${identity.role})` : ""}`} + title={`兑换码${identity ? ` · ${identity.email} (${identity.roles.join("、")})` : ""}`} columns={columns} statusOptions={[ { label: "可用", value: "available" }, @@ -137,31 +215,135 @@ export default function CodesPage() { { label: "已兑换", value: "redeemed" }, { label: "已撤销", value: "revoked" }, ]} - extra={writable ? : null} + extra={ + writable ? ( + + ) : null + } /> - setCreateOpen(false)} footer={null} destroyOnHidden> -
- - - - - + { + setPendingCreate(null); + setCreateOpen(false); + }} + footer={null} + destroyOnHidden + > + + + + + + + + + + + + + + - 0} onCancel={() => setGenerated([])} footer={}> - 关闭后无法再次查看完整兑换码,请立即安全保存。 - {generated.map((record) => {record.code})} + 0} + onCancel={() => setGenerated([])} + footer={} + > + + 关闭后无法再次查看完整兑换码,请立即安全保存。 + + {generated.map((record) => ( + + {record.code} + + ))} - setEditRecord(null)} footer={null} destroyOnHidden> -
- - - + { + setPendingEdit(null); + setEditRecord(null); + }} + footer={null} + destroyOnHidden + > + + + + + + + + + setPendingCreate(null)} + onSubmit={submitCreate} + /> + setPendingEdit(null)} + onSubmit={submitEdit} + /> + setRevokeRecord(null)} + onSubmit={(reason) => revoke(revokeRecord!, reason)} + /> ); } diff --git a/frontend/src/components/admin/feature-flags-management.tsx b/frontend/src/components/admin/feature-flags-management.tsx new file mode 100644 index 00000000..906cead1 --- /dev/null +++ b/frontend/src/components/admin/feature-flags-management.tsx @@ -0,0 +1,170 @@ +"use client"; + +import { PlusOutlined } from "@ant-design/icons"; +import { useGetIdentity } from "@refinedev/core"; +import { useTable } from "@refinedev/antd"; +import { List } from "@refinedev/antd"; +import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, Tag, Typography, type TableColumnsType } from "antd"; +import { useState } from "react"; + +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { ReasonActionModal } from "./reason-action-modal"; +import { formatAdminDate } from "./resource-table"; + +const { Text } = Typography; + +type FeatureFlag = { + id: string; + flagKey: string; + version: number; + enabled: boolean; + rolloutPercentage: number; + config: Record; + status: string; + createdAt: string; + publishedAt: string | null; +}; + +type FlagFilters = { q?: string; status?: string }; + +type FlagForm = { + flagKey: string; + enabled: boolean; + rolloutPercentage: number; + configJson: string; + reason: string; +}; + +export default function FeatureFlagsManagement() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const table = useTable({ + resource: "feature-flags", + syncWithLocation: true, + pagination: { pageSize: 20 }, + onSearch: ({ q, status }) => [ + { field: "q", operator: "contains", value: q }, + { field: "status", operator: "eq", value: status }, + ], + }); + const [form] = Form.useForm(); + const [editing, setEditing] = useState(null); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [publishingId, setPublishingId] = useState(null); + const [publishTarget, setPublishTarget] = useState(null); + const canWrite = Boolean(identity?.permissions.includes("ops.flags.write")); + + function edit(item?: FeatureFlag) { + setEditing(item ?? null); + form.setFieldsValue(item ? { + flagKey: item.flagKey, + enabled: item.enabled, + rolloutPercentage: item.rolloutPercentage, + configJson: JSON.stringify(item.config, null, 2), + reason: "", + } : { + flagKey: "", + enabled: false, + rolloutPercentage: 0, + configJson: "{}", + reason: "", + }); + setOpen(true); + } + + async function save(values: FlagForm) { + setSaving(true); + try { + let config: unknown; + try { + config = JSON.parse(values.configJson); + } catch { + throw new Error("配置 JSON 格式不正确"); + } + await adminRequestJson("/api/admin/feature-flags", { + method: "POST", + body: JSON.stringify({ + action: "save", + id: editing?.id ?? null, + flagKey: values.flagKey.trim(), + enabled: values.enabled, + rolloutPercentage: values.rolloutPercentage, + config, + expectedVersion: editing?.version ?? null, + reason: values.reason.trim(), + }), + }); + message.success("功能开关草稿已保存"); + setOpen(false); + await table.tableQuery.refetch(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存失败"); + } finally { + setSaving(false); + } + } + + async function publish(item: FeatureFlag, reason: string) { + setPublishingId(item.id); + try { + await adminRequestJson("/api/admin/feature-flags", { + method: "POST", + body: JSON.stringify({ action: "publish", id: item.id, expectedVersion: item.version, reason }), + }); + message.success("功能开关已发布"); + await table.tableQuery.refetch(); + setPublishTarget(null); + } finally { + setPublishingId(null); + } + } + + const columns: TableColumnsType = [ + { title: "开关", render: (_, item) => {item.flagKey}v{item.version} }, + { title: "状态", render: (_, item) => {item.status}{item.enabled ? 开启 : 关闭} }, + { title: "灰度", dataIndex: "rolloutPercentage", render: (value) => `${value}%` }, + { title: "配置", dataIndex: "config", render: (value) => {JSON.stringify(value)} }, + { title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate }, + { + title: "操作", + fixed: "right", + render: (_, item) => {canWrite && }{canWrite && item.status !== "published" && }, + }, + ]; + + return } onClick={() => edit()}>新增开关 : null}> + +
+ + +
+ + form.submit()} onCancel={() => setOpen(false)} destroyOnHidden> + form={form} layout="vertical" onFinish={save}> + + + + + + + + setPublishTarget(null)} + onSubmit={(reason) => publish(publishTarget!, reason)} + /> + ; +} diff --git a/frontend/src/components/admin/mfa-security.tsx b/frontend/src/components/admin/mfa-security.tsx new file mode 100644 index 00000000..9239fe2d --- /dev/null +++ b/frontend/src/components/admin/mfa-security.tsx @@ -0,0 +1,279 @@ +"use client"; + +import { + Alert, + Button, + Card, + Descriptions, + Divider, + Form, + Input, + List, + Radio, + Space, + Spin, + Tag, + Typography, +} from "antd"; +import { useEffect, useState } from "react"; + +const { Paragraph, Text, Title } = Typography; + +type MfaStatus = { + required: boolean; + enrolled: boolean; + verified: boolean; + highRiskWritesEnabled: boolean; + expiresIn?: number; +}; + +type Enrollment = { + totpUri: string; + backupCodes: string[]; + verificationRequired: true; +}; + +type Factor = "totp" | "backup"; + +async function mfaRequest(body?: object): Promise { + const response = await fetch("/api/admin/mfa", { + method: body ? "POST" : "GET", + cache: "no-store", + credentials: "same-origin", + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const payload = await response.json().catch(() => null) as { data?: T; error?: unknown } | null; + if (!response.ok || !payload?.data) { + throw new Error(typeof payload?.error === "string" ? payload.error : "MFA 请求失败,请稍后再试"); + } + return payload.data; +} + +export default function MfaSecurity() { + const [status, setStatus] = useState(); + const [enrollment, setEnrollment] = useState(); + const [backupCodes, setBackupCodes] = useState(); + const [factor, setFactor] = useState("totp"); + const [loading, setLoading] = useState(true); + const [action, setAction] = useState(); + const [error, setError] = useState(); + const [notice, setNotice] = useState(); + + useEffect(() => { + let cancelled = false; + void mfaRequest() + .then((value) => { + if (!cancelled) setStatus(value); + }) + .catch((cause: unknown) => { + if (!cancelled) { + setError(cause instanceof Error ? cause.message : "无法读取 MFA 状态"); + } + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + return () => { + cancelled = true; + }; + }, []); + + async function run(name: string, operation: () => Promise): Promise { + setAction(name); + setError(undefined); + setNotice(undefined); + try { + return await operation(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "操作失败,请稍后再试"); + } finally { + setAction(undefined); + } + } + + async function enroll({ password }: { password: string }) { + const data = await run("enroll", () => mfaRequest({ action: "enroll", password })); + if (!data) return; + setEnrollment(data); + setBackupCodes(data.backupCodes); + setNotice("请先保存恢复码,再使用认证器生成的 6 位验证码完成 enrollment。未验证前 MFA 不会启用。"); + } + + async function verify({ code }: { code: string }) { + const data = await run("verify", () => mfaRequest({ action: "verify", code })); + if (!data) return; + setStatus(data); + setEnrollment(undefined); + setNotice("当前管理员 session 已完成真实第二因素验证。高风险操作仍需随后完成权限范围内的邮箱验证码。"); + } + + async function recover({ code }: { code: string }) { + const data = await run("recover", () => mfaRequest({ action: "recover", code })); + if (!data) return; + setStatus(data); + setNotice("恢复码已消费,当前 session 已完成 MFA 验证。请在恢复访问后重新生成恢复码。"); + } + + async function regenerate({ password }: { password: string }) { + const data = await run("regenerate", () => mfaRequest<{ backupCodes: string[] }>({ + action: "regenerate", + password, + })); + if (!data) return; + setBackupCodes(data.backupCodes); + setNotice("新的恢复码已生成,旧恢复码已全部失效。请立即离线保存。"); + } + + async function disable({ password }: { password: string }) { + const data = await run("disable", () => mfaRequest({ action: "disable", password })); + if (!data) return; + setStatus(data); + setEnrollment(undefined); + setBackupCodes(undefined); + setNotice(data.required + ? "MFA 已禁用。该角色要求 MFA,因此高风险写入现已关闭,重新 enrollment 后才能恢复。" + : "MFA 已禁用,当前 MFA 与邮箱重认证证明均已撤销。"); + } + + if (loading && !status) { + return 正在读取 MFA 状态; + } + + return +
+ 安全验证 + + 管理员 MFA 使用 Better Auth 的真实 TOTP 与一次性恢复码。MFA 证明只绑定当前 server session,短时有效且可随 session 撤销。 + +
+ + {error ? : null} + {notice ? : null} + {status?.required && !status.enrolled ? : null} + + + + + {status?.required ? "必须 MFA" : "可选 MFA"} + + + {status?.enrolled ? "已启用" : "未启用"} + + + {status?.verified ? "已验证" : "未验证"} + + + + {status?.highRiskWritesEnabled ? "可发起邮箱重认证" : "已关闭"} + + + + + + {!status?.enrolled && !enrollment ? + 输入当前账户密码后生成认证器 URI 与一次性恢复码。服务器只保存加密后的 seed 与恢复码。 +
+ + + + + +
: null} + + {enrollment ? + + 认证器 URI + + {enrollment.totpUri} + + 一次性恢复码 + + +
+ + + + + +
: null} + + {status?.enrolled && !status.verified ? + 高权限操作前,先用认证器验证码或一次性恢复码完成真实第二因素。 + setFactor(event.target.value as Factor)}> + 认证器验证码 + 恢复码 + +
+ + + + + +
: null} + + {status?.enrolled && status.verified ? + +
+ 重新生成恢复码 + 生成后旧恢复码立即失效,明文只在本次响应显示。 +
+ + + + + +
+ {backupCodes?.length ? : null} + +
+ 禁用 MFA + 禁用会轮换 Better Auth session,并撤销当前 MFA 与邮箱重认证证明。 +
+ + + + + +
+
+
: null} +
; +} + +function BackupCodeList({ codes }: { codes: string[] }) { + return {code}} + />; +} diff --git a/frontend/src/components/admin/model-management.tsx b/frontend/src/components/admin/model-management.tsx new file mode 100644 index 00000000..24f6141d --- /dev/null +++ b/frontend/src/components/admin/model-management.tsx @@ -0,0 +1,408 @@ +"use client"; + +import { PlusOutlined } from "@ant-design/icons"; +import { useGetIdentity } from "@refinedev/core"; +import { List } from "@refinedev/antd"; +import { + App, + Button, + Card, + Col, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Switch, + Table, + Tag, + Typography, + type TableColumnsType, +} from "antd"; +import { useCallback, useEffect, useState } from "react"; + +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { ReasonActionModal } from "./reason-action-modal"; +import { formatAdminDate } from "./resource-table"; + +const { Text } = Typography; + +type Provider = { + id: string; + code: string; + name: string; + providerType: "openai" | "openai-compatible"; + baseUrl: string | null; + secretConfigured: boolean; + enabled: boolean; + updatedAt: string; +}; + +type ModelVersion = { + id: string; + configId: string; + modelId: string; + version: number; + providerId: string; + providerCode: string; + label: string; + description: string; + providerModel: string; + modelTier: "standard" | "premium" | "internal"; + creditCost: number; + contextWindow: number | null; + inputCostMicrousdPerMillion: number; + outputCostMicrousdPerMillion: number; + enabled: boolean; + isDefault: boolean; + fallbackModelId: string | null; + status: string; + settings: Record; + createdAt: string; + publishedAt: string | null; +}; + +type ProviderForm = { + code: string; + name: string; + providerType: "openai" | "openai-compatible"; + baseUrl: string | null; + enabled: boolean; +}; +type ModelForm = Omit & { + versionId?: string; + settingsJson: string; + reason: string; +}; + +type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[] }; +type ModelFilters = { q?: string; status?: string }; +type VersionAction = { action: "publish" | "rollback"; model: ModelVersion }; + +export default function ModelManagement() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const [providerForm] = Form.useForm(); + const [modelForm] = Form.useForm(); + const [filterForm] = Form.useForm(); + const [models, setModels] = useState([]); + const [providers, setProviders] = useState([]); + const [loading, setLoading] = useState(true); + const [providerOpen, setProviderOpen] = useState(false); + const [modelOpen, setModelOpen] = useState(false); + const [editingProvider, setEditingProvider] = useState(null); + const [editingModel, setEditingModel] = useState(null); + const [saving, setSaving] = useState(false); + const [actingId, setActingId] = useState(null); + const [versionAction, setVersionAction] = useState(null); + const [pendingProvider, setPendingProvider] = useState | null>(null); + const [filters, setFilters] = useState({}); + const canWrite = Boolean(identity?.permissions.includes("models.write")); + const canTest = Boolean(identity?.permissions.includes("models.test")); + const canPublish = Boolean(identity?.permissions.includes("models.publish")); + const canRollback = Boolean(identity?.permissions.includes("models.rollback")); + + const load = useCallback(async () => { + setLoading(true); + try { + const search = new URLSearchParams({ page: "1", pageSize: "100" }); + if (filters.q) search.set("q", filters.q); + if (filters.status) search.set("status", filters.status); + const payload = await adminRequestJson(`/api/admin/models?${search}`); + setModels(payload.data); + setProviders(payload.providers); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取模型配置失败"); + } finally { + setLoading(false); + } + }, [filters.q, filters.status, message]); + + useEffect(() => { + const timer = window.setTimeout(() => void load(), 0); + return () => window.clearTimeout(timer); + }, [load]); + + function openProvider(provider?: Provider) { + setEditingProvider(provider ?? null); + providerForm.setFieldsValue(provider ? { + ...provider, + baseUrl: provider.baseUrl, + } : { + code: "", + name: "", + providerType: "openai-compatible", + baseUrl: "https://", + enabled: false, + }); + setProviderOpen(true); + } + + function openModel(model?: ModelVersion) { + setEditingModel(model ?? null); + modelForm.setFieldsValue(model ? { + modelId: model.modelId, + versionId: model.id, + providerId: model.providerId, + label: model.label, + description: model.description, + providerModel: model.providerModel, + modelTier: model.modelTier, + creditCost: model.creditCost, + contextWindow: model.contextWindow, + inputCostMicrousdPerMillion: model.inputCostMicrousdPerMillion, + outputCostMicrousdPerMillion: model.outputCostMicrousdPerMillion, + enabled: model.enabled, + isDefault: model.isDefault, + fallbackModelId: model.fallbackModelId, + settingsJson: JSON.stringify(model.settings, null, 2), + reason: "", + } : { + modelId: "", + providerId: providers[0]?.id, + label: "", + description: "", + providerModel: "", + modelTier: "standard", + creditCost: 1, + contextWindow: null, + inputCostMicrousdPerMillion: 0, + outputCostMicrousdPerMillion: 0, + enabled: false, + isDefault: false, + fallbackModelId: null, + settingsJson: "{}", + reason: "", + }); + setModelOpen(true); + } + + function prepareProviderSave(values: ProviderForm) { + setPendingProvider({ + action: "saveProvider", + id: editingProvider?.id ?? null, + code: values.code.trim(), + name: values.name.trim(), + providerType: values.providerType, + baseUrl: values.providerType === "openai" ? null : values.baseUrl?.trim(), + enabled: values.enabled, + }); + } + + async function saveProvider(reason: string) { + if (!pendingProvider) return; + setSaving(true); + try { + await adminRequestJson("/api/admin/models", { + method: "POST", + body: JSON.stringify({ ...pendingProvider, reason }), + }); + message.success("供应商配置已保存"); + setPendingProvider(null); + setProviderOpen(false); + await load(); + } finally { + setSaving(false); + } + } + + async function saveModel(values: ModelForm) { + setSaving(true); + try { + let settings: unknown; + try { + settings = JSON.parse(values.settingsJson); + } catch { + throw new Error("设置 JSON 格式不正确"); + } + await adminRequestJson("/api/admin/models", { + method: "POST", + body: JSON.stringify({ + action: "saveDraft", + modelId: values.modelId.trim(), + versionId: editingModel?.id ?? null, + providerId: values.providerId, + label: values.label.trim(), + description: values.description?.trim() ?? "", + providerModel: values.providerModel.trim(), + modelTier: values.modelTier, + creditCost: values.creditCost, + contextWindow: values.contextWindow ?? null, + inputCostMicrousdPerMillion: values.inputCostMicrousdPerMillion, + outputCostMicrousdPerMillion: values.outputCostMicrousdPerMillion, + enabled: values.enabled, + isDefault: values.isDefault, + fallbackModelId: values.fallbackModelId?.trim() || null, + settings, + reason: values.reason.trim(), + }), + }); + message.success("模型草稿已保存"); + setModelOpen(false); + await load(); + } catch (error) { + message.error(error instanceof Error ? error.message : "保存失败"); + } finally { + setSaving(false); + } + } + + async function act(body: Record, success: string, id: string) { + setActingId(id); + try { + await adminRequestJson("/api/admin/models", { method: "POST", body: JSON.stringify(body) }); + message.success(success); + await load(); + } finally { + setActingId(null); + } + } + + async function submitVersionAction(reason: string) { + if (!versionAction) return; + const { action, model } = versionAction; + await act(action === "publish" + ? { action, versionId: model.id, reason } + : { action, configId: model.configId, targetVersion: model.version, reason }, + action === "publish" ? "模型已发布" : "模型已回滚", model.id); + setVersionAction(null); + } + + async function testVersion(item: ModelVersion) { + try { + await act({ action: "test", versionId: item.id }, "连接测试通过,可在短有效期内发布", item.id); + } catch (error) { + message.error(error instanceof Error ? error.message : "连接测试失败"); + } + } + + const providerColumns: TableColumnsType = [ + { title: "供应商", render: (_, item) => {item.name}{item.code} }, + { title: "类型", dataIndex: "providerType" }, + { title: "地址", dataIndex: "baseUrl", render: (value) => value ?? "OpenAI 官方" }, + { title: "密钥状态", render: (_, item) => {item.secretConfigured ? "已配置" : "未配置"} }, + { title: "状态", dataIndex: "enabled", render: (value) => value ? 启用 : 停用 }, + { title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate }, + { title: "操作", render: (_, item) => canWrite ? : null }, + ]; + + const modelColumns: TableColumnsType = [ + { title: "模型", render: (_, item) => {item.label}{item.modelId} · v{item.version} }, + { title: "供应商模型", render: (_, item) => `${item.providerCode} / ${item.providerModel}` }, + { title: "档位", dataIndex: "modelTier", render: (value) => {value} }, + { title: "点数", dataIndex: "creditCost" }, + { title: "成本/百万 Token", render: (_, item) => `$${(item.inputCostMicrousdPerMillion / 1_000_000).toFixed(4)} / $${(item.outputCostMicrousdPerMillion / 1_000_000).toFixed(4)}` }, + { title: "路由", render: (_, item) => {item.isDefault && 默认}fallback: {item.fallbackModelId ?? "—"} }, + { title: "状态", render: (_, item) => {item.status}{item.enabled ? 启用 : 停用} }, + { title: "发布时间", dataIndex: "publishedAt", render: formatAdminDate }, + { + title: "操作", + fixed: "right", + render: (_, item) => + {canWrite && } + {canTest && } + {canPublish && item.status !== "published" && } + {canRollback && item.status !== "draft" && } + , + }, + ]; + + return + + } onClick={() => openProvider()}>新增供应商 : null}> +
+ + } onClick={() => openModel()} disabled={!providers.length}>新增模型草稿 : null}> + + + form={filterForm} + layout="inline" + style={{ rowGap: 8 }} + onFinish={(values) => setFilters({ + q: values.q?.trim() || undefined, + status: values.status || undefined, + })} + > + + + + +
+ + + + + providerForm.submit()} onCancel={() => setProviderOpen(false)} destroyOnHidden> + form={providerForm} layout="vertical" onFinish={prepareProviderSave}> + + : null} + 密钥引用由服务器按供应商类型与代码固定映射;控制台不能指定或读取环境变量。 + + + + + modelForm.submit()} onCancel={() => setModelOpen(false)} destroyOnHidden> + form={modelForm} layout="vertical" onFinish={saveModel}> + + + + + + + ({ validator(_, value) { return value && !getFieldValue("enabled") ? Promise.reject(new Error("默认模型必须启用")) : Promise.resolve(); } })]}> + + + + setPendingProvider(null)} + onSubmit={saveProvider} + /> + setVersionAction(null)} + onSubmit={submitVersionAction} + /> + ; +} diff --git a/frontend/src/components/admin/payment-management.tsx b/frontend/src/components/admin/payment-management.tsx index cedadbcd..26b98e3b 100644 --- a/frontend/src/components/admin/payment-management.tsx +++ b/frontend/src/components/admin/payment-management.tsx @@ -1,6 +1,7 @@ "use client"; import { List } from "@refinedev/antd"; +import { useGetIdentity } from "@refinedev/core"; import { Alert, App, @@ -24,6 +25,9 @@ import { import type { Dayjs } from "dayjs"; import { useCallback, useEffect, useState } from "react"; +import { ReasonActionModal } from "@/components/admin/reason-action-modal"; +import type { AdminIdentity } from "@/lib/admin/providers"; + const { Text } = Typography; type Order = { @@ -90,6 +94,7 @@ async function responsePayload(response: Response) { export default function PaymentManagement() { const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); const [filterForm] = Form.useForm(); const [epayForm] = Form.useForm(); const [orders, setOrders] = useState([]); @@ -104,6 +109,8 @@ export default function PaymentManagement() { const [epaySaving, setEpaySaving] = useState(false); const [epayTesting, setEpayTesting] = useState(false); const [epayError, setEpayError] = useState(""); + const [pendingEpaySettings, setPendingEpaySettings] = useState(null); + const canAdjustBilling = Boolean(identity?.permissions.includes("billing.adjustments.write")); const loadPayments = useCallback(async () => { setPaymentLoading(true); @@ -168,19 +175,23 @@ export default function PaymentManagement() { } } - async function saveEpaySettings(values: EpaySettingsForm) { + function prepareEpaySettings(values: EpaySettingsForm) { + setPendingEpaySettings(values); + } + + async function saveEpaySettings() { + if (!pendingEpaySettings) return; setEpaySaving(true); try { await responsePayload(await fetch("/api/admin/epay-settings", { method: "PUT", headers: { "content-type": "application/json" }, - body: JSON.stringify({ ...values, newKey: values.newKey || undefined }), + body: JSON.stringify({ ...pendingEpaySettings, newKey: pendingEpaySettings.newKey || undefined }), })); epayForm.setFieldValue("newKey", ""); message.success("Z-Pay(易支付)配置已保存"); await loadEpaySettings(); - } catch (error) { - message.error(error instanceof Error ? error.message : "保存易支付配置失败"); + setPendingEpaySettings(null); } finally { setEpaySaving(false); } @@ -219,14 +230,14 @@ export default function PaymentManagement() { children: ( } + extra={canAdjustBilling ? : null} > 配置兼容标准 Z-Pay / 易支付协议的支付网关、商户凭据、回调地址与对话页开关。 {epaySettings && 来源:{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]}{epaySettings.complete ? "配置完整" : "配置不完整"}{epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"}{epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}} {epayError && void loadEpaySettings()}>重试} />} - form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional"> + form={epayForm} layout="vertical" onFinish={prepareEpaySettings} requiredMark="optional"> @@ -266,6 +277,15 @@ export default function PaymentManagement() { + setPendingEpaySettings(null)} + onSubmit={saveEpaySettings} + /> ); } diff --git a/frontend/src/components/admin/product-management.tsx b/frontend/src/components/admin/product-management.tsx new file mode 100644 index 00000000..7b2a3ce4 --- /dev/null +++ b/frontend/src/components/admin/product-management.tsx @@ -0,0 +1,273 @@ +"use client"; + +import { PlusOutlined } from "@ant-design/icons"; +import { useGetIdentity } from "@refinedev/core"; +import { useTable } from "@refinedev/antd"; +import { List } from "@refinedev/antd"; +import { + App, + Button, + Col, + Form, + Input, + InputNumber, + Modal, + Row, + Select, + Space, + Switch, + Table, + Tag, + Typography, + type TableColumnsType, +} from "antd"; +import { useState } from "react"; + +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; +import { ReasonActionModal } from "./reason-action-modal"; +import { formatAdminDate } from "./resource-table"; + +const { Text } = Typography; + +type Entitlement = { + featureKey: string; + allowanceType: string; + allowanceCount: number | null; + resetPeriod: string; + modelTier?: string | null; + fairUsePolicyId?: string | null; + metadata: Record; +}; + +type Product = { + id: string; + code: string; + version: number; + name: string; + description: string; + productType: "credit_pack" | "trial" | "subscription"; + billingPeriod: "none" | "day" | "month" | "year"; + intervalCount: number; + priceCents: number; + currency: string; + enabled: boolean; + status: string; + sortOrder: number; + oneTimePerUser: boolean; + effectiveFrom: string | null; + updatedAt: string; + entitlements: Entitlement[]; +}; + +type ProductForm = Omit & { + id?: string; + priceYuan: number; + entitlementsJson: string; +}; + +type PendingProductSave = Record; +type ProductFilters = { q?: string; status?: string }; + +const defaultEntitlements: Entitlement[] = [{ + featureKey: "chat.standard", + allowanceType: "unlimited", + allowanceCount: null, + resetPeriod: "billing_period", + modelTier: "standard", + fairUsePolicyId: null, + metadata: { minuteLimit: 6, dayLimit: 100 }, +}]; + +export default function ProductManagement() { + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const table = useTable({ + resource: "products", + syncWithLocation: true, + pagination: { pageSize: 20 }, + onSearch: ({ q, status }) => [ + { field: "q", operator: "contains", value: q }, + { field: "status", operator: "eq", value: status }, + ], + }); + const [form] = Form.useForm(); + const [editing, setEditing] = useState(null); + const [open, setOpen] = useState(false); + const [saving, setSaving] = useState(false); + const [publishingId, setPublishingId] = useState(null); + const [publishTarget, setPublishTarget] = useState(null); + const [pendingSave, setPendingSave] = useState(null); + const canWrite = Boolean(identity?.permissions.includes("billing.products.write")); + const canPublish = Boolean(identity?.permissions.includes("billing.products.publish")); + + function openProduct(product?: Product) { + setEditing(product ?? null); + form.setFieldsValue(product ? { + id: product.id, + code: product.code, + name: product.name, + description: product.description, + productType: product.productType, + billingPeriod: product.billingPeriod, + intervalCount: product.intervalCount, + priceYuan: product.priceCents / 100, + currency: product.currency, + enabled: product.enabled, + sortOrder: product.sortOrder, + oneTimePerUser: product.oneTimePerUser, + entitlementsJson: JSON.stringify(product.entitlements, null, 2), + } : { + code: "", + name: "", + description: "", + productType: "subscription", + billingPeriod: "month", + intervalCount: 1, + priceYuan: 99, + currency: "CNY", + enabled: false, + sortOrder: 0, + oneTimePerUser: false, + entitlementsJson: JSON.stringify(defaultEntitlements, null, 2), + }); + setOpen(true); + } + + function prepareSave(values: ProductForm) { + let entitlements: unknown; + try { + entitlements = JSON.parse(values.entitlementsJson); + } catch { + message.error("权益 JSON 格式不正确"); + return; + } + setPendingSave({ + action: "save", + id: editing?.id ?? null, + code: values.code.trim(), + name: values.name.trim(), + description: values.description?.trim() ?? "", + productType: values.productType, + billingPeriod: values.billingPeriod, + intervalCount: values.intervalCount, + priceCents: Math.round(values.priceYuan * 100), + currency: values.currency.toUpperCase(), + enabled: values.enabled, + sortOrder: values.sortOrder, + oneTimePerUser: values.oneTimePerUser, + entitlements, + }); + } + + async function save(reason: string) { + if (!pendingSave) return; + setSaving(true); + try { + await adminRequestJson("/api/admin/products", { + method: "POST", + body: JSON.stringify({ ...pendingSave, reason }), + }); + message.success("商品草稿已保存"); + setPendingSave(null); + setOpen(false); + form.resetFields(); + await table.tableQuery.refetch(); + } finally { + setSaving(false); + } + } + + async function publish(product: Product, reason: string) { + setPublishingId(product.id); + try { + await adminRequestJson("/api/admin/products", { + method: "POST", + body: JSON.stringify({ action: "publish", id: product.id, reason }), + }); + message.success("商品已发布"); + await table.tableQuery.refetch(); + setPublishTarget(null); + } finally { + setPublishingId(null); + } + } + + const columns: TableColumnsType = [ + { + title: "商品", + dataIndex: "name", + render: (_, item) => {item.name}{item.code} · v{item.version}, + }, + { title: "类型", dataIndex: "productType", render: (value) => {value} }, + { title: "周期", render: (_, item) => item.billingPeriod === "none" ? "—" : `${item.intervalCount} ${item.billingPeriod}` }, + { title: "价格", render: (_, item) => `¥${(item.priceCents / 100).toFixed(2)}` }, + { title: "权益", dataIndex: "entitlements", render: (items: Entitlement[]) => {items.map((item) => {item.featureKey}: {item.allowanceType})} }, + { title: "状态", render: (_, item) => {item.status}{item.enabled ? 可售 : 停用} }, + { title: "更新时间", dataIndex: "updatedAt", render: formatAdminDate }, + { + title: "操作", + fixed: "right", + render: (_, item) => + {canWrite && } + {canPublish && item.status !== "published" && } + , + }, + ]; + + return } onClick={() => openProduct()}>新建商品 : null}> + +
+ + +
+ + form.submit()} onCancel={() => setOpen(false)} destroyOnHidden> + form={form} layout="vertical" onFinish={prepareSave} requiredMark="optional"> + + + + + + + ({ value, label: value }))} /> + + + + + + + + + + + + + setPendingSave(null)} + onSubmit={save} + /> + setPublishTarget(null)} + onSubmit={(reason) => publish(publishTarget!, reason)} + /> + ; +} diff --git a/frontend/src/components/admin/reason-action-modal.tsx b/frontend/src/components/admin/reason-action-modal.tsx new file mode 100644 index 00000000..bc4d48d2 --- /dev/null +++ b/frontend/src/components/admin/reason-action-modal.tsx @@ -0,0 +1,253 @@ +"use client"; + +import { Alert, Button, Form, Input, Modal, Radio, Space, Spin, Typography } from "antd"; +import Link from "next/link"; +import { useState } from "react"; + +import type { AdminPermission } from "@/lib/admin/auth-policy"; + +const { Text } = Typography; + +type ReasonActionModalProps = { + open: boolean; + title: string; + okText: string; + confirmLoading?: boolean; + danger?: boolean; + reauthPermission?: AdminPermission; + onCancel: () => void; + onSubmit: (reason: string) => void | Promise; +}; + +type FormValues = { reason: string; otp?: string; mfaCode?: string }; +type MfaFactor = "totp" | "backup"; +type MfaStatus = { + required: boolean; + enrolled: boolean; + verified: boolean; + highRiskWritesEnabled: boolean; +}; + +async function adminSecurityRequest(url: string, body?: object): Promise { + const response = await fetch(url, { + method: body ? "POST" : "GET", + cache: "no-store", + credentials: "same-origin", + headers: body ? { "content-type": "application/json" } : undefined, + body: body ? JSON.stringify(body) : undefined, + }); + const value = await response.json().catch(() => null) as { + data?: T; + error?: unknown; + } | null; + if (response.ok && value?.data) return value.data; + throw new Error( + typeof value?.error === "string" ? value.error : "安全验证失败,请稍后再试", + ); +} + +export function ReasonActionModal({ + open, + title, + okText, + confirmLoading = false, + danger = false, + reauthPermission, + onCancel, + onSubmit, +}: ReasonActionModalProps) { + const [form] = Form.useForm(); + const [mfaStatus, setMfaStatus] = useState(); + const [mfaFactor, setMfaFactor] = useState("totp"); + const [mfaLoading, setMfaLoading] = useState(false); + const [otpSent, setOtpSent] = useState(false); + const [reauthLoading, setReauthLoading] = useState(false); + const [reauthError, setReauthError] = useState(); + + const mfaReady = !reauthPermission + || (mfaStatus !== undefined && (!mfaStatus.required || mfaStatus.verified)); + + async function loadMfaStatus() { + if (!reauthPermission) return; + setMfaLoading(true); + setReauthError(undefined); + try { + setMfaStatus(await adminSecurityRequest("/api/admin/mfa")); + } catch (error) { + setMfaStatus(undefined); + setReauthError(error instanceof Error ? error.message : "无法读取 MFA 状态"); + } finally { + setMfaLoading(false); + } + } + + async function verifyMfa() { + const code = form.getFieldValue("mfaCode")?.trim(); + if (!code) { + setReauthError(mfaFactor === "totp" ? "请输入 6 位认证器验证码" : "请输入恢复码"); + return; + } + if (mfaFactor === "totp" && !/^\d{6}$/.test(code)) { + setReauthError("请输入 6 位认证器验证码"); + return; + } + + setMfaLoading(true); + setReauthError(undefined); + try { + const status = await adminSecurityRequest("/api/admin/mfa", { + action: mfaFactor === "totp" ? "verify" : "recover", + code, + }); + setMfaStatus(status); + form.setFieldValue("mfaCode", undefined); + } catch (error) { + setReauthError(error instanceof Error ? error.message : "MFA 验证失败"); + } finally { + setMfaLoading(false); + } + } + + async function requestOtp() { + if (!reauthPermission || !mfaReady) return; + setReauthLoading(true); + setReauthError(undefined); + try { + await adminSecurityRequest("/api/admin/reauth", { + action: "request", + permission: reauthPermission, + }); + setOtpSent(true); + } catch (error) { + setReauthError(error instanceof Error ? error.message : "验证码发送失败"); + } finally { + setReauthLoading(false); + } + } + + async function submit({ reason, otp }: FormValues) { + setReauthError(undefined); + setReauthLoading(true); + try { + if (reauthPermission) { + await adminSecurityRequest("/api/admin/reauth", { + action: "verify", + permission: reauthPermission, + otp, + }); + } + await onSubmit(reason.trim()); + } catch (error) { + setReauthError(error instanceof Error ? error.message : "操作失败,请稍后再试"); + } finally { + setReauthLoading(false); + } + } + + return form.submit()} + onCancel={onCancel} + afterOpenChange={(visible) => { + if (visible) { + form.resetFields(); + void loadMfaStatus(); + } + setMfaStatus(undefined); + setMfaFactor("totp"); + setOtpSent(false); + setReauthError(undefined); + }} + destroyOnHidden + > +
+ + + + + {reauthPermission && mfaLoading && !mfaStatus ? + + 正在确认当前 session 的 MFA 状态 + : null} + + {reauthPermission && mfaStatus?.required && !mfaStatus.enrolled ? + 高风险写入已 fail closed。请先前往 安全验证 完成 enrollment。 + } + style={{ marginBottom: 16 }} + /> : null} + + {reauthPermission && mfaStatus?.required && mfaStatus.enrolled && !mfaStatus.verified ? + + { + setMfaFactor(event.target.value as MfaFactor); + form.setFieldValue("mfaCode", undefined); + }} + > + 认证器验证码 + 恢复码 + + + + { + event.preventDefault(); + void verifyMfa(); + }} + /> + + + + : null} + + {reauthPermission && mfaReady ? + + + + + + + : null} + + {reauthError ? : null} + +
; +} diff --git a/frontend/src/components/admin/resource-table.tsx b/frontend/src/components/admin/resource-table.tsx index 95e596b5..cb5fc7c1 100644 --- a/frontend/src/components/admin/resource-table.tsx +++ b/frontend/src/components/admin/resource-table.tsx @@ -37,10 +37,10 @@ export function ResourceTable({
- + {statusOptions && ( - - )} diff --git a/frontend/src/components/admin/roles-resource.tsx b/frontend/src/components/admin/roles-resource.tsx new file mode 100644 index 00000000..166e3876 --- /dev/null +++ b/frontend/src/components/admin/roles-resource.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { useTable } from "@refinedev/antd"; +import { Alert, Card, Space, Table, Tag } from "antd"; + +interface RoleRecord { + id: string; + code: string; + name: string; + description: string; + requiresMfa: boolean; + permissions: string[]; +} + +export default function RolesResource() { + const table = useTable({ resource: "roles" }); + return ( + + +
value ? 必需 : 普通 }, + { title: "权限", dataIndex: "permissions", render: (permissions: string[]) => {permissions.map((permission) => {permission})} }, + ]} + /> + + ); +} diff --git a/frontend/src/components/admin/users-resource.tsx b/frontend/src/components/admin/users-resource.tsx index a4e93281..9e0124e1 100644 --- a/frontend/src/components/admin/users-resource.tsx +++ b/frontend/src/components/admin/users-resource.tsx @@ -1,8 +1,13 @@ "use client"; -import { Tag, type TableColumnsType } from "antd"; +import { useGetIdentity } from "@refinedev/core"; +import { App, Button, Space, Tag, Typography, type TableColumnsType } from "antd"; +import { useState } from "react"; import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table"; +import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers"; + +const { Text } = Typography; type UserRecord = { id: string; @@ -13,24 +18,71 @@ type UserRecord = { banned: boolean; createdAt: string; credits: number; + birthDate: null; + birthTimeStatus: null; + birthPlace: null; + birthDataMasked: true; +}; + +type RevealedBirthData = { + userId: string; birthDate: string | null; birthTimeStatus: string | null; birthPlace: string | null; }; -const columns: TableColumnsType = [ - { title: "邮箱", dataIndex: "email", sorter: true }, - { title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" }, - { title: "角色", dataIndex: "role", render: (value) => {value} }, - { title: "积分", dataIndex: "credits", sorter: true }, - { title: "出生日期", dataIndex: "birthDate", render: (value) => value || "—" }, - { title: "出生时间状态", dataIndex: "birthTimeStatus", render: (value) => value || "—" }, - { title: "出生地", dataIndex: "birthPlace", render: (value) => value || "—" }, - { title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" }, - { title: "状态", dataIndex: "banned", render: (value) => value ? 已禁用 : 正常 }, - { title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, -]; - export default function UsersPage() { - return resource="users" title="用户资料(只读)" columns={columns} />; + const { message } = App.useApp(); + const { data: identity } = useGetIdentity(); + const [revealed, setRevealed] = useState>({}); + const [revealingId, setRevealingId] = useState(null); + const canReveal = Boolean(identity?.permissions.includes("admin.customers.birth_data.read")); + + async function revealBirthData(userId: string) { + setRevealingId(userId); + try { + const payload = await adminRequestJson<{ data: RevealedBirthData }>( + `/api/admin/customers?revealUserId=${encodeURIComponent(userId)}`, + ); + setRevealed((current) => ({ ...current, [userId]: payload.data })); + message.success("出生资料已读取,本次查看已写入审计日志"); + } catch (error) { + message.error(error instanceof Error ? error.message : "读取出生资料失败"); + } finally { + setRevealingId(null); + } + } + + const maskedValue = (value: string | null | undefined) => value || "未填写"; + const columns: TableColumnsType = [ + { title: "邮箱", dataIndex: "email", sorter: true }, + { title: "姓名", dataIndex: "name", sorter: true, render: (value) => value || "—" }, + { title: "角色", dataIndex: "role", render: (value) => {value} }, + { title: "积分", dataIndex: "credits", sorter: true }, + { + title: "出生资料", + render: (_, item) => { + const birth = revealed[item.id]; + return + {birth ? <> + 日期:{maskedValue(birth.birthDate)} + 时间状态:{maskedValue(birth.birthTimeStatus)} + 地点:{maskedValue(birth.birthPlace)} + : 已脱敏} + {canReveal && } + ; + }, + }, + { title: "邮箱验证", dataIndex: "emailVerified", render: (value) => value ? "已验证" : "未验证" }, + { title: "状态", dataIndex: "banned", render: (value) => value ? 已禁用 : 正常 }, + { title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate }, + ]; + + return resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />; } diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index b7ec5edb..c972c989 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -7,18 +7,20 @@ import type { CrudFilter, DataProvider, HttpError, -CreateParams, -DeleteOneParams, -GetListParams, -GetOneParams, -UpdateParams, + CreateParams, + DeleteOneParams, + GetListParams, + GetOneParams, + UpdateParams, } from "@refinedev/core"; export type AdminIdentity = { id: string; email: string; name: string; - role: "admin"; + roles: string[]; + permissions: string[]; + requiresMfa: boolean; }; const apiBase = "/api/admin"; @@ -26,11 +28,15 @@ let identityCache: AdminIdentity | null = null; function logicalFilters(filters: CrudFilter[] | undefined) { return (filters ?? []).filter( - (filter): filter is Extract => "field" in filter, + (filter): filter is Extract => + "field" in filter, ); } -async function requestJson(url: string, init?: RequestInit): Promise { +export async function adminRequestJson( + url: string, + init?: RequestInit, +): Promise { const response = await fetch(url, { cache: "no-store", credentials: "same-origin", @@ -42,10 +48,13 @@ async function requestJson(url: string, init?: RequestInit): Promise { }); const payload = await response.json().catch(() => null); if (!response.ok) { - const message = payload && typeof payload.error === "string" - ? payload.error - : "后台请求失败"; - throw { message, statusCode: response.status } satisfies HttpError; + const message = + payload && typeof payload.error === "string" + ? payload.error + : "后台请求失败"; + throw Object.assign(new Error(message), { + statusCode: response.status, + } satisfies Partial); } return payload as T; } @@ -65,7 +74,12 @@ function listParams( search.set("order", sorter.order); } for (const filter of logicalFilters(filters)) { - if (filter.value === undefined || filter.value === null || filter.value === "") continue; + if ( + filter.value === undefined || + filter.value === null || + filter.value === "" + ) + continue; if (filter.field === "q" || filter.field === "status") { search.set(filter.field, String(filter.value)); } @@ -74,29 +88,44 @@ function listParams( } export const adminDataProvider: DataProvider = { - async getList({ resource, pagination, sorters, filters }: GetListParams) { + async getList({ + resource, + pagination, + sorters, + filters, + }: GetListParams) { const search = listParams(pagination, sorters, filters); - return requestJson<{ data: TData[]; total: number }>( + return adminRequestJson<{ data: TData[]; total: number }>( `${apiBase}/${resource}?${search}`, ); }, async getOne({ resource, id }: GetOneParams) { - return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`); + return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`); }, - async create({ resource, variables }: CreateParams) { - return requestJson<{ data: TData }>(`${apiBase}/${resource}`, { + async create({ + resource, + variables, + }: CreateParams) { + return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}`, { method: "POST", body: JSON.stringify(variables), }); }, - async update({ resource, id, variables }: UpdateParams) { - return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, { + async update({ + resource, + id, + variables, + }: UpdateParams) { + return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, { method: "PATCH", body: JSON.stringify(variables), }); }, - async deleteOne({ resource, id }: DeleteOneParams) { - return requestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, { + async deleteOne({ + resource, + id, + }: DeleteOneParams) { + return adminRequestJson<{ data: TData }>(`${apiBase}/${resource}/${id}`, { method: "DELETE", }); }, @@ -105,7 +134,9 @@ export const adminDataProvider: DataProvider = { async function loadIdentity(): Promise { if (identityCache) return identityCache; - const payload = await requestJson<{ user: AdminIdentity }>(`${apiBase}/session`); + const payload = await adminRequestJson<{ user: AdminIdentity }>( + `${apiBase}/session`, + ); identityCache = payload.user; return identityCache; } @@ -142,30 +173,49 @@ export const adminAuthProvider: AuthProvider = { return { error: error as HttpError }; }, async getPermissions() { - return (await loadIdentity()).role; + return (await loadIdentity()).permissions; }, async getIdentity() { return loadIdentity(); }, }; -const readOnlyResources = new Set([ - "users", - "credit-transactions", - "consultations", - "audit-logs", -]); +const resourcePermissions: Record = { + administrators: { + read: "admin.users.read", + write: "admin.users.manage_roles", + }, + roles: { read: "admin.users.read" }, + customers: { read: "admin.customers.read" }, + codes: { read: "billing.orders.read", write: "billing.adjustments.write" }, + "credit-transactions": { read: "billing.orders.read" }, + consultations: { read: "billing.orders.read" }, + "audit-logs": { read: "audit.read" }, + payments: { read: "billing.orders.read" }, + packages: { read: "billing.products.read", write: "billing.products.write" }, + products: { read: "billing.products.read", write: "billing.products.write" }, + subscriptions: { + read: "billing.orders.read", + write: "billing.adjustments.write", + }, + orders: { read: "billing.orders.read", write: "billing.adjustments.write" }, + usage: { read: "billing.orders.read" }, + models: { read: "models.read", write: "models.write" }, + "model-releases": { read: "models.read", write: "models.publish" }, + "feature-flags": { read: "admin.access", write: "ops.flags.write" }, + security: { read: "admin.access", write: "admin.access" }, +}; export const adminAccessControlProvider: AccessControlProvider = { async can({ resource, action }) { - const role = (await loadIdentity()).role; - if (action === "list" || action === "show") return { can: true }; - if (readOnlyResources.has(resource ?? "")) { - return { can: false, reason: "此资源只读" }; - } - return role === "admin" + const identity = await loadIdentity(); + const required = resourcePermissions[resource ?? ""]; + if (!required) return { can: false, reason: "未知管理资源" }; + const permission = + action === "list" || action === "show" ? required.read : required.write; + return permission && identity.permissions.includes(permission) ? { can: true } - : { can: false, reason: "无管理员权限" }; + : { can: false, reason: "无此操作权限" }; }, options: { buttons: { enableAccessControl: true, hideIfUnauthorized: true }, diff --git a/frontend/supabase/migrations/20260806050000_operations_feature_flags.sql b/frontend/supabase/migrations/20260806050000_operations_feature_flags.sql new file mode 100644 index 00000000..906508f2 --- /dev/null +++ b/frontend/supabase/migrations/20260806050000_operations_feature_flags.sql @@ -0,0 +1,131 @@ +begin; + +create table if not exists public.feature_flags ( + id uuid primary key default gen_random_uuid(), + flag_key text not null check (flag_key ~ '^[a-z][a-z0-9_.-]{2,79}$'), + version integer not null check (version > 0), + enabled boolean not null default false, + rollout_percentage integer not null default 100 check (rollout_percentage between 0 and 100), + config jsonb not null default '{}'::jsonb check (jsonb_typeof(config)='object'), + status text not null default 'draft' check (status in ('draft','published','retired')), + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + published_at timestamptz, + unique(flag_key,version) +); +create unique index if not exists feature_flags_one_draft_idx on public.feature_flags(flag_key) where status='draft'; +create unique index if not exists feature_flags_one_published_idx on public.feature_flags(flag_key) where status='published'; + +create table if not exists public.notification_templates ( + id uuid primary key default gen_random_uuid(), + template_key text not null check (template_key in ('subscription.expiring','payment.grant_failed','fair_use.blocked')), + version integer not null check (version>0), + channel text not null default 'in_app' check (channel in ('in_app','email')), + subject text not null default '' check (char_length(subject)<=160), + body text not null check (char_length(body) between 1 and 4000), + status text not null default 'draft' check (status in ('draft','published','retired')), + created_by uuid references auth.users(id) on delete set null, + created_at timestamptz not null default now(), + published_at timestamptz, + unique(template_key,channel,version) +); +create unique index if not exists notification_templates_published_idx + on public.notification_templates(template_key,channel) where status='published'; + +create table if not exists public.pricing_experiment_events ( + id uuid primary key default gen_random_uuid(), + user_id uuid references auth.users(id) on delete set null, + experiment_key text not null, + variant text not null, + event_name text not null check (event_name in ('view','checkout','paid','cancelled')), + product_code text, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default clock_timestamp() +); +create index if not exists pricing_experiment_events_created_idx on public.pricing_experiment_events(experiment_key,created_at desc); + +insert into public.feature_flags(flag_key,version,enabled,rollout_percentage,config,status,created_at,published_at) values + ('billing.subscriptions',1,true,100,'{}','published',now(),now()), + ('models.database_catalog',1,true,100,'{}','published',now(),now()), + ('models.circuit_breaker',1,false,0,'{"failureThreshold":5,"cooldownSeconds":300}','published',now(),now()), + ('billing.pro_products',1,false,0,'{}','published',now(),now()) +on conflict(flag_key,version) do nothing; + +insert into public.notification_templates(template_key,version,channel,subject,body,status,published_at) values + ('subscription.expiring',1,'in_app','会员即将到期','你的会员权益即将到期,可在账户页查看结束时间。','published',now()), + ('payment.grant_failed',1,'in_app','支付权益待处理','支付已确认,但权益发放仍在重试。请勿重复付款。','published',now()), + ('fair_use.blocked',1,'in_app','已达到合理使用上限','当前使用频率已达到会员合理使用上限,请稍后再试。','published',now()) +on conflict(template_key,channel,version) do nothing; + +create or replace function public.admin_save_feature_flag( + p_actor_user_id uuid,p_flag_id uuid,p_flag_key text,p_enabled boolean,p_rollout_percentage integer, + p_config jsonb,p_expected_version integer,p_reason text,p_request_id text +) +returns uuid language plpgsql security definer set search_path='' +as $$ declare v_id uuid; v_version integer; +begin + if not public.admin_has_permission(p_actor_user_id,'ops.flags.write') then raise exception 'admin_permission_denied' using errcode='42501'; end if; + if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then raise exception 'admin_reason_required' using errcode='22023'; end if; + if p_flag_id is null then + select coalesce(max(version),0)+1 into v_version from public.feature_flags where flag_key=p_flag_key; + insert into public.feature_flags(flag_key,version,enabled,rollout_percentage,config,created_by) + values(p_flag_key,v_version,p_enabled,p_rollout_percentage,coalesce(p_config,'{}'::jsonb),p_actor_user_id) returning id into v_id; + else + update public.feature_flags set enabled=p_enabled,rollout_percentage=p_rollout_percentage,config=coalesce(p_config,'{}'::jsonb) + where id=p_flag_id and status='draft' and version=p_expected_version returning id into v_id; + if v_id is null then raise exception 'feature_flag_version_conflict' using errcode='40001'; end if; + end if; + insert into audit.admin_audit_logs(actor_user_id,actor_email,actor_role,action,target_type,target_id,after_value, + request_id,permission_used,reason) + select p_actor_user_id,lower(btrim(u.email)),'admin','ops.flag.draft.save','feature_flag',v_id, + jsonb_build_object('flagKey',p_flag_key,'enabled',p_enabled,'rolloutPercentage',p_rollout_percentage), + p_request_id,'ops.flags.write',btrim(p_reason) from identity.users u where u.id=p_actor_user_id on conflict do nothing; + return v_id; +end $$; + +create or replace function public.admin_publish_feature_flag( + p_actor_user_id uuid,p_flag_id uuid,p_expected_version integer,p_reason text,p_request_id text +) +returns uuid language plpgsql security definer set search_path='' +as $$ declare v_flag public.feature_flags%rowtype; +begin + if not public.admin_has_permission(p_actor_user_id,'ops.flags.write') then raise exception 'admin_permission_denied' using errcode='42501'; end if; + if char_length(btrim(coalesce(p_reason,''))) not between 1 and 500 then raise exception 'admin_reason_required' using errcode='22023'; end if; + select * into v_flag from public.feature_flags where id=p_flag_id and status='draft' and version=p_expected_version for update; + if not found then raise exception 'feature_flag_version_conflict' using errcode='40001'; end if; + update public.feature_flags set status='retired' where flag_key=v_flag.flag_key and status='published'; + update public.feature_flags set status='published',published_at=clock_timestamp() where id=p_flag_id; + insert into audit.admin_audit_logs(actor_user_id,actor_email,actor_role,action,target_type,target_id,after_value, + request_id,permission_used,reason) + select p_actor_user_id,lower(btrim(u.email)),'admin','ops.flag.publish','feature_flag',p_flag_id, + jsonb_build_object('flagKey',v_flag.flag_key,'version',v_flag.version,'enabled',v_flag.enabled), + p_request_id,'ops.flags.write',btrim(p_reason) from identity.users u where u.id=p_actor_user_id on conflict do nothing; + return p_flag_id; +end $$; + +create or replace view public.admin_billing_overview as +select + (select count(*) from public.user_subscriptions where status='active' and starts_at<=now() and ends_at>now())::bigint as active_subscriptions, + (select count(*) from public.payment_orders where status='paid' and paid_at>=date_trunc('day',now()))::bigint as paid_orders_today, + (select coalesce(sum(money_cents),0) from public.payment_orders where status='paid' and paid_at>=date_trunc('month',now()))::bigint as revenue_cents_month, + (select count(*) from public.payment_orders where status='grant_pending' or grant_status='failed')::bigint as grant_failures, + (select count(*) from public.usage_reservations where status='completed' and reserved_at>=date_trunc('day',now()))::bigint as completed_usage_today, + (select coalesce(sum(cost_microusd),0) from public.usage_ledger where created_at>=date_trunc('month',now()))::bigint as model_cost_microusd_month; + +alter table public.feature_flags enable row level security; +alter table public.notification_templates enable row level security; +alter table public.pricing_experiment_events enable row level security; +revoke all on table public.feature_flags,public.notification_templates,public.pricing_experiment_events from public,anon,authenticated; +grant select on table public.feature_flags,public.notification_templates,public.pricing_experiment_events to service_role; +revoke all on function public.admin_save_feature_flag(uuid,uuid,text,boolean,integer,jsonb,integer,text,text), + public.admin_publish_feature_flag(uuid,uuid,integer,text,text) from public,anon,authenticated; +grant execute on function public.admin_save_feature_flag(uuid,uuid,text,boolean,integer,jsonb,integer,text,text), + public.admin_publish_feature_flag(uuid,uuid,integer,text,text) to service_role; +do $$ begin if exists(select 1 from pg_roles where rolname='admin_runtime') then + grant select on table public.feature_flags,public.notification_templates,public.pricing_experiment_events to admin_runtime; + grant select on public.admin_billing_overview to admin_runtime; + grant execute on function public.admin_save_feature_flag(uuid,uuid,text,boolean,integer,jsonb,integer,text,text), + public.admin_publish_feature_flag(uuid,uuid,integer,text,text) to admin_runtime; +end if; end $$; + +commit; diff --git a/frontend/supabase/migrations/20260806060000_unified_rectification_usage.sql b/frontend/supabase/migrations/20260806060000_unified_rectification_usage.sql new file mode 100644 index 00000000..e0e0e925 --- /dev/null +++ b/frontend/supabase/migrations/20260806060000_unified_rectification_usage.sql @@ -0,0 +1,843 @@ +begin; + +-- Preserve in-flight legacy reservations before the conversational flow starts +-- using the shared usage ledger. Existing rows were always credit-funded. +insert into public.usage_reservations ( + id, user_id, request_id, feature_key, source, credit_amount, status, + reserved_at, completed_at +) +select + b.reservation_id, + b.user_id, + 'rectification:' || b.case_id::text, + 'rectification', + 'credits', + b.price, + case when b.state = 'charged' then 'completed' else 'reserved' end, + coalesce(b.reserved_at, b.created_at), + case when b.state = 'charged' then coalesce(b.charged_at, b.updated_at) end +from public.birth_time_rectification_billing b +where b.state in ('reserved', 'charged') + and b.reservation_id is not null +on conflict (user_id, request_id) do nothing; + +insert into public.usage_ledger ( + reservation_id, user_id, request_id, feature_key, source, metadata, created_at +) +select + r.id, r.user_id, r.request_id, r.feature_key, r.source, + '{"legacyConversationalBilling":true}'::jsonb, + coalesce(r.completed_at, r.reserved_at) +from public.usage_reservations r +where r.feature_key = 'rectification' + and r.request_id like 'rectification:%' + and r.status = 'completed' +on conflict (reservation_id) do nothing; + +create or replace function public.recover_conversational_rectification_orphan_reservations( + p_user_id uuid, + p_excluded_case_id uuid default null +) +returns integer +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_orphan public.birth_time_rectification_billing%rowtype; + v_balance integer; + v_recovery_action_id uuid; + v_recovery_fingerprint text; + v_recovery_response jsonb; + v_release_success boolean; + v_release_error text; +begin + if p_user_id is null then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended( + p_user_id::text || ':conversational-rectification-case', + 0 + ) + ); + select profile.credits into v_balance + from public.profiles profile + where profile.id = p_user_id + for update; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + for v_orphan in + select orphan_billing.* + from public.birth_time_rectification_billing orphan_billing + left join public.birth_time_rectification_cases orphan_case + on orphan_case.id = orphan_billing.case_id + where orphan_billing.user_id = p_user_id + and ( + p_excluded_case_id is null + or orphan_billing.case_id <> p_excluded_case_id + ) + and orphan_billing.state = 'reserved' + and orphan_case.id is null + order by orphan_billing.reserved_at, orphan_billing.case_id + for update of orphan_billing + loop + v_recovery_action_id := + public.conversational_rectification_billing_receipt_action_id( + v_orphan.reserve_action_id, + 'recover_fee' + ); + v_recovery_fingerprint := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + pg_catalog.jsonb_build_object( + 'kind', 'recover_fee', 'userId', p_user_id, 'caseId', v_orphan.case_id, + 'expectedVersion', 0, 'actionId', v_recovery_action_id, + 'reserveActionId', v_orphan.reserve_action_id + )::text, + 'UTF8' + )), 'hex'); + + select released.success, released.credits, released.error_code + into v_release_success, v_balance, v_release_error + from public.release_usage( + p_user_id, + 'rectification:' || v_orphan.case_id::text, + 'orphaned conversational rectification reservation' + ) released; + if v_release_success is distinct from true or v_release_error is not null then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + update public.birth_time_rectification_billing orphan_billing + set state = 'released', + release_action_id = v_recovery_action_id, + balance_after = v_balance, + released_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where orphan_billing.case_id = v_orphan.case_id + and orphan_billing.user_id = p_user_id + and orphan_billing.state = 'reserved'; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + v_recovery_response := pg_catalog.jsonb_build_object( + 'success', true, 'credits', v_balance, + 'billing_state', 'released', 'error_code', null + ); + insert into public.birth_time_rectification_action_receipts ( + case_id, action_id, user_id, action_kind, expected_turn_version, + result_turn_version, request_fingerprint, request, response + ) values ( + v_orphan.case_id, v_recovery_action_id, p_user_id, 'recover_fee', 0, + 0, v_recovery_fingerprint, + public.conversational_rectification_action_request( + 'recover_fee', p_user_id, v_orphan.case_id, 0, + v_recovery_action_id, v_recovery_fingerprint + ), + v_recovery_response + ); + end loop; + return v_balance; +end; +$$; + +create or replace function public.reserve_conversational_rectification_fee( + p_user_id uuid, + p_case_id uuid, + p_expected_version bigint, + p_action_id uuid, + p_price integer +) +returns table ( + success boolean, + credits integer, + billing_state text, + error_code text +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_receipt public.birth_time_rectification_action_receipts%rowtype; + v_billing public.birth_time_rectification_billing%rowtype; + v_receipt_action_id uuid := + public.conversational_rectification_billing_receipt_action_id( + p_action_id, + 'reserve_fee' + ); + v_balance integer; + v_usage_success boolean; + v_usage_reservation_id uuid; + v_usage_reason text; + v_response jsonb; + v_fingerprint text := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + pg_catalog.jsonb_build_object( + 'kind', 'reserve_fee', 'userId', p_user_id, 'caseId', p_case_id, + 'expectedVersion', p_expected_version, 'actionId', p_action_id, + 'price', p_price + )::text, + 'UTF8' + )), 'hex'); +begin + if p_user_id is null or p_case_id is null or p_action_id is null + or p_expected_version is distinct from 0 + or p_price is null or not (p_price between 1 and 1000000) then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + if p_case_id is distinct from p_action_id then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + -- The account lock prevents two different start actions from reserving two + -- fees before either action has created its case. The action lock preserves + -- exact replay for the same request. + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended( + p_user_id::text || ':conversational-rectification-case', + 0 + ) + ); + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_action_id::text, 0) + ); + + select r.* into v_receipt + from public.birth_time_rectification_action_receipts r + where r.case_id = p_case_id and r.action_id = v_receipt_action_id + for update; + if found then + if v_receipt.user_id is distinct from p_user_id + or v_receipt.action_kind is distinct from 'reserve_fee' + or v_receipt.expected_turn_version is distinct from p_expected_version + or v_receipt.request_fingerprint is distinct from v_fingerprint then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + return query select + (v_receipt.response ->> 'success')::boolean, + nullif(v_receipt.response ->> 'credits', '')::integer, + nullif(v_receipt.response ->> 'billing_state', ''), + nullif(v_receipt.response ->> 'error_code', ''); + return; + end if; + + -- A public action identifies one start attempt even if a buggy caller loses + -- its case id. Reusing that action for another case is a conflict, never a + -- second debit. + select r.* into v_receipt + from public.birth_time_rectification_action_receipts r + where r.user_id = p_user_id + and r.action_id = v_receipt_action_id + and r.action_kind = 'reserve_fee' + for update; + if found then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + perform 1 + from public.birth_time_rectification_cases active_case + where active_case.user_id = p_user_id + and active_case.id <> p_case_id + and active_case.journey_protocol = 'conversational-evidence-v3' + and active_case.status in ('starting', 'active', 'paused', 'confirming') + for update; + if found then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + -- Reservation intentionally precedes the external first-turn calculation, + -- so it cannot share a transaction with case creation. A process/device + -- loss in that gap leaves no case for the account resume RPC to expose. + -- A fresh account-scoped start deterministically releases every such orphan + -- under the same account/profile locks before it attempts another debit. + v_balance := public.recover_conversational_rectification_orphan_reservations( + p_user_id, + p_case_id + ); + + select b.* into v_billing + from public.birth_time_rectification_billing b + where b.case_id = p_case_id + for update; + if found then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + select usage.success, usage.reservation_id, usage.credits, usage.reason + into v_usage_success, v_usage_reservation_id, v_balance, v_usage_reason + from public.authorize_usage( + p_user_id, + 'rectification', + null, + 'rectification:' || p_case_id::text, + p_price + ) usage; + if v_usage_success is distinct from true or v_usage_reservation_id is null then + v_usage_reason := coalesce(v_usage_reason, 'billing_failed'); + v_response := pg_catalog.jsonb_build_object( + 'success', false, 'credits', v_balance, + 'billing_state', null, 'error_code', v_usage_reason + ); + insert into public.birth_time_rectification_action_receipts ( + case_id, action_id, user_id, action_kind, expected_turn_version, + result_turn_version, request_fingerprint, request, response + ) values ( + p_case_id, v_receipt_action_id, p_user_id, 'reserve_fee', 0, + 0, v_fingerprint, + public.conversational_rectification_action_request( + 'reserve_fee', p_user_id, p_case_id, 0, p_action_id, v_fingerprint + ), + v_response + ); + return query select false, v_balance, null::text, v_usage_reason; + return; + end if; + + insert into public.birth_time_rectification_billing ( + case_id, user_id, price, state, reservation_id, reserve_action_id, + balance_after, reserved_at + ) values ( + p_case_id, p_user_id, p_price, 'reserved', + v_usage_reservation_id, p_action_id, v_balance, pg_catalog.now() + ); + + v_response := pg_catalog.jsonb_build_object( + 'success', true, 'credits', v_balance, + 'billing_state', 'reserved', 'error_code', null + ); + insert into public.birth_time_rectification_action_receipts ( + case_id, action_id, user_id, action_kind, expected_turn_version, + result_turn_version, request_fingerprint, request, response + ) values ( + p_case_id, v_receipt_action_id, p_user_id, 'reserve_fee', 0, + 0, v_fingerprint, + public.conversational_rectification_action_request( + 'reserve_fee', p_user_id, p_case_id, 0, p_action_id, v_fingerprint + ), + v_response + ); + + return query select true, v_balance, 'reserved'::text, null::text; +end; +$$; + +create or replace function public.complete_conversational_rectification_fee( + p_user_id uuid, + p_case_id uuid, + p_expected_version bigint, + p_action_id uuid +) +returns table ( + success boolean, + credits integer, + billing_state text, + error_code text +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.birth_time_rectification_cases%rowtype; + v_receipt public.birth_time_rectification_action_receipts%rowtype; + v_billing public.birth_time_rectification_billing%rowtype; + v_receipt_action_id uuid := + public.conversational_rectification_billing_receipt_action_id( + p_action_id, + 'complete_fee' + ); + v_balance integer; + v_usage_success boolean; + v_usage_error text; + v_response jsonb; + v_success boolean; + v_error_code text; + v_state text; + v_fingerprint text := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + pg_catalog.jsonb_build_object( + 'kind', 'complete_fee', 'userId', p_user_id, 'caseId', p_case_id, + 'expectedVersion', p_expected_version, 'actionId', p_action_id + )::text, + 'UTF8' + )), 'hex'); +begin + if p_user_id is null or p_case_id is null or p_action_id is null + or p_expected_version is null or p_expected_version < 0 then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + if p_case_id is distinct from p_action_id then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_action_id::text, 0) + ); + select c.* into v_case + from public.birth_time_rectification_cases c + where c.id = p_case_id and c.user_id = p_user_id + for update; + if not found or v_case.journey_protocol is distinct from 'conversational-evidence-v3' then + raise exception 'conversational_case_not_found' using errcode = 'P0001'; + end if; + + select r.* into v_receipt + from public.birth_time_rectification_action_receipts r + where r.case_id = p_case_id and r.action_id = v_receipt_action_id + for update; + if found then + if v_receipt.user_id is distinct from p_user_id + or v_receipt.action_kind is distinct from 'complete_fee' + or v_receipt.expected_turn_version is distinct from p_expected_version + or v_receipt.request_fingerprint is distinct from v_fingerprint then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + return query select + (v_receipt.response ->> 'success')::boolean, + nullif(v_receipt.response ->> 'credits', '')::integer, + nullif(v_receipt.response ->> 'billing_state', ''), + nullif(v_receipt.response ->> 'error_code', ''); + return; + end if; + if v_case.turn_version is distinct from p_expected_version then + raise exception 'conversational_stale_turn' using errcode = 'P0001'; + end if; + + select profile.credits into v_balance + from public.profiles profile + where profile.id = p_user_id + for update; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + select b.* into v_billing + from public.birth_time_rectification_billing b + where b.case_id = p_case_id and b.user_id = p_user_id + for update; + + if not found then + v_success := false; + v_state := null; + v_error_code := 'billing_missing'; + elsif v_billing.state = 'charged' then + v_success := true; + v_state := 'charged'; + v_error_code := null; + elsif v_billing.state = 'released' then + v_success := false; + v_state := 'released'; + v_error_code := 'reservation_released'; + elsif v_billing.state = 'migration_waived' then + v_success := true; + v_state := 'migration_waived'; + v_error_code := null; + else + select completed.success, completed.credits, completed.error_code + into v_usage_success, v_balance, v_usage_error + from public.complete_usage( + p_user_id, + 'rectification:' || p_case_id::text, + '{"metadata":{"flow":"conversational_rectification"}}'::jsonb + ) completed; + if v_usage_success is distinct from true or v_usage_error is not null then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + update public.birth_time_rectification_billing + set state = 'charged', + billing_receipt_id = pg_catalog.gen_random_uuid(), + complete_action_id = p_action_id, + balance_after = v_balance, + charged_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where case_id = p_case_id and user_id = p_user_id and state = 'reserved'; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + v_success := true; + v_state := 'charged'; + v_error_code := null; + end if; + + v_response := pg_catalog.jsonb_build_object( + 'success', v_success, 'credits', v_balance, + 'billing_state', v_state, 'error_code', v_error_code + ); + insert into public.birth_time_rectification_action_receipts ( + case_id, action_id, user_id, action_kind, expected_turn_version, + result_turn_version, request_fingerprint, request, response + ) values ( + p_case_id, v_receipt_action_id, p_user_id, 'complete_fee', p_expected_version, + v_case.turn_version, v_fingerprint, + public.conversational_rectification_action_request( + 'complete_fee', p_user_id, p_case_id, p_expected_version, + p_action_id, v_fingerprint + ), + v_response + ); + return query select v_success, v_balance, v_state, v_error_code; +end; +$$; + +create or replace function public.release_conversational_rectification_fee( + p_user_id uuid, + p_case_id uuid, + p_expected_version bigint, + p_action_id uuid, + p_price integer +) +returns table ( + success boolean, + credits integer, + billing_state text, + error_code text +) +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.birth_time_rectification_cases%rowtype; + v_receipt public.birth_time_rectification_action_receipts%rowtype; + v_billing public.birth_time_rectification_billing%rowtype; + v_receipt_action_id uuid := + public.conversational_rectification_billing_receipt_action_id( + p_action_id, + 'release_fee' + ); + v_balance integer; + v_result_version bigint := p_expected_version; + v_response jsonb; + v_usage_success boolean; + v_usage_error text; + v_success boolean := true; + v_error_code text; + v_state text := 'released'; + v_fingerprint text := pg_catalog.encode(pg_catalog.sha256(pg_catalog.convert_to( + pg_catalog.jsonb_build_object( + 'kind', 'release_fee', 'userId', p_user_id, 'caseId', p_case_id, + 'expectedVersion', p_expected_version, 'actionId', p_action_id, + 'price', p_price + )::text, + 'UTF8' + )), 'hex'); +begin + if p_user_id is null or p_case_id is null or p_action_id is null + or p_expected_version is null or p_expected_version < 0 + or p_price is null or not (p_price between 1 and 1000000) then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + if p_case_id is distinct from p_action_id then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || p_action_id::text, 0) + ); + + select r.* into v_receipt + from public.birth_time_rectification_action_receipts r + where r.case_id = p_case_id and r.action_id = v_receipt_action_id + for update; + if found then + if v_receipt.user_id is distinct from p_user_id + or v_receipt.action_kind is distinct from 'release_fee' + or v_receipt.expected_turn_version is distinct from p_expected_version + or v_receipt.request_fingerprint is distinct from v_fingerprint then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + return query select + (v_receipt.response ->> 'success')::boolean, + nullif(v_receipt.response ->> 'credits', '')::integer, + nullif(v_receipt.response ->> 'billing_state', ''), + nullif(v_receipt.response ->> 'error_code', ''); + return; + end if; + + select c.* into v_case + from public.birth_time_rectification_cases c + where c.id = p_case_id + for update; + if found then + if v_case.user_id is distinct from p_user_id + or v_case.journey_protocol is distinct from 'conversational-evidence-v3' then + raise exception 'conversational_case_not_found' using errcode = 'P0001'; + end if; + if v_case.turn_version is distinct from p_expected_version then + raise exception 'conversational_stale_turn' using errcode = 'P0001'; + end if; + v_result_version := v_case.turn_version; + elsif p_expected_version is distinct from 0 then + raise exception 'conversational_stale_turn' using errcode = 'P0001'; + end if; + + select profile.credits into v_balance + from public.profiles profile + where profile.id = p_user_id + for update; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + select b.* into v_billing + from public.birth_time_rectification_billing b + where b.case_id = p_case_id + for update; + + if not found then + insert into public.birth_time_rectification_billing ( + case_id, user_id, price, state, release_action_id, + balance_after, released_at + ) values ( + p_case_id, p_user_id, p_price, 'released', p_action_id, + v_balance, pg_catalog.now() + ); + elsif v_billing.user_id is distinct from p_user_id then + raise exception 'conversational_case_not_found' using errcode = 'P0001'; + elsif v_billing.state = 'released' then + v_state := 'released'; + elsif v_billing.state = 'charged' then + v_success := false; + v_state := 'charged'; + v_error_code := 'already_charged'; + elsif v_billing.state = 'migration_waived' then + v_state := 'migration_waived'; + else + select released.success, released.credits, released.error_code + into v_usage_success, v_balance, v_usage_error + from public.release_usage( + p_user_id, + 'rectification:' || p_case_id::text, + 'conversational rectification start failed' + ) released; + if v_usage_success is distinct from true or v_usage_error is not null then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + update public.birth_time_rectification_billing + set state = 'released', + release_action_id = p_action_id, + balance_after = v_balance, + released_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where case_id = p_case_id and user_id = p_user_id and state = 'reserved'; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + -- Creation and fee settlement are separate calls. If settlement fails + -- after the case was created, releasing the reservation must also close + -- that unfinished case or it would block every later account-level start. + if v_case.id is not null + and v_case.status in ('starting', 'active', 'paused', 'confirming') then + update public.birth_time_rectification_cases c + set status = 'abandoned', + turn_state = case + when pg_catalog.jsonb_typeof(c.turn_state) = 'object' + then pg_catalog.jsonb_set( + c.turn_state, '{status}', pg_catalog.to_jsonb('abandoned'::text), true + ) + else c.turn_state + end, + journey_snapshot = case + when pg_catalog.jsonb_typeof(c.journey_snapshot) = 'object' + then pg_catalog.jsonb_set( + c.journey_snapshot, '{status}', pg_catalog.to_jsonb('abandoned'::text), true + ) + else c.journey_snapshot + end, + updated_at = pg_catalog.now() + where c.id = p_case_id + and c.user_id = p_user_id + and c.journey_protocol = 'conversational-evidence-v3' + and c.turn_version = p_expected_version + and c.status in ('starting', 'active', 'paused', 'confirming'); + if not found then + raise exception 'conversational_stale_turn' using errcode = 'P0001'; + end if; + end if; + end if; + + v_response := pg_catalog.jsonb_build_object( + 'success', v_success, 'credits', v_balance, + 'billing_state', v_state, 'error_code', v_error_code + ); + insert into public.birth_time_rectification_action_receipts ( + case_id, action_id, user_id, action_kind, expected_turn_version, + result_turn_version, request_fingerprint, request, response + ) values ( + p_case_id, v_receipt_action_id, p_user_id, 'release_fee', p_expected_version, + v_result_version, v_fingerprint, + public.conversational_rectification_action_request( + 'release_fee', p_user_id, p_case_id, p_expected_version, + p_action_id, v_fingerprint + ), + v_response + ); + return query select v_success, v_balance, v_state, v_error_code; +end; +$$; + + +create or replace function public.conversational_rectification_refund_completed_usage( + p_user_id uuid, + p_case_id uuid, + p_reason text +) +returns integer +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_request_id text := 'rectification:' || p_case_id::text; + v_reservation public.usage_reservations%rowtype; + v_balance integer; +begin + perform pg_catalog.pg_advisory_xact_lock( + pg_catalog.hashtextextended(p_user_id::text || ':' || v_request_id, 0) + ); + select r.* into v_reservation + from public.usage_reservations r + where r.user_id = p_user_id and r.request_id = v_request_id + for update; + if not found or v_reservation.feature_key is distinct from 'rectification' then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + select profile.credits into v_balance + from public.profiles profile + where profile.id = p_user_id + for update; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + if v_reservation.status = 'released' then + return v_balance; + end if; + if v_reservation.status is distinct from 'completed' then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + if v_reservation.source = 'credits' and v_reservation.credit_amount > 0 then + update public.profiles profile + set credits = profile.credits + v_reservation.credit_amount, + updated_at = pg_catalog.clock_timestamp() + where profile.id = p_user_id + returning profile.credits into v_balance; + insert into public.credit_transactions ( + user_id, transaction_type, amount, balance_after, request_id, model + ) values ( + p_user_id, 'refund', v_reservation.credit_amount, v_balance, + v_request_id, v_reservation.requested_model_id + ) on conflict (user_id, transaction_type, request_id) do nothing; + end if; + + update public.usage_reservations + set status = 'released', + released_at = pg_catalog.clock_timestamp(), + release_reason = left(coalesce(p_reason, 'unconfirmed rectification'), 500) + where id = v_reservation.id and status = 'completed'; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + return v_balance; +end; +$$; + +create or replace function public.conversational_rectification_refund_unconfirmed_case( + p_user_id uuid, + p_case_id uuid, + p_action_id uuid +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.birth_time_rectification_cases%rowtype; + v_billing public.birth_time_rectification_billing%rowtype; + v_balance integer; +begin + select c.* into v_case + from public.birth_time_rectification_cases c + where c.id = p_case_id and c.user_id = p_user_id + for update; + if not found + or v_case.journey_protocol is distinct from 'conversational-evidence-v3' + or v_case.status not in ('completed', 'abandoned') + or v_case.turn_state #>> '{candidate,status}' = 'confirmed' then + raise exception 'conversational_action_conflict' using errcode = 'P0001'; + end if; + + select profile.credits into v_balance + from public.profiles profile + where profile.id = p_user_id + for update; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + select b.* into v_billing + from public.birth_time_rectification_billing b + where b.case_id = p_case_id and b.user_id = p_user_id + for update; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + if v_billing.state = 'charged' then + v_balance := public.conversational_rectification_refund_completed_usage( + p_user_id, + p_case_id, + 'unconfirmed conversational rectification' + ); + update public.birth_time_rectification_billing + set state = 'released', + release_action_id = p_action_id, + balance_after = v_balance, + released_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where case_id = p_case_id and user_id = p_user_id and state = 'charged'; + if not found then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + elsif v_billing.state not in ('released', 'migration_waived') then + raise exception 'conversational_billing_failed' using errcode = 'P0001'; + end if; + + return public.conversational_rectification_case_projection(p_user_id, p_case_id); +end; +$$; + +revoke all on function public.conversational_rectification_refund_completed_usage( + uuid, uuid, text +) from public, anon, authenticated, service_role; +revoke all on function public.conversational_rectification_refund_unconfirmed_case( + uuid, uuid, uuid +) from public, anon, authenticated; + +revoke all on function public.reserve_conversational_rectification_fee( + uuid, uuid, bigint, uuid, integer +) from public, anon, authenticated; +revoke all on function public.recover_conversational_rectification_orphan_reservations( + uuid, uuid +) from public, anon, authenticated, service_role; +revoke all on function public.complete_conversational_rectification_fee( + uuid, uuid, bigint, uuid +) from public, anon, authenticated; +revoke all on function public.release_conversational_rectification_fee( + uuid, uuid, bigint, uuid, integer +) from public, anon, authenticated; + +grant execute on function public.reserve_conversational_rectification_fee( + uuid, uuid, bigint, uuid, integer +) to service_role; +grant execute on function public.complete_conversational_rectification_fee( + uuid, uuid, bigint, uuid +) to service_role; +grant execute on function public.release_conversational_rectification_fee( + uuid, uuid, bigint, uuid, integer +) to service_role; + +commit;