From 5800b2ad737c32a7dd35b6d4c18cf68ab99cac80 Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Thu, 13 Aug 2026 15:48:17 +0800 Subject: [PATCH] fix(rectification): allow accepted candidate reselection --- docs/BUG_HISTORY.md | 15 ++ ...ow_rectification_candidate_reselection.sql | 251 ++++++++++++++++++ .../tests/rectification-v9-database.test.ts | 153 +++++++++++ .../tests/rectification-v9-migration.test.ts | 94 +++++++ 4 files changed, 513 insertions(+) create mode 100644 frontend/supabase/migrations/20260813050000_allow_rectification_candidate_reselection.sql diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index 7c673589..9d53efea 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -3093,3 +3093,18 @@ - 防复发:Activity 只能来自服务端清理后的真实工具生命周期,不得从 Agent 正文、未来步骤或前端猜测生成;工具参数、出生资料、评分、权限、Provider metadata 与内部错误不得进入公开事件;Agent 正文、运行状态、完成凭证和候选卡必须保持单一内容所有权。 - 相关记录:BUG-172、BUG-173、BUG-179、BUG-180 - 修复版本:本次 staging 发布提交(精确 SHA 以提交与部署结果为准) + +## BUG-182 | 候选卡允许“改选”但数据库把不同分钟误报为“该时间已采用” + +- 状态:resolved(已验证,待 staging 发布与登录态业务验收) +- 首次发现:2026-08-13 +- 最近更新:2026-08-13 +- 影响面:V9 生时校正候选卡的 `改选为此时间` 操作、Case/Profile/Result 三方采用状态一致性。 +- 用户现象:一个候选分钟已经采用后,点击同一候选结果中的另一分钟,界面返回“该时间已采用”;卡片明明显示“改选为此时间”,实际却无法切换。 +- 触发条件:`agentic_rectification_results.selected_time` 已有值,随后以同一个有效 `result_id` 请求另一个候选分钟。 +- 根因:`accept_agentic_rectification_candidate_for_case` 只实现了首次采用和同分钟幂等重放;只要请求分钟不同,就直接抛出 `agentic_rectification_candidate_already_selected`,没有实现前端合同所承诺的安全改选分支。 +- 修复:新增前向业务迁移,保留同分钟幂等;仅允许 `candidate_accepted`、未 confirmed、同一有效未过期结果、旧采用时间与 Profile/Case/Result 完全一致时改选。改选原子更新 Profile、Result 与 Case,并强制保持 `user_accepted` / `accepted` / `candidate_accepted`,不绕过显式确认门;终态、已确认、资料漂移、更新结果或非候选时间仍拒绝。 +- 验证:新增迁移合同回归覆盖顺序、事务、幂等、改选门、Profile 旧值校验、accepted 写入、confirmed/终态不可变及业务迁移隔离;新增 PostgreSQL 集成场景覆盖首次采用、跨分钟改选、三方状态落库与新分钟幂等重放。部署后仍需在 staging 登录态点击候选卡完成真实业务验收。 +- 防复发:候选卡 CTA 与数据库状态机必须共享同一行为合同;任何“改选”文案都必须有跨分钟成功路径测试,不能只测试按钮未禁用或同值幂等。 +- 相关记录:BUG-127、BUG-144、BUG-179、BUG-181 +- 修复版本:本次 staging 发布提交(精确 SHA 以提交与部署结果为准) diff --git a/frontend/supabase/migrations/20260813050000_allow_rectification_candidate_reselection.sql b/frontend/supabase/migrations/20260813050000_allow_rectification_candidate_reselection.sql new file mode 100644 index 00000000..9c633a6d --- /dev/null +++ b/frontend/supabase/migrations/20260813050000_allow_rectification_candidate_reselection.sql @@ -0,0 +1,251 @@ +begin; + +-- Candidate cards explicitly allow the user to change an already accepted +-- minute. Keep same-time retries idempotent, but permit a different minute +-- from the same live result only while the Case is still candidate_accepted +-- and the profile still matches the previous user-accepted selection. +create or replace function public.accept_agentic_rectification_candidate_for_case( + p_user_id uuid, + p_case_id uuid, + p_result_id uuid, + p_time time without time zone +) +returns jsonb +language plpgsql +security definer +set search_path = '' +as $$ +declare + v_case public.agentic_rectification_cases%rowtype; + v_result public.agentic_rectification_results%rowtype; + v_profile public.profiles%rowtype; + v_snapshot jsonb; + v_selection_kind text; + v_status text; +begin + if p_user_id is null or p_case_id is null or p_result_id is null or p_time is null + or extract(second from p_time) is distinct from 0 then + raise exception 'agentic_rectification_candidate_invalid_input' using errcode = 'P0001'; + end if; + + select * into v_case + from public.agentic_rectification_cases + where id = p_case_id and user_id = p_user_id + for update; + if not found then + raise exception 'agentic_rectification_case_not_found' using errcode = 'P0001'; + end if; + if v_case.status in ('confirmed', 'closed', 'abandoned', 'superseded') then + raise exception 'agentic_rectification_case_terminal' using errcode = 'P0001'; + end if; + + select * into v_result + from public.agentic_rectification_results + where id = p_result_id + and user_id = p_user_id + and case_id = p_case_id + for update; + if not found then + raise exception 'agentic_rectification_candidate_not_found' using errcode = 'P0001'; + end if; + if v_result.invalidated_at is not null or v_result.expires_at <= pg_catalog.now() then + raise exception 'agentic_rectification_candidate_expired' using errcode = 'P0001'; + end if; + if not v_result.selection_allowed then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + if not exists ( + select 1 + from pg_catalog.jsonb_array_elements(v_result.candidates) candidate + where candidate ->> 'time' = pg_catalog.to_char(p_time, 'HH24:MI') + ) then + raise exception 'agentic_rectification_candidate_time_not_allowed' using errcode = 'P0001'; + end if; + + if v_result.selected_time is not null then + select * into v_profile + from public.profiles + where id = p_user_id + for update; + + -- Same candidate and minute is a retry/double-click, not a new choice. + if v_result.selected_time is not distinct from p_time + and v_case.accepted_time is not distinct from p_time then + if not found + or v_profile.active_birth_time is distinct from v_result.selected_time + or v_profile.birth_time is distinct from v_result.selected_time + or v_profile.birth_time_status is distinct from ( + case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end + ) then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + return jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), + 'status', case when v_result.selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end, + 'result_id', v_result.id, + 'case_status', v_case.status, + 'idempotent', true + ); + end if; + + -- A card-driven switch is legal only for an unconfirmed user acceptance. + -- It must never demote a confirmed result or mutate a non-selection Case. + if v_case.status is distinct from 'candidate_accepted' + or v_case.accepted_time is null + or v_case.accepted_time is distinct from v_result.selected_time + or v_case.confirmed_time is not null + or v_result.selection_kind is distinct from 'user_accepted' then + raise exception 'agentic_rectification_candidate_selection_blocked' using errcode = 'P0001'; + end if; + + -- The profile must still reflect the old accepted minute. Any independent + -- profile edit invalidates the switch instead of silently overwriting it. + if not found + or v_profile.active_birth_time is distinct from v_result.selected_time + or v_profile.birth_time is distinct from v_result.selected_time + or v_profile.birth_time_status is distinct from 'accepted' then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + update public.profiles + set active_birth_time = p_time, + birth_time = p_time, + birth_time_status = 'accepted', + rectification_confidence = case + when v_result.overall_confidence = 'high' then 100 + when v_result.overall_confidence = 'medium' then 70 + else 40 + end, + updated_at = pg_catalog.now() + where id = p_user_id; + + update public.agentic_rectification_results + set selected_time = p_time, + selection_kind = 'user_accepted', + selected_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_result.id; + + -- The profile trigger temporarily marks resumable cases needs_rebaseline; + -- this atomic candidate switch is the intended accepted state and wins. + update public.agentic_rectification_cases + set status = 'candidate_accepted', + accepted_time = p_time, + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + return jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), + 'status', 'accepted', + 'result_id', v_result.id, + 'case_status', 'candidate_accepted', + 'idempotent', false + ); + end if; + + -- Fresh acceptance still requires the profile to match the Case baseline. + v_snapshot := v_case.baseline_birth_snapshot; + select * into v_profile + from public.profiles + where id = p_user_id + for update; + + if not found + or v_profile.birth_date is distinct from (v_snapshot ->> 'birth_date')::date + or v_profile.reported_birth_time is distinct from (v_snapshot ->> 'reported_birth_time')::time without time zone + or v_profile.active_birth_time is distinct from (v_snapshot ->> 'active_birth_time')::time without time zone + or v_profile.birth_time_source is distinct from v_snapshot ->> 'birth_time_source' + or v_profile.birth_time_period is distinct from v_snapshot ->> 'birth_time_period' + or v_profile.uncertainty_before_minutes is distinct from (v_snapshot ->> 'uncertainty_before_minutes')::integer + or v_profile.uncertainty_after_minutes is distinct from (v_snapshot ->> 'uncertainty_after_minutes')::integer + or v_profile.latitude is distinct from (v_snapshot ->> 'latitude')::double precision + or v_profile.longitude is distinct from (v_snapshot ->> 'longitude')::double precision + or v_profile.timezone_offset is distinct from (v_snapshot ->> 'timezone_offset')::double precision then + raise exception 'agentic_rectification_candidate_profile_changed' using errcode = 'P0001'; + end if; + + if exists ( + select 1 + from public.agentic_rectification_results newer + where newer.user_id = p_user_id + and newer.case_id = p_case_id + and newer.invalidated_at is null + and newer.created_at > v_result.created_at + ) then + raise exception 'agentic_rectification_candidate_superseded' using errcode = 'P0001'; + end if; + + v_selection_kind := case + when v_result.confirmation_allowed + and v_result.representative_time is not distinct from p_time + then 'engine_confirmed' + else 'user_accepted' + end; + v_status := case when v_selection_kind = 'engine_confirmed' then 'confirmed' else 'accepted' end; + + update public.profiles + set active_birth_time = p_time, + birth_time = p_time, + birth_time_status = v_status, + rectification_confidence = case + when v_result.overall_confidence = 'high' then 100 + when v_result.overall_confidence = 'medium' then 70 + else 40 + end, + updated_at = pg_catalog.now() + where id = p_user_id; + + update public.agentic_rectification_results + set selected_time = p_time, + selection_kind = v_selection_kind, + selected_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_result.id; + + update public.agentic_rectification_results + set invalidated_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where user_id = p_user_id + and case_id = p_case_id + and id <> v_result.id + and invalidated_at is null + and selected_time is null; + + update public.agentic_rectification_cases + set status = 'candidate_accepted', + accepted_time = p_time, + last_activity_at = pg_catalog.now(), + updated_at = pg_catalog.now() + where id = v_case.id; + + return jsonb_build_object( + 'success', true, + 'saved_time', pg_catalog.to_char(p_time, 'HH24:MI'), + 'status', v_status, + 'result_id', v_result.id, + 'case_status', 'candidate_accepted', + 'idempotent', false + ); +end; +$$; + +revoke all on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone) + from public, anon, authenticated; +grant execute on function public.accept_agentic_rectification_candidate_for_case(uuid, uuid, uuid, time without time zone) + to service_role; + +commit; diff --git a/frontend/tests/rectification-v9-database.test.ts b/frontend/tests/rectification-v9-database.test.ts index 6e903f4b..efa2a41c 100644 --- a/frontend/tests/rectification-v9-database.test.ts +++ b/frontend/tests/rectification-v9-database.test.ts @@ -990,3 +990,156 @@ test("v9 agent api migration applies, seeds the runtime flag and guards consent" } } }); + +test("v9 allows changing an accepted minute within the same live candidate result", { skip: skipWithoutDocker }, async () => { + const fixture = startPostgresFixture(); + const schemaUrl = fixture.connectionUrl("schema_owner", "schema-owner-test-password"); + try { + const migration = spawnSync(process.execPath, [runnerPath], { + encoding: "utf8", + env: { ...process.env, SCHEMA_DATABASE_URL: schemaUrl }, + }); + assert.equal(migration.status, 0, migration.stderr); + + const userId = "77777777-7777-4777-8777-777777777777"; + const sessionId = "88888888-8888-4888-8888-888888888888"; + const caseId = "99999999-9999-4999-8999-999999999999"; + + fixture.psqlAs("identity_runtime", "identity-runtime-test-password", ` + insert into identity.users (id, name, email, email_verified, email_verified_at) + values ('${userId}', 'Candidate Reselection Fixture', 'candidate-reselection@example.com', true, now()) + `); + fixture.psql(` + update public.profiles + set birth_date = '1997-08-08', + reported_birth_time = '05:00', + active_birth_time = null, + birth_time = '05:00', + birth_time_status = 'reported', + birth_time_source = 'family_exact', + birth_time_period = null, + uncertainty_before_minutes = 10, + uncertainty_after_minutes = 10, + birth_place_label = '河北省邯郸市武安市', + latitude = 36.420487, + longitude = 114.209936, + timezone_id = 'Asia/Shanghai', + timezone_offset = 8 + where id = '${userId}'; + + insert into public.chat_sessions (id, user_id, title, theme, session_type, messages) + values ('${sessionId}', '${userId}', '候选改选', 'general', 'birth_time_rectification', '[]'::jsonb); + + insert into public.agentic_rectification_cases ( + id, user_id, session_id, status, skill_name, skill_version, + baseline_profile_fingerprint, baseline_birth_snapshot, candidate_range + ) values ( + '${caseId}', '${userId}', '${sessionId}', 'candidate_ready', + '${skillName}', '${skillVersion}', '${fingerprint}', + '${JSON.stringify(snapshot)}'::jsonb, + '${JSON.stringify(range)}'::jsonb + ); + `); + + const service = createLocalPostgresDataClient( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + null, + "service_role", + ); + const persisted = await service.rpc("persist_agentic_rectification_candidate", { + p_user_id: userId, + p_case_id: caseId, + p_engine_result_id: "candidate-reselection-engine", + p_algorithm_version: "candidate-reselection-v1", + p_evidence_ledger_fingerprint: "b".repeat(64), + p_candidate_range_fingerprint: "c".repeat(64), + p_skill_version: skillVersion, + p_candidate_range: range, + p_candidates: [ + { rank: 1, time: "05:07", relative_support: 34, tied_minute_count: 1 }, + { rank: 2, time: "05:08", relative_support: 33, tied_minute_count: 2 }, + { rank: 3, time: "05:09", relative_support: 33, tied_minute_count: 2 }, + ], + p_overall_confidence: "low", + p_margin_percent: 1, + p_selection_allowed: true, + p_confirmation_allowed: false, + p_representative_time: null, + }); + assert.equal(persisted.error, null, rpcError(persisted.error)); + const resultId = String((persisted.data as Record).result_id); + + const firstAccept = await service.rpc("accept_agentic_rectification_candidate_for_case", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_time: "05:07", + }); + assert.equal(firstAccept.error, null, rpcError(firstAccept.error)); + + const switched = await service.rpc("accept_agentic_rectification_candidate_for_case", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_time: "05:08", + }); + assert.equal(switched.error, null, rpcError(switched.error)); + assert.deepEqual( + { + saved_time: (switched.data as Record).saved_time, + status: (switched.data as Record).status, + case_status: (switched.data as Record).case_status, + idempotent: (switched.data as Record).idempotent, + }, + { saved_time: "05:08", status: "accepted", case_status: "candidate_accepted", idempotent: false }, + ); + assert.equal( + fixture.psql(`select active_birth_time || ':' || birth_time || ':' || birth_time_status from public.profiles where id = '${userId}'`), + "05:08:00:05:08:00:accepted", + ); + assert.equal( + fixture.psql(`select selected_time || ':' || selection_kind from public.agentic_rectification_results where id = '${resultId}'`), + "05:08:00:user_accepted", + ); + assert.equal( + fixture.psql(`select accepted_time || ':' || status from public.agentic_rectification_cases where id = '${caseId}'`), + "05:08:00:candidate_accepted", + ); + + const replay = await service.rpc("accept_agentic_rectification_candidate_for_case", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_time: "05:08", + }); + assert.equal(replay.error, null, rpcError(replay.error)); + assert.equal((replay.data as Record).idempotent, true); + + fixture.psql(` + update public.profiles + set birth_time_status = 'confirmed' + where id = '${userId}'; + update public.agentic_rectification_results + set selection_kind = 'engine_confirmed' + where id = '${resultId}'; + update public.agentic_rectification_cases + set status = 'confirmed', confirmed_time = '05:08', completed_at = now() + where id = '${caseId}'; + `); + const confirmedSwitch = await service.rpc("accept_agentic_rectification_candidate_for_case", { + p_user_id: userId, + p_case_id: caseId, + p_result_id: resultId, + p_time: "05:09", + }); + assert.match(rpcError(confirmedSwitch.error), /agentic_rectification_case_terminal/); + } finally { + try { + await closeLocalPostgresDataPool( + fixture.connectionUrl("service_runtime", "service-runtime-test-password"), + ); + } finally { + fixture.stop(); + } + } +}); diff --git a/frontend/tests/rectification-v9-migration.test.ts b/frontend/tests/rectification-v9-migration.test.ts index 1692b0ed..c9bcca49 100644 --- a/frontend/tests/rectification-v9-migration.test.ts +++ b/frontend/tests/rectification-v9-migration.test.ts @@ -506,3 +506,97 @@ test("parallel rectification migration stays out of the identity migration tree" "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", ); }); + +// --------------------------------------------------------------------------- +// 20260813050000_allow_rectification_candidate_reselection.sql +// --------------------------------------------------------------------------- + +const candidateReselectionMigration = readFileSync( + new URL( + "../supabase/migrations/20260813050000_allow_rectification_candidate_reselection.sql", + import.meta.url, + ), + "utf8", +); + +const candidateReselectionMigrationCopy = fileURLToPath( + new URL( + "../db/migrations/20260813050000_allow_rectification_candidate_reselection.sql", + import.meta.url, + ), +); + +test("candidate reselection migration follows parallel cases and stays transactional", () => { + assert.ok( + "20260813050000_allow_rectification_candidate_reselection.sql" > + "20260813040000_allow_parallel_rectification_cases.sql", + ); + assert.match(candidateReselectionMigration, /^begin;[\s\S]*^commit;$/m); + assert.match( + candidateReselectionMigration, + /create or replace function public\.accept_agentic_rectification_candidate_for_case\(/, + ); +}); + +test("candidate reselection preserves replay and safely changes only accepted candidates", () => { + assert.match( + candidateReselectionMigration, + /v_result\.selected_time is not distinct from p_time[\s\S]*v_case\.accepted_time is not distinct from p_time[\s\S]*'idempotent', true/, + ); + assert.match( + candidateReselectionMigration, + /v_case\.status is distinct from 'candidate_accepted'/, + ); + assert.match( + candidateReselectionMigration, + /v_result\.selection_kind is distinct from 'user_accepted'/, + ); + assert.match( + candidateReselectionMigration, + /v_profile\.active_birth_time is distinct from v_result\.selected_time/, + ); + assert.match( + candidateReselectionMigration, + /v_profile\.birth_time_status is distinct from 'accepted'/, + ); + assert.match( + candidateReselectionMigration, + /set selected_time = p_time,[\s\S]*selection_kind = 'user_accepted'/, + ); + assert.match( + candidateReselectionMigration, + /set active_birth_time = p_time,[\s\S]*birth_time = p_time,[\s\S]*birth_time_status = 'accepted'/, + ); + assert.match( + candidateReselectionMigration, + /set status = 'candidate_accepted',[\s\S]*accepted_time = p_time/, + ); + assert.doesNotMatch( + candidateReselectionMigration, + /selected_time is distinct from p_time[\s\S]{0,160}agentic_rectification_candidate_already_selected/, + ); +}); + +test("candidate reselection keeps terminal and confirmed selections immutable", () => { + assert.match( + candidateReselectionMigration, + /v_case\.status in \('confirmed', 'closed', 'abandoned', 'superseded'\)[\s\S]*agentic_rectification_case_terminal/, + ); + assert.match( + candidateReselectionMigration, + /v_result\.selection_kind is distinct from 'user_accepted'[\s\S]*agentic_rectification_candidate_selection_blocked/, + ); + assert.match(candidateReselectionMigration, /birth_time_status = 'accepted'/); + assert.doesNotMatch( + candidateReselectionMigration, + /set selected_time = p_time,[\s\S]{0,180}selection_kind = 'engine_confirmed'/, + ); +}); + +test("candidate reselection migration stays out of the identity migration tree", () => { + assert.equal( + existsSync(candidateReselectionMigrationCopy), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); +});