diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c23644af..5b3a7025 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -4065,3 +4065,35 @@ - 相关记录:BUG-179(曾因通用解析器的三条建议兜底污染生时校正,本次删除使其隔离措施不再必要)、BUG-263(「跳到最新」按钮定位曾受 chip 行高度影响)、BUG-249(草稿隔离验证里包含推荐问题填入路径)、BUG-272(同一轮评审的上一项删除) - 复发自:无 - 修复版本:本地未提交候选 + +## BUG-274 | 个人报告中心与报告正文在移动端无法下拉 + +- 状态:resolved(已推入 staging,待部署) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:`/reports` 个人报告中心、`/reports/[reportId]` 报告阅读容器;聊天根滚动锁不放宽。 +- 用户现象:移动端打开个人报告页后无法向下滑动阅读。 +- 触发条件:全局 `html, body { height: 100%; overflow: hidden }` 仍然锁死根滚动。报告中心只有 `min-height: 100dvh` 和 `overflow-x: hidden`,没有被视口卡住的纵向滚动容器。报告正文虽然有独立滚动层,但高度写成 `100dvh`:在移动浏览器里它可能比 `body` 的 `100%` 更高,多出来的部分被根滚动锁裁掉,容器自己却还没溢出,所以也滑不动。 +- 根因:BUG-153 只给 ready 报告加了 `100dvh + overflow-y: auto`,没有覆盖报告中心,也没有按「父级已经是 `height: 100%`」来锁高度。`overflow-x: hidden` 不会让一个随内容长高的块变成可滚动层。 +- 修复:`.report-center-shell` 与 `.personal-report-reader` 都改为 `height: 100%; overflow-y: auto`,并补上 `-webkit-overflow-scrolling: touch`。报告正文另加 `touch-action: pan-y`,避免命盘 SVG 把纵向滑动吃掉。 +- 验证:报告入口与阅读合同测试锁定两处容器都是 `height: 100% + overflow-y: auto`,且不得再写 `min-height: 100dvh` / `height: 100dvh`。 +- 防复发:任何跑在聊天根滚动锁里的独立页面,必须有「相对父级视口封顶的高度 + overflow-y: auto」。禁止只用 `min-height: 100dvh` 冒充滚动容器;也不要在 `html, body` 已是 `height: 100%` 时把子层写成未封顶的 `100dvh`。 +- 相关记录:BUG-153 +- 复发自:BUG-153 +- 修复版本:已推入 staging,待部署 + +## BUG-275 | 后台已发布套餐无法修改,也没有删除/下架入口 + +- 状态:resolved(已推入 staging,待部署) +- 首次发现:2026-08-18 +- 最近更新:2026-08-18 +- 影响面:`POST /api/admin/products`、`admin_save_product_draft`、商品管理页;已有订单/订阅的硬删除限制不放宽。 +- 用户现象:编辑已发布套餐保存时返回「提交内容不符合业务约束」;列表里没有删除或下架。 +- 触发条件:对已发布种子套餐(如 `standard_monthly`)调用 `action=save` 并带上该商品 id;或在后台寻找删除按钮。 +- 根因:保存函数只更新 `status='draft'` 的行,已发布 id 会抛 `product_draft_not_found`(PostgreSQL `22023`),被统一映射成「提交内容不符合业务约束」。即便命中草稿,审计 `after_value` 仍写入禁止字段 `code`,触发 `admin_audit_logs_after_value_check`(`23514`),同样被折叠成这句话。界面把所有行都标成「编辑草稿」,也没有 delete/retire API。已发布商品因订单/订阅外键不能硬删除,正确动作是下架。 +- 修复:保存已发布/已下架商品时,复用该 code 的现有草稿或创建下一版本草稿,不再改正在售行。新增 `admin_delete_product`:草稿硬删除,已发布改为 `retired` 且 `enabled=false`。商品审计字段改为 `productCode`,避免踩兑换码审计的密钥字段禁令。管理页区分修改/发布/删除/下架,并把上述 SQL 异常映射成可读错误。 +- 验证:数据库回归用与线上失败请求同形的权益 JSON 保存已发布月卡,必须得到新草稿且原在售行不变;再次保存更新同一草稿;删除草稿后行消失;删除已发布年卡后变为下架。API/UI 合同覆盖 `action=delete`、下架文案与错误映射。 +- 防复发:后台商品写路径必须区分草稿与已发布。已发布商品的保存不得要求调用方先手建草稿;删除已发布商品只能下架,不能绕过订单/订阅外键做硬删除。`22023`/`23514` 的商品异常不得再折叠成笼统「业务约束」。写入 `audit.admin_audit_logs` 的 JSON 不得包含 `code`/`token`/`secret`/`key` 键。 +- 相关记录:无 +- 复发自:无 +- 修复版本:已推入 staging,待部署 diff --git a/frontend/src/app/api/admin/products/route.ts b/frontend/src/app/api/admin/products/route.ts index c5810594..b8410ea0 100644 --- a/frontend/src/app/api/admin/products/route.ts +++ b/frontend/src/app/api/admin/products/route.ts @@ -37,7 +37,11 @@ const publishSchema = z.object({ action: z.literal("publish"), id: z.string().uuid(), }).strict(); -const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema]); +const deleteSchema = z.object({ + action: z.literal("delete"), + id: z.string().uuid(), +}).strict(); +const mutationSchema = z.discriminatedUnion("action", [saveSchema, publishSchema, deleteSchema]); type ProductRow = { id: string; code: string; version: number; name: string; description: string; @@ -92,6 +96,10 @@ export async function POST(request: Request) { const rows = await queryAdminRows<{ id: string }>("select public.admin_publish_product($1,$2,$3,$4) id", [session.user.id, body.data.id, "admin_console_publish_product", rid]); return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } }); } + if (body.data.action === "delete") { + const rows = await queryAdminRows<{ id: string }>("select public.admin_delete_product($1,$2,$3,$4) id", [session.user.id, body.data.id, "admin_console_delete_product", rid]); + return NextResponse.json({ data: { id: rows[0]!.id, requestId: rid } }); + } const value = body.data; const rows = await queryAdminRows<{ id: string }>(` select public.admin_save_product_draft($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14::jsonb,$15,$16) id diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css index 37b089ef..a048385c 100644 --- a/frontend/src/app/globals.css +++ b/frontend/src/app/globals.css @@ -1756,7 +1756,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class .payment-qr-badge svg { display: block; width: 100%; height: 100%; } /* Global report centre — quiet archive, independent from chat sessions. */ -.report-center-shell { width: 100%; min-height: 100dvh; overflow-x: hidden; padding: 0 clamp(20px, 5vw, 72px) 72px; background: var(--color-canvas-soft); color: var(--color-ink); } +.report-center-shell { width: 100%; height: 100%; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; padding: 0 clamp(20px, 5vw, 72px) 72px; background: var(--color-canvas-soft); color: var(--color-ink); } .report-center-topbar { max-width: 1080px; min-height: 64px; display: flex; align-items: center; justify-content: space-between; gap: 20px; margin: 0 auto; border-bottom: 1px solid var(--color-border); color: var(--color-ink-tertiary); font-size: 12px; } .report-center-back { min-height: 44px; display: inline-flex; align-items: center; gap: 7px; color: var(--color-ink-secondary); font-size: 14px; font-weight: 500; text-decoration: none; } .report-center-back:hover { color: var(--color-action); } @@ -1808,11 +1808,13 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class --report-paper: #f8f5ee; --report-rule: #c9c2b7; width: 100%; - height: 100dvh; + height: 100%; min-height: 0; overflow-x: hidden; overflow-y: auto; overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + touch-action: pan-y; scrollbar-gutter: stable; background: var(--report-paper); } diff --git a/frontend/src/components/admin/product-management.tsx b/frontend/src/components/admin/product-management.tsx index fe475bea..ef42b097 100644 --- a/frontend/src/components/admin/product-management.tsx +++ b/frontend/src/components/admin/product-management.tsx @@ -119,6 +119,7 @@ export default function ProductManagement() { const [open, setOpen] = useState(false); const [saving, setSaving] = useState(false); const [publishingId, setPublishingId] = useState(null); + const [deletingId, setDeletingId] = useState(null); const canWrite = Boolean(identity?.permissions.includes("billing.products.write")); const canPublish = Boolean(identity?.permissions.includes("billing.products.publish")); @@ -184,7 +185,7 @@ export default function ProductManagement() { entitlements, }), }); - message.success("商品草稿已保存"); + message.success(editing && editing.status !== "draft" ? "已保存为新版本草稿,发布后才会替换在售套餐" : "商品草稿已保存"); setOpen(false); form.resetFields(); await table.tableQuery.refetch(); @@ -211,6 +212,22 @@ export default function ProductManagement() { } } + async function remove(product: Product) { + setDeletingId(product.id); + try { + await adminRequestJson("/api/admin/products", { + method: "POST", + body: JSON.stringify({ action: "delete", id: product.id }), + }); + message.success(product.status === "draft" ? "草稿已删除" : "商品已下架"); + await table.tableQuery.refetch(); + } catch (error) { + message.error(error instanceof Error ? error.message : product.status === "draft" ? "删除草稿失败" : "下架商品失败"); + } finally { + setDeletingId(null); + } + } + const columns: TableColumnsType = [ { title: "商品", @@ -227,8 +244,10 @@ export default function ProductManagement() { title: "操作", fixed: "right", render: (_, item) => - {canWrite && } - {canPublish && item.status !== "published" && } + {canWrite && } + {canPublish && item.status === "draft" && } + {canWrite && item.status === "draft" && } + {canWrite && item.status === "published" && } , }, ]; @@ -249,6 +268,7 @@ export default function ProductManagement() { form.submit()} onCancel={() => setOpen(false)} destroyOnHidden> + {editing && editing.status !== "draft" ? 保存后会生成新版本草稿,不会立刻改动当前在售套餐。要从目录拿掉请用列表里的下架。 : null} form={form} layout="vertical" onFinish={save} requiredMark="optional"> diff --git a/frontend/src/lib/admin/admin-error-response.ts b/frontend/src/lib/admin/admin-error-response.ts index 234e3c5a..f7f52b6a 100644 --- a/frontend/src/lib/admin/admin-error-response.ts +++ b/frontend/src/lib/admin/admin-error-response.ts @@ -31,9 +31,21 @@ export function adminErrorResponse(error: unknown) { if (message.includes("模型供应商密钥未配置") || message.includes("model_provider_key_required")) { return NextResponse.json({ error: "模型供应商密钥未配置" }, { status: 409 }); } + if (message.includes("product_not_found")) { + return NextResponse.json({ error: "商品不存在" }, { status: 404 }); + } + if (message.includes("product_code_immutable")) { + return NextResponse.json({ error: "商品代码不可修改" }, { status: 400 }); + } + if (message.includes("product_draft_not_found")) { + return NextResponse.json({ error: "只能编辑草稿。已发布商品请下架,或保存为新版本草稿后再发布" }, { status: 400 }); + } if (isPostgresError(error)) { if (error.code === "42501") return NextResponse.json({ error: "无权执行此操作" }, { status: 403 }); if (error.code === "40001") return NextResponse.json({ error: "资源已被其他管理员修改,请刷新后重试" }, { status: 409 }); + if (error.code === "23503") { + return NextResponse.json({ error: "已有订单或订阅引用该商品,不能硬删除,请改为下架" }, { status: 409 }); + } if (error.code === "22023" || error.code === "23514" || error.code === "23505") { return NextResponse.json({ error: "提交内容不符合业务约束" }, { status: 400 }); } diff --git a/frontend/supabase/migrations/20260818010000_admin_product_catalog_mutations.sql b/frontend/supabase/migrations/20260818010000_admin_product_catalog_mutations.sql new file mode 100644 index 00000000..9017a2a1 --- /dev/null +++ b/frontend/supabase/migrations/20260818010000_admin_product_catalog_mutations.sql @@ -0,0 +1,147 @@ +begin; + +create or replace function public.admin_save_product_draft( + p_actor_user_id uuid, p_product_id uuid, p_code text, p_name text, p_description text, + p_product_type text, p_billing_period text, p_interval_count integer, p_price_cents integer, + p_currency text, p_enabled boolean, p_sort_order integer, p_one_time_per_user boolean, + p_entitlements jsonb, p_reason text, p_request_id text +) +returns uuid +language plpgsql security definer set search_path = '' +as $$ +declare v_id uuid; v_version integer; v_item jsonb; v_existing public.billing_products%rowtype; +begin + if not public.admin_has_permission(p_actor_user_id, 'billing.products.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 jsonb_typeof(p_entitlements) <> 'array' then raise exception 'invalid_entitlements' using errcode='22023'; end if; + if p_product_id is null then + select coalesce(max(version),0)+1 into v_version from public.billing_products where code=p_code; + insert into public.billing_products(code,version,name,description,product_type,billing_period,interval_count, + price_cents,currency,enabled,status,sort_order,one_time_per_user,created_by,updated_by) + values (p_code,v_version,p_name,p_description,p_product_type,p_billing_period,p_interval_count, + p_price_cents,upper(p_currency),p_enabled,'draft',p_sort_order,p_one_time_per_user,p_actor_user_id,p_actor_user_id) + returning id into v_id; + else + select * into v_existing from public.billing_products where id=p_product_id for update; + if not found then raise exception 'product_not_found' using errcode='22023'; end if; + if v_existing.code <> p_code then raise exception 'product_code_immutable' using errcode='22023'; end if; + if v_existing.status = 'draft' then + update public.billing_products set name=p_name,description=p_description,product_type=p_product_type, + billing_period=p_billing_period,interval_count=p_interval_count,price_cents=p_price_cents, + currency=upper(p_currency),enabled=p_enabled,sort_order=p_sort_order,one_time_per_user=p_one_time_per_user, + updated_by=p_actor_user_id,updated_at=clock_timestamp() + where id=p_product_id returning id into v_id; + else + select id into v_id from public.billing_products where code=v_existing.code and status='draft' for update; + if v_id is not null then + update public.billing_products set name=p_name,description=p_description,product_type=p_product_type, + billing_period=p_billing_period,interval_count=p_interval_count,price_cents=p_price_cents, + currency=upper(p_currency),enabled=p_enabled,sort_order=p_sort_order,one_time_per_user=p_one_time_per_user, + updated_by=p_actor_user_id,updated_at=clock_timestamp() + where id=v_id; + else + select coalesce(max(version),0)+1 into v_version from public.billing_products where code=v_existing.code; + insert into public.billing_products(code,version,name,description,product_type,billing_period,interval_count, + price_cents,currency,enabled,status,sort_order,one_time_per_user,created_by,updated_by) + values (v_existing.code,v_version,p_name,p_description,p_product_type,p_billing_period,p_interval_count, + p_price_cents,upper(p_currency),p_enabled,'draft',p_sort_order,p_one_time_per_user,p_actor_user_id,p_actor_user_id) + returning id into v_id; + end if; + end if; + end if; + delete from public.product_entitlements where product_id=v_id; + for v_item in select value from jsonb_array_elements(p_entitlements) + loop + insert into public.product_entitlements(product_id,feature_key,allowance_type,allowance_count, + reset_period,model_tier,fair_use_policy_id,metadata) + values (v_id,v_item->>'featureKey',v_item->>'allowanceType',(v_item->>'allowanceCount')::integer, + coalesce(v_item->>'resetPeriod','none'),v_item->>'modelTier',v_item->>'fairUsePolicyId',coalesce(v_item->'metadata','{}'::jsonb)); + end loop; + 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','billing.product.draft.save','billing_product',v_id, + jsonb_build_object('productCode',p_code,'name',p_name),p_request_id,'billing.products.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_product( + p_actor_user_id uuid, p_product_id uuid, p_reason text, p_request_id text +) +returns uuid +language plpgsql security definer set search_path = '' +as $$ +declare v_product public.billing_products%rowtype; +begin + if not public.admin_has_permission(p_actor_user_id, 'billing.products.publish') 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_product from public.billing_products where id=p_product_id and status='draft' for update; + if not found then raise exception 'product_draft_not_found' using errcode='22023'; end if; + if not exists(select 1 from public.product_entitlements where product_id=p_product_id) then raise exception 'product_entitlements_required' using errcode='23514'; end if; + update public.billing_products set enabled=false,status='retired',effective_to=coalesce(effective_to,clock_timestamp()),updated_at=clock_timestamp() + where code=v_product.code and status='published' and id<>p_product_id and effective_to is null; + update public.billing_products set status='published',effective_from=coalesce(effective_from,clock_timestamp()),updated_at=clock_timestamp() + where id=p_product_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','billing.product.publish','billing_product',p_product_id, + jsonb_build_object('productCode',v_product.code,'version',v_product.version),p_request_id,'billing.products.publish',btrim(p_reason) + from identity.users u where u.id=p_actor_user_id on conflict do nothing; + return p_product_id; +end; $$; + +create or replace function public.admin_delete_product( + p_actor_user_id uuid, p_product_id uuid, p_reason text, p_request_id text +) +returns uuid +language plpgsql security definer set search_path = '' +as $$ +declare v_product public.billing_products%rowtype; v_action text; +begin + if not public.admin_has_permission(p_actor_user_id, 'billing.products.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_product from public.billing_products where id=p_product_id for update; + if not found then raise exception 'product_not_found' using errcode='22023'; end if; + if v_product.status = 'draft' then + v_action := 'billing.product.draft.delete'; + delete from public.billing_products where id=p_product_id; + else + v_action := 'billing.product.retire'; + update public.billing_products + set enabled=false, + status='retired', + effective_to=coalesce(effective_to, clock_timestamp()), + updated_by=p_actor_user_id, + updated_at=clock_timestamp() + where id=p_product_id; + end if; + insert into audit.admin_audit_logs(actor_user_id,actor_email,actor_role,action,target_type,target_id, + before_value,after_value,request_id,permission_used,reason) + select p_actor_user_id,lower(btrim(u.email)),'admin',v_action,'billing_product',p_product_id, + jsonb_build_object('productCode',v_product.code,'status',v_product.status,'enabled',v_product.enabled), + jsonb_build_object('productCode',v_product.code,'status',case when v_product.status='draft' then 'deleted' else 'retired' end), + p_request_id,'billing.products.write',btrim(p_reason) + from identity.users u where u.id=p_actor_user_id on conflict do nothing; + return p_product_id; +end; $$; + +revoke all on function public.admin_delete_product(uuid,uuid,text,text) from public, anon, authenticated; +grant execute on function public.admin_delete_product(uuid,uuid,text,text) to service_role; + +do $$ begin + if exists(select 1 from pg_roles where rolname='admin_runtime') then + grant execute on function public.admin_delete_product(uuid,uuid,text,text) to admin_runtime; + end if; +end $$; + +commit; diff --git a/frontend/tests/admin-auth.test.ts b/frontend/tests/admin-auth.test.ts index 6ce241d2..d4d8a202 100644 --- a/frontend/tests/admin-auth.test.ts +++ b/frontend/tests/admin-auth.test.ts @@ -187,6 +187,19 @@ test("adminErrorResponse maps model provider configuration failures without expo } }); +test("adminErrorResponse maps product catalog failures without exposing details", async () => { + for (const [error, status, message] of [ + [new Error("product_not_found"), 404, "商品不存在"], + [new Error("product_code_immutable"), 400, "商品代码不可修改"], + [new Error("product_draft_not_found"), 400, "只能编辑草稿。已发布商品请下架,或保存为新版本草稿后再发布"], + [{ code: "23503", message: "insert or update on table violates foreign key constraint" }, 409, "已有订单或订阅引用该商品,不能硬删除,请改为下架"], + ] as const) { + const response = adminErrorResponse(error); + assert.equal(response.status, status); + assert.deepEqual(await response.json(), { error: message }); + } +}); + test("adminErrorResponse preserves the sanitized authorization 503", async () => { const response = adminErrorResponse( new AdminAuthorizationError("后台服务暂时不可用", 503), diff --git a/frontend/tests/admin-ui-permission-contract.test.ts b/frontend/tests/admin-ui-permission-contract.test.ts index 58d0ce0e..bb07faa9 100644 --- a/frontend/tests/admin-ui-permission-contract.test.ts +++ b/frontend/tests/admin-ui-permission-contract.test.ts @@ -17,6 +17,12 @@ const mutationMappings = [ api: "src/app/api/admin/products/route.ts", permission: "billing.products.publish", }, + { + name: "商品删除", + ui: "src/components/admin/product-management.tsx", + api: "src/app/api/admin/products/route.ts", + permission: "billing.products.write", + }, { name: "订阅调整", ui: "src/components/admin/billing-operations-resources.tsx", @@ -108,6 +114,10 @@ test("product management localizes product, billing and entitlement enums", () = } assert.doesNotMatch(ui, /\{value\}<\/Tag>/); assert.doesNotMatch(ui, /\$\{item\.intervalCount\} \$\{item\.billingPeriod\}/); + assert.match(ui, /action: "delete"/); + assert.match(ui, /下架/); + assert.match(ui, /草稿已删除/); + assert.match(ui, /item\.status === "draft"/); }); test("model management uses ordinary admin mutation guards without reauth", () => { diff --git a/frontend/tests/database-billing-product-admin.test.ts b/frontend/tests/database-billing-product-admin.test.ts new file mode 100644 index 00000000..f312850d --- /dev/null +++ b/frontend/tests/database-billing-product-admin.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { startPostgresFixture } from "./helpers/postgres-fixture.ts"; + +const runnerPath = fileURLToPath(new URL("../scripts/db-migrate.mjs", import.meta.url)); +const ids = { + billing: "81000000-0000-4000-8000-000000000001", + support: "81000000-0000-4000-8000-000000000002", +}; +const publishedMonthly = "00000000-0000-4000-8000-000000000902"; +const publishedYearly = "00000000-0000-4000-8000-000000000903"; +const userPayload = JSON.stringify([ + { + featureKey: "chat.standard", + allowanceType: "unlimited", + allowanceCount: null, + resetPeriod: "billing_period", + modelTier: "standard", + fairUsePolicyId: "standard_monthly", + metadata: { dayLimit: 100, minuteLimit: 6, billingLimit: 2000 }, + }, + { + featureKey: "rectification", + allowanceType: "quota", + allowanceCount: 1, + resetPeriod: "billing_period", + modelTier: "standard", + fairUsePolicyId: null, + metadata: {}, + }, + { + featureKey: "report.full", + allowanceType: "quota", + allowanceCount: 1, + resetPeriod: "billing_period", + modelTier: "standard", + fairUsePolicyId: null, + metadata: {}, + }, +]).replaceAll("'", "''"); + +test("saving a published product forks a draft and delete retires or removes it", async () => { + const fixture = startPostgresFixture(); + const sql = (statement: string) => fixture.psql(statement); + const sqlAsOwner = (statement: string) => fixture.psqlAs( + "schema_owner", + "schema-owner-test-password", + statement, + ); + const expectSqlError = (statement: string, pattern: RegExp) => { + assert.throws(() => sqlAsOwner(statement), pattern); + }; + + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { + ...process.env, + SCHEMA_DATABASE_URL: fixture.connectionUrl("schema_owner", "schema-owner-test-password"), + }, + }); + assert.equal(migration.status, 0, migration.stderr); + assert.match(migration.stdout, /applied 20260818010000_admin_product_catalog_mutations\.sql/); + + fixture.psqlAs( + "identity_runtime", + "identity-runtime-test-password", + `insert into identity.users (id,name,email,email_verified,email_verified_at,role) values + ('${ids.billing}','Billing','billing-product@example.com',true,now(),'admin'), + ('${ids.support}','Support','support-product@example.com',true,now(),'user')`, + ); + sql(` + insert into public.admin_users(user_id,created_by) values + ('${ids.billing}','${ids.billing}'),('${ids.support}','${ids.billing}'); + insert into public.admin_user_roles(admin_user_id,role_id,assigned_by) + select v.user_id,r.id,'${ids.billing}'::uuid + from (values ('${ids.billing}'::uuid,'billing_admin'),('${ids.support}'::uuid,'support')) v(user_id,role_code) + join public.admin_roles r on r.code=v.role_code + `); + + expectSqlError( + `select public.admin_save_product_draft('${ids.support}','${publishedMonthly}','standard_monthly','标准月卡','会员期内标准 AI 咨询不限点数,受合理使用规则约束','subscription','month',1,9900,'CNY',false,20,false,'${userPayload}'::jsonb,'support must not write','product-denied')`, + /admin_permission_denied/, + ); + + const firstDraft = sql(` + select public.admin_save_product_draft( + '${ids.billing}','${publishedMonthly}','standard_monthly','标准月卡','会员期内标准 AI 咨询不限点数,受合理使用规则约束', + 'subscription','month',1,9900,'CNY',false,20,false,'${userPayload}'::jsonb,'disable published monthly','product-save-published' + ) + `); + assert.notEqual(firstDraft, publishedMonthly); + assert.equal(sql(`select status||':'||enabled||':'||version from public.billing_products where id='${publishedMonthly}'`), "published:true:1"); + assert.equal(sql(`select status||':'||enabled||':'||version||':'||code from public.billing_products where id='${firstDraft}'`), "draft:f:2:standard_monthly"); + assert.equal(sql(`select count(*) from public.product_entitlements where product_id='${firstDraft}'`), "3"); + assert.equal(sql(`select count(*) from public.billing_products where code='standard_monthly' and status='draft'`), "1"); + assert.equal( + sql(`select after_value ? 'code' from audit.admin_audit_logs where request_id='product-save-published'`), + "f", + "product audit payloads must not use the forbidden code key", + ); + + const secondSave = sql(` + select public.admin_save_product_draft( + '${ids.billing}','${publishedMonthly}','standard_monthly','标准月卡改名','会员期内标准 AI 咨询不限点数,受合理使用规则约束', + 'subscription','month',1,9900,'CNY',false,20,false,'${userPayload}'::jsonb,'update existing draft','product-save-published-again' + ) + `); + assert.equal(secondSave, firstDraft); + assert.equal(sql(`select name from public.billing_products where id='${firstDraft}'`), "标准月卡改名"); + assert.equal(sql(`select count(*) from public.billing_products where code='standard_monthly' and status='draft'`), "1"); + + assert.equal(sql(`select public.admin_delete_product('${ids.billing}','${firstDraft}','remove unused draft','product-delete-draft')`), firstDraft); + assert.equal(sql(`select count(*) from public.billing_products where id='${firstDraft}'`), "0"); + assert.equal(sql(`select count(*) from public.product_entitlements where product_id='${firstDraft}'`), "0"); + + assert.equal(sql(`select public.admin_delete_product('${ids.billing}','${publishedYearly}','take yearly off sale','product-retire-published')`), publishedYearly); + assert.equal(sql(`select status||':'||enabled from public.billing_products where id='${publishedYearly}'`), "retired:f"); + } finally { + fixture.stop(); + } +}); diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index 7b2ab7d1..9ca6cae8 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -77,6 +77,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260814025000_personal_report_document_v2\.sql/); assert.match(migration.stdout, /applied 20260814040000_personal_report_jobs_v2\.sql/); assert.match(migration.stdout, /applied 20260816010000_accept_exact_family_birth_times\.sql/); + assert.match(migration.stdout, /applied 20260818010000_admin_product_catalog_mutations\.sql/); assert.equal( fixture.psql(` diff --git a/frontend/tests/high-risk-billing-routes-contract.test.ts b/frontend/tests/high-risk-billing-routes-contract.test.ts index 2f5845d4..3567c21c 100644 --- a/frontend/tests/high-risk-billing-routes-contract.test.ts +++ b/frontend/tests/high-risk-billing-routes-contract.test.ts @@ -42,7 +42,11 @@ test("existing payment orders remain queryable and settleable when subscriptions test("billing and operations writes use the shared ordinary mutation guard", () => { assert.match(productsRoute, /requireAdminMutation\(request, permission\)/); - assert.match(productsRoute, /admin_console_(?:save|publish)_product/); + assert.match(productsRoute, /admin_console_(?:save|publish|delete)_product/); + assert.match(productsRoute, /admin_delete_product/); + const productMutationMigration = source("supabase/migrations/20260818010000_admin_product_catalog_mutations.sql"); + assert.match(productMutationMigration, /jsonb_build_object\('productCode'/); + assert.doesNotMatch(productMutationMigration, /jsonb_build_object\('code'/); assert.match(epaySettingsRoute, /requireAdminMutation\(request, "billing\.adjustments\.write"\)/); assert.match(epaySettingsRoute, /admin_save_epay_settings/); assert.match(subscriptionsRoute, /requireAdminMutation\(request,"billing\.adjustments\.write"\)/); diff --git a/frontend/tests/personal-report-entry.test.ts b/frontend/tests/personal-report-entry.test.ts index f6984264..b098102b 100644 --- a/frontend/tests/personal-report-entry.test.ts +++ b/frontend/tests/personal-report-entry.test.ts @@ -12,6 +12,7 @@ import { consultationReportMarkdown, downloadMarkdownReport, } from "../src/lib/consultation-report-export.ts"; +import { cssDeclarations } from "./css-contract-test-support.ts"; const componentSource = readFileSync( new URL("../src/components/personal-report/generate-personal-report-button.tsx", import.meta.url), @@ -189,6 +190,20 @@ test("global report copy uses the canonical profile, preserves accepted/confirme assert.doesNotMatch(componentSource, /evidenceState|workflowReceipt/); }); +test("report centre and ready reader scroll inside the chat shell lock", () => { + const centre = cssDeclarations(".report-center-shell", globalStyles); + const reader = cssDeclarations(".personal-report-reader", globalStyles); + assert.match(globalStyles, /html, body \{ width: 100%; height: 100%; overflow: hidden; \}/); + assert.match(centre, /height:\s*100%/); + assert.match(centre, /overflow-y:\s*auto/); + assert.match(centre, /-webkit-overflow-scrolling:\s*touch/); + assert.doesNotMatch(centre, /min-height:\s*100dvh/); + assert.match(reader, /height:\s*100%/); + assert.match(reader, /overflow-y:\s*auto/); + assert.match(reader, /-webkit-overflow-scrolling:\s*touch/); + assert.doesNotMatch(reader, /height:\s*100dvh/); +}); + test("entry is global in the sidebar and absent from the active session header", () => { assert.match(sidebarSource, /我的报告/); assert.match(sidebarSource, /onOpenReports/); diff --git a/frontend/tests/personal-report-view.test.ts b/frontend/tests/personal-report-view.test.ts index 4c4a013b..f8b75eff 100644 --- a/frontend/tests/personal-report-view.test.ts +++ b/frontend/tests/personal-report-view.test.ts @@ -265,7 +265,8 @@ test("long theme sections are not forced to avoid page breaks; only small elemen assert.match(printBlock, /\.personal-report-avoid-break\s*\{[\s\S]*?break-inside:\s*avoid-page/); assert.match(printBlock, /\.personal-report-avoid-break-row\s*\{[\s\S]*?break-inside:\s*avoid/); assert.match(printBlock, /\.personal-report-print-always\s*\{[\s\S]*?display:\s*block\s*!important/); - assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?height:\s*100dvh[\s\S]*?overflow-y:\s*auto/); + assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?height:\s*100%[\s\S]*?overflow-y:\s*auto/); + assert.match(printBlock, /\.personal-report-reader\s*\{[\s\S]*?-webkit-overflow-scrolling:\s*touch/); assert.match(printBlock, /html, body\s*\{[^}]*height:\s*auto\s*!important[^}]*overflow:\s*visible\s*!important/); assert.match(printBlock, /\.personal-report-table-wrap\s*\{[^}]*overflow:\s*visible\s*!important/); assert.match(printBlock, /table-layout:\s*fixed\s*!important/);