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