From 006b067e8249ee91687628661c79ecd289a8b5f7 Mon Sep 17 00:00:00 2001 From: jesse-ux Date: Tue, 20 Oct 2026 12:52:28 +0800 Subject: [PATCH] fix(synastry): open the match page from the live flag and price it at 19 credits The closed sentence was prerendered when the build had no database. The page now asks for the flag in the browser, and synastry.full is published at 19 credits. Co-authored-by: Cursor --- CHANGELOG.md | 8 ++++ docs/BUG_HISTORY.md | 32 ++++++++++++++ docs/testing/synastry-full-report-20261018.md | 2 +- frontend/src/app/(app)/synastry/page.tsx | 6 +-- .../components/synastry/synastry-picker.tsx | 41 ++++++++++++------ .../20261020010000_synastry_full_pricing.sql | 43 +++++++++++++++++++ .../tests/feature-pricing-contract.test.ts | 14 ++++++ frontend/tests/stale-client-recovery.test.ts | 6 +++ frontend/tests/synastry-full-report.test.ts | 12 ++++++ 9 files changed, 146 insertions(+), 18 deletions(-) create mode 100644 frontend/supabase/migrations/20261020010000_synastry_full_pricing.sql diff --git a/CHANGELOG.md b/CHANGELOG.md index a3282cb3..34726279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ # 印度占星 Skill 更新日志 +## 2026-10-20 — 完整合盘报告定价 19 点 + +- `synastry.full` 按标准、高级、内部三个模型档发布,每份 19 点。某一档已经有生效价格时,迁移不再覆盖。Skill 版本不变。 + +## 2026-10-20 — 婚恋合盘页不再把「暂未开放」写死 + +- 打开婚恋合盘时先问当前开关。开关开着就进入选人;关着才显示「合盘服务暂未开放」。构建时读不到数据库不再把这句预渲染进页面。Skill 版本不变(BUG-1397)。 + ## 2026-10-19 — 合盘报告可以按节追问依据 - 报告末尾可以选择前八节里的一节,打开一条绑定这份合盘的新对话。问题预先写好,由用户自己发送。打开对话不扣点,发出去的每一轮按普通对话 `chat.standard` 计费。 diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 786dfe2e..66a9cfe3 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -36904,3 +36904,35 @@ - 复发自:无。 - 修复版本:分支 `codex/synastry-full-report-20261018`。 +## BUG-1397 | 婚恋合盘页在构建时被写成「服务暂未开放」 + +- 状态:resolved +- 首次发现:2026-10-20 +- 最近更新:2026-10-20 +- 影响面:`/synastry`、侧栏「婚恋合盘」 +- 用户现象:后台 `product.compatibility` 已发布、已开启、灰度 100%,打开婚恋合盘仍只看到「合盘服务暂未开放」。 +- 触发条件:构建镜像时没有数据库。页面在服务端调用 `isProductEnabled("compatibility")`,查询失败被当成关闭,Next 把这个结果预渲染成静态页。staging 上该页响应带 `X-Nextjs-Prerender: 1`、`X-Nextjs-Cache: HIT`,部署标记是 `123295135`。 +- 根因:开关读取发生在构建期,而不是每次打开页面。失败分支又把关闭写成页面正文,于是静态结果被缓存,之后把开关打开也不会变。 +- 修复:`/synastry` 改成静态外壳,只渲染选人组件。选人组件打开后再请求 `/api/product-access`。返回 `compatibility: false` 才显示「合盘服务暂未开放」。不给这个次级页加 `force-dynamic`。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 锁定页面源码不再调用 `isProductEnabled`、也不含那句关闭文案;选人组件先看开关,初始状态不是关闭。`frontend/tests/stale-client-recovery.test.ts` 锁定该页不是 `force-dynamic`。 +- 防复发:不要在会预渲染的页面里调用 `isProductEnabled`。次级页继续用静态外壳,开关放到浏览器里读。 +- 相关记录:BUG-1123、BUG-1395。issue #82。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-page-dynamic-20261020`。 + +## BUG-1397 | 婚恋合盘页在构建时被写成「服务暂未开放」 + +- 状态:resolved +- 首次发现:2026-10-20 +- 最近更新:2026-10-20 +- 影响面:`/synastry`、侧栏「婚恋合盘」 +- 用户现象:后台 `product.compatibility` 已发布、已开启、灰度 100%,打开婚恋合盘仍只看到「合盘服务暂未开放」。 +- 触发条件:构建镜像时没有数据库。页面在服务端调用 `isProductEnabled("compatibility")`,查询失败被当成关闭,Next 把这个结果预渲染成静态页。staging 上该页响应带 `X-Nextjs-Prerender: 1`、`X-Nextjs-Cache: HIT`,部署标记是 `123295135`。 +- 根因:开关读取发生在构建期,而不是每次打开页面。失败分支又把关闭写成页面正文,于是静态结果被缓存,之后把开关打开也不会变。 +- 修复:`/synastry` 改成静态外壳,只渲染选人组件。选人组件打开后再请求 `/api/product-access`。返回 `compatibility: false` 才显示「合盘服务暂未开放」。不给这个次级页加 `force-dynamic`。 +- 验证:`frontend/tests/synastry-full-report.test.ts` 锁定页面源码不再调用 `isProductEnabled`、也不含那句关闭文案;选人组件先看开关,初始状态不是关闭。`frontend/tests/stale-client-recovery.test.ts` 锁定该页不是 `force-dynamic`。 +- 防复发:不要在会预渲染的页面里调用 `isProductEnabled`。次级页继续用静态外壳,开关放到浏览器里读。 +- 相关记录:BUG-1123、BUG-1395。issue #82。 +- 复发自:无。 +- 修复版本:分支 `codex/synastry-page-dynamic-20261020`。 + diff --git a/docs/testing/synastry-full-report-20261018.md b/docs/testing/synastry-full-report-20261018.md index e2e5c662..b4f0035a 100644 --- a/docs/testing/synastry-full-report-20261018.md +++ b/docs/testing/synastry-full-report-20261018.md @@ -2,7 +2,7 @@ 登录后做。不要把出生日期、地点或报告正文抄进反馈,只写看到了什么。 -1. 侧栏有「婚恋合盘」。打开后能选两个人。人多时下拉可以滚动。 +1. 侧栏有「婚恋合盘」。打开后能选两个人,不能一打开就是「合盘服务暂未开放」。人多时下拉可以滚动。开关关掉之后,才在打开页面后看到这句。 2. 「+ 新增星盘」能保存一个新人,并出现在列表里。 3. 选同一个人,按钮旁说明要选两个不同的人,不会开始生成。 4. 资料不齐的人不能生成,页面说明要先补出生资料。 diff --git a/frontend/src/app/(app)/synastry/page.tsx b/frontend/src/app/(app)/synastry/page.tsx index 462fc3df..7b683e2f 100644 --- a/frontend/src/app/(app)/synastry/page.tsx +++ b/frontend/src/app/(app)/synastry/page.tsx @@ -1,9 +1,5 @@ import { SynastryPicker } from "@/components/synastry/synastry-picker"; -import { isProductEnabled } from "@/lib/product-access"; -export default async function SynastryPage() { - if (!await isProductEnabled("compatibility")) { - return

