From d96e56f6536cc94c5e2d06fc0e594fb5822b7252 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 19 Sep 2026 02:12:39 +0800 Subject: [PATCH] feat(mobile): drift upload task store with state machine MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - upload_tasks 表(accountId 隔离 + 三个复合索引)、状态列枚举转换器、 datetime 文本落库(UTC 归一,亚秒精度) - 纯状态机(task_state.dart):合法迁移表 + canTransition/assertTransition, completed/cancelled 终态 - TaskStore 仓储:create(clientTaskId 同账号同相册应用侧唯一)、 updateState(迁移守护+旁路字段)、账号隔离读路径(硬守护)、 countsByState 徽章、cleanupCandidates/markCleanable(仅终态可清理—— 评审缺口 1 硬守护,未完成源文件永不清理) - 测试 26 例(状态机表驱动全边 + 守护/隔离/筛选/保留策略); sqlite3 3.x build hooks 在 flutter test 宿主与 Android APK 构建均验证 (libsqlite3.so 已打包全 ABI,运行时验证留 Task 11) Co-Authored-By: Claude Code --- apps/mobile/build.yaml | 11 + .../lib/features/uploads/task_state.dart | 118 ++ .../lib/features/uploads/task_store.dart | 442 +++++ .../lib/features/uploads/task_store.g.dart | 1583 +++++++++++++++++ .../features/uploads/task_state_test.dart | 153 ++ .../features/uploads/task_store_test.dart | 491 +++++ docs/plans/2026-09-18-P3-Flutter-App.md | 4 +- 7 files changed, 2800 insertions(+), 2 deletions(-) create mode 100644 apps/mobile/build.yaml create mode 100644 apps/mobile/lib/features/uploads/task_state.dart create mode 100644 apps/mobile/lib/features/uploads/task_store.dart create mode 100644 apps/mobile/lib/features/uploads/task_store.g.dart create mode 100644 apps/mobile/test/features/uploads/task_state_test.dart create mode 100644 apps/mobile/test/features/uploads/task_store_test.dart diff --git a/apps/mobile/build.yaml b/apps/mobile/build.yaml new file mode 100644 index 0000000..b73d4f9 --- /dev/null +++ b/apps/mobile/build.yaml @@ -0,0 +1,11 @@ +# drift_dev 代码生成选项(P3 计划 Task 5)。 +# +# store_date_time_values_as_text:datetime 列以 ISO-8601 文本落库(默认为 +# 秒级整数),保住 createdAt/updatedAt/nextRetryAt 的亚秒精度——退避计算 +# 与保留策略的年龄比较不丢精度。 +targets: + $default: + builders: + drift_dev: + options: + store_date_time_values_as_text: true diff --git a/apps/mobile/lib/features/uploads/task_state.dart b/apps/mobile/lib/features/uploads/task_state.dart new file mode 100644 index 0000000..472eeaa --- /dev/null +++ b/apps/mobile/lib/features/uploads/task_state.dart @@ -0,0 +1,118 @@ +/// 本地上传任务状态机(P3 计划 Task 5):纯迁移表,无 drift 依赖。 +/// +/// 状态取值与合法迁移对齐技术实施方案 §5.5 与 P3 计划 Task 5/6: +/// - 准备:preparing → queued(入队)/ preparationFailed(准备失败,可回 +/// preparing 重试); +/// - 传输主干:queued → uploading → uploadedConfirming → completed; +/// - 网络等待与重试:waitingNetwork / retryableFailed 可回 queued(重新 +/// 出队)或直接 uploading(续传/重试); +/// - 终态:completed / cancelled,不接受任何后续迁移(completed 的源文件 +/// 仅按保留策略清理,见 task_store.dart 的 cleanupCandidates 硬守护)。 +/// +/// 取消语义(方案 §5.5):未开始(preparing/preparationFailed/queued/ +/// waitingNetwork/retryableFailed)→ cancelled 为确定语义;uploading 进行中 +/// → cancelled 为"尽力"(写入标记,实际中断由引擎在下一个检查点兑现)。 +library; + +/// 上传任务状态(drift 表 `upload_tasks.state` 的取值)。 +/// +/// 注意与服务端会话状态(UploadSessionState:pending/confirmed/...)区分: +/// 本枚举是客户端本地任务生命周期,二者在 uploadedConfirming 阶段交汇。 +enum UploadTaskState { + /// 准备中:选图/拍摄后复制源文件到受管目录、空间检查(Task 6)。 + preparing('preparing'), + + /// 准备失败(独立状态,可重试回 preparing):复制失败/磁盘不足等。 + preparationFailed('preparation_failed'), + + /// 已入队,等待传输引擎出队。 + queued('queued'), + + /// 传输中:POST /uploads → presign PUT → complete 的任一环节。 + uploading('uploading'), + + /// 字节已传完,等待/重试完成核验(POST complete)。 + uploadedConfirming('uploaded_confirming'), + + /// 终态:核验通过,服务端已受理(cleanup 仅针对此态与 cancelled)。 + completed('completed'), + + /// 网络不可达,指数退避等待恢复(nextRetryAt 记录下次尝试时间)。 + waitingNetwork('waiting_network'), + + /// 可重试失败(服务端 5xx/超时等),按退避策略回 queued 重试。 + retryableFailed('retryable_failed'), + + /// 终态:用户取消(uploading 态下为尽力取消)。 + cancelled('cancelled'); + + const UploadTaskState(this.wire); + + /// 落库(drift state 列)取值。 + final String wire; + + /// 解析落库取值;未知值 fail-fast(与 models.dart 的枚举解析约定一致)。 + static UploadTaskState fromWire(String value) => values.firstWhere( + (v) => v.wire == value, + orElse: () => throw FormatException('未知 UploadTaskState: $value'), + ); + + /// 是否终态(不接受任何后续迁移)。 + bool get isTerminal => this == completed || this == cancelled; +} + +/// 合法迁移表(键=当前状态,值=可直接迁入的目标集合)。 +const Map> kUploadTaskTransitions = { + UploadTaskState.preparing: { + UploadTaskState.queued, + UploadTaskState.preparationFailed, + UploadTaskState.cancelled, + }, + UploadTaskState.preparationFailed: { + UploadTaskState.preparing, + UploadTaskState.cancelled, + }, + UploadTaskState.queued: { + UploadTaskState.uploading, + UploadTaskState.waitingNetwork, + UploadTaskState.cancelled, + }, + UploadTaskState.uploading: { + UploadTaskState.uploadedConfirming, + UploadTaskState.waitingNetwork, + UploadTaskState.retryableFailed, + // 尽力取消:见库级文档(进行中取消由引擎在检查点兑现)。 + UploadTaskState.cancelled, + }, + UploadTaskState.uploadedConfirming: { + UploadTaskState.completed, + UploadTaskState.retryableFailed, + UploadTaskState.waitingNetwork, + }, + UploadTaskState.waitingNetwork: { + UploadTaskState.queued, + // 网络恢复直接续传(重试)。 + UploadTaskState.uploading, + UploadTaskState.cancelled, + }, + UploadTaskState.retryableFailed: { + UploadTaskState.queued, + UploadTaskState.cancelled, + }, + // 终态:无出边(completed 的清理走 cleanedAt 标记,不是状态迁移)。 + UploadTaskState.completed: {}, + UploadTaskState.cancelled: {}, +}; + +/// `from → to` 是否合法迁移(自迁移一律非法,状态未变无需写库)。 +bool canTransition(UploadTaskState from, UploadTaskState to) => + kUploadTaskTransitions[from]!.contains(to); + +/// 断言 `from → to` 合法,否则抛 [StateError](携带合法目标集,便于排障)。 +void assertTransition(UploadTaskState from, UploadTaskState to) { + if (!canTransition(from, to)) { + final legal = kUploadTaskTransitions[from]!; + final hint = legal.isEmpty ? '(终态,无合法后继)' : ',合法目标:$legal'; + throw StateError('非法上传任务状态迁移:$from → $to$hint'); + } +} diff --git a/apps/mobile/lib/features/uploads/task_store.dart b/apps/mobile/lib/features/uploads/task_store.dart new file mode 100644 index 0000000..d9df44b --- /dev/null +++ b/apps/mobile/lib/features/uploads/task_store.dart @@ -0,0 +1,442 @@ +/// 本地上传任务库(P3 计划 Task 5):drift schema + [TaskStore] 仓储。 +/// +/// 设计要点(对齐技术实施方案 §5.5 / P3 计划 Task 5/6): +/// - **账号隔离硬守护**:所有任务读路径([TaskStore.get]/ +/// [TaskStore.listByAccount]/[TaskStore.countsByState])无条件附加 +/// `accountId` 过滤——账号切换后不可能读到他人任务(A15 连拍归属、 +/// 队列 UI 均经由这些入口); +/// - **状态机守护**:[TaskStore.updateState] 走 [assertTransition],非法 +/// 迁移抛 [StateError](合法表见 task_state.dart); +/// - **clientTaskId 幂等键**:同账号同相册内唯一,**应用侧**在 +/// [TaskStore.create] 时校验(不加数据库唯一约束,保留引擎重排/重建的 +/// 灵活性,幂等语义由服务端 POST /uploads 409 兜底); +/// - **源文件保留策略硬守护**:[TaskStore.cleanupCandidates] 只返回 +/// completed/cancelled 且超过保留期、未清理(cleanedAt IS NULL)的任务, +/// **未完成任务永不进入清理候选**;[TaskStore.markCleanable] 同样拒绝 +/// 非终态任务(评审缺口 1,单测覆盖)。 +/// +/// 字段增补说明:`cleanedAt`(源文件已清理时间)为 P3 计划 Task 5 字段 +/// 清单之外的实现增补,用于 [TaskStore.markCleanable] 的幂等(清理执行器 +/// 重启后不重复删文件);服务 markCleanable/cleanupCandidates 语义。 +library; + +import 'dart:io'; + +import 'package:drift/drift.dart'; +import 'package:drift/native.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import 'task_state.dart'; + +part 'task_store.g.dart'; + +/// `state` 列的枚举转换器(落库取值见 [UploadTaskState.wire])。 +class _UploadTaskStateConverter extends TypeConverter { + const _UploadTaskStateConverter(); + + @override + UploadTaskState fromSql(String fromDb) => + UploadTaskState.fromWire(fromDb); + + @override + String toSql(UploadTaskState value) => value.wire; +} + +/// 本地上传任务表(表名 upload_tasks)。 +/// +/// 三个复合索引支撑账号隔离后的高频查询:队列列表(accountId)、状态 +/// 徽章(accountId+state)、相册内筛选(accountId+albumId+state,需求 4.3 +/// 三维筛选)。 +@TableIndex(name: 'upload_tasks_account_idx', columns: {#accountId}) +@TableIndex( + name: 'upload_tasks_account_state_idx', + columns: {#accountId, #state}, +) +@TableIndex( + name: 'upload_tasks_account_album_state_idx', + columns: {#accountId, #albumId, #state}, +) +class UploadTasks extends Table { + /// 任务 ID(客户端生成 uuid,PK;与服务端 uploadId 无关)。 + TextColumn get id => text()(); + + /// 账号隔离键(登录账号,见 Session.account)。 + TextColumn get accountId => text()(); + + /// 目标相册 ID。 + TextColumn get albumId => text()(); + + /// 目标分类 ID;缺省为相册默认分类(契约 UploadInitRequest.categoryId)。 + TextColumn get categoryId => text().nullable()(); + + /// 客户端幂等键(同账号同相册内唯一,应用侧校验,见库级文档)。 + TextColumn get clientTaskId => text()(); + + /// 源文件绝对路径(受管目录副本:app 支持目录/uploads/{albumId}/)。 + TextColumn get sourceFilePath => text()(); + + /// 原始文件名(仅元数据, maxLength 255,契约对齐)。 + TextColumn get filename => text()(); + + /// 申明文件字节数(完成核验时与服务端逐一比对)。 + IntColumn get bytes => integer()(); + + /// MIME 类型(白名单 image/jpeg | image/png | image/heic)。 + TextColumn get contentType => text()(); + + /// 任务状态(状态机见 task_state.dart)。 + TextColumn get state => + text().map(const _UploadTaskStateConverter())(); + + /// 已尝试传输次数(退避与上限判断依据)。 + IntColumn get attempt => integer().withDefault(const Constant(0))(); + + /// 下次可重试时间(waitingNetwork/retryableFailed 的退避闹钟);null=无 + /// 待重试。 + DateTimeColumn get nextRetryAt => dateTime().nullable()(); + + /// 服务端上传会话 ID(initUpload 成功后回填,重试复用)。 + TextColumn get serverUploadId => text().nullable()(); + + /// 服务端预创建照片 ID(initUpload 成功后回填)。 + TextColumn get serverPhotoId => text().nullable()(); + + /// 最近一次失败原因(错误码文本,见 core/error_codes.dart;成功迁移时 + /// 清除)。 + TextColumn get lastError => text().nullable()(); + + /// 源文件已清理时间(保留策略执行器写入;null=未清理)。实现增补字段, + /// 见库级文档。 + DateTimeColumn get cleanedAt => dateTime().nullable()(); + + /// 创建时间(入 preparing 时刻)。 + DateTimeColumn get createdAt => dateTime()(); + + /// 最近更新时间(每次状态迁移刷新;保留策略的年龄基准)。 + DateTimeColumn get updatedAt => dateTime()(); + + @override + Set get primaryKey => {id}; +} + +/// drift 数据库(生产=app documents dir 文件;测试=[UploadTaskDb.memory])。 +@DriftDatabase(tables: [UploadTasks]) +class UploadTaskDb extends _$UploadTaskDb { + UploadTaskDb(super.e); + + /// 测试用内存库(drift NativeDatabase.memory,随 close 丢弃)。 + UploadTaskDb.memory() : super(NativeDatabase.memory()); + + @override + int get schemaVersion => 1; +} + +/// 打开生产库:app documents 目录下 upload_tasks.sqlite,后台 isolate 执行 +/// SQL(避免大结果集阻塞 UI)。 +LazyDatabase openAppUploadTaskDb() { + return LazyDatabase(() async { + final dir = await getApplicationDocumentsDirectory(); + final file = File(p.join(dir.path, 'upload_tasks.sqlite')); + return NativeDatabase.createInBackground(file); + }); +} + +/// 新任务的创建参数(初始状态固定 preparing,见状态机)。 +class UploadTaskDraft { + const UploadTaskDraft({ + required this.id, + required this.accountId, + required this.albumId, + this.categoryId, + required this.clientTaskId, + required this.sourceFilePath, + required this.filename, + required this.bytes, + required this.contentType, + }); + + /// 任务 ID(uuid,调用方生成)。 + final String id; + + /// 账号隔离键。 + final String accountId; + + /// 目标相册 ID。 + final String albumId; + + /// 目标分类 ID(可缺省)。 + final String? categoryId; + + /// 客户端幂等键。 + final String clientTaskId; + + /// 源文件路径(受管目录副本)。 + final String sourceFilePath; + + /// 原始文件名。 + final String filename; + + /// 字节数。 + final int bytes; + + /// MIME 类型。 + final String contentType; +} + +/// 状态迁移时一并写入的旁路字段(未提供的列保持不变;`null` 与"清除"通过 +/// clear* 布尔区分)。 +class UploadTaskPatch { + const UploadTaskPatch({ + this.attempt, + this.nextRetryAt, + this.serverUploadId, + this.serverPhotoId, + this.lastError, + this.clearNextRetryAt = false, + this.clearLastError = false, + }); + + /// 新的尝试次数(失败分类时 +1 后写入)。 + final int? attempt; + + /// 新的下一次重试时间(退避计算结果)。 + final DateTime? nextRetryAt; + + /// 服务端上传会话 ID(initUpload 成功时写入)。 + final String? serverUploadId; + + /// 服务端照片 ID(initUpload 成功时写入)。 + final String? serverPhotoId; + + /// 失败原因错误码。 + final String? lastError; + + /// 显式清除 nextRetryAt(成功出队/完成后)。 + final bool clearNextRetryAt; + + /// 显式清除 lastError(迁移到成功路径时)。 + final bool clearLastError; +} + +/// 上传任务仓储:引擎(Task 6)与队列 UI(Task 9)的唯一入口。 +/// +/// 读路径全部账号隔离(硬守护);按 id 的写路径(updateState/markCleanable/ +/// delete)为引擎内部操作,id 是 uuid PK,不构成跨账号读取面。 +class TaskStore { + TaskStore(this._db); + + /// 生产实例(app documents dir 持久库)。 + factory TaskStore.app() => TaskStore(UploadTaskDb(openAppUploadTaskDb())); + + /// 测试实例(内存库)。 + factory TaskStore.memory() => TaskStore(UploadTaskDb.memory()); + + final UploadTaskDb _db; + + /// 关闭底层数据库(App 退出/测试 tearDown)。 + Future close() => _db.close(); + + /// 时间戳统一 UTC 落库(datetime 为 ISO-8601 文本存储:UTC 值以 `Z` + /// 结尾、本地值带 `+hh:mm` 后缀,混存会让 SQL 文本比较错位一个时区 + /// 偏移——全部归一 UTC 后字典序即时间序)。 + static DateTime _utcAt(DateTime? at) => (at ?? DateTime.now()).toUtc(); + + /// 创建任务(初始状态 preparing、attempt=0)。 + /// + /// 抛 [StateError]:同账号同相册内 clientTaskId 已存在(幂等键不可 + /// 复用——应用侧唯一性校验,见库级文档)。 + Future create(UploadTaskDraft draft, {DateTime? now}) { + return _db.transaction(() async { + final conflict = await (_db.select(_db.uploadTasks) + ..where( + (t) => + t.accountId.equals(draft.accountId) & + t.albumId.equals(draft.albumId) & + t.clientTaskId.equals(draft.clientTaskId), + )) + .get(); + if (conflict.isNotEmpty) { + throw StateError( + 'clientTaskId 在同账号同相册内已存在:${draft.clientTaskId}' + '(幂等键不可复用)', + ); + } + final timestamp = _utcAt(now); + await _db.into(_db.uploadTasks).insert( + UploadTasksCompanion.insert( + id: draft.id, + accountId: draft.accountId, + albumId: draft.albumId, + categoryId: Value(draft.categoryId), + clientTaskId: draft.clientTaskId, + sourceFilePath: draft.sourceFilePath, + filename: draft.filename, + bytes: draft.bytes, + contentType: draft.contentType, + state: UploadTaskState.preparing, + createdAt: timestamp, + updatedAt: timestamp, + ), + ); + return await _byId(draft.id); + }); + } + + /// 按 id 读取(**账号隔离硬守护**:accountId 不匹配视同不存在)。 + Future get(String accountId, String id) { + return (_db.select(_db.uploadTasks) + ..where((t) => t.id.equals(id) & t.accountId.equals(accountId))) + .getSingleOrNull(); + } + + /// 状态迁移(带合法迁移守护 + updatedAt 刷新),返回迁移后的任务。 + /// + /// 抛 [StateError]:任务不存在,或 `当前 → to` 不在合法迁移表 + /// ([assertTransition])。 + Future updateState( + String id, + UploadTaskState to, { + UploadTaskPatch patch = const UploadTaskPatch(), + DateTime? now, + }) { + return _db.transaction(() async { + final current = await _byId(id); + assertTransition(current.state, to); + await (_db.update(_db.uploadTasks) + ..where((t) => t.id.equals(id))) + .write( + UploadTasksCompanion( + state: Value(to), + attempt: patch.attempt == null + ? const Value.absent() + : Value(patch.attempt!), + nextRetryAt: patch.clearNextRetryAt + ? const Value(null) + : Value(patch.nextRetryAt?.toUtc()), + serverUploadId: Value(patch.serverUploadId), + serverPhotoId: Value(patch.serverPhotoId), + lastError: patch.clearLastError + ? const Value(null) + : Value(patch.lastError), + updatedAt: Value(_utcAt(now)), + ), + ); + return await _byId(id); + }); + } + + /// 账号任务列表(**accountId 过滤无条件附加——硬守护**)。 + /// + /// 可选筛选(需求 4.3 三维):albumId 相册、states 状态集合、since 创建 + /// 时间下限。按 createdAt 升序(队列 FIFO 语义)。 + Future> listByAccount( + String accountId, { + String? albumId, + Set? states, + DateTime? since, + int? limit, + }) { + final query = _db.select(_db.uploadTasks) + ..where((t) => t.accountId.equals(accountId)); + if (albumId != null) { + query.where((t) => t.albumId.equals(albumId)); + } + if (states != null && states.isNotEmpty) { + query.where((t) => t.state.isInValues(states.toList())); + } + if (since != null) { + query + .where((t) => t.createdAt.isBiggerOrEqualValue(since.toUtc())); + } + query.orderBy([(t) => OrderingTerm.asc(t.createdAt)]); + if (limit != null) { + query.limit(limit); + } + return query.get(); + } + + /// 账号内各状态任务数(队列 UI 徽章;全部状态都会出现,无任务计 0)。 + Future> countsByState(String accountId) async { + final rows = await (_db.selectOnly(_db.uploadTasks) + ..addColumns([_db.uploadTasks.state, _db.uploadTasks.id.count()]) + ..where(_db.uploadTasks.accountId.equals(accountId)) + ..groupBy([_db.uploadTasks.state])) + .map((row) => MapEntry( + row.readWithConverter(_db.uploadTasks.state)!, + row.read(_db.uploadTasks.id.count())!, + )) + .get(); + return { + for (final state in UploadTaskState.values) + state: rows.firstWhere((e) => e.key == state, + orElse: () => MapEntry(state, 0)) + .value, + }; + } + + /// 源文件清理候选:**仅** completed/cancelled、updatedAt 早于 + /// `now - keepDays` 且未清理过的任务(硬守护:未完成任务永不出现—— + /// 评审缺口 1)。 + /// + /// 设备级维护例程(清理磁盘上的受管目录副本),跨账号扫描;不影响任务 + /// 记录本身(UI/历史仍可见,cleanedAt 仅标记源文件已删)。 + Future> cleanupCandidates( + int keepDays, { + DateTime? now, + }) { + final cutoff = (now ?? DateTime.now()) + .subtract(Duration(days: keepDays)) + // UTC 归一:与落库取值同格式(Z 后缀),文本比较即时间比较。 + .toUtc(); + return (_db.select(_db.uploadTasks) + ..where( + (t) => + t.state.isInValues(const [ + UploadTaskState.completed, + UploadTaskState.cancelled, + ]) & + t.cleanedAt.isNull() & + t.updatedAt.isSmallerOrEqualValue(cutoff), + )) + .get(); + } + + /// 标记源文件已清理(cleanedAt=now;幂等——重复标记仅刷新时间)。 + /// + /// 抛 [StateError]:任务不存在或**非终态**(未完成源文件永不清理—— + /// 与 [cleanupCandidates] 同一硬守护的另一面)。 + Future markCleanable(String id, {DateTime? now}) { + return _db.transaction(() async { + final task = await _byId(id); + if (!task.state.isTerminal) { + throw StateError( + '未完成任务不可标记清理(状态:${task.state})——源文件硬守护', + ); + } + final timestamp = _utcAt(now); + await (_db.update(_db.uploadTasks) + ..where((t) => t.id.equals(id))) + .write( + UploadTasksCompanion(cleanedAt: Value(timestamp)), + ); + }); + } + + /// 删除任务记录(连拍未开始取消=直接删;清理策略不依赖删除)。 + Future delete(String id) { + return (_db.delete(_db.uploadTasks)..where((t) => t.id.equals(id))).go(); + } + + /// 事务内按 id 精确读取(不存在即 [StateError]——调用方均持有引擎下发 + /// 的合法 id)。 + Future _byId(String id) async { + final row = await (_db.select(_db.uploadTasks) + ..where((t) => t.id.equals(id))) + .getSingleOrNull(); + if (row == null) { + throw StateError('上传任务不存在:$id'); + } + return row; + } +} diff --git a/apps/mobile/lib/features/uploads/task_store.g.dart b/apps/mobile/lib/features/uploads/task_store.g.dart new file mode 100644 index 0000000..c0b0509 --- /dev/null +++ b/apps/mobile/lib/features/uploads/task_store.g.dart @@ -0,0 +1,1583 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'task_store.dart'; + +// ignore_for_file: type=lint +class $UploadTasksTable extends UploadTasks + with TableInfo<$UploadTasksTable, UploadTask> { + @override + final GeneratedDatabase attachedDatabase; + final String? _alias; + $UploadTasksTable(this.attachedDatabase, [this._alias]); + static const VerificationMeta _idMeta = const VerificationMeta('id'); + @override + late final GeneratedColumn id = GeneratedColumn( + 'id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _accountIdMeta = const VerificationMeta( + 'accountId', + ); + @override + late final GeneratedColumn accountId = GeneratedColumn( + 'account_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _albumIdMeta = const VerificationMeta( + 'albumId', + ); + @override + late final GeneratedColumn albumId = GeneratedColumn( + 'album_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _categoryIdMeta = const VerificationMeta( + 'categoryId', + ); + @override + late final GeneratedColumn categoryId = GeneratedColumn( + 'category_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _clientTaskIdMeta = const VerificationMeta( + 'clientTaskId', + ); + @override + late final GeneratedColumn clientTaskId = GeneratedColumn( + 'client_task_id', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _sourceFilePathMeta = const VerificationMeta( + 'sourceFilePath', + ); + @override + late final GeneratedColumn sourceFilePath = GeneratedColumn( + 'source_file_path', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _filenameMeta = const VerificationMeta( + 'filename', + ); + @override + late final GeneratedColumn filename = GeneratedColumn( + 'filename', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + static const VerificationMeta _bytesMeta = const VerificationMeta('bytes'); + @override + late final GeneratedColumn bytes = GeneratedColumn( + 'bytes', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: true, + ); + static const VerificationMeta _contentTypeMeta = const VerificationMeta( + 'contentType', + ); + @override + late final GeneratedColumn contentType = GeneratedColumn( + 'content_type', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ); + @override + late final GeneratedColumnWithTypeConverter state = + GeneratedColumn( + 'state', + aliasedName, + false, + type: DriftSqlType.string, + requiredDuringInsert: true, + ).withConverter($UploadTasksTable.$converterstate); + static const VerificationMeta _attemptMeta = const VerificationMeta( + 'attempt', + ); + @override + late final GeneratedColumn attempt = GeneratedColumn( + 'attempt', + aliasedName, + false, + type: DriftSqlType.int, + requiredDuringInsert: false, + defaultValue: const Constant(0), + ); + static const VerificationMeta _nextRetryAtMeta = const VerificationMeta( + 'nextRetryAt', + ); + @override + late final GeneratedColumn nextRetryAt = GeneratedColumn( + 'next_retry_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _serverUploadIdMeta = const VerificationMeta( + 'serverUploadId', + ); + @override + late final GeneratedColumn serverUploadId = GeneratedColumn( + 'server_upload_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _serverPhotoIdMeta = const VerificationMeta( + 'serverPhotoId', + ); + @override + late final GeneratedColumn serverPhotoId = GeneratedColumn( + 'server_photo_id', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _lastErrorMeta = const VerificationMeta( + 'lastError', + ); + @override + late final GeneratedColumn lastError = GeneratedColumn( + 'last_error', + aliasedName, + true, + type: DriftSqlType.string, + requiredDuringInsert: false, + ); + static const VerificationMeta _cleanedAtMeta = const VerificationMeta( + 'cleanedAt', + ); + @override + late final GeneratedColumn cleanedAt = GeneratedColumn( + 'cleaned_at', + aliasedName, + true, + type: DriftSqlType.dateTime, + requiredDuringInsert: false, + ); + static const VerificationMeta _createdAtMeta = const VerificationMeta( + 'createdAt', + ); + @override + late final GeneratedColumn createdAt = GeneratedColumn( + 'created_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + static const VerificationMeta _updatedAtMeta = const VerificationMeta( + 'updatedAt', + ); + @override + late final GeneratedColumn updatedAt = GeneratedColumn( + 'updated_at', + aliasedName, + false, + type: DriftSqlType.dateTime, + requiredDuringInsert: true, + ); + @override + List get $columns => [ + id, + accountId, + albumId, + categoryId, + clientTaskId, + sourceFilePath, + filename, + bytes, + contentType, + state, + attempt, + nextRetryAt, + serverUploadId, + serverPhotoId, + lastError, + cleanedAt, + createdAt, + updatedAt, + ]; + @override + String get aliasedName => _alias ?? actualTableName; + @override + String get actualTableName => $name; + static const String $name = 'upload_tasks'; + @override + VerificationContext validateIntegrity( + Insertable instance, { + bool isInserting = false, + }) { + final context = VerificationContext(); + final data = instance.toColumns(true); + if (data.containsKey('id')) { + context.handle(_idMeta, id.isAcceptableOrUnknown(data['id']!, _idMeta)); + } else if (isInserting) { + context.missing(_idMeta); + } + if (data.containsKey('account_id')) { + context.handle( + _accountIdMeta, + accountId.isAcceptableOrUnknown(data['account_id']!, _accountIdMeta), + ); + } else if (isInserting) { + context.missing(_accountIdMeta); + } + if (data.containsKey('album_id')) { + context.handle( + _albumIdMeta, + albumId.isAcceptableOrUnknown(data['album_id']!, _albumIdMeta), + ); + } else if (isInserting) { + context.missing(_albumIdMeta); + } + if (data.containsKey('category_id')) { + context.handle( + _categoryIdMeta, + categoryId.isAcceptableOrUnknown(data['category_id']!, _categoryIdMeta), + ); + } + if (data.containsKey('client_task_id')) { + context.handle( + _clientTaskIdMeta, + clientTaskId.isAcceptableOrUnknown( + data['client_task_id']!, + _clientTaskIdMeta, + ), + ); + } else if (isInserting) { + context.missing(_clientTaskIdMeta); + } + if (data.containsKey('source_file_path')) { + context.handle( + _sourceFilePathMeta, + sourceFilePath.isAcceptableOrUnknown( + data['source_file_path']!, + _sourceFilePathMeta, + ), + ); + } else if (isInserting) { + context.missing(_sourceFilePathMeta); + } + if (data.containsKey('filename')) { + context.handle( + _filenameMeta, + filename.isAcceptableOrUnknown(data['filename']!, _filenameMeta), + ); + } else if (isInserting) { + context.missing(_filenameMeta); + } + if (data.containsKey('bytes')) { + context.handle( + _bytesMeta, + bytes.isAcceptableOrUnknown(data['bytes']!, _bytesMeta), + ); + } else if (isInserting) { + context.missing(_bytesMeta); + } + if (data.containsKey('content_type')) { + context.handle( + _contentTypeMeta, + contentType.isAcceptableOrUnknown( + data['content_type']!, + _contentTypeMeta, + ), + ); + } else if (isInserting) { + context.missing(_contentTypeMeta); + } + if (data.containsKey('attempt')) { + context.handle( + _attemptMeta, + attempt.isAcceptableOrUnknown(data['attempt']!, _attemptMeta), + ); + } + if (data.containsKey('next_retry_at')) { + context.handle( + _nextRetryAtMeta, + nextRetryAt.isAcceptableOrUnknown( + data['next_retry_at']!, + _nextRetryAtMeta, + ), + ); + } + if (data.containsKey('server_upload_id')) { + context.handle( + _serverUploadIdMeta, + serverUploadId.isAcceptableOrUnknown( + data['server_upload_id']!, + _serverUploadIdMeta, + ), + ); + } + if (data.containsKey('server_photo_id')) { + context.handle( + _serverPhotoIdMeta, + serverPhotoId.isAcceptableOrUnknown( + data['server_photo_id']!, + _serverPhotoIdMeta, + ), + ); + } + if (data.containsKey('last_error')) { + context.handle( + _lastErrorMeta, + lastError.isAcceptableOrUnknown(data['last_error']!, _lastErrorMeta), + ); + } + if (data.containsKey('cleaned_at')) { + context.handle( + _cleanedAtMeta, + cleanedAt.isAcceptableOrUnknown(data['cleaned_at']!, _cleanedAtMeta), + ); + } + if (data.containsKey('created_at')) { + context.handle( + _createdAtMeta, + createdAt.isAcceptableOrUnknown(data['created_at']!, _createdAtMeta), + ); + } else if (isInserting) { + context.missing(_createdAtMeta); + } + if (data.containsKey('updated_at')) { + context.handle( + _updatedAtMeta, + updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta), + ); + } else if (isInserting) { + context.missing(_updatedAtMeta); + } + return context; + } + + @override + Set get $primaryKey => {id}; + @override + UploadTask map(Map data, {String? tablePrefix}) { + final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : ''; + return UploadTask( + id: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}id'], + )!, + accountId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}account_id'], + )!, + albumId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}album_id'], + )!, + categoryId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}category_id'], + ), + clientTaskId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}client_task_id'], + )!, + sourceFilePath: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}source_file_path'], + )!, + filename: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}filename'], + )!, + bytes: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}bytes'], + )!, + contentType: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}content_type'], + )!, + state: $UploadTasksTable.$converterstate.fromSql( + attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}state'], + )!, + ), + attempt: attachedDatabase.typeMapping.read( + DriftSqlType.int, + data['${effectivePrefix}attempt'], + )!, + nextRetryAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}next_retry_at'], + ), + serverUploadId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_upload_id'], + ), + serverPhotoId: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}server_photo_id'], + ), + lastError: attachedDatabase.typeMapping.read( + DriftSqlType.string, + data['${effectivePrefix}last_error'], + ), + cleanedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}cleaned_at'], + ), + createdAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}created_at'], + )!, + updatedAt: attachedDatabase.typeMapping.read( + DriftSqlType.dateTime, + data['${effectivePrefix}updated_at'], + )!, + ); + } + + @override + $UploadTasksTable createAlias(String alias) { + return $UploadTasksTable(attachedDatabase, alias); + } + + static TypeConverter $converterstate = + const _UploadTaskStateConverter(); +} + +class UploadTask extends DataClass implements Insertable { + /// 任务 ID(客户端生成 uuid,PK;与服务端 uploadId 无关)。 + final String id; + + /// 账号隔离键(登录账号,见 Session.account)。 + final String accountId; + + /// 目标相册 ID。 + final String albumId; + + /// 目标分类 ID;缺省为相册默认分类(契约 UploadInitRequest.categoryId)。 + final String? categoryId; + + /// 客户端幂等键(同账号同相册内唯一,应用侧校验,见库级文档)。 + final String clientTaskId; + + /// 源文件绝对路径(受管目录副本:app 支持目录/uploads/{albumId}/)。 + final String sourceFilePath; + + /// 原始文件名(仅元数据, maxLength 255,契约对齐)。 + final String filename; + + /// 申明文件字节数(完成核验时与服务端逐一比对)。 + final int bytes; + + /// MIME 类型(白名单 image/jpeg | image/png | image/heic)。 + final String contentType; + + /// 任务状态(状态机见 task_state.dart)。 + final UploadTaskState state; + + /// 已尝试传输次数(退避与上限判断依据)。 + final int attempt; + + /// 下次可重试时间(waitingNetwork/retryableFailed 的退避闹钟);null=无 + /// 待重试。 + final DateTime? nextRetryAt; + + /// 服务端上传会话 ID(initUpload 成功后回填,重试复用)。 + final String? serverUploadId; + + /// 服务端预创建照片 ID(initUpload 成功后回填)。 + final String? serverPhotoId; + + /// 最近一次失败原因(错误码文本,见 core/error_codes.dart;成功迁移时 + /// 清除)。 + final String? lastError; + + /// 源文件已清理时间(保留策略执行器写入;null=未清理)。实现增补字段, + /// 见库级文档。 + final DateTime? cleanedAt; + + /// 创建时间(入 preparing 时刻)。 + final DateTime createdAt; + + /// 最近更新时间(每次状态迁移刷新;保留策略的年龄基准)。 + final DateTime updatedAt; + const UploadTask({ + required this.id, + required this.accountId, + required this.albumId, + this.categoryId, + required this.clientTaskId, + required this.sourceFilePath, + required this.filename, + required this.bytes, + required this.contentType, + required this.state, + required this.attempt, + this.nextRetryAt, + this.serverUploadId, + this.serverPhotoId, + this.lastError, + this.cleanedAt, + required this.createdAt, + required this.updatedAt, + }); + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + map['id'] = Variable(id); + map['account_id'] = Variable(accountId); + map['album_id'] = Variable(albumId); + if (!nullToAbsent || categoryId != null) { + map['category_id'] = Variable(categoryId); + } + map['client_task_id'] = Variable(clientTaskId); + map['source_file_path'] = Variable(sourceFilePath); + map['filename'] = Variable(filename); + map['bytes'] = Variable(bytes); + map['content_type'] = Variable(contentType); + { + map['state'] = Variable( + $UploadTasksTable.$converterstate.toSql(state), + ); + } + map['attempt'] = Variable(attempt); + if (!nullToAbsent || nextRetryAt != null) { + map['next_retry_at'] = Variable(nextRetryAt); + } + if (!nullToAbsent || serverUploadId != null) { + map['server_upload_id'] = Variable(serverUploadId); + } + if (!nullToAbsent || serverPhotoId != null) { + map['server_photo_id'] = Variable(serverPhotoId); + } + if (!nullToAbsent || lastError != null) { + map['last_error'] = Variable(lastError); + } + if (!nullToAbsent || cleanedAt != null) { + map['cleaned_at'] = Variable(cleanedAt); + } + map['created_at'] = Variable(createdAt); + map['updated_at'] = Variable(updatedAt); + return map; + } + + UploadTasksCompanion toCompanion(bool nullToAbsent) { + return UploadTasksCompanion( + id: Value(id), + accountId: Value(accountId), + albumId: Value(albumId), + categoryId: categoryId == null && nullToAbsent + ? const Value.absent() + : Value(categoryId), + clientTaskId: Value(clientTaskId), + sourceFilePath: Value(sourceFilePath), + filename: Value(filename), + bytes: Value(bytes), + contentType: Value(contentType), + state: Value(state), + attempt: Value(attempt), + nextRetryAt: nextRetryAt == null && nullToAbsent + ? const Value.absent() + : Value(nextRetryAt), + serverUploadId: serverUploadId == null && nullToAbsent + ? const Value.absent() + : Value(serverUploadId), + serverPhotoId: serverPhotoId == null && nullToAbsent + ? const Value.absent() + : Value(serverPhotoId), + lastError: lastError == null && nullToAbsent + ? const Value.absent() + : Value(lastError), + cleanedAt: cleanedAt == null && nullToAbsent + ? const Value.absent() + : Value(cleanedAt), + createdAt: Value(createdAt), + updatedAt: Value(updatedAt), + ); + } + + factory UploadTask.fromJson( + Map json, { + ValueSerializer? serializer, + }) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return UploadTask( + id: serializer.fromJson(json['id']), + accountId: serializer.fromJson(json['accountId']), + albumId: serializer.fromJson(json['albumId']), + categoryId: serializer.fromJson(json['categoryId']), + clientTaskId: serializer.fromJson(json['clientTaskId']), + sourceFilePath: serializer.fromJson(json['sourceFilePath']), + filename: serializer.fromJson(json['filename']), + bytes: serializer.fromJson(json['bytes']), + contentType: serializer.fromJson(json['contentType']), + state: serializer.fromJson(json['state']), + attempt: serializer.fromJson(json['attempt']), + nextRetryAt: serializer.fromJson(json['nextRetryAt']), + serverUploadId: serializer.fromJson(json['serverUploadId']), + serverPhotoId: serializer.fromJson(json['serverPhotoId']), + lastError: serializer.fromJson(json['lastError']), + cleanedAt: serializer.fromJson(json['cleanedAt']), + createdAt: serializer.fromJson(json['createdAt']), + updatedAt: serializer.fromJson(json['updatedAt']), + ); + } + @override + Map toJson({ValueSerializer? serializer}) { + serializer ??= driftRuntimeOptions.defaultSerializer; + return { + 'id': serializer.toJson(id), + 'accountId': serializer.toJson(accountId), + 'albumId': serializer.toJson(albumId), + 'categoryId': serializer.toJson(categoryId), + 'clientTaskId': serializer.toJson(clientTaskId), + 'sourceFilePath': serializer.toJson(sourceFilePath), + 'filename': serializer.toJson(filename), + 'bytes': serializer.toJson(bytes), + 'contentType': serializer.toJson(contentType), + 'state': serializer.toJson(state), + 'attempt': serializer.toJson(attempt), + 'nextRetryAt': serializer.toJson(nextRetryAt), + 'serverUploadId': serializer.toJson(serverUploadId), + 'serverPhotoId': serializer.toJson(serverPhotoId), + 'lastError': serializer.toJson(lastError), + 'cleanedAt': serializer.toJson(cleanedAt), + 'createdAt': serializer.toJson(createdAt), + 'updatedAt': serializer.toJson(updatedAt), + }; + } + + UploadTask copyWith({ + String? id, + String? accountId, + String? albumId, + Value categoryId = const Value.absent(), + String? clientTaskId, + String? sourceFilePath, + String? filename, + int? bytes, + String? contentType, + UploadTaskState? state, + int? attempt, + Value nextRetryAt = const Value.absent(), + Value serverUploadId = const Value.absent(), + Value serverPhotoId = const Value.absent(), + Value lastError = const Value.absent(), + Value cleanedAt = const Value.absent(), + DateTime? createdAt, + DateTime? updatedAt, + }) => UploadTask( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + albumId: albumId ?? this.albumId, + categoryId: categoryId.present ? categoryId.value : this.categoryId, + clientTaskId: clientTaskId ?? this.clientTaskId, + sourceFilePath: sourceFilePath ?? this.sourceFilePath, + filename: filename ?? this.filename, + bytes: bytes ?? this.bytes, + contentType: contentType ?? this.contentType, + state: state ?? this.state, + attempt: attempt ?? this.attempt, + nextRetryAt: nextRetryAt.present ? nextRetryAt.value : this.nextRetryAt, + serverUploadId: serverUploadId.present + ? serverUploadId.value + : this.serverUploadId, + serverPhotoId: serverPhotoId.present + ? serverPhotoId.value + : this.serverPhotoId, + lastError: lastError.present ? lastError.value : this.lastError, + cleanedAt: cleanedAt.present ? cleanedAt.value : this.cleanedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + ); + UploadTask copyWithCompanion(UploadTasksCompanion data) { + return UploadTask( + id: data.id.present ? data.id.value : this.id, + accountId: data.accountId.present ? data.accountId.value : this.accountId, + albumId: data.albumId.present ? data.albumId.value : this.albumId, + categoryId: data.categoryId.present + ? data.categoryId.value + : this.categoryId, + clientTaskId: data.clientTaskId.present + ? data.clientTaskId.value + : this.clientTaskId, + sourceFilePath: data.sourceFilePath.present + ? data.sourceFilePath.value + : this.sourceFilePath, + filename: data.filename.present ? data.filename.value : this.filename, + bytes: data.bytes.present ? data.bytes.value : this.bytes, + contentType: data.contentType.present + ? data.contentType.value + : this.contentType, + state: data.state.present ? data.state.value : this.state, + attempt: data.attempt.present ? data.attempt.value : this.attempt, + nextRetryAt: data.nextRetryAt.present + ? data.nextRetryAt.value + : this.nextRetryAt, + serverUploadId: data.serverUploadId.present + ? data.serverUploadId.value + : this.serverUploadId, + serverPhotoId: data.serverPhotoId.present + ? data.serverPhotoId.value + : this.serverPhotoId, + lastError: data.lastError.present ? data.lastError.value : this.lastError, + cleanedAt: data.cleanedAt.present ? data.cleanedAt.value : this.cleanedAt, + createdAt: data.createdAt.present ? data.createdAt.value : this.createdAt, + updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt, + ); + } + + @override + String toString() { + return (StringBuffer('UploadTask(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('albumId: $albumId, ') + ..write('categoryId: $categoryId, ') + ..write('clientTaskId: $clientTaskId, ') + ..write('sourceFilePath: $sourceFilePath, ') + ..write('filename: $filename, ') + ..write('bytes: $bytes, ') + ..write('contentType: $contentType, ') + ..write('state: $state, ') + ..write('attempt: $attempt, ') + ..write('nextRetryAt: $nextRetryAt, ') + ..write('serverUploadId: $serverUploadId, ') + ..write('serverPhotoId: $serverPhotoId, ') + ..write('lastError: $lastError, ') + ..write('cleanedAt: $cleanedAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt') + ..write(')')) + .toString(); + } + + @override + int get hashCode => Object.hash( + id, + accountId, + albumId, + categoryId, + clientTaskId, + sourceFilePath, + filename, + bytes, + contentType, + state, + attempt, + nextRetryAt, + serverUploadId, + serverPhotoId, + lastError, + cleanedAt, + createdAt, + updatedAt, + ); + @override + bool operator ==(Object other) => + identical(this, other) || + (other is UploadTask && + other.id == this.id && + other.accountId == this.accountId && + other.albumId == this.albumId && + other.categoryId == this.categoryId && + other.clientTaskId == this.clientTaskId && + other.sourceFilePath == this.sourceFilePath && + other.filename == this.filename && + other.bytes == this.bytes && + other.contentType == this.contentType && + other.state == this.state && + other.attempt == this.attempt && + other.nextRetryAt == this.nextRetryAt && + other.serverUploadId == this.serverUploadId && + other.serverPhotoId == this.serverPhotoId && + other.lastError == this.lastError && + other.cleanedAt == this.cleanedAt && + other.createdAt == this.createdAt && + other.updatedAt == this.updatedAt); +} + +class UploadTasksCompanion extends UpdateCompanion { + final Value id; + final Value accountId; + final Value albumId; + final Value categoryId; + final Value clientTaskId; + final Value sourceFilePath; + final Value filename; + final Value bytes; + final Value contentType; + final Value state; + final Value attempt; + final Value nextRetryAt; + final Value serverUploadId; + final Value serverPhotoId; + final Value lastError; + final Value cleanedAt; + final Value createdAt; + final Value updatedAt; + final Value rowid; + const UploadTasksCompanion({ + this.id = const Value.absent(), + this.accountId = const Value.absent(), + this.albumId = const Value.absent(), + this.categoryId = const Value.absent(), + this.clientTaskId = const Value.absent(), + this.sourceFilePath = const Value.absent(), + this.filename = const Value.absent(), + this.bytes = const Value.absent(), + this.contentType = const Value.absent(), + this.state = const Value.absent(), + this.attempt = const Value.absent(), + this.nextRetryAt = const Value.absent(), + this.serverUploadId = const Value.absent(), + this.serverPhotoId = const Value.absent(), + this.lastError = const Value.absent(), + this.cleanedAt = const Value.absent(), + this.createdAt = const Value.absent(), + this.updatedAt = const Value.absent(), + this.rowid = const Value.absent(), + }); + UploadTasksCompanion.insert({ + required String id, + required String accountId, + required String albumId, + this.categoryId = const Value.absent(), + required String clientTaskId, + required String sourceFilePath, + required String filename, + required int bytes, + required String contentType, + required UploadTaskState state, + this.attempt = const Value.absent(), + this.nextRetryAt = const Value.absent(), + this.serverUploadId = const Value.absent(), + this.serverPhotoId = const Value.absent(), + this.lastError = const Value.absent(), + this.cleanedAt = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + this.rowid = const Value.absent(), + }) : id = Value(id), + accountId = Value(accountId), + albumId = Value(albumId), + clientTaskId = Value(clientTaskId), + sourceFilePath = Value(sourceFilePath), + filename = Value(filename), + bytes = Value(bytes), + contentType = Value(contentType), + state = Value(state), + createdAt = Value(createdAt), + updatedAt = Value(updatedAt); + static Insertable custom({ + Expression? id, + Expression? accountId, + Expression? albumId, + Expression? categoryId, + Expression? clientTaskId, + Expression? sourceFilePath, + Expression? filename, + Expression? bytes, + Expression? contentType, + Expression? state, + Expression? attempt, + Expression? nextRetryAt, + Expression? serverUploadId, + Expression? serverPhotoId, + Expression? lastError, + Expression? cleanedAt, + Expression? createdAt, + Expression? updatedAt, + Expression? rowid, + }) { + return RawValuesInsertable({ + if (id != null) 'id': id, + if (accountId != null) 'account_id': accountId, + if (albumId != null) 'album_id': albumId, + if (categoryId != null) 'category_id': categoryId, + if (clientTaskId != null) 'client_task_id': clientTaskId, + if (sourceFilePath != null) 'source_file_path': sourceFilePath, + if (filename != null) 'filename': filename, + if (bytes != null) 'bytes': bytes, + if (contentType != null) 'content_type': contentType, + if (state != null) 'state': state, + if (attempt != null) 'attempt': attempt, + if (nextRetryAt != null) 'next_retry_at': nextRetryAt, + if (serverUploadId != null) 'server_upload_id': serverUploadId, + if (serverPhotoId != null) 'server_photo_id': serverPhotoId, + if (lastError != null) 'last_error': lastError, + if (cleanedAt != null) 'cleaned_at': cleanedAt, + if (createdAt != null) 'created_at': createdAt, + if (updatedAt != null) 'updated_at': updatedAt, + if (rowid != null) 'rowid': rowid, + }); + } + + UploadTasksCompanion copyWith({ + Value? id, + Value? accountId, + Value? albumId, + Value? categoryId, + Value? clientTaskId, + Value? sourceFilePath, + Value? filename, + Value? bytes, + Value? contentType, + Value? state, + Value? attempt, + Value? nextRetryAt, + Value? serverUploadId, + Value? serverPhotoId, + Value? lastError, + Value? cleanedAt, + Value? createdAt, + Value? updatedAt, + Value? rowid, + }) { + return UploadTasksCompanion( + id: id ?? this.id, + accountId: accountId ?? this.accountId, + albumId: albumId ?? this.albumId, + categoryId: categoryId ?? this.categoryId, + clientTaskId: clientTaskId ?? this.clientTaskId, + sourceFilePath: sourceFilePath ?? this.sourceFilePath, + filename: filename ?? this.filename, + bytes: bytes ?? this.bytes, + contentType: contentType ?? this.contentType, + state: state ?? this.state, + attempt: attempt ?? this.attempt, + nextRetryAt: nextRetryAt ?? this.nextRetryAt, + serverUploadId: serverUploadId ?? this.serverUploadId, + serverPhotoId: serverPhotoId ?? this.serverPhotoId, + lastError: lastError ?? this.lastError, + cleanedAt: cleanedAt ?? this.cleanedAt, + createdAt: createdAt ?? this.createdAt, + updatedAt: updatedAt ?? this.updatedAt, + rowid: rowid ?? this.rowid, + ); + } + + @override + Map toColumns(bool nullToAbsent) { + final map = {}; + if (id.present) { + map['id'] = Variable(id.value); + } + if (accountId.present) { + map['account_id'] = Variable(accountId.value); + } + if (albumId.present) { + map['album_id'] = Variable(albumId.value); + } + if (categoryId.present) { + map['category_id'] = Variable(categoryId.value); + } + if (clientTaskId.present) { + map['client_task_id'] = Variable(clientTaskId.value); + } + if (sourceFilePath.present) { + map['source_file_path'] = Variable(sourceFilePath.value); + } + if (filename.present) { + map['filename'] = Variable(filename.value); + } + if (bytes.present) { + map['bytes'] = Variable(bytes.value); + } + if (contentType.present) { + map['content_type'] = Variable(contentType.value); + } + if (state.present) { + map['state'] = Variable( + $UploadTasksTable.$converterstate.toSql(state.value), + ); + } + if (attempt.present) { + map['attempt'] = Variable(attempt.value); + } + if (nextRetryAt.present) { + map['next_retry_at'] = Variable(nextRetryAt.value); + } + if (serverUploadId.present) { + map['server_upload_id'] = Variable(serverUploadId.value); + } + if (serverPhotoId.present) { + map['server_photo_id'] = Variable(serverPhotoId.value); + } + if (lastError.present) { + map['last_error'] = Variable(lastError.value); + } + if (cleanedAt.present) { + map['cleaned_at'] = Variable(cleanedAt.value); + } + if (createdAt.present) { + map['created_at'] = Variable(createdAt.value); + } + if (updatedAt.present) { + map['updated_at'] = Variable(updatedAt.value); + } + if (rowid.present) { + map['rowid'] = Variable(rowid.value); + } + return map; + } + + @override + String toString() { + return (StringBuffer('UploadTasksCompanion(') + ..write('id: $id, ') + ..write('accountId: $accountId, ') + ..write('albumId: $albumId, ') + ..write('categoryId: $categoryId, ') + ..write('clientTaskId: $clientTaskId, ') + ..write('sourceFilePath: $sourceFilePath, ') + ..write('filename: $filename, ') + ..write('bytes: $bytes, ') + ..write('contentType: $contentType, ') + ..write('state: $state, ') + ..write('attempt: $attempt, ') + ..write('nextRetryAt: $nextRetryAt, ') + ..write('serverUploadId: $serverUploadId, ') + ..write('serverPhotoId: $serverPhotoId, ') + ..write('lastError: $lastError, ') + ..write('cleanedAt: $cleanedAt, ') + ..write('createdAt: $createdAt, ') + ..write('updatedAt: $updatedAt, ') + ..write('rowid: $rowid') + ..write(')')) + .toString(); + } +} + +abstract class _$UploadTaskDb extends GeneratedDatabase { + _$UploadTaskDb(QueryExecutor e) : super(e); + $UploadTaskDbManager get managers => $UploadTaskDbManager(this); + late final $UploadTasksTable uploadTasks = $UploadTasksTable(this); + late final Index uploadTasksAccountIdx = Index( + 'upload_tasks_account_idx', + 'CREATE INDEX upload_tasks_account_idx ON upload_tasks (account_id)', + ); + late final Index uploadTasksAccountStateIdx = Index( + 'upload_tasks_account_state_idx', + 'CREATE INDEX upload_tasks_account_state_idx ON upload_tasks (account_id, state)', + ); + late final Index uploadTasksAccountAlbumStateIdx = Index( + 'upload_tasks_account_album_state_idx', + 'CREATE INDEX upload_tasks_account_album_state_idx ON upload_tasks (account_id, album_id, state)', + ); + @override + Iterable> get allTables => + allSchemaEntities.whereType>(); + @override + List get allSchemaEntities => [ + uploadTasks, + uploadTasksAccountIdx, + uploadTasksAccountStateIdx, + uploadTasksAccountAlbumStateIdx, + ]; + @override + DriftDatabaseOptions get options => + const DriftDatabaseOptions(storeDateTimeAsText: true); +} + +typedef $$UploadTasksTableCreateCompanionBuilder = + UploadTasksCompanion Function({ + required String id, + required String accountId, + required String albumId, + Value categoryId, + required String clientTaskId, + required String sourceFilePath, + required String filename, + required int bytes, + required String contentType, + required UploadTaskState state, + Value attempt, + Value nextRetryAt, + Value serverUploadId, + Value serverPhotoId, + Value lastError, + Value cleanedAt, + required DateTime createdAt, + required DateTime updatedAt, + Value rowid, + }); +typedef $$UploadTasksTableUpdateCompanionBuilder = + UploadTasksCompanion Function({ + Value id, + Value accountId, + Value albumId, + Value categoryId, + Value clientTaskId, + Value sourceFilePath, + Value filename, + Value bytes, + Value contentType, + Value state, + Value attempt, + Value nextRetryAt, + Value serverUploadId, + Value serverPhotoId, + Value lastError, + Value cleanedAt, + Value createdAt, + Value updatedAt, + Value rowid, + }); + +class $$UploadTasksTableFilterComposer + extends Composer<_$UploadTaskDb, $UploadTasksTable> { + $$UploadTasksTableFilterComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnFilters get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get accountId => $composableBuilder( + column: $table.accountId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get albumId => $composableBuilder( + column: $table.albumId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get categoryId => $composableBuilder( + column: $table.categoryId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get clientTaskId => $composableBuilder( + column: $table.clientTaskId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get sourceFilePath => $composableBuilder( + column: $table.sourceFilePath, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get filename => $composableBuilder( + column: $table.filename, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get bytes => $composableBuilder( + column: $table.bytes, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get contentType => $composableBuilder( + column: $table.contentType, + builder: (column) => ColumnFilters(column), + ); + + ColumnWithTypeConverterFilters + get state => $composableBuilder( + column: $table.state, + builder: (column) => ColumnWithTypeConverterFilters(column), + ); + + ColumnFilters get attempt => $composableBuilder( + column: $table.attempt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get nextRetryAt => $composableBuilder( + column: $table.nextRetryAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverUploadId => $composableBuilder( + column: $table.serverUploadId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get serverPhotoId => $composableBuilder( + column: $table.serverPhotoId, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get cleanedAt => $composableBuilder( + column: $table.cleanedAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnFilters(column), + ); + + ColumnFilters get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnFilters(column), + ); +} + +class $$UploadTasksTableOrderingComposer + extends Composer<_$UploadTaskDb, $UploadTasksTable> { + $$UploadTasksTableOrderingComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + ColumnOrderings get id => $composableBuilder( + column: $table.id, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get accountId => $composableBuilder( + column: $table.accountId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get albumId => $composableBuilder( + column: $table.albumId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get categoryId => $composableBuilder( + column: $table.categoryId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get clientTaskId => $composableBuilder( + column: $table.clientTaskId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get sourceFilePath => $composableBuilder( + column: $table.sourceFilePath, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get filename => $composableBuilder( + column: $table.filename, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get bytes => $composableBuilder( + column: $table.bytes, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get contentType => $composableBuilder( + column: $table.contentType, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get state => $composableBuilder( + column: $table.state, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get attempt => $composableBuilder( + column: $table.attempt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get nextRetryAt => $composableBuilder( + column: $table.nextRetryAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverUploadId => $composableBuilder( + column: $table.serverUploadId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get serverPhotoId => $composableBuilder( + column: $table.serverPhotoId, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get lastError => $composableBuilder( + column: $table.lastError, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get cleanedAt => $composableBuilder( + column: $table.cleanedAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get createdAt => $composableBuilder( + column: $table.createdAt, + builder: (column) => ColumnOrderings(column), + ); + + ColumnOrderings get updatedAt => $composableBuilder( + column: $table.updatedAt, + builder: (column) => ColumnOrderings(column), + ); +} + +class $$UploadTasksTableAnnotationComposer + extends Composer<_$UploadTaskDb, $UploadTasksTable> { + $$UploadTasksTableAnnotationComposer({ + required super.$db, + required super.$table, + super.joinBuilder, + super.$addJoinBuilderToRootComposer, + super.$removeJoinBuilderFromRootComposer, + }); + GeneratedColumn get id => + $composableBuilder(column: $table.id, builder: (column) => column); + + GeneratedColumn get accountId => + $composableBuilder(column: $table.accountId, builder: (column) => column); + + GeneratedColumn get albumId => + $composableBuilder(column: $table.albumId, builder: (column) => column); + + GeneratedColumn get categoryId => $composableBuilder( + column: $table.categoryId, + builder: (column) => column, + ); + + GeneratedColumn get clientTaskId => $composableBuilder( + column: $table.clientTaskId, + builder: (column) => column, + ); + + GeneratedColumn get sourceFilePath => $composableBuilder( + column: $table.sourceFilePath, + builder: (column) => column, + ); + + GeneratedColumn get filename => + $composableBuilder(column: $table.filename, builder: (column) => column); + + GeneratedColumn get bytes => + $composableBuilder(column: $table.bytes, builder: (column) => column); + + GeneratedColumn get contentType => $composableBuilder( + column: $table.contentType, + builder: (column) => column, + ); + + GeneratedColumnWithTypeConverter get state => + $composableBuilder(column: $table.state, builder: (column) => column); + + GeneratedColumn get attempt => + $composableBuilder(column: $table.attempt, builder: (column) => column); + + GeneratedColumn get nextRetryAt => $composableBuilder( + column: $table.nextRetryAt, + builder: (column) => column, + ); + + GeneratedColumn get serverUploadId => $composableBuilder( + column: $table.serverUploadId, + builder: (column) => column, + ); + + GeneratedColumn get serverPhotoId => $composableBuilder( + column: $table.serverPhotoId, + builder: (column) => column, + ); + + GeneratedColumn get lastError => + $composableBuilder(column: $table.lastError, builder: (column) => column); + + GeneratedColumn get cleanedAt => + $composableBuilder(column: $table.cleanedAt, builder: (column) => column); + + GeneratedColumn get createdAt => + $composableBuilder(column: $table.createdAt, builder: (column) => column); + + GeneratedColumn get updatedAt => + $composableBuilder(column: $table.updatedAt, builder: (column) => column); +} + +class $$UploadTasksTableTableManager + extends + RootTableManager< + _$UploadTaskDb, + $UploadTasksTable, + UploadTask, + $$UploadTasksTableFilterComposer, + $$UploadTasksTableOrderingComposer, + $$UploadTasksTableAnnotationComposer, + $$UploadTasksTableCreateCompanionBuilder, + $$UploadTasksTableUpdateCompanionBuilder, + ( + UploadTask, + BaseReferences<_$UploadTaskDb, $UploadTasksTable, UploadTask>, + ), + UploadTask, + PrefetchHooks Function() + > { + $$UploadTasksTableTableManager(_$UploadTaskDb db, $UploadTasksTable table) + : super( + TableManagerState( + db: db, + table: table, + createFilteringComposer: () => + $$UploadTasksTableFilterComposer($db: db, $table: table), + createOrderingComposer: () => + $$UploadTasksTableOrderingComposer($db: db, $table: table), + createComputedFieldComposer: () => + $$UploadTasksTableAnnotationComposer($db: db, $table: table), + updateCompanionCallback: + ({ + Value id = const Value.absent(), + Value accountId = const Value.absent(), + Value albumId = const Value.absent(), + Value categoryId = const Value.absent(), + Value clientTaskId = const Value.absent(), + Value sourceFilePath = const Value.absent(), + Value filename = const Value.absent(), + Value bytes = const Value.absent(), + Value contentType = const Value.absent(), + Value state = const Value.absent(), + Value attempt = const Value.absent(), + Value nextRetryAt = const Value.absent(), + Value serverUploadId = const Value.absent(), + Value serverPhotoId = const Value.absent(), + Value lastError = const Value.absent(), + Value cleanedAt = const Value.absent(), + Value createdAt = const Value.absent(), + Value updatedAt = const Value.absent(), + Value rowid = const Value.absent(), + }) => UploadTasksCompanion( + id: id, + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + clientTaskId: clientTaskId, + sourceFilePath: sourceFilePath, + filename: filename, + bytes: bytes, + contentType: contentType, + state: state, + attempt: attempt, + nextRetryAt: nextRetryAt, + serverUploadId: serverUploadId, + serverPhotoId: serverPhotoId, + lastError: lastError, + cleanedAt: cleanedAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + createCompanionCallback: + ({ + required String id, + required String accountId, + required String albumId, + Value categoryId = const Value.absent(), + required String clientTaskId, + required String sourceFilePath, + required String filename, + required int bytes, + required String contentType, + required UploadTaskState state, + Value attempt = const Value.absent(), + Value nextRetryAt = const Value.absent(), + Value serverUploadId = const Value.absent(), + Value serverPhotoId = const Value.absent(), + Value lastError = const Value.absent(), + Value cleanedAt = const Value.absent(), + required DateTime createdAt, + required DateTime updatedAt, + Value rowid = const Value.absent(), + }) => UploadTasksCompanion.insert( + id: id, + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + clientTaskId: clientTaskId, + sourceFilePath: sourceFilePath, + filename: filename, + bytes: bytes, + contentType: contentType, + state: state, + attempt: attempt, + nextRetryAt: nextRetryAt, + serverUploadId: serverUploadId, + serverPhotoId: serverPhotoId, + lastError: lastError, + cleanedAt: cleanedAt, + createdAt: createdAt, + updatedAt: updatedAt, + rowid: rowid, + ), + withReferenceMapper: (p0) => p0 + .map( + (e) => ( + e.readTable<$UploadTasksTable, UploadTask>(table), + BaseReferences<_$UploadTaskDb, $UploadTasksTable, UploadTask>( + db, + table, + e, + ), + ), + ) + .toList(), + prefetchHooksCallback: null, + ), + ); +} + +typedef $$UploadTasksTableProcessedTableManager = + ProcessedTableManager< + _$UploadTaskDb, + $UploadTasksTable, + UploadTask, + $$UploadTasksTableFilterComposer, + $$UploadTasksTableOrderingComposer, + $$UploadTasksTableAnnotationComposer, + $$UploadTasksTableCreateCompanionBuilder, + $$UploadTasksTableUpdateCompanionBuilder, + ( + UploadTask, + BaseReferences<_$UploadTaskDb, $UploadTasksTable, UploadTask>, + ), + UploadTask, + PrefetchHooks Function() + >; + +class $UploadTaskDbManager { + final _$UploadTaskDb _db; + $UploadTaskDbManager(this._db); + $$UploadTasksTableTableManager get uploadTasks => + $$UploadTasksTableTableManager(_db, _db.uploadTasks); +} diff --git a/apps/mobile/test/features/uploads/task_state_test.dart b/apps/mobile/test/features/uploads/task_state_test.dart new file mode 100644 index 0000000..2a3501b --- /dev/null +++ b/apps/mobile/test/features/uploads/task_state_test.dart @@ -0,0 +1,153 @@ +/// 上传任务状态机表驱动测试(P3 计划 Task 5)。 +/// +/// 覆盖:合法迁移表与方案 §5.5 逐边对齐、全量非法对拒绝、自迁移拒绝、 +/// 终态无出边、wire 落库取值往返。 +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; + +/// 方案 §5.5 / P3 计划 Task 5/6 的合法迁移表(人工誊写,作为对照基准)。 +final Map> expectedTransitions = { + UploadTaskState.preparing: { + UploadTaskState.queued, + UploadTaskState.preparationFailed, + UploadTaskState.cancelled, + }, + UploadTaskState.preparationFailed: { + UploadTaskState.preparing, + UploadTaskState.cancelled, + }, + UploadTaskState.queued: { + UploadTaskState.uploading, + UploadTaskState.waitingNetwork, + UploadTaskState.cancelled, + }, + UploadTaskState.uploading: { + UploadTaskState.uploadedConfirming, + UploadTaskState.waitingNetwork, + UploadTaskState.retryableFailed, + UploadTaskState.cancelled, // 尽力取消 + }, + UploadTaskState.uploadedConfirming: { + UploadTaskState.completed, + UploadTaskState.retryableFailed, + UploadTaskState.waitingNetwork, + }, + UploadTaskState.waitingNetwork: { + UploadTaskState.queued, + UploadTaskState.uploading, // 网络恢复直接续传 + UploadTaskState.cancelled, + }, + UploadTaskState.retryableFailed: { + UploadTaskState.queued, + UploadTaskState.cancelled, + }, + UploadTaskState.completed: {}, + UploadTaskState.cancelled: {}, +}; + +void main() { + test('合法迁移表与方案基准逐状态一致(多边/少边都失败)', () { + for (final entry in expectedTransitions.entries) { + final actual = kUploadTaskTransitions[entry.key]!; + expect(actual, equals(entry.value), + reason: '${entry.key} 的合法目标集合与基准不一致'); + } + expect( + kUploadTaskTransitions.keys, + equals(expectedTransitions.keys.toSet()), + reason: '迁移表的键必须覆盖全部状态', + ); + }); + + test('全部合法边:canTransition=true 且 assertTransition 不抛', () { + var legalEdgeCount = 0; + for (final from in UploadTaskState.values) { + for (final to in kUploadTaskTransitions[from]!) { + legalEdgeCount++; + expect(canTransition(from, to), isTrue, reason: '$from → $to 应合法'); + expect(() => assertTransition(from, to), returnsNormally, + reason: '$from → $to 断言不应抛'); + } + } + expect(legalEdgeCount, 20, reason: '合法边总数(防意外增删边)'); + }); + + test('全量非法对(9x9 减合法边):canTransition=false 且 assertTransition 抛 StateError', () { + for (final from in UploadTaskState.values) { + for (final to in UploadTaskState.values) { + if (kUploadTaskTransitions[from]!.contains(to)) continue; + expect(canTransition(from, to), isFalse, + reason: '$from → $to 应非法'); + expect(() => assertTransition(from, to), throwsA(isA()), + reason: '$from → $to 断言应抛 StateError'); + } + } + }); + + test('自迁移一律非法(状态未变不该写库)', () { + for (final state in UploadTaskState.values) { + expect(canTransition(state, state), isFalse, reason: '$state → 自身'); + } + }); + + test('终态(completed/cancelled)无出边,且 isTerminal 仅为这两个', () { + for (final state in UploadTaskState.values) { + if (state == UploadTaskState.completed || + state == UploadTaskState.cancelled) { + expect(state.isTerminal, isTrue); + expect(kUploadTaskTransitions[state]!, isEmpty, + reason: '$state 是终态不应有出边'); + } else { + expect(state.isTerminal, isFalse, reason: '$state 不是终态'); + } + } + }); + + test('典型非法路径被拒绝(状态机硬守护)', () { + // 不能跳过准备直接传。 + expect(canTransition(UploadTaskState.preparing, UploadTaskState.uploading), + isFalse); + // 不能从 queued 直接完成。 + expect( + canTransition(UploadTaskState.queued, UploadTaskState.completed), + isFalse); + // completed 是终态:不能复活。 + expect( + canTransition(UploadTaskState.completed, UploadTaskState.queued), + isFalse); + // cancelled 是终态。 + expect( + canTransition(UploadTaskState.cancelled, UploadTaskState.preparing), + isFalse); + // preparationFailed 只能回 preparing 或取消,不能直接入队。 + expect( + canTransition( + UploadTaskState.preparationFailed, UploadTaskState.queued), + isFalse); + }); + + test('wire 落库取值全枚举往返;未知值 fail-fast', () { + const wires = [ + 'preparing', + 'preparation_failed', + 'queued', + 'uploading', + 'uploaded_confirming', + 'completed', + 'waiting_network', + 'retryable_failed', + 'cancelled', + ]; + expect( + UploadTaskState.values.map((s) => s.wire).toSet(), + equals(wires.toSet()), + ); + for (final wire in wires) { + expect(UploadTaskState.fromWire(wire).wire, wire); + } + expect(() => UploadTaskState.fromWire('failed'), throwsFormatException); + expect(() => UploadTaskState.fromWire('UPLOADING'), throwsFormatException); + }); +} diff --git a/apps/mobile/test/features/uploads/task_store_test.dart b/apps/mobile/test/features/uploads/task_store_test.dart new file mode 100644 index 0000000..77e5f0d --- /dev/null +++ b/apps/mobile/test/features/uploads/task_store_test.dart @@ -0,0 +1,491 @@ +/// 上传任务库(TaskStore)测试(P3 计划 Task 5)。 +/// +/// 内存库(drift NativeDatabase.memory)驱动,覆盖: +/// - CRUD 与幂等键(clientTaskId 同账号同相册唯一,应用侧校验); +/// - 状态迁移守护(updateState 非法迁移拒绝且不落库); +/// - 账号隔离硬守护(读路径无条件 accountId 过滤); +/// - 列表筛选(相册/状态/时间)与 FIFO 排序; +/// - countsByState(队列 UI 徽章); +/// - 源文件保留策略(cleanupCandidates 仅终态+过期;未完成永不清理—— +/// 评审缺口 1 的硬守护测试); +/// - datetime 文本落库的亚秒精度。 +library; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; + +/// 可覆盖默认值的 draft 构造器。 +UploadTaskDraft draft({ + String id = 'task-0001', + String accountId = 'alice', + String albumId = 'alb-1', + String? categoryId, + String clientTaskId = 'ct-0001', + String sourceFilePath = '/managed/uploads/alb-1/task-0001.jpg', + String filename = 'IMG_0001.jpg', + int bytes = 1024, + String contentType = 'image/jpeg', +}) { + return UploadTaskDraft( + id: id, + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + clientTaskId: clientTaskId, + sourceFilePath: sourceFilePath, + filename: filename, + bytes: bytes, + contentType: contentType, + ); +} + +/// 将任务沿合法路径推进到目标状态(供 cleanup/counts 测试摆数据)。 +Future advanceTo( + TaskStore store, + String id, + UploadTaskState target, { + DateTime? at, +}) async { + var current = (await store.get('alice', id))!; + const path = >{ + UploadTaskState.queued: [UploadTaskState.queued], + UploadTaskState.uploading: [UploadTaskState.queued, UploadTaskState.uploading], + UploadTaskState.completed: [ + UploadTaskState.queued, + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + UploadTaskState.completed, + ], + UploadTaskState.cancelled: [UploadTaskState.cancelled], + UploadTaskState.waitingNetwork: [ + UploadTaskState.queued, + UploadTaskState.waitingNetwork, + ], + UploadTaskState.retryableFailed: [ + UploadTaskState.queued, + UploadTaskState.uploading, + UploadTaskState.retryableFailed, + ], + }; + for (final state in path[target]!) { + current = await store.updateState(id, state, now: at); + } + expect(current.state, target); + return current; +} + +void main() { + late TaskStore store; + + setUp(() { + store = TaskStore.memory(); + }); + + tearDown(() async { + await store.close(); + }); + + group('create(初始态与幂等键)', () { + test('初始状态 preparing、attempt=0、可空字段空、时间戳落库', () async { + final createdAt = DateTime.utc(2026, 9, 19, 1, 2, 3, 456); + final task = await store.create(draft(), now: createdAt); + + expect(task.state, UploadTaskState.preparing); + expect(task.attempt, 0); + expect(task.nextRetryAt, isNull); + expect(task.serverUploadId, isNull); + expect(task.serverPhotoId, isNull); + expect(task.lastError, isNull); + expect(task.cleanedAt, isNull); + expect(task.accountId, 'alice'); + expect(task.albumId, 'alb-1'); + expect(task.bytes, 1024); + expect(task.createdAt, createdAt); + expect(task.updatedAt, createdAt); + }); + + test('datetime 文本落库保住亚秒/微秒精度,且本地输入统一 UTC 落库', () async { + final stamp = DateTime.utc(2026, 9, 19, 8, 30, 15, 123, 456); + await store.create(draft(), now: stamp); + final task = await store.get('alice', 'task-0001'); + expect(task!.createdAt, stamp); // 完整相等而非秒级截断 + expect(task.updatedAt, stamp); + + // 本地时区输入(DateTime.now() 非 UTC):同一时刻、UTC 归一落库—— + // 文本存储下字典序即时间序,SQL 时间比较(since/cutoff)不因 + // `Z`/`+08:00` 后缀混存而错位。 + final local = DateTime.now(); + await store.create(draft(id: 'task-local', clientTaskId: 'ct-local'), now: local); + final normalized = await store.get('alice', 'task-local'); + expect(normalized!.createdAt.isUtc, isTrue); + expect(normalized.createdAt, local.toUtc()); + }); + + test('同账号同相册重复 clientTaskId 拒绝(应用侧唯一性)', () async { + await store.create(draft()); + await expectLater( + store.create(draft(id: 'task-0002')), + throwsA(isA()), + ); + // 唯一存在的仍是第一条。 + final tasks = await store.listByAccount('alice'); + expect(tasks, hasLength(1)); + expect(tasks.single.id, 'task-0001'); + }); + + test('幂等键作用域=账号+相册:换相册或换账号可复用同一 clientTaskId', () async { + await store.create(draft()); + await store.create( + draft(id: 'task-0002', albumId: 'alb-2'), + ); + await store.create( + draft(id: 'task-0003', accountId: 'bob'), + ); + expect(await store.countsByState('alice'), containsPair( + UploadTaskState.preparing, 2)); + expect(await store.countsByState('bob'), containsPair( + UploadTaskState.preparing, 1)); + }); + }); + + group('updateState(迁移守护 + 旁路字段)', () { + test('全生命周期推进:patch 写入 serverId/attempt/nextRetryAt/lastError 并可在成功时清除', () async { + await store.create(draft()); + final t0 = DateTime.utc(2026, 9, 19, 9); + + final queued = await store.updateState( + 'task-0001', + UploadTaskState.queued, + now: t0, + ); + expect(queued.state, UploadTaskState.queued); + expect(queued.updatedAt, t0); + + final uploading = await store.updateState( + 'task-0001', + UploadTaskState.uploading, + patch: UploadTaskPatch( + serverUploadId: 'upl-1', + serverPhotoId: 'pho-1', + ), + now: t0.add(const Duration(minutes: 1)), + ); + expect(uploading.serverUploadId, 'upl-1'); + expect(uploading.serverPhotoId, 'pho-1'); + + // 核验失败 → 重试路径:attempt+1、退避时间、错误码。 + final failed = await store.updateState( + 'task-0001', + UploadTaskState.retryableFailed, + patch: UploadTaskPatch( + attempt: 1, + nextRetryAt: t0.add(const Duration(minutes: 10)), + lastError: 'UPLOAD_VERIFY_FAILED', + ), + now: t0.add(const Duration(minutes: 2)), + ); + expect(failed.attempt, 1); + expect(failed.nextRetryAt, t0.add(const Duration(minutes: 10))); + expect(failed.lastError, 'UPLOAD_VERIFY_FAILED'); + + // 重试:retryableFailed → queued → uploading(合法路径)。 + await store.updateState( + 'task-0001', + UploadTaskState.queued, + now: t0.add(const Duration(minutes: 10)), + ); + await store.updateState( + 'task-0001', + UploadTaskState.uploading, + now: t0.add(const Duration(minutes: 11)), + ); + await store.updateState( + 'task-0001', + UploadTaskState.uploadedConfirming, + now: t0.add(const Duration(minutes: 12)), + ); + final done = await store.updateState( + 'task-0001', + UploadTaskState.completed, + patch: const UploadTaskPatch( + clearNextRetryAt: true, + clearLastError: true, + ), + now: t0.add(const Duration(minutes: 13)), + ); + expect(done.state, UploadTaskState.completed); + expect(done.nextRetryAt, isNull); + expect(done.lastError, isNull); + expect(done.cleanedAt, isNull, reason: '完成不等于已清理'); + }); + + test('非法迁移抛 StateError 且不落库(守护)', () async { + final createdAt = DateTime.utc(2026, 9, 19, 9); + await store.create(draft(), now: createdAt); + await expectLater( + store.updateState('task-0001', UploadTaskState.completed), + throwsA(isA()), + ); + final task = await store.get('alice', 'task-0001'); + expect(task!.state, UploadTaskState.preparing); + expect(task.updatedAt, createdAt, reason: '失败的迁移不得刷新 updatedAt'); + }); + + test('终态出边一律拒绝(completed 后不可复活)', () async { + await store.create(draft()); + await advanceTo(store, 'task-0001', UploadTaskState.completed); + await expectLater( + store.updateState('task-0001', UploadTaskState.queued), + throwsA(isA()), + ); + expect( + (await store.get('alice', 'task-0001'))!.state, + UploadTaskState.completed, + ); + }); + + test('不存在的 id 抛 StateError', () async { + await expectLater( + store.updateState('nope', UploadTaskState.queued), + throwsA(isA()), + ); + }); + }); + + group('账号隔离(硬守护)', () { + test('listByAccount/get/countsByState 均不可见他人任务', () async { + await store.create(draft(accountId: 'alice', clientTaskId: 'ct-a')); + await store.create( + draft(id: 'task-b', accountId: 'bob', clientTaskId: 'ct-b'), + ); + + final aliceTasks = await store.listByAccount('alice'); + expect(aliceTasks.map((t) => t.accountId), everyElement('alice')); + expect(aliceTasks, hasLength(1)); + + // get 跨账号视同不存在。 + expect(await store.get('alice', 'task-b'), isNull); + expect(await store.get('bob', 'task-0001'), isNull); + + // 徽章计数同样隔离。 + final bobCounts = await store.countsByState('bob'); + expect( + bobCounts.values.fold(0, (a, b) => a + b), + 1, + reason: 'bob 只应见到自己的 1 条', + ); + }); + + test('筛选组合不放松 accountId 过滤(相册/状态条件均在其内)', () async { + await store.create(draft(albumId: 'alb-1', clientTaskId: 'ct-1')); + await store.create( + draft(id: 'task-bob', accountId: 'bob', albumId: 'alb-1', clientTaskId: 'ct-1'), + ); + final onlyAlice = await store.listByAccount( + 'alice', + albumId: 'alb-1', + states: const {UploadTaskState.preparing}, + ); + expect(onlyAlice, hasLength(1)); + expect(onlyAlice.single.accountId, 'alice'); + }); + }); + + group('listByAccount(三维筛选 + FIFO)', () { + test('albumId 筛选', () async { + await store.create(draft(albumId: 'alb-1', clientTaskId: 'ct-1')); + await store.create( + draft(id: 'task-2', albumId: 'alb-2', clientTaskId: 'ct-2'), + ); + final tasks = + await store.listByAccount('alice', albumId: 'alb-2'); + expect(tasks.map((t) => t.albumId), everyElement('alb-2')); + expect(tasks, hasLength(1)); + }); + + test('states 筛选(集合内 IN)', () async { + await store.create(draft(clientTaskId: 'ct-1')); + await store.create(draft(id: 'task-2', clientTaskId: 'ct-2')); + await store.create(draft(id: 'task-3', clientTaskId: 'ct-3')); + await advanceTo(store, 'task-2', UploadTaskState.completed); + await advanceTo(store, 'task-3', UploadTaskState.cancelled); + + final active = await store.listByAccount( + 'alice', + states: {UploadTaskState.preparing, UploadTaskState.queued}, + ); + expect(active.map((t) => t.id), ['task-0001']); + + final terminal = await store.listByAccount( + 'alice', + states: {UploadTaskState.completed, UploadTaskState.cancelled}, + ); + expect(terminal.map((t) => t.id).toSet(), {'task-2', 'task-3'}); + }); + + test('since 时间筛选(含边界:createdAt == since 计入)', () async { + final t0 = DateTime.utc(2026, 9, 19, 10); + await store.create(draft(clientTaskId: 'ct-1'), now: t0); + await store.create( + draft(id: 'task-2', clientTaskId: 'ct-2'), + now: t0.add(const Duration(hours: 1)), + ); + final tasks = await store.listByAccount( + 'alice', + since: t0.add(const Duration(hours: 1)), + ); + expect(tasks.map((t) => t.id), ['task-2']); + }); + + test('按 createdAt 升序(队列 FIFO)+ limit', () async { + final t0 = DateTime.utc(2026, 9, 19, 10); + for (var i = 1; i <= 3; i++) { + await store.create( + draft( + id: 'task-$i', + clientTaskId: 'ct-$i', + ), + now: t0.add(Duration(minutes: i)), + ); + } + final tasks = await store.listByAccount('alice'); + expect(tasks.map((t) => t.id), ['task-1', 'task-2', 'task-3']); + expect( + (await store.listByAccount('alice', limit: 2)).map((t) => t.id), + ['task-1', 'task-2'], + ); + }); + }); + + group('countsByState(队列徽章)', () { + test('全状态键 + 无任务计 0 + 分组计数正确', () async { + await store.create(draft(clientTaskId: 'ct-1')); + await store.create(draft(id: 'task-2', clientTaskId: 'ct-2')); + await store.create(draft(id: 'task-3', clientTaskId: 'ct-3')); + await advanceTo(store, 'task-2', UploadTaskState.completed); + await advanceTo(store, 'task-3', UploadTaskState.waitingNetwork); + + final counts = await store.countsByState('alice'); + expect(counts.keys.toSet(), UploadTaskState.values.toSet(), + reason: '徽章需要全部状态键(含 0)'); + expect(counts[UploadTaskState.preparing], 1); + expect(counts[UploadTaskState.completed], 1); + expect(counts[UploadTaskState.waitingNetwork], 1); + expect(counts[UploadTaskState.uploading], 0); + expect(counts[UploadTaskState.cancelled], 0); + }); + }); + + group('源文件保留策略(评审缺口 1 硬守护)', () { + final now = DateTime.utc(2026, 9, 19, 12); + final ancient = DateTime.utc(2026, 8, 30, 12); // now - 20 天(超过保留期)。 + + test('cleanupCandidates:仅过期终态(completed+cancelled),新鲜与已清理的排除', () async { + await store.create( + draft(clientTaskId: 'ct-old-done'), + now: ancient, + ); + await advanceTo( + store, + 'task-0001', + UploadTaskState.completed, + at: ancient.add(const Duration(minutes: 5)), + ); + + await store.create( + draft(id: 'task-cancel', clientTaskId: 'ct-old-cancel'), + now: ancient, + ); + await advanceTo( + store, + 'task-cancel', + UploadTaskState.cancelled, + at: ancient.add(const Duration(minutes: 5)), + ); + + // 保留期内完成的任务。 + await store.create( + draft(id: 'task-fresh', clientTaskId: 'ct-fresh'), + now: now.subtract(const Duration(days: 2)), + ); + await advanceTo( + store, + 'task-fresh', + UploadTaskState.completed, + at: now.subtract(const Duration(days: 2)).add(const Duration(minutes: 5)), + ); + + var candidates = await store.cleanupCandidates(14, now: now); + expect(candidates.map((t) => t.id).toSet(), {'task-0001', 'task-cancel'}); + + // 标记清理后不再出现(幂等)。 + await store.markCleanable('task-0001', now: now); + candidates = await store.cleanupCandidates(14, now: now); + expect(candidates.map((t) => t.id), ['task-cancel']); + }); + + test('硬守护:过期但未完成的任务永不出现在清理候选(全部非终态)', () async { + // 各非终态摆一条"古老"任务(updatedAt 均早于保留期界)。 + await store.create(draft(clientTaskId: 'ct-p'), now: ancient); + await store.create(draft(id: 'task-q', clientTaskId: 'ct-q'), now: ancient); + await advanceTo(store, 'task-q', UploadTaskState.queued, at: ancient); + await store.create(draft(id: 'task-u', clientTaskId: 'ct-u'), now: ancient); + await advanceTo(store, 'task-u', UploadTaskState.uploading, at: ancient); + await store.create(draft(id: 'task-w', clientTaskId: 'ct-w'), now: ancient); + await advanceTo( + store, 'task-w', UploadTaskState.waitingNetwork, at: ancient); + await store.create(draft(id: 'task-r', clientTaskId: 'ct-r'), now: ancient); + await advanceTo( + store, 'task-r', UploadTaskState.retryableFailed, at: ancient); + await store.create(draft(id: 'task-pf', clientTaskId: 'ct-pf'), now: ancient); + await store.updateState( + 'task-pf', + UploadTaskState.preparationFailed, + now: ancient, + ); + // uploading 停在核验中的古老任务。 + await store.create(draft(id: 'task-uc', clientTaskId: 'ct-uc'), now: ancient); + await advanceTo( + store, + 'task-uc', + UploadTaskState.uploading, + at: ancient, + ); + await store.updateState( + 'task-uc', + UploadTaskState.uploadedConfirming, + now: ancient, + ); + + final candidates = await store.cleanupCandidates(14, now: now); + expect(candidates, isEmpty, + reason: '全部为未完成的古老任务,绝不进入清理候选'); + }); + + test('markCleanable:非终态拒绝(守护另一面)且不落 cleanedAt', () async { + await store.create(draft()); + await expectLater( + store.markCleanable('task-0001'), + throwsA(isA()), + ); + expect((await store.get('alice', 'task-0001'))!.cleanedAt, isNull); + }); + + test('markCleanable:终态可标记且幂等;delete 移除记录', () async { + await store.create(draft()); + await advanceTo(store, 'task-0001', UploadTaskState.completed); + final markAt = DateTime.utc(2026, 9, 20); + await store.markCleanable('task-0001', now: markAt); + expect((await store.get('alice', 'task-0001'))!.cleanedAt, markAt); + // 幂等:重复标记只刷新时间。 + final markAgain = markAt.add(const Duration(days: 1)); + await store.markCleanable('task-0001', now: markAgain); + expect((await store.get('alice', 'task-0001'))!.cleanedAt, markAgain); + + expect(await store.delete('task-0001'), 1); + expect(await store.get('alice', 'task-0001'), isNull); + expect(await store.delete('task-0001'), 0, reason: '重复删除返回 0'); + }); + }); +} diff --git a/docs/plans/2026-09-18-P3-Flutter-App.md b/docs/plans/2026-09-18-P3-Flutter-App.md index e56cd07..6b53628 100644 --- a/docs/plans/2026-09-18-P3-Flutter-App.md +++ b/docs/plans/2026-09-18-P3-Flutter-App.md @@ -44,8 +44,8 @@ ## Task 5: 上传域模型与本地任务库(drift) -- [ ] drift schema:upload_tasks(id uuid、accountId 隔离键、albumId、categoryId、clientTaskId、源文件路径(受管目录 copy)、filename/bytes/contentType、state 状态机 preparing/preparation_failed/queued/uploading/uploaded-confirming/completed/waiting_network/retryable_failed/cancelled、attempt/nextRetryAt、serverSessionId/photoId、createdAt/updatedAt、账号切换过滤=accountId 索引) -- [ ] 状态机单测(合法迁移表驱动;账号隔离查询;任务保留策略=completed 仅可清理标记) +- [x] drift schema:upload_tasks(id uuid、accountId 隔离键、albumId、categoryId、clientTaskId、源文件路径(受管目录 copy)、filename/bytes/contentType、state 状态机 preparing/preparation_failed/queued/uploading/uploaded-confirming/completed/waiting_network/retryable_failed/cancelled、attempt/nextRetryAt、serverSessionId/photoId、createdAt/updatedAt、账号切换过滤=accountId 索引) +- [x] 状态机单测(合法迁移表驱动;账号隔离查询;任务保留策略=completed 仅可清理标记) - [ ] Commit `feat(mobile): drift upload task store with state machine` ## Task 6: 上传引擎(Dart 侧)