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");