合盘服务暂未开放

; - } +export default function SynastryPage() { return ; } diff --git a/frontend/src/components/synastry/synastry-picker.tsx b/frontend/src/components/synastry/synastry-picker.tsx index fbcbc67f..4fc44d7b 100644 --- a/frontend/src/components/synastry/synastry-picker.tsx +++ b/frontend/src/components/synastry/synastry-picker.tsx @@ -24,8 +24,27 @@ export function SynastryPicker() { const [creditCost, setCreditCost] = useState(null); const [pricingNote, setPricingNote] = useState(""); const [earlier, setEarlier] = useState([]); + const [serviceClosed, setServiceClosed] = useState(false); async function load(preferId?: string) { + const priceResponse = await fetch("/api/product-access", { cache: "no-store" }); + const price = await priceResponse.json().catch(() => null) as { compatibility?: boolean; synastryCreditCost?: number | null } | null; + if (priceResponse.ok && price?.compatibility === false) { + setServiceClosed(true); + return; + } + if (!priceResponse.ok || price?.compatibility !== true) { + setMessage("星盘档案暂时读不出来"); + return; + } + setServiceClosed(false); + if (typeof price.synastryCreditCost === "number") { + setCreditCost(price.synastryCreditCost); + setPricingNote(""); + } else { + setCreditCost(null); + setPricingNote("计费配置不可用"); + } const [account, response] = await Promise.all([ fetchAccount(), fetch("/api/chart-profiles", { cache: "no-store" }), @@ -45,18 +64,7 @@ export function SynastryPicker() { const wanted = preferId || new URLSearchParams(window.location.search).get("with") || ""; const wantedPerson = rows.find((person) => person.id === wanted); if (wantedPerson && !wantedPerson.incomplete) setRight(wanted); - const [priceResponse, historyResponse] = await Promise.all([ - fetch("/api/product-access", { cache: "no-store" }), - fetch("/api/reports?reportType=synastry_full&limit=10", { cache: "no-store" }), - ]); - const price = await priceResponse.json().catch(() => null) as { synastryCreditCost?: number | null } | null; - if (typeof price?.synastryCreditCost === "number") { - setCreditCost(price.synastryCreditCost); - setPricingNote(""); - } else { - setCreditCost(null); - setPricingNote("计费配置不可用"); - } + const historyResponse = await fetch("/api/reports?reportType=synastry_full&limit=10", { cache: "no-store" }); const history = await historyResponse.json().catch(() => null) as { reports?: Earlier[] } | null; setEarlier(Array.isArray(history?.reports) ? history.reports.filter((report) => report.id) : []); } @@ -115,6 +123,15 @@ export function SynastryPicker() { } } + if (serviceClosed) { + return ( + <> + +

合盘服务暂未开放

+ + ); + } + return ( <> diff --git a/frontend/supabase/migrations/20261020010000_synastry_full_pricing.sql b/frontend/supabase/migrations/20261020010000_synastry_full_pricing.sql new file mode 100644 index 00000000..49bab48d --- /dev/null +++ b/frontend/supabase/migrations/20261020010000_synastry_full_pricing.sql @@ -0,0 +1,43 @@ +begin; + +-- Publish synastry.full at 19 credits for every model tier. Skip a tier that +-- already has a live published price so an admin change is left in place. +insert into public.feature_pricing ( + feature_key, + model_tier, + credit_cost, + version, + status, + enabled, + effective_from, + created_at, + updated_at +) +select + 'synastry.full', + tiers.tier, + 19, + coalesce(( + select max(p.version) + from public.feature_pricing p + where p.feature_key = 'synastry.full' + and p.model_tier = tiers.tier + ), 0) + 1, + 'published', + true, + clock_timestamp(), + clock_timestamp(), + clock_timestamp() +from (values ('standard'), ('premium'), ('internal')) as tiers(tier) +where not exists ( + select 1 + from public.feature_pricing p + where p.feature_key = 'synastry.full' + and p.model_tier = tiers.tier + and p.status = 'published' + and p.enabled + and (p.effective_from is null or p.effective_from <= clock_timestamp()) + and (p.effective_to is null or p.effective_to > clock_timestamp()) +); + +commit; diff --git a/frontend/tests/feature-pricing-contract.test.ts b/frontend/tests/feature-pricing-contract.test.ts index 9ac60fec..3aafd9e6 100644 --- a/frontend/tests/feature-pricing-contract.test.ts +++ b/frontend/tests/feature-pricing-contract.test.ts @@ -64,6 +64,20 @@ test("feature pricing admin uses readable labels while preserving stable values" assert.match(simulator, /modelTierLabel/); }); +test("synastry.full is published at 19 credits for every model tier", () => { + const pricing = readFileSync( + fileURLToPath(new URL("../supabase/migrations/20261020010000_synastry_full_pricing.sql", import.meta.url)), + "utf8", + ); + assert.match(pricing, /'synastry\.full'/); + assert.match(pricing, /19/); + for (const tier of ["standard", "premium", "internal"]) { + assert.match(pricing, new RegExp(`'${tier}'`)); + } + assert.match(pricing, /'published'/); + assert.match(pricing, /where not exists/); +}); + 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/); diff --git a/frontend/tests/stale-client-recovery.test.ts b/frontend/tests/stale-client-recovery.test.ts index ea5e4663..1b7cb07f 100644 --- a/frontend/tests/stale-client-recovery.test.ts +++ b/frontend/tests/stale-client-recovery.test.ts @@ -84,6 +84,12 @@ test("the web build stamps the Git SHA as Next deploymentId and does not cache t // 原因: BUG-1123——/reports 是不读 cookies 的客户端外壳,force-dynamic 让每次点「我的报告」 // 先等一轮服务端渲染;与 BUG-716 的 /chart 同样改为静态外壳,数据由客户端取。 assert.doesNotMatch(reportsPage, /export const dynamic = "force-dynamic"/); + const synastryPage = readFileSync(new URL("../src/app/(app)/synastry/page.tsx", import.meta.url), "utf8"); + // 原值:页面在服务端调用 isProductEnabled,构建时读不到库就预渲染成「合盘服务暂未开放」 + // 新值:静态外壳,不导出 force-dynamic + // 原因:BUG-1397。开关改在浏览器里读。force-dynamic 会让次级页每次跳转先等一轮服务端渲染(BUG-1123) + assert.doesNotMatch(synastryPage, /export const dynamic = "force-dynamic"/); + assert.doesNotMatch(synastryPage, /isProductEnabled/); assert.match(adminLayout, /export const dynamic = "force-dynamic"/); assert.match(layoutSource, /StaleClientRecovery/); }); diff --git a/frontend/tests/synastry-full-report.test.ts b/frontend/tests/synastry-full-report.test.ts index 7c81389a..33025a40 100644 --- a/frontend/tests/synastry-full-report.test.ts +++ b/frontend/tests/synastry-full-report.test.ts @@ -107,6 +107,18 @@ test("technical appendix has six program blocks and the fixed closing line", () assert.equal(JSON.stringify(blocks).includes("1879"), false); }); +test("the synastry page is a static shell and reads the flag in the browser", () => { + const page = readFileSync(new URL("../src/app/(app)/synastry/page.tsx", import.meta.url), "utf8"); + const picker = readFileSync(new URL("../src/components/synastry/synastry-picker.tsx", import.meta.url), "utf8"); + assert.doesNotMatch(page, /isProductEnabled|合盘服务暂未开放|force-dynamic/); + assert.match(page, //); + assert.match(picker, /\/api\/product-access/); + assert.match(picker, /\[serviceClosed, setServiceClosed\] = useState\(false\)/); + assert.match(picker, /compatibility === false/); + assert.match(picker, /合盘服务暂未开放/); + assert.ok(picker.indexOf("setServiceClosed(true)") < picker.indexOf("fetchAccount()")); +}); + test("old synastry links open the pair page instead of a chat draft", () => { const people = readFileSync(new URL("../src/components/people/people-page.tsx", import.meta.url), "utf8"); const deepLink = readFileSync(new URL("../src/components/people-home-links.tsx", import.meta.url), "utf8");