diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 795d41bc..5425d5a4 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -7006,3 +7006,19 @@ - 相关记录:BUG-452、BUG-453 - 复发自:BUG-452(非终态出口只覆盖 message,未覆盖 opening) - 修复版本:待发布(Skill 保持 `10.0.13`) + +## BUG-457 | 功能定价草稿保存成功但列表为空且菜单入口被隐藏 + +- 状态:resolved +- 首次发现:2026-08-31 +- 最近更新:2026-08-31 +- 影响面:`/admin/feature-pricing`、`/admin/pricing-simulator`、`GET /api/admin/feature-pricing`、后台功能定价可读性 +- 用户现象:管理员保存定价草稿后接口没有报错,但列表仍为空;左侧菜单找不到“功能定价”;功能和模型档位只显示内部英文键,非研发人员无法判断含义。 +- 触发条件:以 `admin_runtime` 读取启用 RLS 的 `public.feature_pricing`;或由 Refine 根据 `resourcePermissions` 生成后台菜单;或打开功能定价、定价测算列表。 +- 根因:建表迁移只向 `admin_runtime` 授予了 `SELECT`,没有为启用 RLS 的表创建读取策略,因此安全定义者函数可以保存草稿,但后台直查被 RLS 静默过滤为 0 行;同时 `feature-pricing` 和 `pricing-simulator` 未加入菜单权限映射;界面直接渲染稳定内部键值。 +- 修复:新增向前迁移,为 `admin_runtime` 添加 `feature_pricing` 只读 RLS 策略;补齐两个 Refine 资源的权限映射;统一提供中文标签并保留括号内原始键值,供功能定价和定价测算共同使用,同时把草稿、已发布、已停用状态本地化。 +- 验证:契约测试锁定菜单映射、中文标签/原始值以及只读 RLS 策略;本地 PostgreSQL fixture 插入 `rectification/standard` 草稿后,以 `admin_runtime` 成功读回该行;TypeScript、ESLint 与聚焦测试通过。 +- 防复发:任何后台运行角色直查启用 RLS 的新表时,表权限和对应只读 policy 必须成对交付并用该真实角色查询验证;新增 Refine resource 必须同步 `resourcePermissions`;后台稳定键值必须显示可读标签且不得改变提交值。 +- 相关记录:BUG-454、BUG-455 +- 复发自:无 +- 修复版本:待发布 diff --git a/frontend/src/components/admin/feature-pricing-management.tsx b/frontend/src/components/admin/feature-pricing-management.tsx index 66ee6228..4b79dd61 100644 --- a/frontend/src/components/admin/feature-pricing-management.tsx +++ b/frontend/src/components/admin/feature-pricing-management.tsx @@ -5,6 +5,12 @@ import { useGetIdentity } from "@refinedev/core"; import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Table, Tag, Typography } from "antd"; import { useEffect, useState } from "react"; +import { + FEATURE_PRICING_OPTIONS, + MODEL_TIER_OPTIONS, + featurePricingLabel, + modelTierLabel, +} from "@/lib/admin/feature-pricing-labels"; import { adminRequestJson } from "@/lib/admin/providers"; type Row = { @@ -19,8 +25,11 @@ type Row = { effectiveTo: string | null; }; -const featureOptions = ["chat.standard", "chat.premium", "rectification", "report.full", "report.export", "profile.extra"]; -const tierOptions = ["standard", "premium", "internal"]; +const statusLabels: Record = { + draft: "草稿(draft)", + published: "已发布(published)", + retired: "已停用(retired)", +}; export function FeaturePricingManagement() { const { data: identity } = useGetIdentity<{ permissions?: string[] }>(); @@ -74,11 +83,11 @@ export function FeaturePricingManagement() { loading={isLoading} dataSource={rows} columns={[ - { title: "功能", dataIndex: "featureKey" }, - { title: "模型档位", dataIndex: "modelTier" }, + { title: "功能", dataIndex: "featureKey", render: featurePricingLabel }, + { title: "模型档位", dataIndex: "modelTier", render: modelTierLabel }, { title: "积分", dataIndex: "creditCost" }, { title: "版本", dataIndex: "version" }, - { title: "状态", dataIndex: "status", render: (value: string) => {value} }, + { title: "状态", dataIndex: "status", render: (value: string) => {statusLabels[value] ?? value} }, { title: "操作", render: (_: unknown, row: Row) => ( @@ -95,8 +104,8 @@ export function FeaturePricingManagement() { /> { setEditing(undefined); form.resetFields(); }} onOk={save} okButtonProps={{ disabled: !canWrite }}>
- ({ value, label: value }))} disabled={Boolean(editing)} /> +
diff --git a/frontend/src/components/admin/pricing-simulator.tsx b/frontend/src/components/admin/pricing-simulator.tsx index 3d4d9eb8..b2a589ac 100644 --- a/frontend/src/components/admin/pricing-simulator.tsx +++ b/frontend/src/components/admin/pricing-simulator.tsx @@ -3,6 +3,7 @@ import { Alert, Button, Card, Col, Divider, InputNumber, Row, Space, Statistic, Table, Tag, Typography } from "antd"; import { useEffect, useMemo, useState } from "react"; +import { featurePricingLabel, modelTierLabel } from "@/lib/admin/feature-pricing-labels"; import { adminRequestJson } from "@/lib/admin/providers"; import { CONSULTATION_DOMAIN_DURATION_MS, @@ -132,8 +133,8 @@ export function PricingSimulator() { row.pricing?.modelTier ?? 无发布配置 }, + { title: "功能", dataIndex: "featureKey", render: featurePricingLabel }, + { title: "模型档位", render: (_, row) => row.pricing ? modelTierLabel(row.pricing.modelTier) : 无发布配置 }, { title: "售价", render: (_, row) => row.estimate.saleCny === null ? "无发布价格" : `${row.pricing?.creditCost} 积分 / ${formatCny(row.estimate.saleCny)}` }, { title: "单位成本", render: (_, row) => row.estimate.source === "unavailable" ? 无实测数据 : <>{formatCny(row.estimate.costCny)}{formatNumber(row.estimate.costMicrousd, " µUSD")} · {row.estimate.source === "ledger" ? "账本实测" : "模型估算"} }, { title: "毛利率", render: (_, row) => row.estimate.marginPercent === null ? "需汇率或价格" : `${row.estimate.marginPercent.toFixed(1)}%` }, diff --git a/frontend/src/lib/admin/feature-pricing-labels.ts b/frontend/src/lib/admin/feature-pricing-labels.ts new file mode 100644 index 00000000..68296212 --- /dev/null +++ b/frontend/src/lib/admin/feature-pricing-labels.ts @@ -0,0 +1,22 @@ +export const FEATURE_PRICING_OPTIONS = [ + { value: "chat.standard", label: "标准咨询(chat.standard)" }, + { value: "chat.premium", label: "高级咨询(chat.premium)" }, + { value: "rectification", label: "生时校正(rectification)" }, + { value: "report.full", label: "完整报告(report.full)" }, + { value: "report.export", label: "报告导出(report.export)" }, + { value: "profile.extra", label: "额外档案(profile.extra)" }, +] as const; + +export const MODEL_TIER_OPTIONS = [ + { value: "standard", label: "标准模型(standard)" }, + { value: "premium", label: "高级模型(premium)" }, + { value: "internal", label: "内部模型(internal)" }, +] as const; + +export function featurePricingLabel(value: string) { + return FEATURE_PRICING_OPTIONS.find((option) => option.value === value)?.label ?? value; +} + +export function modelTierLabel(value: string) { + return MODEL_TIER_OPTIONS.find((option) => option.value === value)?.label ?? value; +} diff --git a/frontend/src/lib/admin/providers.ts b/frontend/src/lib/admin/providers.ts index b8ff1653..17d84bc1 100644 --- a/frontend/src/lib/admin/providers.ts +++ b/frontend/src/lib/admin/providers.ts @@ -194,6 +194,8 @@ const resourcePermissions: Record = { payments: { read: "billing.orders.read" }, packages: { read: "billing.products.read", write: "billing.products.write" }, products: { read: "billing.products.read", write: "billing.products.write" }, + "feature-pricing": { read: "billing.products.read", write: "billing.products.write" }, + "pricing-simulator": { read: "billing.products.read" }, subscriptions: { read: "billing.orders.read", write: "billing.adjustments.write", diff --git a/frontend/supabase/migrations/20260831020000_feature_pricing_admin_runtime_read_policy.sql b/frontend/supabase/migrations/20260831020000_feature_pricing_admin_runtime_read_policy.sql new file mode 100644 index 00000000..f7f11c2a --- /dev/null +++ b/frontend/supabase/migrations/20260831020000_feature_pricing_admin_runtime_read_policy.sql @@ -0,0 +1,15 @@ +begin; + +do $$ +begin + if exists (select 1 from pg_roles where rolname = 'admin_runtime') then + grant select on table public.feature_pricing to admin_runtime; + + drop policy if exists feature_pricing_admin_read on public.feature_pricing; + create policy feature_pricing_admin_read on public.feature_pricing + for select to admin_runtime using (true); + end if; +end; +$$; + +commit; diff --git a/frontend/tests/admin-contracts.test.ts b/frontend/tests/admin-contracts.test.ts index 09dd8afb..1cc1fa43 100644 --- a/frontend/tests/admin-contracts.test.ts +++ b/frontend/tests/admin-contracts.test.ts @@ -73,9 +73,13 @@ test("admin navigation exposes separated RBAC and billing resources", () => { assert.match(adminApp, /name: "products", list: "\/admin\/products"/); assert.match(adminApp, /name: "subscriptions", list: "\/admin\/subscriptions"/); assert.match(adminApp, /name: "orders", list: "\/admin\/orders"/); + assert.match(adminApp, /name: "feature-pricing", list: "\/admin\/feature-pricing"/); + assert.match(adminApp, /name: "pricing-simulator", list: "\/admin\/pricing-simulator"/); assert.match(adminApp, /name: "security", list: "\/admin\/security"/); assert.match(adminApp, /CreditCardOutlined/); assert.match(adminApp, /ShoppingOutlined/); + assert.match(providers, /"feature-pricing": \{ read: "billing\.products\.read", write: "billing\.products\.write" \}/); + assert.match(providers, /"pricing-simulator": \{ read: "billing\.products\.read" \}/); }); test("admin shell uses compact operational controls without the return-to-chat sider override", () => { diff --git a/frontend/tests/database-local-business.test.ts b/frontend/tests/database-local-business.test.ts index d01636b2..e08fee29 100644 --- a/frontend/tests/database-local-business.test.ts +++ b/frontend/tests/database-local-business.test.ts @@ -85,6 +85,7 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic assert.match(migration.stdout, /applied 20260824010000_rectification_inference_transition_ledger\.sql/); assert.match(migration.stdout, /applied 20260824020000_rectification_choice_action\.sql/); assert.match(migration.stdout, /applied 20260824030000_rectification_turn_origin\.sql/); + assert.match(migration.stdout, /applied 20260831020000_feature_pricing_admin_runtime_read_policy\.sql/); assert.equal( fixture.psql(` @@ -132,6 +133,19 @@ test("local PostgreSQL applies the reviewed business schema and serves authentic "true:f:f", ); + fixture.psql(` + insert into public.feature_pricing(feature_key, model_tier, credit_cost, version) + values ('rectification', 'standard', 3, 1) + `); + assert.equal( + fixture.psqlAs( + "admin_runtime", + "admin-runtime-test-password", + "select feature_key || ':' || model_tier || ':' || credit_cost || ':' || status from public.feature_pricing", + ), + "rectification:standard:3:draft", + ); + // Existing assertion updated for the requested durable per-section table. // Original value omitted personal_report_sections because the table did not exist. assert.equal( diff --git a/frontend/tests/feature-pricing-contract.test.ts b/frontend/tests/feature-pricing-contract.test.ts index dfabc586..3ddaf724 100644 --- a/frontend/tests/feature-pricing-contract.test.ts +++ b/frontend/tests/feature-pricing-contract.test.ts @@ -3,6 +3,22 @@ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; import test from "node:test"; +const management = readFileSync( + fileURLToPath(new URL("../src/components/admin/feature-pricing-management.tsx", import.meta.url)), + "utf8", +); +const simulator = readFileSync( + fileURLToPath(new URL("../src/components/admin/pricing-simulator.tsx", import.meta.url)), + "utf8", +); +const labels = readFileSync( + fileURLToPath(new URL("../src/lib/admin/feature-pricing-labels.ts", import.meta.url)), + "utf8", +); +const readPolicyMigration = readFileSync( + fileURLToPath(new URL("../supabase/migrations/20260831020000_feature_pricing_admin_runtime_read_policy.sql", import.meta.url)), + "utf8", +); const source = readFileSync( fileURLToPath(new URL("../src/lib/feature-pricing.ts", import.meta.url)), "utf8", @@ -20,3 +36,36 @@ test("feature pricing resolver has explicit fail-closed errors", () => { assert.match(source, /feature_pricing_unavailable/); assert.match(source, /class FeaturePricingError/); }); + + +test("feature pricing admin uses readable labels while preserving stable values", () => { + for (const [value, label] of [ + ["chat.standard", "标准咨询"], + ["chat.premium", "高级咨询"], + ["rectification", "生时校正"], + ["report.full", "完整报告"], + ["report.export", "报告导出"], + ["profile.extra", "额外档案"], + ["standard", "标准模型"], + ["premium", "高级模型"], + ["internal", "内部模型"], + ]) { + assert.match(labels, new RegExp(`value: "${value.replace(".", "\\.")}"`)); + assert.match(labels, new RegExp(label)); + } + assert.match(management, /FEATURE_PRICING_OPTIONS/); + assert.match(management, /MODEL_TIER_OPTIONS/); + assert.match(management, /featurePricingLabel/); + assert.match(management, /modelTierLabel/); + assert.match(management, /草稿(draft)/); + assert.match(management, /已发布(published)/); + assert.match(simulator, /featurePricingLabel/); + assert.match(simulator, /modelTierLabel/); +}); + +test("admin runtime can list feature pricing rows through an explicit read-only RLS policy", () => { + assert.match(readPolicyMigration, /grant select on table public\.feature_pricing to admin_runtime/); + assert.match(readPolicyMigration, /create policy feature_pricing_admin_read on public\.feature_pricing/); + assert.match(readPolicyMigration, /for select to admin_runtime using \(true\)/); + assert.doesNotMatch(readPolicyMigration, /for (?:insert|update|delete|all)/); +});