fix(synastry): open the match page from the live flag and price it at 19 credits
Independent Staging Quality Gate / validate (push) Failing after 14m48s
Independent Staging Quality Gate / publish (push) Skipped

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 <cursoragent@cursor.com>
This commit is contained in:
jesse-ux
2026-10-20 12:52:28 +08:00
co-authored by Cursor
parent 06dba28d3a
commit 006b067e82
9 changed files with 146 additions and 18 deletions
+8
View File
@@ -1,5 +1,13 @@
# 印度占星 Skill 更新日志
## 2026-10-20 — 完整合盘报告定价 19 点
- `synastry.full` 按标准、高级、内部三个模型档发布,每份 19 点。某一档已经有生效价格时,迁移不再覆盖。Skill 版本不变。
## 2026-10-20 — 婚恋合盘页不再把「暂未开放」写死
- 打开婚恋合盘时先问当前开关。开关开着就进入选人;关着才显示「合盘服务暂未开放」。构建时读不到数据库不再把这句预渲染进页面。Skill 版本不变(BUG-1397)。
## 2026-10-19 — 合盘报告可以按节追问依据
- 报告末尾可以选择前八节里的一节,打开一条绑定这份合盘的新对话。问题预先写好,由用户自己发送。打开对话不扣点,发出去的每一轮按普通对话 `chat.standard` 计费。
+32
View File
@@ -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`。
@@ -2,7 +2,7 @@
登录后做。不要把出生日期、地点或报告正文抄进反馈,只写看到了什么。
1. 侧栏有「婚恋合盘」。打开后能选两个人。人多时下拉可以滚动。
1. 侧栏有「婚恋合盘」。打开后能选两个人,不能一打开就是「合盘服务暂未开放」。人多时下拉可以滚动。开关关掉之后,才在打开页面后看到这句。
2. 「+ 新增星盘」能保存一个新人,并出现在列表里。
3. 选同一个人,按钮旁说明要选两个不同的人,不会开始生成。
4. 资料不齐的人不能生成,页面说明要先补出生资料。
+1 -5
View File
@@ -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 <p>合盘服务暂未开放</p>;
}
export default function SynastryPage() {
return <SynastryPicker />;
}
@@ -24,8 +24,27 @@ export function SynastryPicker() {
const [creditCost, setCreditCost] = useState<number | null>(null);
const [pricingNote, setPricingNote] = useState("");
const [earlier, setEarlier] = useState<Earlier[]>([]);
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 (
<>
<SecondaryHeader title="婚恋合盘" />
<p>合盘服务暂未开放</p>
</>
);
}
return (
<>
<SecondaryHeader title="婚恋合盘" />
@@ -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;
@@ -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/);
@@ -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/);
});
@@ -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, /<SynastryPicker \/>/);
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");