diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md
index bdf890a1..df271f96 100644
--- a/docs/BUG_HISTORY.md
+++ b/docs/BUG_HISTORY.md
@@ -5339,3 +5339,19 @@
- 复发自:无
- 修复版本:97620634
+## BUG-353 | 不确定时间只扫五档时段,补充描述里的钟点纠正用不上
+
+- 状态:resolved
+- 首次发现:2026-08-22
+- 最近更新:2026-08-22
+- 影响面:出生时间表单、`profiles.declared_window_start/end`、V9 Case 候选窗、普通咨询 declared window、账户 PATCH
+- 用户现象:用户已把不确定时间改成 14:30–15:00,或写在补充描述里,生时纠正和 Agent 仍按下午 12:00–17:59 扫描。
+- 触发条件:`birth_time_source=period_only` 且用户记得的是一段钟点而不是五档粗时段;旧资料把钟点写在 `birth_time_clue`。
+- 根因:`period_only` 只把 `birth_time_period` 映射成五档固定窗。`birth_time_clue` 不进 Case fingerprint、不进 `candidate_range`、也不被解析。Case 窗在创建时冻结,聊天工具不能改。正则解析备注会把“6—8 点”这类歧义话当成扫描分钟。
+- 修复:不确定路径改为开始/结束钟点,不再收集备注。保存 `declared_window_start/end`;自定义钟点优先于五档时段;开始结束相同则拒绝;允许跨午夜。旧 `afternoon` 等行回填对应钟点,用户可以收紧到 14:30–15:00。收紧后 fingerprint 变化,首页开新 Case。不解析 clue,保存时把 clue 写成 null。
+- 验证:`frontend/tests/birth-time-intake.test.ts` 锁定开始结束表单、14:30–15:00 持久化且不留下 afternoon 桶;`frontend/tests/declared-birth-window.test.ts` 锁定自定义钟点覆盖 afternoon;`frontend/tests/rectification-v9-case-service.test.ts` 锁定 Case 开窗 14:30–15:00 且 fingerprint 变化;`frontend/tests/consultation-route-service.test.ts` 锁定咨询窗;`frontend/tests/account-api.test.ts` 锁定 PATCH 接受开始结束、拒绝同一分钟。
+- 防复发:不得把备注正则解析成扫描窗。不得只扫五档时段而丢掉用户选择的开始结束。不得把自定义窗的中点写成 `reported_birth_time`。改窗必须开新 Case,旧会话继续展示冻结窗。
+- 相关记录:BUG-197、BUG-198
+- 复发自:BUG-197(不确定入口恢复后仍用五档时段加备注,备注从未进入扫描窗)
+- 修复版本:待提交
+
diff --git a/frontend/src/app/api/account/route.ts b/frontend/src/app/api/account/route.ts
index bbe86bdc..65408dfa 100644
--- a/frontend/src/app/api/account/route.ts
+++ b/frontend/src/app/api/account/route.ts
@@ -49,26 +49,43 @@ export async function GET() {
const admin = createAdminSupabaseClient();
let { data: profile, error: profileError } = await supabase
.from("profiles")
- .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
+ .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
if (profileError && isMissingProfileColumn(profileError)) {
- const fallback = await supabase
+ const withoutDeclaredWindow = await supabase
.from("profiles")
- .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id")
+ .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,name,birth_time,rectification_case_id")
.eq("id", userId)
.single();
- profile = fallback.data ? {
- ...fallback.data,
- birth_place_label: undefined,
- birth_place_type: undefined,
- birth_place_provider: undefined,
- birth_place_provider_id: undefined,
- timezone_id: undefined,
- timezone_source: undefined,
- } : null;
- profileError = fallback.error;
+ if (!withoutDeclaredWindow.error && withoutDeclaredWindow.data) {
+ profile = {
+ ...withoutDeclaredWindow.data,
+ declared_window_start: undefined,
+ declared_window_end: undefined,
+ };
+ profileError = withoutDeclaredWindow.error;
+ } else if (withoutDeclaredWindow.error && isMissingProfileColumn(withoutDeclaredWindow.error)) {
+ const fallback = await supabase
+ .from("profiles")
+ .select("credits,active_birth_time,birth_time_status,birth_date,reported_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,name,birth_time,rectification_case_id")
+ .eq("id", userId)
+ .single();
+ profile = fallback.data ? {
+ ...fallback.data,
+ birth_place_label: undefined,
+ birth_place_type: undefined,
+ birth_place_provider: undefined,
+ birth_place_provider_id: undefined,
+ timezone_id: undefined,
+ timezone_source: undefined,
+ } : null;
+ profileError = fallback.error;
+ } else {
+ profile = withoutDeclaredWindow.data;
+ profileError = withoutDeclaredWindow.error;
+ }
}
if (profileError || !profile) {
@@ -170,28 +187,45 @@ export async function PATCH(request: Request) {
const admin = createAdminSupabaseClient();
let { data: currentProfile, error: currentProfileError } = await admin
.from("profiles")
- .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
+ .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", userId)
.maybeSingle();
if (currentProfileError && isMissingProfileColumn(currentProfileError)) {
- const fallback = await admin
+ const withoutDeclaredWindow = await admin
.from("profiles")
- .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
+ .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", userId)
.maybeSingle();
- currentProfile = fallback.data ? {
- ...fallback.data,
- latitude: undefined,
- longitude: undefined,
- timezone_offset: undefined,
- birth_place_label: undefined,
- birth_place_type: undefined,
- birth_place_provider: undefined,
- birth_place_provider_id: undefined,
- timezone_id: undefined,
- timezone_source: undefined,
- } : null;
- currentProfileError = fallback.error;
+ if (!withoutDeclaredWindow.error && withoutDeclaredWindow.data) {
+ currentProfile = {
+ ...withoutDeclaredWindow.data,
+ declared_window_start: undefined,
+ declared_window_end: undefined,
+ };
+ currentProfileError = withoutDeclaredWindow.error;
+ } else if (withoutDeclaredWindow.error && isMissingProfileColumn(withoutDeclaredWindow.error)) {
+ const fallback = await admin
+ .from("profiles")
+ .select("birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,rectification_case_id,country_code,province_code,city_code,district_code")
+ .eq("id", userId)
+ .maybeSingle();
+ currentProfile = fallback.data ? {
+ ...fallback.data,
+ latitude: undefined,
+ longitude: undefined,
+ timezone_offset: undefined,
+ birth_place_label: undefined,
+ birth_place_type: undefined,
+ birth_place_provider: undefined,
+ birth_place_provider_id: undefined,
+ timezone_id: undefined,
+ timezone_source: undefined,
+ } : null;
+ currentProfileError = fallback.error;
+ } else {
+ currentProfile = withoutDeclaredWindow.data;
+ currentProfileError = withoutDeclaredWindow.error;
+ }
}
if (currentProfileError) {
return NextResponse.json({ error: "暂时无法核对现有出生资料" }, { status: 500 });
@@ -210,6 +244,12 @@ export async function PATCH(request: Request) {
...(payload.birth_time_period !== undefined
? { birth_time_period: payload.birth_time_period }
: {}),
+ ...(payload.declared_window_start !== undefined
+ ? { declared_window_start: payload.declared_window_start }
+ : {}),
+ ...(payload.declared_window_end !== undefined
+ ? { declared_window_end: payload.declared_window_end }
+ : {}),
...(payload.birth_time_clue !== undefined
? { birth_time_clue: payload.birth_time_clue }
: {}),
@@ -261,6 +301,30 @@ export async function PATCH(request: Request) {
data = fallback.data;
error = fallback.error;
}
+ if (error && isMissingProfileColumn(error)) {
+ const {
+ declared_window_start: _start,
+ declared_window_end: _end,
+ ...withoutDeclaredWindow
+ } = withCoordinates;
+ void _start;
+ void _end;
+ const fallback = await writeProfile(withoutDeclaredWindow);
+ data = fallback.data;
+ error = fallback.error;
+ }
+ if (error && isMissingProfileColumn(error)) {
+ const {
+ declared_window_start: _start,
+ declared_window_end: _end,
+ ...withoutCoordinatesOrWindow
+ } = withoutCoordinates;
+ void _start;
+ void _end;
+ const fallback = await writeProfile(withoutCoordinatesOrWindow);
+ data = fallback.data;
+ error = fallback.error;
+ }
if (!error && !data && invalidatesUnconfirmedApplication) {
return NextResponse.json({
diff --git a/frontend/src/app/api/consult/route.ts b/frontend/src/app/api/consult/route.ts
index 8a043994..ed563592 100644
--- a/frontend/src/app/api/consult/route.ts
+++ b/frontend/src/app/api/consult/route.ts
@@ -341,7 +341,7 @@ export async function POST(request: Request) {
async loadProfile(profileUserId) {
const { data, error } = await supabase
.from("profiles")
- .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
+ .select("name,birth_date,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source")
.eq("id", profileUserId)
.single();
if (error || !data) throw new ConsultationProfileTruthError("profile_unavailable");
diff --git a/frontend/src/app/api/onboarding/route.ts b/frontend/src/app/api/onboarding/route.ts
index e6291810..ccab3ae5 100644
--- a/frontend/src/app/api/onboarding/route.ts
+++ b/frontend/src/app/api/onboarding/route.ts
@@ -20,7 +20,7 @@ function createProfileRepository(
async loadProfile(userId) {
return admin
.from("profiles")
- .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,onboarding_payload,onboarding_version,onboarding_generated_at")
+ .select("id,name,birth_date,birth_time,reported_birth_time,active_birth_time,birth_time_source,birth_time_period,declared_window_start,declared_window_end,birth_time_clue,uncertainty_before_minutes,uncertainty_after_minutes,birth_time_status,country_code,province_code,city_code,district_code,latitude,longitude,timezone_offset,birth_place_label,birth_place_type,birth_place_provider,birth_place_provider_id,timezone_id,timezone_source,onboarding_payload,onboarding_version,onboarding_generated_at")
.eq("id", userId)
.maybeSingle();
},
diff --git a/frontend/src/app/globals.css b/frontend/src/app/globals.css
index 900c86c5..ca82a8f6 100644
--- a/frontend/src/app/globals.css
+++ b/frontend/src/app/globals.css
@@ -2050,8 +2050,17 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.birth-time-source-option b { font-size: var(--type-body-sm); line-height: 1.35; }
.birth-time-source-option small { font-size: var(--type-caption); line-height: 1.5; }
.birth-time-detail-grid { grid-template-columns: minmax(0, .9fr) minmax(0, 1.1fr); gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-lg); background: var(--color-canvas-soft); }
-.birth-time-period-details { align-items: start; }
-.birth-time-period-details > label { align-self: start; }
+.birth-time-window-details { align-items: start; }
+.birth-time-window-details > label { align-self: start; }
+.birth-time-window-intro {
+ grid-column: 1 / -1;
+ margin: 0;
+ color: var(--color-ink-secondary);
+ font-size: var(--type-caption);
+ line-height: 1.5;
+}
+.birth-time-window-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 40px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
+.birth-time-window-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); }
.birth-time-detail-grid > label { gap: 7px; }
.birth-time-detail-grid > label > span { color: var(--color-ink-secondary); font-size: var(--type-caption); font-weight: 700; }
.birth-time-detail-grid textarea {
@@ -2081,8 +2090,6 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
.birth-time-detail-grid textarea::-webkit-resizer {
background: color-mix(in srgb, var(--color-border-strong) 70%, transparent);
}
-.birth-time-period-details .birth-time-skip-button { grid-column: 1 / -1; justify-self: start; min-height: 40px; padding-inline: 13px; border-color: transparent; background: transparent; color: var(--color-action); font-size: var(--type-caption); }
-.birth-time-period-details .birth-time-skip-button:hover { background: color-mix(in srgb, var(--color-action-soft) 72%, transparent); }
.birth-time-detail-note[role="status"] { display: grid; gap: var(--space-3); padding: var(--space-4); border: 1px solid var(--color-border); border-radius: var(--radius-md); background: var(--color-canvas-soft); }
.birth-time-detail-note[role="status"] p { margin: 0; }
.onboarding-card-actions { align-items: center; justify-content: space-between; gap: var(--space-4); padding-top: var(--space-2); }
@@ -2091,7 +2098,7 @@ input:not([type="radio"]):not([type="checkbox"]):not([class^="ant-"]):not([class
@media (max-width: 620px) {
.onboarding-card-heading { align-items: flex-start; flex-direction: column; gap: var(--space-2); }
.birth-time-source-list, .birth-time-detail-grid { grid-template-columns: 1fr; }
- .birth-time-period-details .birth-time-skip-button { grid-column: auto; justify-self: stretch; }
+ .birth-time-window-details .birth-time-skip-button { grid-column: auto; justify-self: stretch; }
.onboarding-card-actions { align-items: stretch; flex-direction: column; }
.onboarding-card-actions .button-primary { width: 100%; }
}
diff --git a/frontend/src/app/page.tsx b/frontend/src/app/page.tsx
index 32cd6de1..110780d9 100644
--- a/frontend/src/app/page.tsx
+++ b/frontend/src/app/page.tsx
@@ -61,6 +61,7 @@ import {
birthTimePersistenceValues,
declaredBirthInputChanged,
describeBirthTimeDraft,
+ hydrateDeclaredWindowDraft,
isDeclaredBirthProfileComplete,
isBirthTimeDraftReady,
birthTimeDraftReadyHint,
@@ -358,6 +359,8 @@ const emptyProfile: Profile = {
reportedTime: "",
birthTimeSource: "",
birthTimePeriod: "",
+ declaredWindowStart: "",
+ declaredWindowEnd: "",
birthTimeClue: "",
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
@@ -659,6 +662,8 @@ function readProfile(value: unknown): Profile {
active_birth_time?: unknown;
birth_time_source?: unknown;
birth_time_period?: unknown;
+ declared_window_start?: unknown;
+ declared_window_end?: unknown;
birth_time_clue?: unknown;
uncertainty_before_minutes?: unknown;
uncertainty_after_minutes?: unknown;
@@ -696,6 +701,13 @@ function readProfile(value: unknown): Profile {
const reportedTime = persistedReportedTime || (source === "legacy_import" ? time : "");
const knownPeriods = ["early_morning", "morning", "afternoon", "evening", "late_night"] as const;
const period = knownPeriods.find((item) => item === profile.birth_time_period) ?? "";
+ const windowStart = typeof profile.declared_window_start === "string" ? profile.declared_window_start.slice(0, 5) : "";
+ const windowEnd = typeof profile.declared_window_end === "string" ? profile.declared_window_end.slice(0, 5) : "";
+ const declaredWindow = hydrateDeclaredWindowDraft({
+ period,
+ start: windowStart,
+ end: windowEnd,
+ });
const knownStatuses = ["reported", "assessing", "rectifying", "candidate", "accepted", "confirmed"] as const;
const status = knownStatuses.find((item) => item === profile.birth_time_status)
?? (time ? "confirmed" : "");
@@ -723,8 +735,10 @@ function readProfile(value: unknown): Profile {
time: typeof time === "string" ? time : "",
reportedTime: typeof reportedTime === "string" ? reportedTime : "",
birthTimeSource: source,
- birthTimePeriod: period,
- birthTimeClue: typeof profile.birth_time_clue === "string" ? profile.birth_time_clue.slice(0, 240) : "",
+ birthTimePeriod: declaredWindow.birthTimePeriod,
+ declaredWindowStart: declaredWindow.declaredWindowStart,
+ declaredWindowEnd: declaredWindow.declaredWindowEnd,
+ birthTimeClue: "",
uncertaintyBeforeMinutes: typeof profile.uncertainty_before_minutes === "number" ? profile.uncertainty_before_minutes : null,
uncertaintyAfterMinutes: typeof profile.uncertainty_after_minutes === "number" ? profile.uncertainty_after_minutes : null,
birthTimeStatus: status,
@@ -1475,7 +1489,9 @@ export default function Home() {
time: isCompletedCandidatePreview ? "04:53" : isRectificationPreview || isAssessmentLoadingPreview ? "" : "12:30",
reportedTime: isRectificationPreview ? "14:30" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "" : "12:30",
birthTimeSource: isRectificationPreview ? "approximate" : isAssessmentLoadingPreview || isCompletedCandidatePreview ? "period_only" : "legacy_import",
- birthTimePeriod: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
+ ...hydrateDeclaredWindowDraft({
+ period: isAssessmentLoadingPreview || isCompletedCandidatePreview ? "early_morning" : "",
+ }),
birthTimeClue: "",
uncertaintyBeforeMinutes: isRectificationPreview ? 30 : null,
uncertaintyAfterMinutes: isRectificationPreview ? 30 : null,
diff --git a/frontend/src/components/birth-time-intake.tsx b/frontend/src/components/birth-time-intake.tsx
index fb015225..bd67915c 100644
--- a/frontend/src/components/birth-time-intake.tsx
+++ b/frontend/src/components/birth-time-intake.tsx
@@ -6,7 +6,6 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@
import { birthTimeConsultationOptionsCopy } from "@/lib/birth-time-consultation-consent";
import {
birthTimeDisplayState,
- birthTimePeriodOptions,
birthTimeSourceDefaults,
birthTimeSourceOptions,
type BirthTimeDraft,
@@ -30,9 +29,18 @@ function splitBirthTime(value: string) {
type BirthClockSelectProps = {
readonly value: string;
readonly onChange: (value: string) => void;
+ readonly ariaLabel?: string;
+ readonly hourLabel?: string;
+ readonly minuteLabel?: string;
};
-function BirthClockSelect({ value, onChange }: BirthClockSelectProps) {
+function BirthClockSelect({
+ value,
+ onChange,
+ ariaLabel = "选择出生时间",
+ hourLabel = "出生时间小时",
+ minuteLabel = "出生时间分钟",
+}: BirthClockSelectProps) {
const currentTime = value || "";
const [timeParts, setTimeParts] = useState(() => splitBirthTime(currentTime));
@@ -44,9 +52,9 @@ function BirthClockSelect({ value, onChange }: BirthClockSelectProps) {
};
return (
-
+
: