diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 82a74fe5..a8e603c5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3348,3 +3348,18 @@ - 防复发:资料完整性、Case 可创建性和是否可以立即执行分钟级扫描必须分层;宽范围应先通过事件问题逐步缩小,不得以 `profile_incomplete` 阻止用户进入,也不得生成虚假具体出生时间。 - 相关记录:BUG-127、BUG-196、BUG-197 - 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准) + +## BUG-199 | Case 创建把可解析的空时区偏移误判为出生资料不完整 + +- 状态:resolved(本地候选,待 staging 发布与登录态业务验收) +- 首次发现:2026-08-15 +- 最近更新:2026-08-15 +- 影响面:`/api/rectification/cases/open`、保存了 IANA 时区但 `timezone_offset` 为空的全球出生地点资料,尤其是 `period_only` / `unknown` 用户。 +- 用户现象:用户已选择“晚上”等合法出生时段,页面也认为出生资料完整,但开始生时校正仍返回 HTTP 422 `profile_incomplete`。 +- 触发条件:Profile 已有出生日期、地点标签、坐标、`timezone_id` 和合法时间声明,但缓存字段 `timezone_offset` 为 `null`。 +- 根因:资料表单和账户保存合同允许用 IANA `timezone_id` 表达完整地点,既有普通咨询与 Journey 链路也会按出生日期和参考时间动态解析历史 offset;V9 Case 服务却在调用同一解析器之前直接强制 `timezone_offset !== null`,把可恢复的派生字段缺失误判成用户资料缺失。 +- 修复:V9 Profile 读取后先调用共享 `resolveMissingBirthTimezoneOffset`;具体时间使用填报分钟,时段声明使用服务器定义的时段参考时刻,未知时间使用中午参考时刻,只用于解析该日期的历史 UTC offset,不会生成或确认具体出生分钟。解析成功后再执行原有完整性和候选范围校验;解析服务异常映射为 `profile_unavailable`,不再冒充 `profile_incomplete`。 +- 验证:新增真实服务边界回归,先证明 `period_only + evening + timezone_id + timezone_offset null` 在 RPC 前稳定抛出 `profile_incomplete`,修复后确认调用历史时区接口、候选范围仍为 `18:00–22:59`、baseline 使用解析得到的 offset 并成功创建 Case。聚焦测试、Lint、TypeScript、远端 SHA 与 staging 业务结果按本次发布记录补充。 +- 防复发:IANA 时区是地点真相,`timezone_offset` 是依赖出生日期与参考时刻的派生值;所有需要 offset 的服务必须先走共享解析边界,再区分真正资料不完整与下游服务异常。 +- 相关记录:BUG-127、BUG-198 +- 修复版本:本次 staging 修复提交(精确 SHA 以远端分支与 staging health 验收结果为准) diff --git a/frontend/src/lib/rectification-agentic/v9/case-service.ts b/frontend/src/lib/rectification-agentic/v9/case-service.ts index d56e5b18..c2161e38 100644 --- a/frontend/src/lib/rectification-agentic/v9/case-service.ts +++ b/frontend/src/lib/rectification-agentic/v9/case-service.ts @@ -8,6 +8,7 @@ */ import { createHash } from "node:crypto"; import type { SupabaseClient } from "@supabase/supabase-js"; +import { resolveMissingBirthTimezoneOffset } from "../../birth-profile-timezone.ts"; import { normalizePersistedBirthDate } from "../../birth-time-intake-model.ts"; import { resolveActiveSkillPackage, @@ -180,7 +181,13 @@ export async function loadV9RectificationProfile( .single(); if (error || !data) throw new RectificationCaseServiceError("profile_unavailable"); - const row = data as Record; + let resolvedData: unknown; + try { + resolvedData = await resolveMissingBirthTimezoneOffset(data); + } catch { + throw new RectificationCaseServiceError("profile_unavailable"); + } + const row = resolvedData as Record; const birthDate = normalizePersistedBirthDate(row.birth_date); const latitude = numberOrNull(row.latitude); const longitude = numberOrNull(row.longitude); diff --git a/frontend/tests/rectification-v9-case-service.test.ts b/frontend/tests/rectification-v9-case-service.test.ts index 821b7053..b88f0b85 100644 --- a/frontend/tests/rectification-v9-case-service.test.ts +++ b/frontend/tests/rectification-v9-case-service.test.ts @@ -168,6 +168,68 @@ test("period-only profiles open with their selected server-owned range", async ( } }); +test("period-only profiles with an IANA timezone can open when the cached offset is missing", async () => { + const originalFetch = globalThis.fetch; + const capturedArgs: { value: Record | null } = { value: null }; + let timezoneLookupCalled = false; + globalThis.fetch = async (input, init) => { + timezoneLookupCalled = true; + assert.equal(String(input), "http://127.0.0.1:5200/api/location/timezone"); + assert.deepEqual(JSON.parse(String(init?.body)), { + latitude: 36.420487, + longitude: 114.209936, + birthDate: "1997-08-08", + birthTime: "20:30", + }); + return new Response(JSON.stringify({ + available: true, + timezoneId: "Asia/Shanghai", + timezoneOffset: 8, + }), { status: 200 }); + }; + + try { + const accounting = fakeAccounting({ + profile: { + ...completeProfile, + reported_birth_time: null, + birth_time_source: "period_only", + birth_time_period: "evening", + timezone_offset: null, + }, + rpc: async (_fn, args) => { + capturedArgs.value = args; + return { + data: { + disposition: "created", + case_id: caseId, + session_id: sessionId, + status: "draft", + should_start_opening: true, + skill_version: "9.0.0", + }, + error: null, + }; + }, + }); + + const response = await openRectificationCase(accounting, "user-1", { + intent: "homepage", + requestId, + }); + + assert.equal(response.disposition, "created"); + assert.equal(timezoneLookupCalled, true); + assert.deepEqual(capturedArgs.value?.p_candidate_range, { start_time: "18:00", end_time: "22:59" }); + assert.equal( + (capturedArgs.value?.p_baseline_birth_snapshot as Record).timezone_offset, + 8, + ); + } finally { + globalThis.fetch = originalFetch; + } +}); + test("unknown-time profiles open with a full-day server-owned range", async () => { const capturedArgs: { value: Record | null } = { value: null }; const accounting = fakeAccounting({