fix(admin): remove redundant confirmations and repair code access
Independent Staging Quality Gate / validate (push) Failing after 12m38s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-16 17:54:58 +08:00
parent f12a43ad1d
commit 945d61fa1c
21 changed files with 314 additions and 413 deletions
+18 -17
View File
@@ -3466,32 +3466,33 @@
- 相关记录:BUG-177、BUG-198、BUG-199
- 修复版本:本地未提交候选
## BUG-207 | 管理端业务操作重复要求邮箱验证码手工原因
## BUG-207 | 管理端业务操作重复要求邮箱验证码手工原因与二次确认弹窗
- 状态:resolved(补充修复已完成,待提交与发布)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:管理端兑换码生成/编辑/撤销、管理员角色变更、账务与订阅调整、商品保存/发布、功能开关发布、易支付设置等写操作。
- 用户现象:管理员已经登录后台并具备对应权限,执行批量生成兑换码等日常营销操作时仍需发送并等待邮箱验证码;首轮修复删除邮箱验证后,业务确认弹窗仍强制填写“操作原因”,日常营销操作仍被无必要的二次输入打断
- 根因:多个管理写入被统一接入 operation-level `requireHighRiskAdminMutation`,公共 `ReasonActionModal` 内置权限级邮箱 OTP challenge/proof,导致账户登录安全与每次业务操作二次验证被错误叠加到所有管理业务
- 修复:所有管理业务写入统一`requireAdminMutation`,继续强制管理员会话、细粒度权限与可信 Origin;删除 `/api/admin/reauth`、high-risk challenge/proof cookie 及相关 UI 参数。公共业务确认弹窗改为无需填写任何内容的 `ConfirmActionModal`,兑换码、管理员角色、订单/订阅调整、商品、功能开关、易支付与用户重置请求体不再要求客户端 `reason`服务端按动作注入固定审计标识并继续传给数据库 RPC 的非空 reason 参数
- 安全边界:保留 request ID、数据库权限检查、领域 RPC、append-only 审计最后一位 Owner 保护;保留账户级 Better Auth TOTP MFA 一次性恢复码;普通用户登录、注册找回密码的邮箱 OTP 不受影响。
- 验证:管理端权限、业务确认、兑换码/账务合同、账户级 MFA 等 5 个聚焦测试文件共 47/47 通过;`tsc --noEmit`17 个目标源文件 ESLint 与 `git diff --check` 通过。首轮邮箱复核移除提交已发布到 staging,但“无需手工原因”的补充修复尚未发布。
- 防复发:新增管理业务时只能在登录、权限、Origin、服务端审计标识和数据库审计边界内扩展;不得把邮箱 OTP手工原因输入放回公共业务确认弹窗。账户级 MFA 与普通用户身份验证必须保持独立。
- 相关记录:BUG-155、BUG-156
- 影响面:管理端兑换码生成/编辑/撤销、管理员角色变更、账务与订阅调整、商品保存/发布、功能开关发布、模型发布、易支付设置等写操作。
- 用户现象:管理员已经登录后台并具备对应权限,执行批量生成兑换码等日常操作时仍需发送邮箱验证码;首轮删除邮箱验证后,又先填写“操作原因”,提交真实业务表单后还出现额外的“确定”弹窗,形成连续二次确认
- 根因:多个管理写入被统一接入 operation-level `requireHighRiskAdminMutation`,公共 `ReasonActionModal` 内置权限级邮箱 OTP challenge/proof;后续只把它替换成 `ConfirmActionModal`,仍保留了多余的操作级确认层,没有让真实的数据录入表单直接执行
- 修复:所有管理业务写入统一使`requireAdminMutation`,继续强制管理员会话、对应权限与可信 Origin;删除 `/api/admin/reauth`、high-risk challenge/proof cookie`ReasonActionModal``ConfirmActionModal`、相关 `Popconfirm` / `Modal.confirm` 与 pending-confirm 状态。真实的数据录入 Modal 继续保留,但点击表单的“生成 / 保存 / 发布”等主按钮即直接执行;无额外参数的撤销、重试与开关动作由原按钮直接执行。客户端不再提交手工 `reason`服务端按动作注入固定审计标识并继续传给数据库 RPC 的非空审计字段
- 安全边界:保留 request ID、数据库 actor 身份校验、领域 RPC、细粒度权限、可信 Origin、append-only 审计最后一位 Owner 保护;保留账户级 Better Auth TOTP MFA 一次性恢复码;普通用户登录、注册找回密码的邮箱 OTP 不受影响。
- 验证:全局源码扫描确认管理业务中不存在 `ConfirmActionModal``Popconfirm``Modal.confirm``reason-action-modal` 或“操作原因”;管理端权限、业务直提交流程、兑换码/账务合同、账户级 MFA 等 6 个聚焦测试文件共 52/52 通过;`tsc --noEmit`改动 TS/TSX 文件 ESLint 与 `git diff --check` 通过。
- 防复发:新增管理业务时只能在登录、权限、Origin、服务端审计标识和数据库审计边界内扩展;不得把邮箱 OTP手工原因或通用二次确认弹窗放回日常管理操作。只有真实的数据录入/选择表单可以使用 Modal,账户级 MFA 与普通用户身份验证必须保持独立。
- 相关记录:BUG-155、BUG-156、BUG-209
- 修复版本:本地未提交候选(首轮邮箱复核移除:`4f6cf5782d3871a28e531a4dff9bcc6a2633ce09`
## BUG-208 | self-hosted 管理端生成兑换码返回通用 500
## BUG-209 | self-hosted 管理端生成兑换码返回通用 500,随后合法管理员被权限链拒绝
- 状态:resolved(本地修复,待提交与发布)
- 状态:resolved(本地修复,待提交、迁移与发布)
- 首次发现:2026-08-16
- 最近更新:2026-08-16
- 影响面:self-hosted runtime 的 `POST /api/admin/codes` 批量生成兑换码列表读取、普通用户兑换 production 未在本次诊断中验证。
- 用户现象:具备权限的管理员提交合法点数、数量、到期时间和备注后,接口返回 `500 {"error":"后台服务暂时不可用"}`,无法生成兑换码
- 根因:`runCodeRpc()` 将 JavaScript 对象数组直接作为 `$5::jsonb` 参数交给 `node-postgres`。实际 `pg` serializer 会把该值编码成 PostgreSQL array 文本(例如 `{"{\\"codeHash\\":...}"}`),而不是 JSON 数组;RPC 的 `jsonb` 入参因此无法按预期解析,底层错误又被通用管理错误映射收敛为 500。staging 容器原始 SQLSTATE 未取得,因此不记录未经验证的具体错误码
- 修复:调用 `public.admin_create_redemption_codes` 前显式执行 `JSON.stringify(input.p_codes)`RPC 签名、权限、request ID、明文码仅单次返回和 append-only 审计保持不变。同时由服务端注入 `admin_console_create_redemption_codes` 审计标识,不再依赖客户端原因
- 验证:本地使用项目实际 `pg` serializer 对比确认:对象数组会被编码为 PostgreSQL array 文本,显式序列化后保持合法 JSON 数组文本;新增源码合同锁定 `JSON.stringify(input.p_codes)`、服务端审计标识和无客户端 reason相关 5 个聚焦测试文件共 47/47 通过;`tsc --noEmit`目标 ESLint 与 `git diff --check` 通过。
- 防复发:self-hosted `pg``jsonb` 参数必须显式 JSON 序列化;数据库/源码合同测试需要锁定 `JSON.stringify(input.p_codes)`,不能只依赖 Supabase RPC 测试覆盖参数编码
- 影响面:self-hosted runtime 的兑换码列表、批量生成、编辑与撤销;普通用户兑换 production 未在本次修复中验证。
- 用户现象:第一次提交合法点数、数量、到期时间和备注时,`POST /api/admin/codes` 返回 `500 {"error":"后台服务暂时不可用"}`;修正参数序列化后,已登录且能进入后台的管理员再次提交无 `reason` 请求,返回 `{"error":"无权执行此操作"}`
- 根因:存在两个独立问题。第一,`runCodeRpc()` 将 JavaScript 对象数组直接作为 `$5::jsonb` 参数交给 `node-postgres`,被编码成 PostgreSQL array 文本而不是 JSON 数组。第二,兑换码页面、Refine access-control、API 与 PostgreSQL wrapper 使用了不一致且过窄的 `billing.adjustments.write`;部分合法后台角色只有所有管理员共有的 `admin.access`,因此请求在 UI、API 或数据库任一层都可能被拒绝
- 修复:调用 `public.admin_create_redemption_codes` 前显式执行 `JSON.stringify(input.p_codes)`。兑换码列表、创建、编辑、撤销的 UI 可写判断、Refine 资源读写权限、GET/POST/PATCH/DELETE 路由及三个 PostgreSQL wrapper 全部统一为 `admin.access`。新增向前迁移重建 wrapper 与审计 trigger,把兑换码审计行的 `permission_used` 统一写成 `admin.access`;保留服务端固定审计标识、原 RPC 签名、request ID、明文码仅单次返回和数据库 actor 身份校验
- 验证:本地使用项目实际 `pg` serializer 对比确认显式序列化后保持合法 JSON 数组文本;源码合同锁定兑换码 UI、Refine、四个 API 方法、三个数据库 wrapper 与审计权限一致,且不再依赖客户端 `reason` 或操作确认弹窗。相关 6 个聚焦测试文件共 52/52 通过;`tsc --noEmit`改动 TS/TSX 文件 ESLint 与 `git diff --check` 通过。
- 发布要求:必须先把 `20260816010000_admin_redemption_admin_access.sql` 应用到 staging 数据库,再部署同一精确 SHA;只部署应用代码仍会被旧 PostgreSQL wrapper 按 `billing.adjustments.write` 拒绝
- 防复发:self-hosted `pg``jsonb` 参数必须显式 JSON 序列化;一个管理资源的列表、UI access-control、API guard、数据库 permission check 与审计 `permission_used` 必须使用同一权限语义,不能只改前端或 API。
- 相关记录:BUG-155、BUG-207
- 修复版本:本地未提交候选
@@ -28,7 +28,7 @@ export async function PATCH(
try {
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
"admin.access",
);
const parsedParams = paramsSchema.safeParse(await context.params);
const parsedBody = updateCodeSchema.safeParse(
@@ -64,7 +64,7 @@ export async function DELETE(
try {
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
"admin.access",
);
const parsed = paramsSchema.safeParse(await context.params);
if (!parsed.success) return invalidQueryResponse();
+2 -2
View File
@@ -64,7 +64,7 @@ function serializedCodeRow(row: CodeRow) {
export async function GET(request: Request) {
try {
await requirePermission("billing.orders.read");
await requirePermission("admin.access");
const parsed = parseListQuery(request);
if (!parsed.success) return invalidQueryResponse(parsed.error.flatten());
const { page, pageSize, sort, order, q, status } = parsed.data;
@@ -124,7 +124,7 @@ export async function POST(request: Request) {
try {
const session = await requireAdminMutation(
request,
"billing.adjustments.write",
"admin.access",
);
const parsed = createCodesSchema.safeParse(
await request.json().catch(() => null),
@@ -7,7 +7,6 @@ import { App, Button, Card, Form, Input, Modal, Select, Space, Table, Tag, Typog
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ConfirmActionModal } from "./confirm-action-modal";
import { formatAdminDate } from "./resource-table";
const roleOptions = [
@@ -30,12 +29,11 @@ interface Administrator {
}
type AssignmentForm = { email: string; roleCode: RoleCode };
type PendingRoleAction = {
type RoleAction = {
action: "assign" | "revoke";
roleCode: RoleCode;
userId?: string;
email?: string;
label: string;
};
export default function AdministratorsResource() {
@@ -50,7 +48,6 @@ export default function AdministratorsResource() {
const [assignmentForm] = Form.useForm<AssignmentForm>();
const [assignmentOpen, setAssignmentOpen] = useState(false);
const [assignmentUser, setAssignmentUser] = useState<Administrator | null>(null);
const [pendingAction, setPendingAction] = useState<PendingRoleAction | null>(null);
const [saving, setSaving] = useState(false);
const canManage = Boolean(identity?.permissions.includes("admin.users.manage_roles"));
@@ -60,34 +57,32 @@ export default function AdministratorsResource() {
setAssignmentOpen(true);
}
function prepareAssignment(values: AssignmentForm) {
const role = roleOptions.find((item) => item.value === values.roleCode)!;
setPendingAction({
async function assignRole(values: AssignmentForm) {
await submitRoleAction({
action: "assign",
roleCode: values.roleCode,
...(assignmentUser ? { userId: assignmentUser.id } : { email: values.email.trim() }),
label: `${assignmentUser?.email ?? values.email.trim()} · ${role.label}`,
});
}
async function submitRoleAction() {
if (!pendingAction) return;
async function submitRoleAction(action: RoleAction) {
setSaving(true);
try {
await adminRequestJson("/api/admin/administrators", {
method: pendingAction.action === "assign" ? "POST" : "DELETE",
method: action.action === "assign" ? "POST" : "DELETE",
body: JSON.stringify({
userId: pendingAction.userId,
email: pendingAction.email,
roleCode: pendingAction.roleCode,
userId: action.userId,
email: action.email,
roleCode: action.roleCode,
}),
});
message.success(pendingAction.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销");
setPendingAction(null);
message.success(action.action === "assign" ? "管理员角色已分配" : "管理员角色已撤销");
setAssignmentOpen(false);
setAssignmentUser(null);
assignmentForm.resetFields();
await tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : "管理员角色操作失败");
} finally {
setSaving(false);
}
@@ -121,7 +116,7 @@ export default function AdministratorsResource() {
closable={canManage}
onClose={(event) => {
event.preventDefault();
setPendingAction({ action: "revoke", roleCode: role, userId: item.id, label: `${item.email} · ${role}` });
void submitRoleAction({ action: "revoke", roleCode: role, userId: item.id });
}}
>{role}</Tag>)}</Space>,
},
@@ -132,13 +127,14 @@ export default function AdministratorsResource() {
<Modal
title={assignmentUser ? `${assignmentUser.email} 分配角色` : "按邮箱分配管理员角色"}
open={assignmentOpen}
okText="继续"
okText="分配"
cancelText="取消"
confirmLoading={saving}
onOk={() => assignmentForm.submit()}
onCancel={() => setAssignmentOpen(false)}
destroyOnHidden
>
<Form<AssignmentForm> form={assignmentForm} layout="vertical" onFinish={prepareAssignment}>
<Form<AssignmentForm> form={assignmentForm} layout="vertical" onFinish={(values) => void assignRole(values)}>
<Form.Item name="email" label="用户邮箱" rules={[{ required: true }, { type: "email" }]}>
<Input disabled={Boolean(assignmentUser)} autoComplete="email" />
</Form.Item>
@@ -150,15 +146,6 @@ export default function AdministratorsResource() {
</Form.Item>
</Form>
</Modal>
<ConfirmActionModal
open={Boolean(pendingAction)}
title={pendingAction?.action === "revoke" ? `撤销角色:${pendingAction.label}` : `分配角色:${pendingAction?.label ?? ""}`}
okText={pendingAction?.action === "revoke" ? "确认撤销" : "确认分配"}
danger={pendingAction?.action === "revoke"}
confirmLoading={saving}
onCancel={() => setPendingAction(null)}
onConfirm={submitRoleAction}
/>
</Card>
);
}
@@ -18,7 +18,6 @@ import {
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ConfirmActionModal } from "./confirm-action-modal";
import { formatAdminDate, ResourceTable } from "./resource-table";
const { Text } = Typography;
@@ -48,8 +47,6 @@ export function SubscriptionsResource() {
const [selected, setSelected] = useState<Subscription | null>(null);
const [saving, setSaving] = useState(false);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [revokeTarget, setRevokeTarget] = useState<Subscription | null>(null);
const [extendDays, setExtendDays] = useState<number | null>(null);
const canAdjust = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
@@ -71,19 +68,16 @@ export function SubscriptionsResource() {
await table.tableQuery.refetch();
}
function prepareExtend(values: AdjustmentForm) {
setExtendDays(values.days);
}
async function extend() {
if (!selected || extendDays === null) return;
async function extend(values: AdjustmentForm) {
if (!selected) return;
setSaving(true);
try {
await adjust(selected, "extend", extendDays);
await adjust(selected, "extend", values.days);
message.success("订阅已延长");
setExtendDays(null);
setSelected(null);
form.resetFields();
} catch (error) {
message.error(error instanceof Error ? error.message : "延长订阅失败");
} finally {
setSaving(false);
}
@@ -94,7 +88,8 @@ export function SubscriptionsResource() {
try {
await adjust(item, "revoke", undefined);
message.success("订阅已撤销");
setRevokeTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "撤销订阅失败");
} finally {
setRevokingId(null);
}
@@ -146,7 +141,6 @@ export function SubscriptionsResource() {
disabled={item.status !== "active"}
onClick={() => {
setSelected(item);
setExtendDays(null);
form.setFieldsValue({ days: 30 });
}}
>
@@ -159,7 +153,7 @@ export function SubscriptionsResource() {
danger
disabled={item.status !== "active"}
loading={revokingId === item.id}
onClick={() => setRevokeTarget(item)}
onClick={() => void revoke(item)}
>
</Button>
@@ -179,9 +173,10 @@ export function SubscriptionsResource() {
/>
<Modal
title="人工延长订阅"
open={Boolean(selected && extendDays === null)}
okText="继续"
open={Boolean(selected)}
okText="确认延长"
cancelText="取消"
confirmLoading={saving}
onOk={() => form.submit()}
onCancel={() => setSelected(null)}
destroyOnHidden
@@ -189,7 +184,7 @@ export function SubscriptionsResource() {
<Form<AdjustmentForm>
form={form}
layout="vertical"
onFinish={prepareExtend}
onFinish={extend}
>
<Form.Item name="days" label="延长天数" rules={[{ required: true }]}>
<InputNumber
@@ -201,23 +196,6 @@ export function SubscriptionsResource() {
</Form.Item>
</Form>
</Modal>
<ConfirmActionModal
open={Boolean(selected && extendDays !== null)}
title="延长订阅权益"
okText="确认延长"
confirmLoading={saving}
onCancel={() => setExtendDays(null)}
onConfirm={extend}
/>
<ConfirmActionModal
open={Boolean(revokeTarget)}
title="撤销订阅权益"
okText="确认撤销"
danger
confirmLoading={Boolean(revokingId)}
onCancel={() => setRevokeTarget(null)}
onConfirm={() => revoke(revokeTarget!)}
/>
</List>
);
}
@@ -248,17 +226,18 @@ export function OrdersResource() {
const { data: identity } = useGetIdentity<AdminIdentity>();
const invalidate = useInvalidate();
const [target, setTarget] = useState<{
order: Order;
orderId: string;
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() {
if (!target) return;
setSaving(true);
async function adjust(
order: Order,
action: "retry_grant" | "compensate" | "record_refund",
) {
setTarget({ orderId: order.id, action });
try {
const result = await adminRequestJson<{
data: { actionSuccess: boolean };
@@ -266,26 +245,25 @@ export function OrdersResource() {
method: "POST",
headers: { "x-request-id": crypto.randomUUID() },
body: JSON.stringify({
id: target.order.id,
action: target.action,
expectedVersion: target.order.adjustmentVersion,
id: order.id,
action,
expectedVersion: order.adjustmentVersion,
}),
});
await invalidate({ resource: "orders", invalidates: ["list"] });
if (target.action === "retry_grant" && !result.data.actionSuccess) {
if (action === "retry_grant" && !result.data.actionSuccess) {
message.warning("已执行重试,但权益发放仍失败,请查看最新错误");
} else {
message.success(
target.action === "record_refund"
action === "record_refund"
? "已记录账务全额退款状态"
: "订单权益操作已完成",
);
}
setTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "订单操作失败");
} finally {
setSaving(false);
setTarget(null);
}
}
@@ -369,7 +347,8 @@ export function OrdersResource() {
<Button
size="small"
disabled={item.grantStatus !== "failed"}
onClick={() => setTarget({ order: item, action: "retry_grant" })}
loading={target?.orderId === item.id && target.action === "retry_grant"}
onClick={() => void adjust(item, "retry_grant")}
>
</Button>
@@ -378,7 +357,8 @@ export function OrdersResource() {
disabled={
item.grantStatus !== "failed" || item.grantType !== "credits"
}
onClick={() => setTarget({ order: item, action: "compensate" })}
loading={target?.orderId === item.id && target.action === "compensate"}
onClick={() => void adjust(item, "compensate")}
>
</Button>
@@ -390,7 +370,8 @@ export function OrdersResource() {
item.paidAt == null ||
item.status === "refunded"
}
onClick={() => setTarget({ order: item, action: "record_refund" })}
loading={target?.orderId === item.id && target.action === "record_refund"}
onClick={() => void adjust(item, "record_refund")}
>
退
</Button>
@@ -398,14 +379,11 @@ export function OrdersResource() {
) : null,
},
];
const modalTitle =
target?.action === "retry_grant"
? "重试失败的权益发放"
: target?.action === "compensate"
? "人工补偿失败的积分权益"
: "仅记录账务全额退款(不会调用支付网关)";
return (
<>
<Typography.Paragraph type="secondary" style={{ marginBottom: 12 }}>
退
</Typography.Paragraph>
<ResourceTable<Order>
resource="orders"
title="支付订单与权益发放"
@@ -419,15 +397,6 @@ export function OrdersResource() {
"recorded",
].map((value) => ({ value, label: value }))}
/>
<ConfirmActionModal
open={Boolean(target)}
title={modalTitle}
okText="确认执行"
danger={target?.action === "record_refund"}
confirmLoading={saving}
onCancel={() => setTarget(null)}
onConfirm={adjust}
/>
</>
);
}
@@ -22,7 +22,6 @@ import {
import dayjs from "dayjs";
import { useState } from "react";
import { ConfirmActionModal } from "@/components/admin/confirm-action-modal";
import {
formatAdminDate,
ResourceTable,
@@ -72,52 +71,54 @@ export default function CodesPage() {
const { mutateAsync: updateCode, mutation: updateMutation } =
useUpdate<CodeRecord>();
const [createOpen, setCreateOpen] = useState(false);
const [pendingCreate, setPendingCreate] = useState<CreateValues | null>(null);
const [editRecord, setEditRecord] = useState<CodeRecord | null>(null);
const [pendingEdit, setPendingEdit] = useState<EditValues | null>(null);
const [generated, setGenerated] = useState<CodeRecord[]>([]);
const [revokeRecord, setRevokeRecord] = useState<CodeRecord | null>(null);
const [revoking, setRevoking] = useState(false);
const [revokingId, setRevokingId] = useState<string | null>(null);
const [createForm] = Form.useForm<CreateValues>();
const [editForm] = Form.useForm<EditValues>();
const writable = Boolean(
identity?.permissions.includes("billing.adjustments.write"),
);
const writable = Boolean(identity?.permissions.includes("admin.access"));
async function submitCreate() {
if (!pendingCreate) return;
const result = await createCodes({
resource: "codes",
values: {
credits: pendingCreate.credits,
count: pendingCreate.count,
expiresAt: pendingCreate.expiresAt?.toISOString() ?? null,
note: pendingCreate.note?.trim() || null,
},
successNotification: false,
});
setGenerated(result.data.generated);
setPendingCreate(null);
setCreateOpen(false);
createForm.resetFields();
async function submitCreate(values: CreateValues) {
try {
const result = await createCodes({
resource: "codes",
values: {
credits: values.credits,
count: values.count,
expiresAt: values.expiresAt?.toISOString() ?? null,
note: values.note?.trim() || null,
},
successNotification: false,
});
setGenerated(result.data.generated);
setCreateOpen(false);
createForm.resetFields();
} catch (error) {
message.error(error instanceof Error ? error.message : "生成兑换码失败");
}
}
async function submitEdit() {
if (!editRecord || !pendingEdit) return;
await updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: pendingEdit.note?.trim() || null,
expiresAt: pendingEdit.expiresAt?.toISOString() ?? null,
},
});
setPendingEdit(null);
setEditRecord(null);
async function submitEdit(values: EditValues) {
if (!editRecord) return;
try {
await updateCode({
resource: "codes",
id: editRecord.id,
values: {
note: values.note?.trim() || null,
expiresAt: values.expiresAt?.toISOString() ?? null,
},
successNotification: false,
});
setEditRecord(null);
message.success("兑换码已保存");
} catch (error) {
message.error(error instanceof Error ? error.message : "保存兑换码失败");
}
}
async function revoke(record: CodeRecord) {
setRevoking(true);
setRevokingId(record.id);
try {
await adminRequestJson(`/api/admin/codes/${record.id}`, {
method: "DELETE",
@@ -125,11 +126,10 @@ export default function CodesPage() {
});
await invalidate({ resource: "codes", invalidates: ["list"] });
message.success("兑换码已撤销");
setRevokeRecord(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "撤销失败");
} finally {
setRevoking(false);
setRevokingId(null);
}
}
@@ -175,7 +175,6 @@ export default function CodesPage() {
<Button
size="small"
onClick={() => {
setPendingEdit(null);
setEditRecord(record);
editForm.setFieldsValue({
note: record.note ?? undefined,
@@ -188,8 +187,8 @@ export default function CodesPage() {
<Button
danger
size="small"
loading={revoking && revokeRecord?.id === record.id}
onClick={() => setRevokeRecord(record)}
loading={revokingId === record.id}
onClick={() => void revoke(record)}
>
</Button>
@@ -216,10 +215,7 @@ export default function CodesPage() {
writable ? (
<Button
type="primary"
onClick={() => {
setPendingCreate(null);
setCreateOpen(true);
}}
onClick={() => setCreateOpen(true)}
>
</Button>
@@ -230,10 +226,7 @@ export default function CodesPage() {
<Modal
title="批量生成兑换码"
open={createOpen}
onCancel={() => {
setPendingCreate(null);
setCreateOpen(false);
}}
onCancel={() => setCreateOpen(false)}
footer={null}
destroyOnHidden
>
@@ -241,7 +234,7 @@ export default function CodesPage() {
form={createForm}
layout="vertical"
initialValues={{ credits: 10, count: 1 }}
onFinish={setPendingCreate}
onFinish={(values) => void submitCreate(values)}
>
<Form.Item
name="credits"
@@ -289,14 +282,15 @@ export default function CodesPage() {
<Modal
title="编辑未兑换码"
open={Boolean(editRecord)}
onCancel={() => {
setPendingEdit(null);
setEditRecord(null);
}}
onCancel={() => setEditRecord(null)}
footer={null}
destroyOnHidden
>
<Form form={editForm} layout="vertical" onFinish={setPendingEdit}>
<Form
form={editForm}
layout="vertical"
onFinish={(values) => void submitEdit(values)}
>
<Form.Item name="expiresAt" label="到期时间">
<DatePicker showTime style={{ width: "100%" }} />
</Form.Item>
@@ -313,31 +307,6 @@ export default function CodesPage() {
</Button>
</Form>
</Modal>
<ConfirmActionModal
open={Boolean(pendingCreate)}
title="生成兑换码"
okText="确认生成"
confirmLoading={createMutation.isPending}
onCancel={() => setPendingCreate(null)}
onConfirm={submitCreate}
/>
<ConfirmActionModal
open={Boolean(pendingEdit)}
title="编辑未兑换码"
okText="确认保存"
confirmLoading={updateMutation.isPending}
onCancel={() => setPendingEdit(null)}
onConfirm={submitEdit}
/>
<ConfirmActionModal
open={Boolean(revokeRecord)}
title="撤销兑换码"
okText="确认撤销"
danger
confirmLoading={revoking}
onCancel={() => setRevokeRecord(null)}
onConfirm={() => revoke(revokeRecord!)}
/>
</>
);
}
@@ -1,59 +0,0 @@
"use client";
import { Alert, Modal, Typography } from "antd";
import { useState } from "react";
type ConfirmActionModalProps = {
open: boolean;
title: string;
okText: string;
danger?: boolean;
confirmLoading?: boolean;
onCancel: () => void;
onConfirm: () => Promise<void> | void;
};
export function ConfirmActionModal({
open,
title,
okText,
danger = false,
confirmLoading = false,
onCancel,
onConfirm,
}: ConfirmActionModalProps) {
const [actionLoading, setActionLoading] = useState(false);
const [actionError, setActionError] = useState<string>();
async function confirm() {
setActionError(undefined);
setActionLoading(true);
try {
await onConfirm();
} catch (error) {
setActionError(error instanceof Error ? error.message : "操作失败,请稍后再试");
} finally {
setActionLoading(false);
}
}
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText="取消"
okButtonProps={{ danger }}
confirmLoading={confirmLoading || actionLoading}
onCancel={onCancel}
onOk={() => void confirm()}
afterOpenChange={() => setActionError(undefined)}
destroyOnHidden
>
<Typography.Paragraph type="secondary">
ID
</Typography.Paragraph>
{actionError ? <Alert type="error" showIcon message={actionError} /> : null}
</Modal>
);
}
@@ -8,7 +8,6 @@ import { App, Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Ta
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ConfirmActionModal } from "./confirm-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
@@ -51,7 +50,6 @@ export default function FeatureFlagsManagement() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [publishTarget, setPublishTarget] = useState<FeatureFlag | null>(null);
const canWrite = Boolean(identity?.permissions.includes("ops.flags.write"));
function edit(item?: FeatureFlag) {
@@ -110,7 +108,8 @@ export default function FeatureFlagsManagement() {
});
message.success("功能开关已发布");
await table.tableQuery.refetch();
setPublishTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "发布失败");
} finally {
setPublishingId(null);
}
@@ -125,7 +124,7 @@ export default function FeatureFlagsManagement() {
{
title: "操作",
fixed: "right",
render: (_, item) => <Space>{canWrite && <Button type="link" onClick={() => edit(item)}>稿</Button>}{canWrite && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}></Button>}</Space>,
render: (_, item) => <Space>{canWrite && <Button type="link" onClick={() => edit(item)}>稿</Button>}{canWrite && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}></Button>}</Space>,
},
];
@@ -152,13 +151,5 @@ export default function FeatureFlagsManagement() {
<Form.Item name="configJson" label="配置 JSON" rules={[{ required: true }]}><Input.TextArea rows={6} spellCheck={false} /></Form.Item>
</Form>
</Modal>
<ConfirmActionModal
open={Boolean(publishTarget)}
title={`发布功能开关${publishTarget ? `${publishTarget.flagKey}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
onCancel={() => setPublishTarget(null)}
onConfirm={() => publish(publishTarget!)}
/>
</List>;
}
@@ -78,7 +78,6 @@ type ModelsPayload = { data: ModelVersion[]; total: number; providers: Provider[
type DiscoveredModel = { id: string; label?: string };
type DiscoveredModelsPayload = { data: DiscoveredModel[] };
type ModelFilters = { q?: string; status?: string };
type VersionAction = { action: "publish" | "rollback"; model: ModelVersion };
const providerTypeLabels: Record<ProviderType, string> = {
openai: "OpenAI 官方",
@@ -114,7 +113,6 @@ export default function ModelManagement() {
const [discovering, setDiscovering] = useState(false);
const [discoveredModels, setDiscoveredModels] = useState<DiscoveredModel[]>([]);
const [actingId, setActingId] = useState<string | null>(null);
const [versionAction, setVersionAction] = useState<VersionAction | null>(null);
const [filters, setFilters] = useState<ModelFilters>({});
const canWrite = Boolean(identity?.permissions.includes("models.write"));
const canTest = Boolean(identity?.permissions.includes("models.test"));
@@ -271,15 +269,12 @@ export default function ModelManagement() {
}
}
async function submitVersionAction() {
if (!versionAction) return;
const { action, model } = versionAction;
async function runVersionAction(action: "publish" | "rollback", model: ModelVersion) {
try {
await act(action === "publish"
? { action, versionId: model.id }
: { action, configId: model.configId, targetVersion: model.version },
action === "publish" ? "模型已发布" : "模型已回滚", model.id);
setVersionAction(null);
} catch (error) {
message.error(error instanceof Error ? error.message : action === "publish" ? "发布失败" : "回滚失败");
}
@@ -318,8 +313,8 @@ export default function ModelManagement() {
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openModel(item)}>稿</Button>}
{canTest && <Button type="link" loading={actingId === item.id} onClick={() => void testVersion(item)}></Button>}
{canPublish && item.status !== "published" && <Button type="link" disabled={item.isDefault && !item.enabled} title={item.isDefault && !item.enabled ? "默认模型必须先启用" : "发布前必须先通过此版本的短期连接测试"} loading={actingId === item.id} onClick={() => setVersionAction({ action: "publish", model: item })}></Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => setVersionAction({ action: "rollback", model: item })}></Button>}
{canPublish && item.status !== "published" && <Button type="link" disabled={item.isDefault && !item.enabled} title={item.isDefault && !item.enabled ? "默认模型必须先启用" : "发布前必须先通过此版本的短期连接测试"} loading={actingId === item.id} onClick={() => void runVersionAction("publish", item)}></Button>}
{canRollback && item.status !== "draft" && <Button type="link" danger loading={actingId === item.id} onClick={() => void runVersionAction("rollback", item)}></Button>}
</Space>,
},
];
@@ -412,21 +407,5 @@ export default function ModelManagement() {
</Row>
</Form>
</Modal>
<Modal
open={Boolean(versionAction)}
title={versionAction?.action === "rollback" ? `回滚到 v${versionAction.model.version}` : "发布模型版本"}
okText={versionAction?.action === "rollback" ? "确认回滚" : "确认发布"}
okButtonProps={{ danger: versionAction?.action === "rollback" }}
confirmLoading={Boolean(actingId)}
onCancel={() => setVersionAction(null)}
onOk={() => void submitVersionAction()}
destroyOnHidden
>
<Text>
{versionAction?.action === "rollback"
? "确认将此历史版本恢复为新的已发布版本?"
: "确认发布此模型版本?"}
</Text>
</Modal>
</List>;
}
@@ -2,7 +2,7 @@
import { PlusOutlined } from "@ant-design/icons";
import { List } from "@refinedev/antd";
import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Popconfirm, Row, Col, Space, Switch, Table, Typography, type TableColumnsType } from "antd";
import { Alert, App, Button, Card, Form, Input, InputNumber, Modal, Row, Col, Space, Switch, Table, Typography, type TableColumnsType } from "antd";
import { useCallback, useEffect, useState } from "react";
const { Text } = Typography;
@@ -135,7 +135,7 @@ export function PackageManagement() {
{ title: "点数", dataIndex: "credits", align: "right" },
{ title: "排序", dataIndex: "sortOrder", align: "right" },
{ title: "状态", dataIndex: "enabled", render: (enabled) => <Text type={enabled ? undefined : "secondary"}>{enabled ? "启用" : "停用"}</Text> },
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}></Button>{item.enabled && <Popconfirm title="停用此套餐?" description="停用后用户将无法继续购买。" okText="停用" cancelText="取消" onConfirm={() => disablePackage(item.id)}><Button type="link" danger loading={disablingId === item.id}></Button></Popconfirm>}</Space> },
{ title: "操作", key: "actions", fixed: "right", render: (_, item) => <Space><Button type="link" onClick={() => openEditModal(item)}></Button>{item.enabled && <Button type="link" danger loading={disablingId === item.id} onClick={() => void disablePackage(item.id)}></Button>}</Space> },
];
return (
@@ -25,7 +25,6 @@ import {
import type { Dayjs } from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { ConfirmActionModal } from "@/components/admin/confirm-action-modal";
import type { AdminIdentity } from "@/lib/admin/providers";
const { Text } = Typography;
@@ -109,7 +108,6 @@ export default function PaymentManagement() {
const [epaySaving, setEpaySaving] = useState(false);
const [epayTesting, setEpayTesting] = useState(false);
const [epayError, setEpayError] = useState("");
const [pendingEpaySettings, setPendingEpaySettings] = useState<EpaySettingsForm | null>(null);
const canAdjustBilling = Boolean(identity?.permissions.includes("billing.adjustments.write"));
const loadPayments = useCallback(async () => {
@@ -175,23 +173,19 @@ export default function PaymentManagement() {
}
}
function prepareEpaySettings(values: EpaySettingsForm) {
setPendingEpaySettings(values);
}
async function saveEpaySettings() {
if (!pendingEpaySettings) return;
async function saveEpaySettings(values: EpaySettingsForm) {
setEpaySaving(true);
try {
await responsePayload(await fetch("/api/admin/epay-settings", {
method: "PUT",
headers: { "content-type": "application/json" },
body: JSON.stringify({ ...pendingEpaySettings, newKey: pendingEpaySettings.newKey || undefined }),
body: JSON.stringify({ ...values, newKey: values.newKey || undefined }),
}));
epayForm.setFieldValue("newKey", "");
message.success("Z-Pay(易支付)配置已保存");
await loadEpaySettings();
setPendingEpaySettings(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "保存易支付配置失败");
} finally {
setEpaySaving(false);
}
@@ -237,7 +231,7 @@ export default function PaymentManagement() {
{epaySettings && <Text type="secondary">{{ database: "数据库", environment: "环境变量", unconfigured: "未配置" }[epaySettings.source]} · {epaySettings.complete ? "配置完整" : "配置不完整"} · {epaySettings.keyConfigured ? "密钥已配置" : "密钥未配置"} · {epaySettings.chatEnabled ? "对话支付开放" : "对话支付关闭"}</Text>}
{epayError && <Alert type="error" showIcon message="易支付配置读取失败" description={epayError} action={<Button size="small" onClick={() => void loadEpaySettings()}></Button>} />}
<Alert type="info" showIcon message="商户密钥不会回显" description="密钥输入框始终为空;更新现有数据库配置时留空会保留原密钥。首次从环境变量迁移到数据库时必须重新输入密钥。" />
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={prepareEpaySettings} requiredMark="optional">
<Form<EpaySettingsForm> form={epayForm} layout="vertical" onFinish={saveEpaySettings} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} lg={12}><Form.Item name="gatewayUrl" label="网关地址" rules={[{ required: true, message: "请输入网关地址" }, { type: "url", message: "请输入有效 URL" }]}><Input placeholder="https://pay.example.com" /></Form.Item></Col>
<Col xs={24} lg={12}><Form.Item name="pid" label="商户 ID" rules={[{ required: true, message: "请输入商户 ID" }, { max: 200 }]}><Input /></Form.Item></Col>
@@ -277,14 +271,6 @@ export default function PaymentManagement() {
</Space>
</Card>
</Space>
<ConfirmActionModal
open={Boolean(pendingEpaySettings)}
title="保存易支付设置"
okText="确认保存"
confirmLoading={epaySaving}
onCancel={() => setPendingEpaySettings(null)}
onConfirm={saveEpaySettings}
/>
</List>
);
}
@@ -23,7 +23,6 @@ import {
import { useState } from "react";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
import { ConfirmActionModal } from "./confirm-action-modal";
import { formatAdminDate } from "./resource-table";
const { Text } = Typography;
@@ -91,7 +90,6 @@ type ProductForm = Omit<Product, "id" | "version" | "priceCents" | "status" | "e
entitlementsJson: string;
};
type PendingProductSave = Record<string, unknown>;
type ProductFilters = { q?: string; status?: string };
const defaultEntitlements: Entitlement[] = [{
@@ -121,8 +119,6 @@ export default function ProductManagement() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [publishTarget, setPublishTarget] = useState<Product | null>(null);
const [pendingSave, setPendingSave] = useState<PendingProductSave | null>(null);
const canWrite = Boolean(identity?.permissions.includes("billing.products.write"));
const canPublish = Boolean(identity?.permissions.includes("billing.products.publish"));
@@ -159,7 +155,7 @@ export default function ProductManagement() {
setOpen(true);
}
function prepareSave(values: ProductForm) {
async function save(values: ProductForm) {
let entitlements: unknown;
try {
entitlements = JSON.parse(values.entitlementsJson);
@@ -167,37 +163,33 @@ export default function ProductManagement() {
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() {
if (!pendingSave) return;
setSaving(true);
try {
await adminRequestJson("/api/admin/products", {
method: "POST",
body: JSON.stringify(pendingSave),
body: JSON.stringify({
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,
}),
});
message.success("商品草稿已保存");
setPendingSave(null);
setOpen(false);
form.resetFields();
await table.tableQuery.refetch();
} catch (error) {
message.error(error instanceof Error ? error.message : "保存商品草稿失败");
} finally {
setSaving(false);
}
@@ -212,7 +204,8 @@ export default function ProductManagement() {
});
message.success("商品已发布");
await table.tableQuery.refetch();
setPublishTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "发布商品失败");
} finally {
setPublishingId(null);
}
@@ -235,7 +228,7 @@ export default function ProductManagement() {
fixed: "right",
render: (_, item) => <Space>
{canWrite && <Button type="link" onClick={() => openProduct(item)}>稿</Button>}
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => setPublishTarget(item)}></Button>}
{canPublish && item.status !== "published" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}></Button>}
</Space>,
},
];
@@ -256,7 +249,7 @@ export default function ProductManagement() {
<Table {...table.tableProps} columns={columns} rowKey="id" scroll={{ x: "max-content" }} />
</Space>
<Modal title={editing ? `编辑 ${editing.name}` : "新建商品"} open={open} width={860} confirmLoading={saving} okText="保存草稿" cancelText="取消" onOk={() => form.submit()} onCancel={() => setOpen(false)} destroyOnHidden>
<Form<ProductForm> form={form} layout="vertical" onFinish={prepareSave} requiredMark="optional">
<Form<ProductForm> form={form} layout="vertical" onFinish={save} requiredMark="optional">
<Row gutter={16}>
<Col xs={24} md={8}><Form.Item name="code" label="商品代码" rules={[{ required: true }, { pattern: /^[a-z][a-z0-9_]{1,79}$/ }]}><Input disabled={Boolean(editing)} /></Form.Item></Col>
<Col xs={24} md={8}><Form.Item name="name" label="名称" rules={[{ required: true }, { max: 80 }]}><Input /></Form.Item></Col>
@@ -277,21 +270,5 @@ export default function ProductManagement() {
</Row>
</Form>
</Modal>
<ConfirmActionModal
open={Boolean(pendingSave)}
title="保存商品草稿"
okText="确认保存"
confirmLoading={saving}
onCancel={() => setPendingSave(null)}
onConfirm={save}
/>
<ConfirmActionModal
open={Boolean(publishTarget)}
title={`发布商品${publishTarget ? `${publishTarget.name}` : ""}`}
okText="确认发布"
confirmLoading={Boolean(publishingId)}
onCancel={() => setPublishTarget(null)}
onConfirm={() => publish(publishTarget!)}
/>
</List>;
}
@@ -4,8 +4,6 @@ import { useGetIdentity } from "@refinedev/core";
import { App, Button, Space, Typography, type TableColumnsType } from "antd";
import { useState } from "react";
import { ConfirmActionModal } from "./confirm-action-modal";
import { formatAdminDate, ResourceTable } from "@/components/admin/resource-table";
import { adminRequestJson, type AdminIdentity } from "@/lib/admin/providers";
@@ -38,7 +36,6 @@ export default function UsersPage() {
const { data: identity } = useGetIdentity<AdminIdentity>();
const [revealed, setRevealed] = useState<Record<string, RevealedBirthData>>({});
const [revealingId, setRevealingId] = useState<string | null>(null);
const [resetTarget, setResetTarget] = useState<UserRecord | null>(null);
const [resetting, setResetting] = useState(false);
const canReset = Boolean(identity?.permissions.includes("admin.users.manage_roles"));
const canReveal = Boolean(identity?.permissions.includes("admin.customers.birth_data.read"));
@@ -58,8 +55,7 @@ export default function UsersPage() {
}
}
async function resetAccount() {
if (!resetTarget) return;
async function resetAccount(user: UserRecord) {
setResetting(true);
try {
await adminRequestJson<{ data: { credits: number } }>(
@@ -67,21 +63,19 @@ export default function UsersPage() {
{
method: "POST",
body: JSON.stringify({
userId: resetTarget.id,
userId: user.id,
confirmation: "RESET",
}),
},
);
message.success(`已重置 ${resetTarget.email},登录身份、管理员角色和积分保持不变`);
message.success(`已重置 ${user.email},登录身份、管理员角色和积分保持不变`);
setRevealed((current) => {
const next = { ...current };
delete next[resetTarget.id];
delete next[user.id];
return next;
});
setResetTarget(null);
} catch (error) {
message.error(error instanceof Error ? error.message : "重置账号失败");
throw error;
} finally {
setResetting(false);
}
@@ -118,22 +112,11 @@ export default function UsersPage() {
{ title: "注册时间", dataIndex: "createdAt", sorter: true, render: formatAdminDate },
...(canReset ? [{
title: "操作",
render: (_: unknown, item: UserRecord) => <Button danger size="small" onClick={() => setResetTarget(item)}>
render: (_: unknown, item: UserRecord) => <Button danger size="small" loading={resetting} onClick={() => void resetAccount(item)}>
</Button>,
}] : []),
];
return <>
<ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />
<ConfirmActionModal
open={Boolean(resetTarget)}
title={`确认重置 ${resetTarget?.email ?? "该账号"} 的资料与会话?登录身份、管理员角色、积分及账务审计记录会保留。`}
okText="确认重置"
danger
confirmLoading={resetting}
onCancel={() => setResetTarget(null)}
onConfirm={resetAccount}
/>
</>;
return <ResourceTable<UserRecord> resource="customers" title="用户资料(列表始终脱敏)" columns={columns} />;
}
+1 -1
View File
@@ -187,7 +187,7 @@ const resourcePermissions: Record<string, { read: string; write?: string }> = {
},
roles: { read: "admin.users.read" },
customers: { read: "admin.customers.read" },
codes: { read: "billing.orders.read", write: "billing.adjustments.write" },
codes: { read: "admin.access", write: "admin.access" },
"credit-transactions": { read: "billing.orders.read" },
consultations: { read: "billing.orders.read" },
"audit-logs": { read: "audit.read" },
@@ -0,0 +1,106 @@
-- Redemption-code management is an ordinary admin-console capability.
-- Keep actor verification, trusted request IDs and server-owned audit markers,
-- but do not reuse the narrower billing adjustment permission.
create or replace function public.require_admin_redemption_reason()
returns trigger
language plpgsql
set search_path = ''
as $$
declare
v_reason text := nullif(btrim(current_setting('app.admin_redemption_reason', true)), '');
begin
if new.action in ('redemption_code.create','redemption_code.update','redemption_code.revoke') then
if v_reason is null or char_length(v_reason) > 500 then
raise exception 'admin_reason_required' using errcode='22023';
end if;
new.permission_used := 'admin.access';
new.reason := v_reason;
end if;
return new;
end;
$$;
revoke all on function public.require_admin_redemption_reason()
from public, anon, authenticated, service_role;
create or replace function public.admin_create_redemption_codes(
p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_codes jsonb,p_reason text
)
returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz,
redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz)
language plpgsql security definer set search_path = ''
as $$
begin
if not public.admin_has_permission(p_actor_user_id,'admin.access') 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;
perform set_config('app.admin_redemption_reason',btrim(p_reason),true);
return query select * from public.admin_create_redemption_codes(
p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_codes
);
end;
$$;
create or replace function public.admin_update_redemption_code(
p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_code_id uuid,
p_set_note boolean,p_note text,p_set_expires_at boolean,p_expires_at timestamptz,p_reason text
)
returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz,
redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz)
language plpgsql security definer set search_path = ''
as $$
begin
if not public.admin_has_permission(p_actor_user_id,'admin.access') 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;
perform set_config('app.admin_redemption_reason',btrim(p_reason),true);
return query select * from public.admin_update_redemption_code(
p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_code_id,
p_set_note,p_note,p_set_expires_at,p_expires_at
);
end;
$$;
create or replace function public.admin_revoke_redemption_code(
p_actor_user_id uuid,p_actor_email text,p_actor_role text,p_request_id text,p_code_id uuid,p_reason text
)
returns table(id uuid,code_mask text,credits integer,expires_at timestamptz,note text,created_at timestamptz,
redeemed_by uuid,redeemed_email text,redeemed_at timestamptz,revoked_by uuid,revoked_at timestamptz)
language plpgsql security definer set search_path = ''
as $$
begin
if not public.admin_has_permission(p_actor_user_id,'admin.access') 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;
perform set_config('app.admin_redemption_reason',btrim(p_reason),true);
return query select * from public.admin_revoke_redemption_code(
p_actor_user_id,p_actor_email,p_actor_role,p_request_id,p_code_id
);
end;
$$;
revoke all on function public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text),
public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text),
public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)
from public, anon, authenticated, service_role;
do $$
begin
if exists(select 1 from pg_roles where rolname='admin_runtime') then
grant execute on function public.admin_create_redemption_codes(uuid,text,text,text,jsonb,text),
public.admin_update_redemption_code(uuid,text,text,text,uuid,boolean,text,boolean,timestamptz,text),
public.admin_revoke_redemption_code(uuid,text,text,text,uuid,text)
to admin_runtime;
end if;
end
$$;
+2 -5
View File
@@ -25,7 +25,6 @@ const compatibilityRoles = readFileSync(new URL("../../deploy/postgres/002-ensur
const administratorsRoute = readFileSync(new URL("../src/app/api/admin/administrators/route.ts", import.meta.url), "utf8");
const mfaRoute = readFileSync(new URL("../src/app/api/admin/mfa/route.ts", import.meta.url), "utf8");
const mfaSecurity = readFileSync(new URL("../src/components/admin/mfa-security.tsx", import.meta.url), "utf8");
const confirmActionModal = readFileSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url), "utf8");
const customersRoute = readFileSync(new URL("../src/app/api/admin/customers/route.ts", import.meta.url), "utf8");
const adminUser = readFileSync(new URL("../src/lib/supabase/admin.ts", import.meta.url), "utf8");
const codesRoute = readFileSync(new URL("../src/app/api/admin/codes/route.ts", import.meta.url), "utf8");
@@ -50,7 +49,7 @@ test("admin APIs use persisted Better Auth roles with admin-only boundaries", ()
assert.doesNotMatch(auth, /ADMIN_EMAILS|isAdminEmail/);
assert.match(authBoundary, /readAuthProvider\(\)\?\.trim\(\) !== "self-hosted"/);
assert.match(authBoundary, /后台服务暂时不可用", 503/);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/);
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
assert.doesNotMatch(codesRoute, /requireHighRiskAdminMutation/);
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
@@ -237,9 +236,7 @@ test("administrator writes keep permission and origin checks without operation-l
assert.doesNotMatch(adminHttp, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
assert.doesNotMatch(administratorsRoute, /requireHighRiskAdminMutation/);
assert.equal(existsSync(new URL("../src/app/api/admin/reauth/route.ts", import.meta.url)), false);
assert.doesNotMatch(confirmActionModal, /操作原因|name="reason"|TextArea/);
assert.match(confirmActionModal, /await onConfirm\(\)/);
assert.doesNotMatch(confirmActionModal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
assert.equal(existsSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url)), false);
assert.match(authFactory, /twoFactor\(/);
assert.match(authFactory, /schema: identityModelMapping\.twoFactor/);
+3 -6
View File
@@ -1,5 +1,5 @@
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { existsSync, readFileSync } from "node:fs";
import test from "node:test";
import {
@@ -75,13 +75,10 @@ test("MFA API keeps native Better Auth enrollment, recovery, rotation, and proof
assert.match(migration, /grant select, insert, update, delete on table identity\.two_factors[\s\S]*identity_runtime/);
});
test("business confirmation UI has no per-operation input while account MFA remains separate", () => {
const modal = readFileSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url), "utf8");
test("business operation confirmation UI is removed while account MFA remains separate", () => {
const http = readFileSync(new URL("../src/lib/admin/http.ts", import.meta.url), "utf8");
assert.doesNotMatch(modal, /操作原因|name="reason"|TextArea/);
assert.match(modal, /await onConfirm\(\)/);
assert.doesNotMatch(modal, /邮箱验证码|\/api\/admin\/reauth|reauthPermission/);
assert.equal(existsSync(new URL("../src/components/admin/confirm-action-modal.tsx", import.meta.url)), false);
assert.match(http, /requireAdminMutation/);
assert.doesNotMatch(http, /requireHighRiskAdminMutation|verifyHighRiskAdminProof/);
});
@@ -43,20 +43,19 @@ test("admin mutations retain session, permission, trusted-origin, server audit,
const codesRoute = source("src/app/api/admin/codes/route.ts");
const codesHelper = source("src/lib/admin/codes.ts");
const codesUi = source("src/components/admin/codes-resource.tsx");
const modal = source("src/components/admin/confirm-action-modal.tsx");
assert.equal(existsSync(new URL("src/components/admin/confirm-action-modal.tsx", root)), false);
assert.match(http, /requirePermission\(permission, request\.headers\)/);
assert.match(http, /isTrustedAdminMutationRequest\(request, process\.env\.ADMIN_USER_ORIGIN\)/);
assert.match(codesRoute, /requireAdminMutation\([\s\S]*"billing\.adjustments\.write"/);
assert.match(codesRoute, /requireAdminMutation\([\s\S]*"admin\.access"/);
assert.doesNotMatch(codesRoute, /reason:\s*z\.string/);
assert.match(codesRoute, /p_reason:\s*"admin_console_create_redemption_codes"/);
assert.match(codesHelper, /JSON\.stringify\(input\.p_codes\)/);
assert.match(codesRoute, /requestId\(request\)/);
assert.match(codesRoute, /admin_create_redemption_codes/);
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
assert.match(codesUi, /okText="确认生成"/);
assert.doesNotMatch(modal, /操作原因|TextArea|name="reason"/);
assert.match(modal, /await onConfirm\(\)/);
assert.match(codesUi, /permissions\.includes\("admin\.access"\)/);
assert.match(codesUi, /onFinish=\{\(values\) => void submitCreate\(values\)\}/);
assert.doesNotMatch(codesUi, /ConfirmActionModal|pendingCreate|pendingEdit|revokeRecord/);
});
test("account-level TOTP MFA and customer login email OTP remain available", () => {
@@ -33,13 +33,13 @@ const mutationMappings = [
name: "兑换码创建",
ui: "src/components/admin/codes-resource.tsx",
api: "src/app/api/admin/codes/route.ts",
permission: "billing.adjustments.write",
permission: "admin.access",
},
{
name: "兑换码编辑与撤销",
ui: "src/components/admin/codes-resource.tsx",
api: "src/app/api/admin/codes/[id]/route.ts",
permission: "billing.adjustments.write",
permission: "admin.access",
},
{
name: "功能开关发布",
@@ -66,7 +66,7 @@ test("admin mutation UI permissions match ordinary API guards without operation
await t.test(mapping.name, () => {
const ui = source(mapping.ui);
const api = source(mapping.api);
assert.match(ui, /ConfirmActionModal/);
assert.doesNotMatch(ui, /ConfirmActionModal|Popconfirm|Modal\.confirm/);
assert.doesNotMatch(ui, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
assert.match(api, new RegExp(String.raw`requireAdminMutation\(\s*request,\s*(?:permission|["']${mapping.permission.replaceAll(".", "\\.")}["']),?\s*\)`));
assert.doesNotMatch(api, /requireHighRiskAdminMutation/);
@@ -75,6 +75,17 @@ test("admin mutation UI permissions match ordinary API guards without operation
}
});
test("redemption-code list and mutations are available to every authenticated admin role", () => {
const providers = source("src/lib/admin/providers.ts");
const route = source("src/app/api/admin/codes/route.ts");
assert.match(
providers,
/codes:\s*\{\s*read:\s*"admin\.access",\s*write:\s*"admin\.access"\s*\}/,
);
assert.match(route, /await requirePermission\("admin\.access"\)/);
});
test("product management localizes product, billing and entitlement enums", () => {
const ui = source("src/components/admin/product-management.tsx");
@@ -115,18 +126,16 @@ test("model management uses ordinary admin mutation guards without reauth", () =
assert.doesNotMatch(ui, /验证并(?:保存|获取)/);
});
test("admin request failures are real Error instances and confirmation modal keeps failures visible", () => {
test("admin request failures are real Error instances and operation confirmation UI is absent", () => {
const providers = source("src/lib/admin/providers.ts");
const modal = source("src/components/admin/confirm-action-modal.tsx");
assert.match(
providers,
/Object\.assign\(new Error\(message\),\s*\{\s*statusCode: response\.status,/,
);
assert.doesNotMatch(providers, /throw \{ message, statusCode/);
assert.match(modal, /catch \(error\)[\s\S]*setActionError\(error instanceof Error \? error\.message/);
assert.doesNotMatch(modal, /操作原因|name="reason"|TextArea/);
assert.match(modal, /await onConfirm\(\)/);
assert.doesNotMatch(modal, /reauth|邮箱验证码|发送验证码/);
for (const path of mutationMappings.map((mapping) => mapping.ui)) {
assert.doesNotMatch(source(path), /ConfirmActionModal|Popconfirm|Modal\.confirm/);
}
});
test("customer list is always masked and a single explicit reveal is audited each time", () => {
@@ -154,7 +163,8 @@ test("administrator UI supports all six roles and exposes last-owner protection"
for (const role of ["owner", "model_admin", "billing_admin", "operations", "support", "auditor"]) {
assert.match(ui, new RegExp(`["']${role}["']`));
}
assert.match(ui, /method: pendingAction\.action === "assign" \? "POST" : "DELETE"/);
assert.match(ui, /method: action\.action === "assign" \? "POST" : "DELETE"/);
assert.doesNotMatch(ui, /pendingAction|ConfirmActionModal/);
const mutationSection = route.slice(route.indexOf("async function mutate"));
assert.match(mutationSection, /last_owner_protected/);
assert.match(mutationSection, /不能撤销最后一位 Owner,请先分配另一位 Owner/);
+1 -1
View File
@@ -22,7 +22,7 @@ test("admin surfaces await database-backed administrator checks", () => {
assert.match(adminSource, /export async function isAdminUser/);
assert.match(adminSource, /admin_has_permission\(\$1, 'admin\.access'\)/);
assert.match(sessionSource, /await requirePermission\("admin\.access"\)/);
assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.match(codesSource, /await requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/);
assert.doesNotMatch(codesSource, /requireHighRiskAdminMutation/);
assert.match(accountSource, /const isAdmin = await isAdminUser\(user\)/);
assert.match(accountSource, /isAdmin,/);
@@ -20,6 +20,8 @@ const codeRoute = source("src/app/api/admin/codes/[id]/route.ts");
const codesHelper = source("src/lib/admin/codes.ts");
const billingOperationsUi = source("src/components/admin/billing-operations-resources.tsx");
const codesUi = source("src/components/admin/codes-resource.tsx");
const redemptionAccessMigration = source("supabase/migrations/20260816010000_admin_redemption_admin_access.sql");
const baseRedemptionMigration = source("supabase/migrations/20260805010000_reconcile_admin_redemption_audit.sql");
test("billing.subscriptions only gates new trial and subscription purchases", () => {
assert.match(packagesRoute, /loadRuntimeFeatureFlags\(\["billing\.subscriptions"\]\)/);
@@ -94,7 +96,8 @@ test("order adjustments require permission, version, server audit reason, reques
});
test("redemption-code writes generate server audit reasons and encode JSONB for self-hosted pg", () => {
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"billing\.adjustments\.write",?\s*\)/);
assert.match(codesRoute, /await requirePermission\("admin\.access"\)/);
assert.match(codesRoute, /requireAdminMutation\(\s*request,\s*"admin\.access",?\s*\)/);
assert.equal((codeRoute.match(/requireAdminMutation\(/g) ?? []).length, 2);
assert.doesNotMatch(codeRoute, /requireHighRiskAdminMutation/);
assert.doesNotMatch(codesRoute, /reason:\s*z\.string/);
@@ -108,9 +111,15 @@ test("redemption-code writes generate server audit reasons and encode JSONB for
assert.match(codesHelper, /public\.admin_update_redemption_code\(/);
assert.match(codesHelper, /public\.admin_revoke_redemption_code\(/);
assert.doesNotMatch(codesHelper, /createAdminSupabaseClient|\.rpc\(|\$\{functionName\}/);
assert.match(codesUi, /permissions\.includes\("billing\.adjustments\.write"\)/);
assert.match(codesUi, /permissions\.includes\("admin\.access"\)/);
assert.doesNotMatch(codesUi, /reauthPermission|邮箱验证码|\/api\/admin\/reauth/);
assert.match(codesUi, /open=\{Boolean\(pendingCreate\)\}[\s\S]*onConfirm=\{submitCreate\}/);
assert.match(codesUi, /open=\{Boolean\(pendingEdit\)\}[\s\S]*onConfirm=\{submitEdit\}/);
assert.match(codesUi, /<ConfirmActionModal[\s\S]*onConfirm=\{\(\) => revoke\(revokeRecord!\)\}/);
assert.match(codesUi, /onFinish=\{\(values\) => void submitCreate\(values\)\}/);
assert.match(codesUi, /onFinish=\{\(values\) => void submitEdit\(values\)\}/);
assert.match(codesUi, /onClick=\{\(\) => void revoke\(record\)\}/);
assert.doesNotMatch(codesUi, /ConfirmActionModal|pendingCreate|pendingEdit|revokeRecord/);
assert.equal((redemptionAccessMigration.match(/admin_has_permission\(p_actor_user_id,'admin\.access'\)/g) ?? []).length, 3);
assert.match(redemptionAccessMigration, /new\.permission_used := 'admin\.access'/);
assert.match(baseRedemptionMigration, /admin_verified_actor_email/);
assert.match(redemptionAccessMigration, /grant execute on function public\.admin_create_redemption_codes/);
assert.doesNotMatch(redemptionAccessMigration, /billing\.adjustments\.write/);
});