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
co-authored by Cursor
parent 3c9b0bb32b
commit f4a2ba86ae
13 changed files with 398 additions and 8 deletions
+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/);