fix: avoid repeated daily starlanguage refresh
Independent Staging Quality Gate / validate (push) Failing after 11m57s
Independent Staging Quality Gate / publish (push) Has been skipped

This commit is contained in:
Jesse_Chen
2026-08-15 23:28:49 +08:00
parent cf945b3455
commit 1084dce770
4 changed files with 88 additions and 2 deletions
+16 -1
View File
@@ -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 | 咨询完成后的账户刷新重复请求每日星语接口
- 状态:resolvedstaging 修复候选,待部署验收)
- 首次发现: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
- 修复版本:待提交(本地可测)
+2 -1
View File
@@ -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) {
@@ -0,0 +1,18 @@
export function preserveShallowEqual<T extends object>(current: T, next: T): T {
if (Object.is(current, next)) return current;
const currentRecord = current as Record<string, unknown>;
const nextRecord = next as Record<string, unknown>;
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;
}
+52
View File
@@ -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\)/);
});