diff --git a/.github/workflows/apply-production-rectification-migrations.yml b/.github/workflows/apply-production-rectification-migrations.yml index 37e7231e..af391a3f 100644 --- a/.github/workflows/apply-production-rectification-migrations.yml +++ b/.github/workflows/apply-production-rectification-migrations.yml @@ -67,6 +67,7 @@ jobs: frontend/supabase/migrations/20260724020000_align_global_birthplace_rectification_contract.sql \ frontend/supabase/migrations/20260724030000_allow_assistant_only_rectification_regenerate.sql \ frontend/supabase/migrations/20260725010000_structured_conversational_date_confirmation.sql \ + frontend/supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql \ "$DEPLOY_USER@$DEPLOY_HOST:$REMOTE_DIR/" - name: Check or apply reviewed migrations @@ -118,7 +119,8 @@ jobs: "$REMOTE_DIR/20260724010000_global_birth_locations.sql" \ "$REMOTE_DIR/20260724020000_align_global_birthplace_rectification_contract.sql" \ "$REMOTE_DIR/20260724030000_allow_assistant_only_rectification_regenerate.sql" \ - "$REMOTE_DIR/20260725010000_structured_conversational_date_confirmation.sql" + "$REMOTE_DIR/20260725010000_structured_conversational_date_confirmation.sql" \ + "$REMOTE_DIR/20260725020000_repair_structured_conversational_date_validator.sql" do filename="$(basename "$sql_file")" checksum="$(sha256sum "$sql_file" | awk '{print $1}')" diff --git a/docs/BUG_HISTORY.md b/docs/BUG_HISTORY.md index aaaf4103..2024b2b5 100644 --- a/docs/BUG_HISTORY.md +++ b/docs/BUG_HISTORY.md @@ -1338,3 +1338,18 @@ - 防复发:新增需要生产执行的 migration 时,必须同时更新受审生产迁移清单并由测试覆盖上传和执行两处引用。 - 相关记录:BUG-068、BUG-069 - 修复版本:待提交(本地可测) + +## BUG-071 | 结构化日期校验器的冗余正则在 PostgreSQL 执行时报错 + +- 状态:resolved +- 首次发现:2026-07-25 +- 最近更新:2026-07-25 +- 影响面:生产环境生时校正首次建案、`proposedDate` 持久化与预留点数释放 +- 用户现象:开始生时校正时接口返回 `409 action_conflict` 和“请加载最新进度后再试”,预留点数随后自动释放且没有创建 case。 +- 触发条件:首轮 Agent 生成带 `followUp.answerMode=yes_no` 和 `followUp.proposedDate` 的 evidence request,数据库开始执行结构化日期校验。 +- 根因:`20260725010000_structured_conversational_date_confirmation.sql` 同时保留了一个冗余的聚合日期正则;该正则括号不平衡,PostgreSQL 在函数运行时抛出 `2201B invalid regular expression`。下方按 `year/month/day` 精度分别校验的正则已经完整覆盖格式约束。 +- 修复:新增向前迁移 `20260725020000_repair_structured_conversational_date_validator.sql`,删除冗余聚合正则,只保留现有精度分支;同步加入生产迁移工作流。 +- 验证:生产事务回滚 dry-run 通过;以 `2020-10 + month + yes_no` 调用校验器返回 true;迁移账本、函数定义和生产健康状态均复核。 +- 防复发:数据库 JSON 合同的日期格式只维护一组精度分支;新增生产迁移时必须执行真实 PostgreSQL 函数调用,而不是只做 SQL 文本断言。 +- 相关记录:BUG-067、BUG-070 +- 修复版本:本次修复提交(生产已向前迁移) diff --git a/frontend/supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql b/frontend/supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql new file mode 100644 index 00000000..ac420d47 --- /dev/null +++ b/frontend/supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql @@ -0,0 +1,107 @@ +begin; + +-- Repair the structured date validator after the initial migration shipped a +-- redundant aggregate date regex with unbalanced parentheses. The precision- +-- specific checks below are the single source of truth. +create or replace function public.conversational_rectification_valid_evidence_request( + p_value jsonb +) +returns boolean +language sql +immutable +strict +set search_path = '' +as $$ + select pg_catalog.jsonb_typeof(p_value) = 'object' + and pg_catalog.octet_length(p_value::text) <= 2048 + and public.conversational_rectification_numbers_are_stable(p_value) + and public.conversational_rectification_has_only_keys( + p_value, + array['domains', 'datePrecision', 'freeTextAllowed', 'prompt', 'followUp']::text[] + ) + and p_value ?& array['domains', 'datePrecision', 'freeTextAllowed']::text[] + and pg_catalog.jsonb_typeof(p_value -> 'domains') = 'array' + and pg_catalog.jsonb_array_length(p_value -> 'domains') between 1 and 4 + and not exists ( + select 1 + from pg_catalog.jsonb_array_elements(p_value -> 'domains') domain + where pg_catalog.jsonb_typeof(domain) is distinct from 'string' + or domain #>> '{}' not in ( + 'career', 'education', 'finance', 'health_pressure', 'relocation', + 'relationship', 'family', 'other' + ) + ) + and pg_catalog.jsonb_typeof(p_value -> 'datePrecision') = 'string' + and p_value ->> 'datePrecision' in ('month_preferred', 'year_accepted') + and pg_catalog.jsonb_typeof(p_value -> 'freeTextAllowed') = 'boolean' + and p_value -> 'freeTextAllowed' = 'true'::jsonb + and ( + not (p_value ? 'prompt') + or ( + pg_catalog.jsonb_typeof(p_value -> 'prompt') = 'string' + and pg_catalog.char_length(pg_catalog.btrim(p_value ->> 'prompt')) between 1 and 1000 + ) + ) + and ( + not (p_value ? 'followUp') + or ( + pg_catalog.jsonb_typeof(p_value -> 'followUp') = 'object' + and public.conversational_rectification_has_only_keys( + p_value -> 'followUp', + array['kind', 'evidenceId', 'answerMode', 'proposedDate']::text[] + ) + and (p_value -> 'followUp') ?& array['kind', 'evidenceId']::text[] + and p_value #>> '{followUp,kind}' in ('new_event', 'event_date', 'event_detail') + and ( + not ((p_value -> 'followUp') ? 'answerMode') + or ( + pg_catalog.jsonb_typeof(p_value #> '{followUp,answerMode}') = 'string' + and p_value #>> '{followUp,answerMode}' in ('free_text', 'yes_no') + ) + ) + and ( + not ((p_value -> 'followUp') ? 'proposedDate') + or p_value #> '{followUp,proposedDate}' = 'null'::jsonb + or ( + pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate}') = 'object' + and public.conversational_rectification_has_only_keys( + p_value #> '{followUp,proposedDate}', + array['value', 'precision']::text[] + ) + and (p_value #> '{followUp,proposedDate}') ?& array['value', 'precision']::text[] + and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate,value}') = 'string' + and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate,precision}') = 'string' + and ( + (p_value #>> '{followUp,proposedDate,precision}' = 'year' + and p_value #>> '{followUp,proposedDate,value}' ~ '^[0-9]{4}$') + or (p_value #>> '{followUp,proposedDate,precision}' = 'month' + and p_value #>> '{followUp,proposedDate,value}' ~ '^[0-9]{4}-((0[1-9])|(1[0-2]))$') + or (p_value #>> '{followUp,proposedDate,precision}' = 'day' + and p_value #>> '{followUp,proposedDate,value}' + ~ '^[0-9]{4}-((0[1-9])|(1[0-2]))-((0[1-9])|([12][0-9])|(3[01]))$') + ) + ) + ) + and ( + (p_value #>> '{followUp,kind}' = 'new_event' + and p_value #>> '{followUp,evidenceId}' is null) + or (p_value #>> '{followUp,kind}' <> 'new_event' + and public.conversational_rectification_valid_uuid_text( + p_value #>> '{followUp,evidenceId}' + )) + ) + and ( + (p_value #>> '{followUp,answerMode}' = 'yes_no' + and p_value #>> '{followUp,kind}' = 'event_date' + and pg_catalog.jsonb_typeof(p_value #> '{followUp,proposedDate}') = 'object') + or (p_value #>> '{followUp,answerMode}' is distinct from 'yes_no' + and ( + not ((p_value -> 'followUp') ? 'proposedDate') + or p_value #> '{followUp,proposedDate}' = 'null'::jsonb + )) + ) + ) + ); +$$; + +commit; diff --git a/frontend/tests/conversational-rectification-domain-migration.test.ts b/frontend/tests/conversational-rectification-domain-migration.test.ts index 6f54342d..a0695afe 100644 --- a/frontend/tests/conversational-rectification-domain-migration.test.ts +++ b/frontend/tests/conversational-rectification-domain-migration.test.ts @@ -44,6 +44,13 @@ const structuredDateConfirmationMigration = readFileSync( ), "utf8", ); +const repairedStructuredDateValidatorMigration = readFileSync( + new URL( + "../supabase/migrations/20260725020000_repair_structured_conversational_date_validator.sql", + import.meta.url, + ), + "utf8", +); const productionMigrationWorkflow = readFileSync( new URL( "../../.github/workflows/apply-production-rectification-migrations.yml", @@ -139,9 +146,29 @@ test("durable evidence requests persist strict structured date confirmation", () assert.match(structuredDateConfirmationMigration, /valid_uuid_text/); }); -test("production workflow uploads and applies the structured date confirmation migration", () => { - assert.equal( - productionMigrationWorkflow.match(/20260725010000_structured_conversational_date_confirmation\.sql/g)?.length, - 2, +test("repaired structured date validator relies on precision-specific date checks", () => { + assert.doesNotMatch( + repairedStructuredDateValidatorMigration, + /\^\[0-9\]\{4\}\(-/, + ); + assert.match( + repairedStructuredDateValidatorMigration, + /proposedDate,precision}' = 'month'/, + ); + assert.match( + repairedStructuredDateValidatorMigration, + /proposedDate,precision}' = 'day'/, ); }); + +test("production workflow uploads and applies the structured date confirmation migrations", () => { + for (const migration of [ + "20260725010000_structured_conversational_date_confirmation", + "20260725020000_repair_structured_conversational_date_validator", + ]) { + assert.equal( + productionMigrationWorkflow.match(new RegExp(`${migration}\\.sql`, "g"))?.length, + 2, + ); + } +});