From 46f9d93fd3ddd294ba4b848b41d837f3c875a655 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Mon, 28 Sep 2026 17:27:52 +0800 Subject: [PATCH] fix(app): guard force-column migration against downgrade-then-reupgrade Review hardening for Task 8 (test-server workflow: testers install older APKs over a newer DB): - Old APK opening a v2 database hits drift's downgrade path, which leaves the force column in place but writes user_version back to 1. Reinstalling the new APK then re-runs onUpgrade(1,2) and addColumn crashes with "duplicate column name: force", failing the DB open. - onUpgrade now checks PRAGMA table_info(upload_tasks) and only adds the force column when absent; the normal v1 -> v2 path is unchanged. - Migration test simulates the downgrade-reupgrade cycle on a file-backed database (v2 open -> user_version forced back to 1 -> re-upgrade), asserting the open succeeds, legacy rows survive, and the column stays writable. RED before the guard (SqliteException: duplicate column name). Co-Authored-By: Claude Code --- .../lib/features/uploads/task_store.dart | 15 +- .../features/uploads/task_store_test.dart | 130 ++++++++++++------ 2 files changed, 105 insertions(+), 40 deletions(-) diff --git a/apps/mobile/lib/features/uploads/task_store.dart b/apps/mobile/lib/features/uploads/task_store.dart index fc7e47f..efa6048 100644 --- a/apps/mobile/lib/features/uploads/task_store.dart +++ b/apps/mobile/lib/features/uploads/task_store.dart @@ -141,7 +141,20 @@ class UploadTaskDb extends _$UploadTaskDb { MigrationStrategy get migration => MigrationStrategy( onUpgrade: (m, from, to) async { if (from == 1) { - await m.addColumn(uploadTasks, uploadTasks.force); + // 降级-重升级防护:旧 APK 覆盖安装打开 v2 库时,drift 降级 + // 路径不改表、只把 user_version 写回 1(force 列留存);随后 + // 新 APK 重升级再次走到这里——无条件 addColumn 会撞 + // 「duplicate column name: force」致库打开失败。先探测列 + // 存在性,缺列才补(正常 v1 → v2 路径列必缺省,行为不变)。 + final columns = await m.database + .customSelect('PRAGMA table_info(upload_tasks)') + .get(); + final forceExists = columns.any( + (row) => row.read('name') == 'force', + ); + if (!forceExists) { + await m.addColumn(uploadTasks, uploadTasks.force); + } } }, ); diff --git a/apps/mobile/test/features/uploads/task_store_test.dart b/apps/mobile/test/features/uploads/task_store_test.dart index 567a99b..700dd3f 100644 --- a/apps/mobile/test/features/uploads/task_store_test.dart +++ b/apps/mobile/test/features/uploads/task_store_test.dart @@ -11,6 +11,8 @@ /// - datetime 文本落库的亚秒精度。 library; +import 'dart:io'; + import 'package:drift/drift.dart' show driftRuntimeOptions; import 'package:drift/native.dart' show NativeDatabase; import 'package:flutter_test/flutter_test.dart'; @@ -558,46 +560,52 @@ void main() { }); group('schema 迁移(v1 → v2:force 列,Task 8)', () { + /// v1 底库种子(建表无 force 列 + legacy 行 + user_version=1)。语句 + /// 拆列供内存/文件两种打开方式复用(raw 类型由 setup 上下文推断, + /// 免引 sqlite3 传递依赖)。 + const List v1SeedStatements = [ + '''CREATE TABLE IF NOT EXISTS "upload_tasks" ( + "id" TEXT NOT NULL PRIMARY KEY, + "account_id" TEXT NOT NULL, + "album_id" TEXT NOT NULL, + "category_id" TEXT NULL, + "client_task_id" TEXT NOT NULL, + "source_file_path" TEXT NOT NULL, + "filename" TEXT NOT NULL, + "bytes" INTEGER NOT NULL, + "content_type" TEXT NOT NULL, + "state" TEXT NOT NULL, + "attempt" INTEGER NOT NULL DEFAULT 0, + "next_retry_at" TEXT NULL, + "server_upload_id" TEXT NULL, + "server_photo_id" TEXT NULL, + "last_error" TEXT NULL, + "cleaned_at" TEXT NULL, + "created_at" TEXT NOT NULL, + "updated_at" TEXT NOT NULL + );''', + '''INSERT INTO upload_tasks ( + "id", "account_id", "album_id", "client_task_id", + "source_file_path", "filename", "bytes", "content_type", + "state", "created_at", "updated_at" + ) VALUES ( + 'task-legacy', 'alice', 'alb-1', 'ct-legacy', + '/managed/uploads/alb-1/legacy.jpg', 'IMG_LEGACY.jpg', 2048, + 'image/jpeg', 'completed', + '2026-09-01T00:00:00.000Z', '2026-09-01T00:01:00.000Z' + );''', + 'PRAGMA user_version = 1;', + ]; + + void runSeed(raw) { + for (final statement in v1SeedStatements) { + raw.execute(statement); + } + } + /// v1 底库(无 force 列)+ 一条既有行,user_version=1——升级后打开。 - UploadTaskDb legacyV1Db() => UploadTaskDb( - NativeDatabase.memory(setup: (raw) { - raw.execute(''' - CREATE TABLE IF NOT EXISTS "upload_tasks" ( - "id" TEXT NOT NULL PRIMARY KEY, - "account_id" TEXT NOT NULL, - "album_id" TEXT NOT NULL, - "category_id" TEXT NULL, - "client_task_id" TEXT NOT NULL, - "source_file_path" TEXT NOT NULL, - "filename" TEXT NOT NULL, - "bytes" INTEGER NOT NULL, - "content_type" TEXT NOT NULL, - "state" TEXT NOT NULL, - "attempt" INTEGER NOT NULL DEFAULT 0, - "next_retry_at" TEXT NULL, - "server_upload_id" TEXT NULL, - "server_photo_id" TEXT NULL, - "last_error" TEXT NULL, - "cleaned_at" TEXT NULL, - "created_at" TEXT NOT NULL, - "updated_at" TEXT NOT NULL - ); - '''); - raw.execute(''' - INSERT INTO upload_tasks ( - "id", "account_id", "album_id", "client_task_id", - "source_file_path", "filename", "bytes", "content_type", - "state", "created_at", "updated_at" - ) VALUES ( - 'task-legacy', 'alice', 'alb-1', 'ct-legacy', - '/managed/uploads/alb-1/legacy.jpg', 'IMG_LEGACY.jpg', 2048, - 'image/jpeg', 'completed', - '2026-09-01T00:00:00.000Z', '2026-09-01T00:01:00.000Z' - ); - '''); - raw.execute('PRAGMA user_version = 1;'); - }), - ); + UploadTaskDb legacyV1Db() => + UploadTaskDb(NativeDatabase.memory(setup: runSeed)); test('旧库升级:既有行保留、force=false 默认、新列可写', () async { final db = legacyV1Db(); @@ -617,5 +625,49 @@ void main() { ); expect(created.force, isTrue); }); + + test('降级重开后再升级:force 列已存在不重复补列(旧 APK 覆盖安装防护)', + () async { + final tmp = await Directory.systemTemp.createTemp('store-mig-'); + addTearDown(() async { + try { + await tmp.delete(recursive: true); + } on Object { + // Windows 句柄延迟释放:残留交由系统临时目录清理。 + } + }); + final dbFile = File('${tmp.path}/upload_tasks.sqlite'); + + // ① 新 APK 打开 v1 底库:升级补 force 列(user_version → 2)。 + final first = TaskStore( + UploadTaskDb(NativeDatabase(dbFile, setup: runSeed)), + ); + expect((await first.get('alice', 'task-legacy'))!.force, isFalse, + reason: '前置:v1 → v2 升级成功'); + await first.close(); + + // ② 模拟旧 APK 覆盖安装后的降级打开残留态:drift 降级路径不删列、 + // 只把 user_version 写回 1(force 列留存)——setup 直接置 1。 + // ③ 再装回新 APK:from=1 重跑 onUpgrade——不得因列已存在抛 + // 「duplicate column name: force」致库打不开(测试服务器工作流: + // 测试员旧包覆盖装新包的常态路径)。 + final second = TaskStore( + UploadTaskDb( + NativeDatabase( + dbFile, + setup: (raw) => raw.execute('PRAGMA user_version = 1;'), + ), + ), + ); + addTearDown(second.close); + + final legacy = await second.get('alice', 'task-legacy'); + expect(legacy, isNotNull, reason: '降级-重升级循环不丢既有行'); + expect(legacy!.filename, 'IMG_LEGACY.jpg'); + final created = await second.create( + draft(id: 'task-after', clientTaskId: 'ct-after', force: true), + ); + expect(created.force, isTrue, reason: '重升级后新列仍可写'); + }); }); }