From 7050f7ee178f824423f76da1d1ef1c07f6f8f35d Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Mon, 17 Aug 2026 10:25:16 +0800 Subject: [PATCH] fix(admin): restore product visibility and code copying --- docs/BUG_HISTORY.md | 28 +++++++++++++++++++ .../src/components/admin/codes-resource.tsx | 5 +++- ...ng_catalog_admin_runtime_read_policies.sql | 20 +++++++++++++ frontend/tests/database-billing-admin.test.ts | 18 ++++++++++++ .../high-risk-billing-routes-contract.test.ts | 1 + 5 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 frontend/supabase/migrations/20260817010000_billing_catalog_admin_runtime_read_policies.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 1c936a74..f8986134 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -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 +- 修复版本:本地未提交候选 diff --git a/frontend/src/components/admin/codes-resource.tsx b/frontend/src/components/admin/codes-resource.tsx index 30579697..d14ff925 100644 --- a/frontend/src/components/admin/codes-resource.tsx +++ b/frontend/src/components/admin/codes-resource.tsx @@ -273,7 +273,10 @@ export default function CodesPage() { 关闭后无法再次查看完整兑换码,请立即安全保存。 {generated.map((record) => ( - + {record.code} ))} diff --git a/frontend/supabase/migrations/20260817010000_billing_catalog_admin_runtime_read_policies.sql b/frontend/supabase/migrations/20260817010000_billing_catalog_admin_runtime_read_policies.sql new file mode 100644 index 00000000..e4f9939a --- /dev/null +++ b/frontend/supabase/migrations/20260817010000_billing_catalog_admin_runtime_read_policies.sql @@ -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; diff --git a/frontend/tests/database-billing-admin.test.ts b/frontend/tests/database-billing-admin.test.ts index e98844a2..328f4fe9 100644 --- a/frontend/tests/database-billing-admin.test.ts +++ b/frontend/tests/database-billing-admin.test.ts @@ -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')`, diff --git a/frontend/tests/high-risk-billing-routes-contract.test.ts b/frontend/tests/high-risk-billing-routes-contract.test.ts index b3398eb1..2f5845d4 100644 --- a/frontend/tests/high-risk-billing-routes-contract.test.ts +++ b/frontend/tests/high-risk-billing-routes-contract.test.ts @@ -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/);