fix: restore mobile report scrolling and published product edits
Independent Staging Quality Gate / validate (push) Has been cancelled
Independent Staging Quality Gate / publish (push) Has been cancelled

Report pages now scroll inside the chat shell lock, and admin product save forks a draft or retires a published plan instead of rejecting with a generic constraint error.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Jesse_Chen
2026-08-18 17:28:22 +08:00
parent 3c9b0bb32b
commit f4a2ba86ae
13 changed files with 398 additions and 8 deletions
+32
View File
@@ -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,待部署
+9 -1
View File
@@ -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
+4 -2
View File
@@ -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);
}
@@ -119,6 +119,7 @@ export default function ProductManagement() {
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [publishingId, setPublishingId] = useState<string | null>(null);
const [deletingId, setDeletingId] = useState<string | null>(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<Product> = [
{
title: "商品",
@@ -227,8 +244,10 @@ export default function ProductManagement() {
title: "操作",
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={() => void publish(item)}></Button>}
{canWrite && <Button type="link" onClick={() => openProduct(item)}>{item.status === "draft" ? "编辑草稿" : "修改"}</Button>}
{canPublish && item.status === "draft" && <Button type="link" loading={publishingId === item.id} onClick={() => void publish(item)}></Button>}
{canWrite && item.status === "draft" && <Button type="link" loading={deletingId === item.id} onClick={() => void remove(item)}></Button>}
{canWrite && item.status === "published" && <Button type="link" loading={deletingId === item.id} onClick={() => void remove(item)}></Button>}
</Space>,
},
];
@@ -249,6 +268,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>
{editing && editing.status !== "draft" ? <Text type="secondary">稿</Text> : null}
<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>
@@ -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 });
}
@@ -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;
+13
View File
@@ -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),
@@ -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, /<Tag>\{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", () => {
@@ -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();
}
});
@@ -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(`
@@ -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"\)/);
@@ -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/);
+2 -1
View File
@@ -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/);