diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index c248615e..44afda64 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -184,7 +184,7 @@ - 防复发:咨询路由测试锁定“有效填报分钟无需授权即可使用”;页面契约禁止重新引入生时校正 toast 或阻断式选择。 - 相关记录:BUG-003、BUG-004 - 复发自:无 -- 修复版本:待提交(本地可测) +- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging 验收结果为准) ## BUG-010 | 浏览器直连 Supabase 导致自托管 PostgreSQL staging 误报未配置 @@ -3378,3 +3378,18 @@ - 防复发:缺少准确出生分钟只限制分钟敏感的个性化结论,不得把用户入口改写成占星教学。首页卡片和 Agent 推荐问题必须直接表达用户要解决的事;Prompt 约束之外必须保留服务端输出校验和版本化缓存失效。 - 相关记录:BUG-194、BUG-197、BUG-198 - 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准) + +## BUG-201 | 咨询完成后的账户刷新重复请求每日星语接口 + +- 状态:resolved(staging 修复候选,待部署验收) +- 首次发现:2026-08-15 +- 最近更新:2026-08-15 +- 影响面:首页每日星语卡片、咨询完成后的账户与积分刷新、`POST /api/daily-starlanguage`。 +- 用户现象:每完成一次普通咨询,浏览器都会再次请求每日星语接口;即使账户返回的标准化出生资料没有任何变化,也会重复生成同一张每日卡片。 +- 触发条件:咨询流成功完成后调用 `refreshAccount()`;账户接口返回与当前状态值完全相同但引用不同的 Profile 对象。 +- 根因:`readProfile()` 每次都会创建新的标准化对象,`refreshAccount()` 又无条件用该对象替换 Profile state;每日星语 effect 需要跟踪完整 Profile,因此依赖对象引用并在引用变化后重新执行。问题不在咨询结算,也不能通过移除账户刷新或缩减 Profile 依赖来规避。 +- 修复:保留咨询完成后的账户与积分刷新;新增浅等值引用保持 helper。账户刷新得到的新 Profile 与当前 Profile 所有标准化字段等值时继续使用当前引用,只有真实字段变化时才替换 state,从而避免无意义地重跑每日星语及其他 Profile 对象 effect。 +- 验证:先添加回归测试并确认因 helper 尚不存在而失败;修复后 Profile 引用行为与 `refreshAccount()` 集成测试 3/3 通过。相关 account、consultation entrypoint、starter questions 聚焦测试合计 53/53 通过;目标 ESLint 与 TypeScript `--noEmit` 通过;`git diff --check` 按本次本地验收执行。 +- 防复发:服务器资料刷新不得把“值相同”转化为无意义的状态引用变化;依赖完整 Profile 的 effect 必须在真实资料变化时执行,不能为消除重复请求而遗漏依赖字段。 +- 相关记录:BUG-200 +- 修复版本:待提交(本地可测) diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx index 876b22f8..60362b8b 100644 --- a/frontend/src/app/page.tsx +++ b/frontend/src/app/page.tsx @@ -102,6 +102,7 @@ import { requestOnboardingWithRecovery, } from "@/lib/onboarding-client"; import { protectOnboardingPhrases } from "@/lib/onboarding-copy"; +import { preserveShallowEqual } from "@/lib/preserve-shallow-equal"; import { SessionModelPersistenceQueue, persistSessionModelSelection, @@ -1797,7 +1798,7 @@ export default function Home() { const latest = await fetchAccount(); if (!accountRefreshGuard.current.isCurrent(requestIdentity)) return; const nextProfile = readProfile(latest.profile); - setProfile(nextProfile); + setProfile((current) => preserveShallowEqual(current, nextProfile)); setAccount(latest); setAccountError(""); } catch (caught) { diff --git a/frontend/src/lib/preserve-shallow-equal.ts b/frontend/src/lib/preserve-shallow-equal.ts new file mode 100644 index 00000000..21612303 --- /dev/null +++ b/frontend/src/lib/preserve-shallow-equal.ts @@ -0,0 +1,18 @@ +export function preserveShallowEqual(current: T, next: T): T { + if (Object.is(current, next)) return current; + + const currentRecord = current as Record; + const nextRecord = next as Record; + const currentKeys = Object.keys(currentRecord); + const nextKeys = Object.keys(nextRecord); + + if (currentKeys.length !== nextKeys.length) return next; + + for (const key of currentKeys) { + if (!Object.hasOwn(nextRecord, key) || !Object.is(currentRecord[key], nextRecord[key])) { + return next; + } + } + + return current; +} diff --git a/frontend/tests/profile-state.test.ts b/frontend/tests/profile-state.test.ts new file mode 100644 index 00000000..c1d0eb91 --- /dev/null +++ b/frontend/tests/profile-state.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; +import { preserveShallowEqual } from "../src/lib/preserve-shallow-equal.ts"; + +const pageSource = readFileSync(new URL("../src/app/page.tsx", import.meta.url), "utf8"); + +test("preserves the current profile reference when refreshed values are unchanged", () => { + const current = { + name: "测试用户", + date: "2000-01-01", + time: "", + birthTimeSource: "period_only", + birthTimePeriod: "evening", + timezoneId: "Asia/Shanghai", + latitude: 31.23, + longitude: 121.47, + timezoneOffset: null, + }; + const refreshed = { ...current }; + + assert.notEqual(refreshed, current); + assert.equal(preserveShallowEqual(current, refreshed), current); +}); + +test("uses the refreshed profile reference when a value changed", () => { + const current = { + name: "测试用户", + birthTimePeriod: "evening", + timezoneId: "Asia/Shanghai", + }; + const refreshed = { + ...current, + birthTimePeriod: "morning", + }; + + assert.equal(preserveShallowEqual(current, refreshed), refreshed); +}); + +test("account refresh preserves an equivalent normalized profile instead of replacing it", () => { + const refreshAccountStart = pageSource.indexOf("async function refreshAccount()"); + const refreshAccountSource = pageSource.slice( + refreshAccountStart, + pageSource.indexOf("function updateSession", refreshAccountStart), + ); + + assert.match( + refreshAccountSource, + /setProfile\(\(current\) => preserveShallowEqual\(current, nextProfile\)\)/, + ); + assert.doesNotMatch(refreshAccountSource, /setProfile\(nextProfile\)/); +});