From d6c359b2058443bacf88e53e36531ac83381cc2a Mon Sep 17 00:00:00 2001 From: Jesse_Chen Date: Wed, 16 Sep 2026 01:35:01 +0000 Subject: [PATCH] =?UTF-8?q?fix(db):=20=E6=8A=8A=E6=A0=A1=E6=AD=A3=E6=A0=87?= =?UTF-8?q?=E9=A2=98/=E6=B4=BB=E8=B7=83=E6=97=B6=E9=97=B4=E4=BF=AE?= =?UTF-8?q?=E8=A1=A5=E6=90=AC=E6=88=90=E8=BF=81=E7=A7=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BUG-699 / BUG-704 的数据修补原先只存在于 frontend/scripts/repair-rectification-session-titles.mjs,需要 SCHEMA_DATABASE_URL 才能跑,产品负责人没有任何按钮能执行它,所以那批 错日期的会话标题一直没修。 脚本里本来就是纯 SQL,搬进一次性迁移即可复用现成的 `Migrate Staging Database` 按钮: - 新增 20260916020000_rectification_session_title_repair.sql,两段 update 逐字取自脚本(合同测试比对,改了哪边都会红);权限守卫沿用 20260915010000 的 schema_owner 写法。 - 幂等:改完之后两段 where 都不再匹配同一行,重复应用影响 0 行。 - 各自 get diagnostics + raise notice 打出行数;db-migrate.mjs 加 notice 转发,否则 node-postgres 会把 NOTICE 丢掉,迁移日志里一个数字都看不到。 - 脚本降级为只读核对工具:--apply 改为报错并指向迁移;导入不再连库。 生产停在 7b620c7a(没有 use-rectification-surface.ts,标题固定且不写库), 两段 where 自然匹配 0 行,是预期内的 no-op。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45 --- frontend/scripts/db-migrate.mjs | 7 ++ .../repair-rectification-session-titles.mjs | 62 +++++++++----- ...000_rectification_session_title_repair.sql | 79 ++++++++++++++++++ ...ion-session-title-repair-migration.test.ts | 83 +++++++++++++++++++ 4 files changed, 210 insertions(+), 21 deletions(-) create mode 100644 frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql create mode 100644 frontend/tests/rectification-session-title-repair-migration.test.ts diff --git a/frontend/scripts/db-migrate.mjs b/frontend/scripts/db-migrate.mjs index 6b56215d..5eb8de71 100644 --- a/frontend/scripts/db-migrate.mjs +++ b/frontend/scripts/db-migrate.mjs @@ -139,6 +139,13 @@ export async function runMigrations({ }) { const files = await loadMigrationFiles(migrationsDirectories ?? migrationsDirectory); const client = new Client({ connectionString }); + // Data-repair migrations report how many rows they touched with RAISE NOTICE. + // node-postgres drops notices when nothing listens, which would leave the + // staging migration log with no row counts at all (BUG-699 / BUG-704 repair). + client.on("notice", (notice) => { + const message = typeof notice?.message === "string" ? notice.message.trim() : ""; + if (message) logger.log(`notice ${message}`); + }); let locked = false; try { diff --git a/frontend/scripts/repair-rectification-session-titles.mjs b/frontend/scripts/repair-rectification-session-titles.mjs index bab79e96..10080516 100644 --- a/frontend/scripts/repair-rectification-session-titles.mjs +++ b/frontend/scripts/repair-rectification-session-titles.mjs @@ -1,8 +1,23 @@ +// 只读核对工具。**修补本身已由迁移承担**: +// frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql +// 产品跑 Gitea → `Migrate Staging Database` 就会应用它,不需要 SSH 或库口令。 +// +// 本文件保留下来有两个用处: +// 1. 排查时数一数还有多少行没对上(不写库,只 select count); +// 2. 下面三段 SQL 是那条迁移的**唯一出处**,迁移逐字抄它们。 +// tests/rectification-session-title-repair-migration.test.ts 会比对两边, +// 改了这里而没同步迁移(或反过来)测试就红。 +// +// 需要 SCHEMA_DATABASE_URL 才能连库;没有它的环境只能跑上面那个合同测试。 + +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + import pg from "pg"; const { Client } = pg; -const TITLE_MATCH_SQL = ` +export const TITLE_MATCH_SQL = ` session_type = 'birth_time_rectification' and title ~ '^[0-9]{1,2}月[0-9]{1,2}日[[:space:]]*·[[:space:]]*生时校正([[:space:]]+[0-9]{2}:[0-9]{2})?$' and ( @@ -16,7 +31,7 @@ and ( const TITLE_COUNT_SQL = `select count(*)::int as n from public.chat_sessions where ${TITLE_MATCH_SQL}`; -const TITLE_APPLY_SQL = ` +export const TITLE_APPLY_SQL = ` update public.chat_sessions set title = case when created_at is null then '生时校正' @@ -43,7 +58,7 @@ where session.session_type = 'birth_time_rectification' and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at) `; -const ACTIVITY_APPLY_SQL = ` +export const ACTIVITY_APPLY_SQL = ` update public.chat_sessions as session set updated_at = coalesce(turns.last_turn_at, case_row.last_activity_at) from public.agentic_rectification_cases as case_row @@ -70,29 +85,34 @@ function connectionString() { } async function main() { - const apply = process.argv.includes("--apply"); + if (process.argv.includes("--apply")) { + throw new Error( + "--apply has been removed; the repair now ships as migration " + + "20260916020000_rectification_session_title_repair.sql. " + + "Apply it with Gitea -> Migrate Staging Database.", + ); + } const client = new Client({ connectionString: connectionString() }); await client.connect(); try { - const titlesBefore = await client.query(TITLE_COUNT_SQL); - const activityBefore = await client.query(ACTIVITY_COUNT_SQL); - console.log(`mismatched_dated_rectification_titles=${titlesBefore.rows[0]?.n ?? 0}`); - console.log(`stale_rectification_updated_at=${activityBefore.rows[0]?.n ?? 0}`); - if (!apply) return; - const titlesUpdated = await client.query(TITLE_APPLY_SQL); - const activityUpdated = await client.query(ACTIVITY_APPLY_SQL); - const titlesAfter = await client.query(TITLE_COUNT_SQL); - const activityAfter = await client.query(ACTIVITY_COUNT_SQL); - console.log(`updated_title_rows=${titlesUpdated.rowCount ?? 0}`); - console.log(`updated_activity_rows=${activityUpdated.rowCount ?? 0}`); - console.log(`mismatched_after=${titlesAfter.rows[0]?.n ?? 0}`); - console.log(`stale_updated_at_after=${activityAfter.rows[0]?.n ?? 0}`); + const titles = await client.query(TITLE_COUNT_SQL); + const activity = await client.query(ACTIVITY_COUNT_SQL); + console.log(`mismatched_dated_rectification_titles=${titles.rows[0]?.n ?? 0}`); + console.log(`stale_rectification_updated_at=${activity.rows[0]?.n ?? 0}`); } finally { await client.end(); } } -main().catch((error) => { - console.error(error instanceof Error ? error.message : error); - process.exit(1); -}); +// Only connect when run as a command. Importing this file (the contract test +// does) must not open a database connection. +const invokedPath = process.argv[1] + ? pathToFileURL(resolve(process.argv[1])).href + : undefined; + +if (invokedPath === import.meta.url) { + main().catch((error) => { + console.error(error instanceof Error ? error.message : error); + process.exitCode = 1; + }); +} diff --git a/frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql b/frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql new file mode 100644 index 00000000..2812a3f9 --- /dev/null +++ b/frontend/supabase/migrations/20260916020000_rectification_session_title_repair.sql @@ -0,0 +1,79 @@ +-- BUG-699 / BUG-704 的一次性数据修补,搬成迁移,好让产品直接用现成的 +-- `Migrate Staging Database` 按钮跑,不必 SSH、不必拿库口令。 +-- +-- 两段 SQL 逐字来自 frontend/scripts/repair-rectification-session-titles.mjs +-- (该脚本自此降级为只读核对工具): +-- 1. 标题:把 `M月D日 · 生时校正` 里与 created_at 对不上的日期按 +-- Asia/Shanghai 的月日修回;取不到 created_at 就退回不带日期的 +-- 「生时校正」,不猜日期。不匹配该正则的手改标题一律不动。 +-- 2. 活跃时间:把历史冻结的 chat_sessions.updated_at 按最后一条 turn / +-- last_activity_at 回填,`updated_at <` 是单调守卫,只前进不回拨。 +-- +-- 幂等:两段 where 在改完之后都不再匹配被改过的行——标题改完月日即与 +-- created_at 相等(created_at is null 的改成「生时校正」,连正则都不再匹配), +-- updated_at 回填后不再小于目标值。重复应用影响 0 行。 +-- +-- 生产(停在 7b620c7a,没有 use-rectification-surface.ts,标题固定为 +-- 「生时校正」且不写库)上不存在被改坏的标题,两段 where 自然匹配 0 行, +-- 是预期内的 no-op,不是失败。 +-- +-- 行数由 RAISE NOTICE 打出,产品在迁移日志里能直接看到修了多少行。 + +begin; + +do $migration$ +begin + if current_user <> 'schema_owner' then + raise exception 'rectification_session_title_repair_requires_schema_owner' + using errcode = '42501'; + end if; +end +$migration$; + +do $repair$ +declare + repaired_titles integer; + refreshed_activity integer; +begin +update public.chat_sessions +set title = case + when created_at is null then '生时校正' + else to_char(created_at at time zone 'Asia/Shanghai', 'FMMM') + || '月' + || to_char(created_at at time zone 'Asia/Shanghai', 'FMDD') + || '日 · 生时校正' +end +where +session_type = 'birth_time_rectification' +and title ~ '^[0-9]{1,2}月[0-9]{1,2}日[[:space:]]*·[[:space:]]*生时校正([[:space:]]+[0-9]{2}:[0-9]{2})?$' +and ( + created_at is null + or (substring(title from '^([0-9]{1,2})月'))::int + is distinct from extract(month from created_at at time zone 'Asia/Shanghai')::int + or (substring(title from '月([0-9]{1,2})日'))::int + is distinct from extract(day from created_at at time zone 'Asia/Shanghai')::int +) +; + get diagnostics repaired_titles = row_count; + +update public.chat_sessions as session +set updated_at = coalesce(turns.last_turn_at, case_row.last_activity_at) +from public.agentic_rectification_cases as case_row +left join lateral ( + select max(turn.created_at) as last_turn_at + from public.agentic_rectification_turns as turn + where turn.case_id = case_row.id +) as turns on true +where case_row.session_id = session.id + and case_row.user_id = session.user_id + and session.session_type = 'birth_time_rectification' + and session.updated_at < coalesce(turns.last_turn_at, case_row.last_activity_at) +; + get diagnostics refreshed_activity = row_count; + + raise notice 'rectification_session_title_repair repaired_titles=%', repaired_titles; + raise notice 'rectification_session_title_repair refreshed_activity=%', refreshed_activity; +end +$repair$; + +commit; diff --git a/frontend/tests/rectification-session-title-repair-migration.test.ts b/frontend/tests/rectification-session-title-repair-migration.test.ts new file mode 100644 index 00000000..b52fdf1a --- /dev/null +++ b/frontend/tests/rectification-session-title-repair-migration.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import { existsSync, readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { + ACTIVITY_APPLY_SQL, + TITLE_APPLY_SQL, + TITLE_MATCH_SQL, +} from "../scripts/repair-rectification-session-titles.mjs"; + +const filename = "20260916020000_rectification_session_title_repair.sql"; +const migration = readFileSync( + new URL(`../supabase/migrations/${filename}`, import.meta.url), + "utf8", +); + +// Only trailing whitespace differs: the script builds `where ${TITLE_MATCH_SQL}`, +// which leaves a space before the newline that no .sql file should carry. +const normalize = (value: string) => value.replace(/[ \t]+$/gm, "").trim(); + +test("repair migration carries the checker's SQL verbatim", () => { + for (const [name, sql] of [ + ["TITLE_MATCH_SQL", TITLE_MATCH_SQL], + ["TITLE_APPLY_SQL", TITLE_APPLY_SQL], + ["ACTIVITY_APPLY_SQL", ACTIVITY_APPLY_SQL], + ] as const) { + assert.ok( + normalize(migration).includes(normalize(sql)), + `${name} must be copied into ${filename} without being rewritten`, + ); + } +}); + +test("repair migration keeps the guards that make it safe to re-run", () => { + // Asia/Shanghai, not UTC: the wall-clock date is what the user reads (BUG-699). + assert.match(migration, /at time zone 'Asia\/Shanghai'/); + // Never guess a date when created_at is missing. + assert.match(migration, /when created_at is null then '生时校正'/); + // Monotonic: updated_at only moves forward (BUG-704). + assert.match( + migration, + /session\.updated_at < coalesce\(turns\.last_turn_at, case_row\.last_activity_at\)/, + ); + // Only schema_owner may run it, like every other business migration. + assert.match(migration, /rectification_session_title_repair_requires_schema_owner/); + assert.match(migration, /^begin;[\s\S]*^commit;$/m); +}); + +test("repair migration reports its row counts so staging logs show a number", () => { + assert.match(migration, /get diagnostics repaired_titles = row_count;/); + assert.match(migration, /get diagnostics refreshed_activity = row_count;/); + assert.match(migration, /raise notice '[^']*repaired_titles=%', repaired_titles;/); + assert.match(migration, /raise notice '[^']*refreshed_activity=%', refreshed_activity;/); + + // The counts only reach the log because the migrator forwards notices. + const migrator = readFileSync( + new URL("../scripts/db-migrate.mjs", import.meta.url), + "utf8", + ); + assert.match(migrator, /client\.on\("notice"/); +}); + +test("repair migration sorts last and is not copied into the identity foundation", () => { + assert.ok(filename > "20260916010000_consultation_session_capacity.sql"); + assert.match(filename, /^\d{14}_[a-z0-9_]+\.sql$/); + assert.equal( + existsSync(fileURLToPath(new URL(`../db/migrations/${filename}`, import.meta.url))), + false, + "business migration must not be copied into frontend/db/migrations (BUG-127/BUG-144)", + ); +}); + +test("the checker no longer writes; --apply points at the migration", () => { + const checker = readFileSync( + new URL("../scripts/repair-rectification-session-titles.mjs", import.meta.url), + "utf8", + ); + assert.match(checker, /--apply has been removed/); + assert.match(checker, new RegExp(filename.replace(/\./g, "\\."))); + // Importing it must not have opened a connection. + assert.match(checker, /invokedPath === import\.meta\.url/); +});