fix(admin): restore product visibility and code copying
Independent Staging Quality Gate / validate (push) Successful in 13m47s
Independent Staging Quality Gate / publish (push) Successful in 10m6s

This commit is contained in:
Jesse_Chen
2026-08-17 10:25:16 +08:00
parent 1cbdb774e0
commit 7050f7ee17
5 changed files with 71 additions and 1 deletions
+28
View File
@@ -3555,3 +3555,31 @@
- 防复发:跨响应、UI 状态和持久化边界共享的 receipt 必须只有一个 canonical schema;禁止在写入合同中复制 `.strict()` 子结构。新增字段必须以包含完整真实消息形状的 round-trip 回归验证。
- 相关记录:BUG-186、BUG-189
- 修复版本:本次 staging 发布候选(精确 SHA 以远端 staging 为准)
## BUG-212 | 管理端新生成兑换码复制为 `[object Object]`
- 状态:resolved(本地修复,待提交与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:管理端批量生成兑换码后的“完整兑换码(仅显示本次)”弹窗。
- 用户现象:完整兑换码在页面上显示正常,但点击复制图标后,剪贴板内容是 `[object Object]`,无法直接发送或兑换。
- 根因:Ant Design `Typography.Paragraph` 开启了布尔值 `copyable`,其直接子节点却是嵌套的 React `Typography.Text` 元素;组件默认复制子节点时把 React 元素对象字符串化,因而写入 `[object Object]`,而不是业务字段中的明文兑换码。
- 修复:为每个新生成兑换码的 `copyable` 显式指定 `text: record.code ?? ""`;显示仍使用 code 样式,完整明文仍只存在于本次创建响应与当前弹窗,不改变列表脱敏和服务端存储边界。
- 验证:兑换码管理源码合同锁定复制源必须是 `record.code`,禁止再次依赖嵌套 React 子节点的默认字符串转换;运行对应聚焦合同测试及 `git diff --check`
- 防复发:只要可复制 UI 的 children 不是直接字符串,就必须显式提供 copyable text;一次性秘密值不得从 mask、ReactNode 或对象隐式转换。
- 相关记录:BUG-207、BUG-209
- 修复版本:本地未提交候选
## BUG-213 | 会员页显示预置套餐但管理端商品列表为空
- 状态:resolved(本地修复,待提交、迁移与发布)
- 首次发现:2026-08-17
- 最近更新:2026-08-17
- 影响面:管理端商品列表与详情;会员页公开套餐读取的数据来源说明。
- 用户现象:会员页显示“体验卡 / 标准月卡 / 标准年卡”,但 `GET /api/admin/products` 返回 `{"data":[],"total":0}`,看起来像套餐被前端写死且无法在后台管理。
- 根因:套餐不是前端常量,而是 `20260806020000_billing_products_subscriptions.sql` 预置到 `billing_products` / `product_entitlements` 的默认发布商品。公开 `/api/payment/packages` 通过 `service_runtime` 能读取这些记录;管理 API 通过受限 `admin_runtime` 查询。原迁移虽然授予 `admin_runtime` 表级 SELECT,但两张表已启用 RLS,且只创建了 anon/authenticated 公开策略,遗漏 admin_runtime SELECT policy,导致合法管理查询被 RLS 静默过滤为零行。
- 修复:新增向前迁移,为 `admin_runtime` 重新授予 `billing_products``product_entitlements` SELECT,并分别创建 `using (true)` 的管理员只读策略。API 仍先执行 `billing.products.read` 权限校验;未给 admin_runtime 增加 service_role 成员关系,也未放开直接写表,保存与发布继续只能经过既有审计 RPC。
- 验证:数据库回归以真实 `admin_runtime` 连接读取预置 `standard_monthly` 商品及其 3 条权益;同时保留无法 `set role service_role` 与敏感 Profile 列不可读断言。发布后还需确认管理商品接口不再为空,并与公开套餐接口中的商品 ID/版本一致。
- 防复发:对启用 RLS 的管理资源,table grant 与 RLS policy 必须成对验证;后台列表测试必须使用 `admin_runtime` 真实角色,不能只用 schema owner 绕过 RLS。
- 相关记录:BUG-156、BUG-209
- 修复版本:本地未提交候选
@@ -273,7 +273,10 @@ export default function CodesPage() {
</Typography.Paragraph>
{generated.map((record) => (
<Typography.Paragraph copyable key={record.code}>
<Typography.Paragraph
copyable={{ text: record.code ?? "" }}
key={record.code}
>
<Typography.Text code>{record.code}</Typography.Text>
</Typography.Paragraph>
))}
@@ -0,0 +1,20 @@
begin;
do $$
begin
if exists (select 1 from pg_roles where rolname = 'admin_runtime') then
grant select on table public.billing_products, public.product_entitlements
to admin_runtime;
drop policy if exists billing_products_admin_select on public.billing_products;
create policy billing_products_admin_select on public.billing_products
for select to admin_runtime using (true);
drop policy if exists product_entitlements_admin_select on public.product_entitlements;
create policy product_entitlements_admin_select on public.product_entitlements
for select to admin_runtime using (true);
end if;
end;
$$;
commit;
@@ -162,6 +162,24 @@ test("billing, subscriptions, usage authorization, RBAC, and model publication r
assert.equal(sql("select pg_has_role('admin_runtime','service_role','MEMBER')"), "f");
assert.equal(sql("select has_function_privilege('admin_runtime','public.admin_permission_keys(uuid)','execute')"), "t");
assert.equal(sql("select has_column_privilege('admin_runtime','public.profiles','birth_date','select')"), "f");
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
"select name from public.billing_products where code='standard_monthly'",
),
"标准月卡",
"admin product lists must not be hidden by billing_products RLS",
);
assert.equal(
fixture.psqlAs(
"admin_runtime",
"admin-runtime-test-password",
"select count(*) from public.product_entitlements where product_id='00000000-0000-4000-8000-000000000902'",
),
"3",
"admin product details must not be hidden by product_entitlements RLS",
);
expectAdminRuntimeError("set role service_role", /permission denied to set role/);
expectSqlError(
`select * from public.admin_manage_role('${ids.owner}','${ids.owner}','owner',false,'不得移除最后 Owner','last-owner')`,
@@ -119,6 +119,7 @@ test("redemption-code writes generate server audit reasons and encode JSONB for
assert.match(codesUi, /onFinish=\{\(values\) => void submitEdit\(values\)\}/);
assert.match(codesUi, /onClick=\{\(\) => void revoke\(record\)\}/);
assert.doesNotMatch(codesUi, /ConfirmActionModal|pendingCreate|pendingEdit|revokeRecord/);
assert.match(codesUi, /copyable=\{\{ text: record\.code \?\? "" \}\}/);
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/);