From 3ddb0ea2d483c806f6e46b1bfc53c3aef4324f31 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B5=85=E5=BF=B5?= Date: Sat, 19 Sep 2026 02:50:40 +0800 Subject: [PATCH] feat(mobile): upload engine with preparation, batching states and retries Co-Authored-By: Claude Code --- apps/mobile/lib/app/app.dart | 2 + apps/mobile/lib/core/device_id.dart | 91 ++ apps/mobile/lib/features/uploads/models.dart | 16 +- .../mobile/lib/features/uploads/preparer.dart | 306 ++++++ .../lib/features/uploads/providers.dart | 43 + .../lib/features/uploads/task_store.dart | 54 +- .../lib/features/uploads/upload_engine.dart | 602 ++++++++++++ apps/mobile/lib/l10n/app_en.arb | 12 +- apps/mobile/lib/l10n/app_localizations.dart | 60 ++ .../mobile/lib/l10n/app_localizations_en.dart | 32 + .../mobile/lib/l10n/app_localizations_zh.dart | 30 + apps/mobile/lib/l10n/app_zh.arb | 12 +- apps/mobile/lib/pages/home_page.dart | 28 +- .../mobile/lib/pages/server_history_page.dart | 340 +++++++ apps/mobile/pubspec.lock | 56 ++ apps/mobile/pubspec.yaml | 2 + apps/mobile/test/core/device_id_test.dart | 119 +++ .../test/features/uploads/preparer_test.dart | 258 ++++++ .../features/uploads/task_store_test.dart | 53 ++ .../features/uploads/upload_engine_test.dart | 875 ++++++++++++++++++ .../test/features/uploads_models_test.dart | 50 + apps/mobile/test/helpers/fakes.dart | 4 + .../test/pages/server_history_page_test.dart | 199 ++++ 23 files changed, 3232 insertions(+), 12 deletions(-) create mode 100644 apps/mobile/lib/core/device_id.dart create mode 100644 apps/mobile/lib/features/uploads/preparer.dart create mode 100644 apps/mobile/lib/features/uploads/providers.dart create mode 100644 apps/mobile/lib/features/uploads/upload_engine.dart create mode 100644 apps/mobile/lib/pages/server_history_page.dart create mode 100644 apps/mobile/test/core/device_id_test.dart create mode 100644 apps/mobile/test/features/uploads/preparer_test.dart create mode 100644 apps/mobile/test/features/uploads/upload_engine_test.dart create mode 100644 apps/mobile/test/pages/server_history_page_test.dart diff --git a/apps/mobile/lib/app/app.dart b/apps/mobile/lib/app/app.dart index 0ae07a6..c67100f 100644 --- a/apps/mobile/lib/app/app.dart +++ b/apps/mobile/lib/app/app.dart @@ -6,6 +6,7 @@ import '../l10n/app_localizations.dart'; import '../pages/home_page.dart'; import '../pages/login_page.dart'; import '../pages/password_change_page.dart'; +import '../pages/server_history_page.dart'; import 'locale_controller.dart'; import 'theme.dart'; @@ -51,6 +52,7 @@ class _PhotoLiveAppState extends ConsumerState { '/login': (context) => const LoginPage(), '/home': (context) => const HomePage(), '/password-change': (context) => const PasswordChangePage(), + '/server-history': (context) => const ServerHistoryPage(), }, home: const LoginPage(), ); diff --git a/apps/mobile/lib/core/device_id.dart b/apps/mobile/lib/core/device_id.dart new file mode 100644 index 0000000..3b2f037 --- /dev/null +++ b/apps/mobile/lib/core/device_id.dart @@ -0,0 +1,91 @@ +/// 设备标识(P3 计划 Task 6,A19 设备来源语义)。 +/// +/// 每安装一次生成一个随机 32 位十六进制串(16 字节 `Random.secure`), +/// 持久化后跨进程重启稳定;随 `initUpload` 上报为可选 sourceDeviceId, +/// 服务端历史(GET /uploads)据此区分上传来源设备。 +/// +/// 非机密(不可当作设备指纹做安全决策,服务端仅作展示归属),明文存 +/// shared_preferences 足够——不占用 secure storage。卸载重装视为新设备 +/// (per-install 语义,符合「客户端首次生成并持久化」的契约描述)。 +library; + +import 'dart:math'; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// 存储抽象(测试注入内存实现;生产=shared_preferences)。 +abstract class DeviceIdStore { + Future read(); + Future write(String value); +} + +/// shared_preferences 生产实现(单键)。 +class SharedPrefsDeviceIdStore implements DeviceIdStore { + const SharedPrefsDeviceIdStore(); + + static const String kKey = 'device.id'; + + @override + Future read() async { + final prefs = await SharedPreferences.getInstance(); + return prefs.getString(kKey); + } + + @override + Future write(String value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(kKey, value); + } +} + +/// 设备标识:懒初始化 + 进程内缓存(并发调用共享首次生成结果)。 +class DeviceId { + DeviceId({DeviceIdStore? store, Random? random}) + : _store = store ?? const SharedPrefsDeviceIdStore(), + _random = random ?? Random.secure(); + + final DeviceIdStore _store; + final Random _random; + + Future? _pending; + + /// 合法取值形状:32 位小写十六进制。 + static final RegExp kPattern = RegExp(r'^[0-9a-f]{32}$'); + + /// 当前设备标识(首次调用生成并持久化;后续读缓存)。 + Future value() => _pending ??= _loadOrCreate(); + + Future _loadOrCreate() async { + String? stored; + try { + stored = await _store.read(); + } on Object { + // 存储读取失败按未生成处理:重新生成并尽力写回。 + } + if (stored != null && kPattern.hasMatch(stored)) { + return stored; + } + final generated = _generate(); + try { + await _store.write(generated); + } on Object { + // 写回失败不阻断:本次进程内仍可用(下次启动重新生成—— + // sourceDeviceId 可选,服务端容忍 null/变更)。 + } + return generated; + } + + /// 16 随机字节 → 32 位小写十六进制。 + String _generate() { + final bytes = List.generate(16, (_) => _random.nextInt(256)); + return hexEncode(bytes); + } + + /// 字节 → 小写十六进制(独立函数,单测直接覆盖)。 + static String hexEncode(List bytes) => + bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(); +} + +/// 设备标识 Provider(全局单例语义:整个 App 共享同一 per-install 值)。 +final deviceIdProvider = Provider((ref) => DeviceId()); diff --git a/apps/mobile/lib/features/uploads/models.dart b/apps/mobile/lib/features/uploads/models.dart index 993953a..d885729 100644 --- a/apps/mobile/lib/features/uploads/models.dart +++ b/apps/mobile/lib/features/uploads/models.dart @@ -81,8 +81,9 @@ class UploadCredential { /// `initUpload`,POST /uploads):幂等初始化——相同 clientTaskId 且参数 /// 一致时返回原会话,参数不一致 409(UPLOAD_SESSION_CONFLICT)。 /// -/// 注:契约增补(P3 计划 Task 3)将顺带加入可选 sourceDeviceId -/// (A19 设备来源语义);当前以契约现状为准,不含该字段。 +/// sourceDeviceId 为 P3 Task 3 契约增补(已落地):客户端首次生成并 +/// 持久的设备标识(A19 设备来源语义,见 core/device_id.dart);可选, +/// 同一 clientTaskId 幂等重放时服务端保留首次落库的值。 class UploadInitRequest { const UploadInitRequest({ required this.albumId, @@ -91,6 +92,7 @@ class UploadInitRequest { required this.bytes, required this.contentType, this.categoryId, + this.sourceDeviceId, }); /// 目标相册 ID。 @@ -111,6 +113,9 @@ class UploadInitRequest { /// MIME 类型(白名单 image/jpeg | image/png | image/heic)。 final String contentType; + /// 发起设备标识(可选,minLength 8 / maxLength 64;A19)。 + final String? sourceDeviceId; + Map toJson() => { 'albumId': albumId, if (categoryId != null) 'categoryId': categoryId, @@ -118,6 +123,7 @@ class UploadInitRequest { 'filename': filename, 'bytes': bytes, 'contentType': contentType, + if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId, }; } @@ -243,6 +249,7 @@ class UploadSessionRecord { required this.contentType, required this.createdAt, required this.photo, + this.sourceDeviceId, }); factory UploadSessionRecord.fromJson(Map json) => @@ -257,6 +264,7 @@ class UploadSessionRecord { photo: UploadSessionPhoto.fromJson( json['photo'] as Map, ), + sourceDeviceId: json['sourceDeviceId'] as String?, ); /// 上传会话 ID。 @@ -280,6 +288,9 @@ class UploadSessionRecord { /// 会话创建时间。 final DateTime createdAt; + /// 发起设备标识(init 时未携带为 null;P3 Task 3 契约增补,A19)。 + final String? sourceDeviceId; + /// 会话关联照片的当前状态。 final UploadSessionPhoto photo; @@ -291,6 +302,7 @@ class UploadSessionRecord { 'expectedBytes': expectedBytes, 'contentType': contentType, 'createdAt': createdAt.toUtc().toIso8601String(), + if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId, 'photo': photo.toJson(), }; } diff --git a/apps/mobile/lib/features/uploads/preparer.dart b/apps/mobile/lib/features/uploads/preparer.dart new file mode 100644 index 0000000..01f19db --- /dev/null +++ b/apps/mobile/lib/features/uploads/preparer.dart @@ -0,0 +1,306 @@ +/// 上传准备阶段(P3 计划 Task 6)。 +/// +/// 选图(image_picker)或连拍(camera)得到的源文件 → 复制到受管目录 +/// (app 支持目录/uploads/{accountId}/{albumId}/{taskId}{ext})→ stat 字节 +/// → MIME 嗅探(扩展名映射 + jpg/png 魔数校验)→ 尺寸上限本地预检 → +/// 入队 queued。任一步失败 → 独立状态 preparationFailed(可重试,见 +/// [UploadPreparer.retry]),lastError 携带客户端伪码([PrepErrorCodes])。 +/// +/// 设计取舍(对齐计划 Task 6 评审口径): +/// - **串行执行**:文件复制是最容易造成 IO 风暴的环节(连拍场景一次 +/// 落盘多张),队列化逐张处理;传输并发由 UploadEngine 另行控制; +/// - **磁盘空间不显式预检**:引入 disk_space 插件成本(新依赖 + 平台 +/// 误差)高于收益——复制失败本身即失败信号(ENOSPC 走 copy 失败分支 +/// → preparationFailed,用户可见可重试)。此为有意裁剪,登记为计划 +/// 偏差; +/// - **本地尺寸上限**:客户端无法得知服务端 UPLOAD_MAX 环境变量,取 +/// 保守常量 50MB 预检(超限直接 preparationFailed,不浪费复制与传输); +/// 界于本地上限与服务端真实上限之间的文件由服务端 400 兜底。 +library; + +import 'dart:async'; +import 'dart:io'; +import 'dart:math'; + +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../core/device_id.dart'; +import 'task_state.dart'; +import 'task_store.dart'; + +/// 准备阶段失败伪码(客户端本地,契约外;lastError 展示用, +/// UI 层映射本地化文案)。 +abstract final class PrepErrorCodes { + /// 源文件不可读(已被清理/权限/不存在)。 + static const String sourceUnreadable = 'PREP_SOURCE_UNREADABLE'; + + /// MIME 不在白名单(jpg/png/heic 之外)。 + static const String unsupportedType = 'PREP_UNSUPPORTED_TYPE'; + + /// 超过本地尺寸上限(50MB 预检)。 + static const String tooLarge = 'PREP_TOO_LARGE'; + + /// 复制到受管目录失败(含磁盘满/复制期间源被改动)。 + static const String copyFailed = 'PREP_COPY_FAILED'; +} + +/// 上传准备器:入队唯一入口(队列 UI/连拍页调用;引擎消费产出)。 +/// +/// 所有操作串行(连拍入队逐张复制,避免 IO 风暴,见库级注释)。 +class UploadPreparer { + UploadPreparer( + this._store, { + Future Function()? baseDirProvider, + Random? random, + this.maxBytes = kMaxUploadBytes, + this.onEnqueued, + }) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory, + _random = random ?? Random.secure(); + + final TaskStore _store; + final Future Function() _baseDirProvider; + final Random _random; + + /// 本地尺寸预检上限(默认 [kMaxUploadBytes];测试可注入小值)。 + final int maxBytes; + + /// 本地尺寸预检上限(50MB;服务端 UPLOAD_MAX 由 400 兜底,见库级注释)。 + static const int kMaxUploadBytes = 50 * 1024 * 1024; + + /// 入队成功回调(唤醒传输引擎立即出队)。 + final void Function()? onEnqueued; + + /// 串行队列尾部。 + Future _tail = Future.value(); + + /// 准备一张图片并入队。 + /// + /// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内 + /// 可直接 File 读取);[filename] 缺省取源文件 basename。 + /// + /// 返回入队后的任务(state=queued);失败返回 state=preparationFailed + /// 的任务(不抛异常——准备失败是业务状态而非调用错误)。 + Future prepare({ + required String accountId, + required String albumId, + String? categoryId, + required String sourcePath, + String? filename, + }) { + return _serialize(() => _prepare( + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + sourcePath: sourcePath, + filename: filename, + )); + } + + /// 重试准备失败的任务(preparationFailed → preparing → 复制 → queued)。 + /// + /// 源文件原始路径不落库(任务表仅存受管副本路径),调用方(队列 UI) + /// 需重新提供 [sourcePath](重新选图,或相机缓存仍存时直传)。 + Future retry({ + required String accountId, + required String taskId, + required String sourcePath, + }) { + return _serialize(() => _retry( + accountId: accountId, + taskId: taskId, + sourcePath: sourcePath, + )); + } + + /// 串行化:所有准备操作首尾相接(错误吞掉以不断链,错误经返回值上抛)。 + Future _serialize(Future Function() op) { + final result = _tail.then((_) => op()); + _tail = result.then((_) {}, onError: (Object _) {}); + return result; + } + + Future _prepare({ + required String accountId, + required String albumId, + required String? categoryId, + required String sourcePath, + required String? filename, + }) async { + final id = _newId(); + final clientTaskId = _newId(); + final ext = _normalizeExt(sourcePath); + final contentType = await _sniffContentType(sourcePath, ext); + final bytes = await _statBytes(sourcePath); + + // 快速失败预检:类型不在白名单 / 源不可读(bytes=0)/ 超本地上限。 + final failCode = _preCheck(contentType: contentType, bytes: bytes); + final dir = await _managedDir(accountId, albumId); + final targetPath = p.join(dir.path, '$id$ext'); + + final task = await _store.create( + UploadTaskDraft( + id: id, + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + clientTaskId: clientTaskId, + sourceFilePath: targetPath, + filename: filename ?? p.basename(sourcePath), + // 预检失败时占位(该行永不进入传输;成功路径 bytes 来自源 stat)。 + bytes: bytes > 0 ? bytes : 1, + contentType: contentType ?? 'application/octet-stream', + ), + ); + + if (failCode != null) { + return _store.updateState( + id, + UploadTaskState.preparationFailed, + patch: UploadTaskPatch(lastError: failCode), + ); + } + + return _copyAndQueue(task, sourcePath: sourcePath); + } + + Future _retry({ + required String accountId, + required String taskId, + required String sourcePath, + }) async { + final task = await _store.get(accountId, taskId); + if (task == null) { + throw StateError('上传任务不存在:$taskId'); + } + if (task.state != UploadTaskState.preparationFailed) { + throw StateError('仅 preparationFailed 可重试准备(当前:${task.state})'); + } + final preparing = await _store.updateState( + taskId, + UploadTaskState.preparing, + patch: const UploadTaskPatch(clearLastError: true), + ); + return _copyAndQueue(preparing, sourcePath: sourcePath); + } + + /// 复制到受管路径并入队;复制失败 → preparationFailed(返回,不抛)。 + Future _copyAndQueue( + UploadTask task, { + required String sourcePath, + }) async { + final source = File(sourcePath); + final target = File(task.sourceFilePath); + try { + final expected = await source.length(); + if (expected <= 0) { + throw StateError('empty source'); + } + await target.parent.create(recursive: true); + await source.copy(task.sourceFilePath); + final copied = await target.length(); + if (copied != expected) { + // 源在复制期间被改动(相机流水写入中)——按复制失败处理, + // 用户可重试。 + if (await target.exists()) { + await target.delete(); + } + throw StateError('copy size mismatch'); + } + // 声明值与副本对齐(重试换源场景下行内 bytes 可能是占位值; + // TaskStore.updateMeta 旁路写入,不迁移状态)。 + if (copied != task.bytes) { + await _store.updateMeta(task.id, bytes: copied); + } + } on Object { + return _store.updateState( + task.id, + UploadTaskState.preparationFailed, + patch: const UploadTaskPatch(lastError: PrepErrorCodes.copyFailed), + ); + } + final queued = await _store.updateState( + task.id, + UploadTaskState.queued, + patch: const UploadTaskPatch(clearLastError: true), + ); + onEnqueued?.call(); + return queued; + } + + /// 快速预检:返回失败伪码(null=通过)。 + String? _preCheck({required String? contentType, required int bytes}) { + if (bytes <= 0) return PrepErrorCodes.sourceUnreadable; + if (contentType == null) return PrepErrorCodes.unsupportedType; + if (bytes > maxBytes) return PrepErrorCodes.tooLarge; + return null; + } + + /// MIME 嗅探:魔数优先(jpg/png),回退扩展名映射(含无魔数的 heic)。 + /// + /// 返回 null=无法确定白名单内类型(调用方按 unsupportedType 失败)。 + Future _sniffContentType(String sourcePath, String ext) async { + try { + final head = await File(sourcePath).openRead(0, 8).first; + if (head.length >= 3 && + head[0] == 0xFF && + head[1] == 0xD8 && + head[2] == 0xFF) { + return 'image/jpeg'; + } + if (head.length >= 4 && + head[0] == 0x89 && + head[1] == 0x50 && + head[2] == 0x4E && + head[3] == 0x47) { + return 'image/png'; + } + } on Object { + // 魔数读取失败(源不存在/权限):交由 _statBytes→sourceUnreadable。 + } + return _contentTypeByExt(ext); + } + + /// 扩展名 → 白名单 MIME(未知扩展返回 null)。 + static String? _contentTypeByExt(String ext) { + switch (ext) { + case '.jpg': + case '.jpeg': + return 'image/jpeg'; + case '.png': + return 'image/png'; + case '.heic': + case '.heif': + return 'image/heic'; + } + return null; + } + + /// stat 源字节数;不可读返回 0(触发 sourceUnreadable 预检失败)。 + Future _statBytes(String sourcePath) async { + try { + return await File(sourcePath).length(); + } on Object { + return 0; + } + } + + /// 归一小写扩展名(含点;无扩展返回空串)。 + static String _normalizeExt(String sourcePath) => + p.extension(sourcePath).toLowerCase(); + + /// 受管目录:{base}/uploads/{accountId}/{albumId}/。 + Future _managedDir(String accountId, String albumId) async { + final base = await _baseDirProvider(); + final dir = Directory(p.join(base.path, 'uploads', accountId, albumId)); + await dir.create(recursive: true); + return dir; + } + + /// 32 位十六进制随机 ID(任务 id 与 clientTaskId 各一次生成, + /// 与 core/device_id.dart 同一编码方式)。 + String _newId() { + final bytes = List.generate(16, (_) => _random.nextInt(256)); + return DeviceId.hexEncode(bytes); + } +} diff --git a/apps/mobile/lib/features/uploads/providers.dart b/apps/mobile/lib/features/uploads/providers.dart new file mode 100644 index 0000000..6630745 --- /dev/null +++ b/apps/mobile/lib/features/uploads/providers.dart @@ -0,0 +1,43 @@ +/// 上传域 Provider 装配(P3 计划 Task 6)。 +/// +/// 生产装配:TaskStore.app()(documents/upload_tasks.sqlite)+ ApiClient +/// (Bearer 自动注入)+ DeviceId(shared_preferences)。引擎生命周期 +/// (start(accountId)/stop,随登录态切换)由 App 壳/队列 UI 驱动—— +/// Task 9 队列 UI 落地时接线;本文件保证依赖图就绪。 +library; + +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../core/api_client.dart'; +import '../../core/device_id.dart'; +import 'preparer.dart'; +import 'task_store.dart'; +import 'upload_engine.dart'; + +/// 任务库(生产:app documents dir 持久库;测试 override 为 memory)。 +final taskStoreProvider = Provider((ref) { + final store = TaskStore.app(); + ref.onDispose(store.close); + return store; +}); + +/// 传输引擎(裸 Dio 直传 presign;并行/保留期配置见 upload_engine.dart +/// 的 dart-define 常量)。 +final uploadEngineProvider = Provider((ref) { + final engine = UploadEngine( + ref.watch(taskStoreProvider), + ref.watch(apiClientProvider), + ref.watch(deviceIdProvider), + ); + ref.onDispose(engine.dispose); + return engine; +}); + +/// 准备器(入队回调唤醒引擎立即出队)。 +final uploadPreparerProvider = Provider((ref) { + final engine = ref.watch(uploadEngineProvider); + return UploadPreparer( + ref.watch(taskStoreProvider), + onEnqueued: engine.notifyWorkAvailable, + ); +}); diff --git a/apps/mobile/lib/features/uploads/task_store.dart b/apps/mobile/lib/features/uploads/task_store.dart index d9df44b..d34c19b 100644 --- a/apps/mobile/lib/features/uploads/task_store.dart +++ b/apps/mobile/lib/features/uploads/task_store.dart @@ -292,6 +292,10 @@ class TaskStore { /// 状态迁移(带合法迁移守护 + updatedAt 刷新),返回迁移后的任务。 /// + /// patch 语义(与 [UploadTaskPatch] 文档一致):**未提供的列保持不变**, + /// 显式清除走 clear* 布尔;serverUploadId/serverPhotoId 仅在提供时 + /// 写入(write-once 回填,无清除通道)。 + /// /// 抛 [StateError]:任务不存在,或 `当前 → to` 不在合法迁移表 /// ([assertTransition])。 Future updateState( @@ -313,12 +317,54 @@ class TaskStore { : Value(patch.attempt!), nextRetryAt: patch.clearNextRetryAt ? const Value(null) - : Value(patch.nextRetryAt?.toUtc()), - serverUploadId: Value(patch.serverUploadId), - serverPhotoId: Value(patch.serverPhotoId), + : patch.nextRetryAt == null + ? const Value.absent() + : Value(patch.nextRetryAt!.toUtc()), + serverUploadId: patch.serverUploadId == null + ? const Value.absent() + : Value(patch.serverUploadId), + serverPhotoId: patch.serverPhotoId == null + ? const Value.absent() + : Value(patch.serverPhotoId), lastError: patch.clearLastError ? const Value(null) - : Value(patch.lastError), + : patch.lastError == null + ? const Value.absent() + : Value(patch.lastError), + updatedAt: Value(_utcAt(now)), + ), + ); + return await _byId(id); + }); + } + + /// 旁路字段回填(**不迁移状态**):引擎在 initUpload 成功后写 + /// serverUploadId/serverPhotoId——此时状态已为 uploading,无合法自迁移, + /// 需绕过状态机直接回填(仅刷新 updatedAt)。bytes 供准备器重试换源后 + /// 对齐声明值(副本 stat 与行内声明可能有历史偏差)。 + /// + /// 提供的字段写入,未提供的保持不变(与 [updateState] 的 patch 语义 + /// 一致)。 + Future updateMeta( + String id, { + String? serverUploadId, + String? serverPhotoId, + int? bytes, + DateTime? now, + }) { + return _db.transaction(() async { + await _byId(id); + await (_db.update(_db.uploadTasks) + ..where((t) => t.id.equals(id))) + .write( + UploadTasksCompanion( + serverUploadId: serverUploadId == null + ? const Value.absent() + : Value(serverUploadId), + serverPhotoId: serverPhotoId == null + ? const Value.absent() + : Value(serverPhotoId), + bytes: bytes == null ? const Value.absent() : Value(bytes), updatedAt: Value(_utcAt(now)), ), ); diff --git a/apps/mobile/lib/features/uploads/upload_engine.dart b/apps/mobile/lib/features/uploads/upload_engine.dart new file mode 100644 index 0000000..eb13a14 --- /dev/null +++ b/apps/mobile/lib/features/uploads/upload_engine.dart @@ -0,0 +1,602 @@ +/// 上传传输引擎 + 源文件清理执行器(P3 计划 Task 6)。 +/// +/// 架构(对齐技术实施方案 §5.5): +/// - **驱动**:周期 timer + 工作可用通知([notifyWorkAvailable],准备器 +/// 入队时挂接)双触发;每次唤醒执行一轮调度([drain])——补足并行 +/// 配额([kMaxParallelUploads],dart-define MAX_PARALLEL_UPLOADS 覆盖); +/// - **出队**:账号隔离(TaskStore 读路径硬守护)内取最旧就绪任务: +/// queued 恒就绪;waitingNetwork/retryableFailed 需 nextRetryAt 到期 +/// (retryableFailed 的 nextRetryAt=null 表示自动重试上限已到/需人工 +/// 介入,永不自动出队);网络恢复([onNetworkRestored])对 +/// waitingNetwork 立即绕过退避; +/// - **传输三步**:initUpload(幂等,clientTaskId + sourceDeviceId)→ +/// presign PUT(dio 流式 + 进度回调 + 取消令牌;**裸 Dio**——不挂 +/// ApiClient 拦截器,避免 Bearer 头污染 MinIO 预签名签名)→ +/// completeUpload(幂等核验)→ completed; +/// - **失败分类**([UploadEngineSourceMissing] 之外见 [_classifyFailure]): +/// | 错误 | 目标状态 | 自动重试 | +/// |---|---|---| +/// | 网络不可达/超时/429 限速 | waitingNetwork | 退避 5s·2^n 封顶 10min,无次数上限 | +/// | 401 会话过期 | waitingNetwork | 30s 短退避;全局驱逐由 ApiClient 处理,不烧次数 | +/// | 400 UPLOAD_VERIFY_FAILED / 5xx / 未知 | retryableFailed | 退避重试,3 次后停止自动重试 | +/// | 409 ALBUM_UPLOAD_DISABLED / UPLOAD_SESSION_CONFLICT / 源文件缺失 | retryableFailed | 不自动重试(人工修正后 retryNow) | +/// | presign 403 凭证过期 | (renew 后原状重传) | renewUpload 至多 2 次 | +/// - **取消**([cancel]):未开始 → cancelled 后删行删文件(确定语义); +/// 进行中 → cancelToken 尽力取消 + 置 cancelled(文件保留,由清理 +/// 执行器按保留期回收); +/// - **崩溃恢复**:进程被杀时卡在 uploading/uploadedConfirming 且不在 +/// in-flight 表内的任务,每轮 drain 前归位 waitingNetwork 立即重试 +/// (initUpload/completeUpload 幂等,重传安全;A05 模拟器级在 Task 11 +/// 集成验证); +/// - **A15 连拍归属不变**:引擎只读任务行的 albumId/categoryId(无任何 +/// 写通道),入队后相册/分类切换不影响既有任务; +/// - **清理执行器**([cleanup],随每轮 drain 与 App 启动执行):仅终态 +/// (completed/cancelled,TaskStore 硬守护——未完成源文件永不清理)且 +/// 超过 KEEP_COMPLETED_DAYS 保留期的源文件删除 + cleanedAt 标记 +/// (行保留作记录)。 +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; + +import '../../core/api_client.dart'; +import '../../core/app_exception.dart'; +import '../../core/device_id.dart'; +import '../../core/error_codes.dart'; +import 'models.dart'; +import 'task_state.dart'; +import 'task_store.dart'; + +/// 并行传输上限(计划:批量并发为可配置小值;dart-define +/// MAX_PARALLEL_UPLOADS 覆盖)。 +const int kMaxParallelUploads = + int.fromEnvironment('MAX_PARALLEL_UPLOADS', defaultValue: 2); + +/// completed/cancelled 任务源文件保留天数(dart-define +/// KEEP_COMPLETED_DAYS 覆盖)。 +const int kKeepCompletedDays = + int.fromEnvironment('KEEP_COMPLETED_DAYS', defaultValue: 14); + +/// 网络类失败的基础退避(5s·2^(attempt-1),封顶 10min)。 +const Duration kBackoffBase = Duration(seconds: 5); + +/// 退避封顶。 +const Duration kBackoffCap = Duration(minutes: 10); + +/// retryableFailed 自动重试次数上限(UPLOAD_VERIFY_FAILED 等;到达后 +/// 停止自动重试,等人工 [UploadEngine.retryNow])。 +const int kMaxAutoRetryAttempts = 3; + +/// presign 凭证过期(403)的最多元数(renewUpload 次数)。 +const int kMaxCredentialRenewals = 2; + +/// 传输引擎。 +/// +/// 事件驱动 + 周期兜底;不 own isolate(传输多为 IO 等待,UI isolate +/// 足够;Android 后台延续由 Task 7 原生桥接承担)。 +class UploadEngine { + UploadEngine( + this._store, + this._apiClient, + this._deviceId, { + Dio? objectDio, + DateTime Function()? now, + this.maxParallel = kMaxParallelUploads, + this.keepCompletedDays = kKeepCompletedDays, + this.onProgress, + this.onError, + }) : _objectDio = objectDio ?? Dio(), + _now = now ?? DateTime.now; + + final TaskStore _store; + final ApiClient _apiClient; + final DeviceId _deviceId; + + /// presign PUT 专用裸 Dio(无鉴权拦截器,见库级注释)。 + final Dio _objectDio; + final DateTime Function() _now; + + /// 并行传输上限(运行时 <1 钳位为 1,见 [_effectiveParallel])。 + final int maxParallel; + + int get _effectiveParallel => maxParallel < 1 ? 1 : maxParallel; + + /// 源文件保留天数(清理执行器)。 + final int keepCompletedDays; + + /// 传输进度回调(taskId, 已发字节, 总字节;队列 UI 进度条挂接点)。 + final void Function(String taskId, int sent, int total)? onProgress; + + /// 引擎级异常观察(未预期中断性错误;分类内失败不经此处)。 + final void Function(String taskId, Object error)? onError; + + /// 周期唤醒 timer([start] 创建,[stop] 取消;测试不 start,直接 + /// 驱动 [drain]/[settle])。 + Timer? _timer; + + /// 当前引擎服务的账号(出队范围;账号切换=重新 [start])。 + String? _accountId; + + /// in-flight 任务表(崩溃恢复的「不在表内」判据 + 防同任务双发)。 + final Map _inFlight = {}; + + /// drain 进行中Future(并发调用折叠为同轮——所有 await 方都等到完整 + /// 调度结束;结束后清空)。 + Future? _drainPending; + + /// 网络恢复信号:下一轮 drain 对 waitingNetwork 绕过退避(一次性)。 + bool _bypassBackoffOnce = false; + + /// 源文件缺失伪码(客户端本地;受管副本被系统清理等,不可恢复)。 + static const String sourceMissingCode = 'UPLOAD_SOURCE_MISSING'; + + /// 启动引擎(账号切换时重复调用即换 scope;在途传输不被中断,自然 + /// 收尾后不再出队旧账号任务)。 + void start(String accountId, + {Duration period = const Duration(seconds: 15)}) { + _accountId = accountId; + _timer?.cancel(); + _timer = Timer.periodic(period, (_) => drain()); + unawaited(drain()); + } + + /// 停止引擎(登出/退后台策略;不中断在途传输,仅停止再出队)。 + void stop() { + _timer?.cancel(); + _timer = null; + _accountId = null; + } + + /// 工作可用通知(准备器入队回调挂接):尽快调度一轮。 + void notifyWorkAvailable() => unawaited(drain()); + + /// 网络恢复通知(连接监听挂接):waitingNetwork 立即绕过退避重试。 + void onNetworkRestored() { + _bypassBackoffOnce = true; + unawaited(drain()); + } + + /// 是否仍在运行(周期 timer 挂着)。 + bool get isRunning => _timer != null; + + /// 等待全部在途传输结束(测试与优雅停机用)。 + Future settle() => Future.wait( + [for (final handle in _inFlight.values) handle.done], + ).then((_) {}); + + /// 一轮调度:崩溃恢复 → 补足并行配额。并发调用折叠为同轮(见 + /// [_drainPending]),所有 await 方等到完整调度结束。 + Future drain() { + final pending = _drainPending; + if (pending != null) return pending; + final future = _doDrain().whenComplete(() => _drainPending = null); + _drainPending = future; + return future; + } + + Future _doDrain() async { + final accountId = _accountId; + if (accountId == null) return; + final bypass = _bypassBackoffOnce; + _bypassBackoffOnce = false; + await _recoverInterrupted(accountId); + while (_inFlight.length < _effectiveParallel) { + final task = await _pickReady(accountId, bypassBackoff: bypass); + if (task == null) break; + _launch(task); + } + } + + /// 就绪任务挑选(账号内 createdAt 最旧优先——store 已排序)。 + Future _pickReady( + String accountId, { + bool bypassBackoff = false, + }) async { + final candidates = await _store.listByAccount( + accountId, + states: const { + UploadTaskState.queued, + UploadTaskState.waitingNetwork, + UploadTaskState.retryableFailed, + }, + ); + final now = _now(); + for (final task in candidates) { + if (_inFlight.containsKey(task.id)) continue; + switch (task.state) { + case UploadTaskState.queued: + return task; + case UploadTaskState.waitingNetwork: + // 网络恢复绕过退避(一次性,仅此状态)。 + if (bypassBackoff || _retryDue(task.nextRetryAt, now)) { + return task; + } + case UploadTaskState.retryableFailed: + // nextRetryAt=null:自动重试上限已到,等人工 retryNow。 + if (_retryDue(task.nextRetryAt, now)) { + return task; + } + case UploadTaskState.preparing: + case UploadTaskState.preparationFailed: + case UploadTaskState.uploading: + case UploadTaskState.uploadedConfirming: + case UploadTaskState.completed: + case UploadTaskState.cancelled: + break; // 不在候选集(防御性穷举)。 + } + } + return null; + } + + static bool _retryDue(DateTime? nextRetryAt, DateTime now) => + nextRetryAt != null && !nextRetryAt.isAfter(now); + + /// 崩溃恢复:uploading/uploadedConfirming 但不在 in-flight 表 → + /// waitingNetwork(nextRetryAt=now 立即重试;幂等保障安全)。 + Future _recoverInterrupted(String accountId) async { + final stuck = await _store.listByAccount( + accountId, + states: const { + UploadTaskState.uploading, + UploadTaskState.uploadedConfirming, + }, + ); + for (final task in stuck) { + if (_inFlight.containsKey(task.id)) continue; + try { + await _store.updateState( + task.id, + UploadTaskState.waitingNetwork, + patch: UploadTaskPatch(nextRetryAt: _now()), + now: _now(), + ); + } on StateError { + // 并发迁移(取消等)——恢复放弃该任务本轮处理。 + } + } + } + + /// 启动一次传输:登记 in-flight(同步,防双发)后转入后台协程。 + void _launch(UploadTask task) { + final handle = _TransferHandle(); + _inFlight[task.id] = handle; + final future = _runTransfer(task, handle); + handle.done = future; + unawaited( + future.whenComplete(() { + _inFlight.remove(task.id); + // 完成即补位(可能仍有就绪工作)。 + unawaited(drain()); + }), + ); + } + + /// 单任务传输协程:状态入 uploading → init → PUT(含 renew)→ + /// complete,失败全程分类落库。 + Future _runTransfer(UploadTask task, _TransferHandle handle) async { + // retryableFailed → queued → uploading(状态机需经 queued 中转); + // waitingNetwork/queued → uploading 直达。 + try { + var current = task; + if (current.state == UploadTaskState.retryableFailed) { + current = await _store.updateState( + current.id, + UploadTaskState.queued, + now: _now(), + ); + } + await _store.updateState( + current.id, + UploadTaskState.uploading, + patch: const UploadTaskPatch(clearNextRetryAt: true), + now: _now(), + ); + await _transferSteps(current, handle); + } on DioException catch (e) { + // 仅来自 PUT(api 调用已归一 AppException);取消已在 + // _transferSteps 内短路,到达这里的均为待分类失败。 + await _classifyFailure(task, e); + } on AppException catch (e) { + await _classifyFailure(task, e); + } on StateError { + // 并发取消抢走了状态迁移(任务已 cancelled):尽力取消语义达成。 + } on Object catch (e) { + // 未预期错误(文件系统等):按可重试失败兜底 + 观察。 + onError?.call(task.id, e); + await _classifyFailure( + task, + AppException(code: ErrorCodes.internalError, message: 'engine: $e'), + ); + } + } + + Future _transferSteps(UploadTask task, _TransferHandle handle) async { + // a. initUpload(幂等:重试复用 clientTaskId,服务端返回原会话)。 + final init = await _apiClient.initUpload( + UploadInitRequest( + albumId: task.albumId, + categoryId: task.categoryId, + clientTaskId: task.clientTaskId, + filename: task.filename, + bytes: task.bytes, + contentType: task.contentType, + sourceDeviceId: await _deviceId.value(), + ), + ); + // 回填会话/照片 ID(状态仍 uploading,旁路写入,见 TaskStore.updateMeta)。 + await _store.updateMeta( + task.id, + serverUploadId: init.uploadId, + serverPhotoId: init.photoId, + now: _now(), + ); + + // b. presign PUT(流式 + 进度 + 取消令牌;403 → renew 换凭证重传)。 + var credential = init.credential; + var renewals = 0; + while (true) { + try { + await _putObject(task, credential, handle.cancelToken); + break; + } on DioException catch (e) { + if (handle.cancelToken.isCancelled) { + // 用户取消:状态已置 cancelled(cancel() 内),终止协程。 + return; + } + if (e.response?.statusCode == 403 && + renewals < kMaxCredentialRenewals) { + renewals++; + final renewed = await _apiClient.renewUpload(init.uploadId); + credential = renewed.credential; + continue; + } + rethrow; + } + } + + // c. completeUpload(幂等核验)→ completed。 + await _store.updateState( + task.id, + UploadTaskState.uploadedConfirming, + now: _now(), + ); + final complete = await _apiClient.completeUpload(init.uploadId); + await _store.updateState( + task.id, + UploadTaskState.completed, + patch: UploadTaskPatch( + serverPhotoId: complete.photoId, + clearLastError: true, + clearNextRetryAt: true, + ), + now: _now(), + ); + } + + /// presign PUT:裸 Dio 直传 credential.url,严格携带 credential.headers + /// (+ Content-Length——流式发送 dio 无法自动推断,显式声明)。进度经 + /// 计数流上报(引擎自持,不依赖适配器对 onSendProgress 的实现)。 + Future _putObject( + UploadTask task, + UploadCredential credential, + CancelToken cancelToken, + ) async { + final file = File(task.sourceFilePath); + if (!await file.exists()) { + // 源文件缺失:不可恢复,分类按「不自动重试」处理。 + throw const AppException( + code: UploadEngine.sourceMissingCode, + message: 'source file missing', + ); + } + final length = await file.length(); + var sent = 0; + final progressStream = + file.openRead().map((List chunk) { + sent += chunk.length; + onProgress?.call(task.id, sent, length); + return chunk; + }); + await _objectDio.put( + credential.url, + data: progressStream, + cancelToken: cancelToken, + options: Options( + method: credential.method, + headers: { + ...credential.headers, + Headers.contentLengthHeader: length, + }, + // 响应体非 JSON(MinIO XML/空),防 transformer 解析报错。 + responseType: ResponseType.plain, + receiveDataWhenStatusError: true, + ), + ); + } + + /// 失败分类与状态落库(分类表见库级注释;attempt 每次 +1,目标状态 + /// 自 uploading/uploadedConfirming 均合法迁移)。 + Future _classifyFailure(UploadTask task, Object error) async { + final AppException app; + if (error is AppException) { + app = error; + } else if (error is DioException) { + app = ApiClient.mapDioException(error); + } else { + app = AppException(code: ErrorCodes.internalError, message: '$error'); + } + final code = app.code; + final status = app.statusCode; + final attempt = task.attempt + 1; + + // 1) 网络类(含超时)与限速:waitingNetwork 退避(无次数上限)。 + if (code == ErrorCodes.networkError || + code == ErrorCodes.networkTimeout || + code == ErrorCodes.rateLimited) { + await _fail( + task, + UploadTaskState.waitingNetwork, + UploadTaskPatch( + attempt: attempt, + nextRetryAt: _now().add(backoffFor(attempt)), + lastError: code, + ), + ); + return; + } + + // 2) 会话过期:waitingNetwork 短退避(重登录后续传;全局驱逐由 + // ApiClient.onUnauthorized 处理,不烧自动重试次数、attempt 不变)。 + if (status == 401 || code == ErrorCodes.authTokenExpired) { + await _fail( + task, + UploadTaskState.waitingNetwork, + UploadTaskPatch( + nextRetryAt: _now().add(const Duration(seconds: 30)), + lastError: code, + ), + ); + return; + } + + // 3) 客户端重试无意义(服务端开关/幂等冲突/源文件缺失):不自动 + // 重试,用户可见 lastError,修正后 retryNow 手动重试。 + if (code == ErrorCodes.albumUploadDisabled || + code == ErrorCodes.uploadSessionConflict || + code == UploadEngine.sourceMissingCode) { + await _fail( + task, + UploadTaskState.retryableFailed, + UploadTaskPatch(lastError: code), + ); + return; + } + + // 4) 其余(UPLOAD_VERIFY_FAILED / 5xx / 未知):retryableFailed 退避 + // 自动重试,达上限后停止(nextRetryAt 缺省=null)。 + final autoRetry = attempt < kMaxAutoRetryAttempts; + await _fail( + task, + UploadTaskState.retryableFailed, + UploadTaskPatch( + attempt: attempt, + nextRetryAt: autoRetry ? _now().add(backoffFor(attempt)) : null, + lastError: code, + ), + ); + } + + /// 分类落地(并发取消致 StateError 时静默——cancelled 已是终态)。 + Future _fail( + UploadTask task, + UploadTaskState to, + UploadTaskPatch patch, + ) async { + try { + await _store.updateState(task.id, to, patch: patch, now: _now()); + } on StateError { + // 已被并发取消(cancelled 终态):保留取消语义。 + } + } + + /// 退避时长:5s·2^(attempt-1),封顶 10min(attempt ≥ 1)。 + /// + /// 指数先钳位:Dart VM 下 `1 << n`(n ≥ 64)归零,超大 attempt 会使 + /// 乘数溢出为 0;且 2^7 档(640s)已越过封顶,更大指数无意义。 + static Duration backoffFor(int attempt) { + if (attempt < 1) return kBackoffBase; + const maxShift = 7; + final shift = attempt - 1 < maxShift ? attempt - 1 : maxShift; + final shifted = kBackoffBase * (1 << shift); + return shifted > kBackoffCap ? kBackoffCap : shifted; + } + + /// 取消任务(语义见库级注释)。返回是否生效(终态/不存在=false)。 + Future cancel(String accountId, String taskId) async { + final task = await _store.get(accountId, taskId); + if (task == null || task.state.isTerminal) return false; + await _store.updateState( + taskId, + UploadTaskState.cancelled, + patch: const UploadTaskPatch(clearNextRetryAt: true), + now: _now(), + ); + final handle = _inFlight[taskId]; + if (handle != null) { + // 进行中:尽力取消(协程在取消异常/下一检查点收敛)。 + handle.cancelToken.cancel(); + return true; + } + // 未开始:删行 + 删受管文件(best-effort;失败不回滚取消)。 + await _store.delete(taskId); + try { + final file = File(task.sourceFilePath); + if (await file.exists()) { + await file.delete(); + } + } on Object { + // 残留文件由清理执行器路径之外的孤儿——接受(行已删,无清理候选)。 + } + return true; + } + + /// 手动重试(队列 UI「重试」按钮):retryableFailed(含达上限)/ + /// waitingNetwork 任务立即重新入队出队。 + Future retryNow(String accountId, String taskId) async { + final task = await _store.get(accountId, taskId); + if (task == null) return; + if (task.state != UploadTaskState.retryableFailed && + task.state != UploadTaskState.waitingNetwork) { + return; + } + await _store.updateState( + taskId, + UploadTaskState.queued, + patch: const UploadTaskPatch(clearNextRetryAt: true), + now: _now(), + ); + unawaited(drain()); + } + + /// 清理执行器:删除超过保留期的终态任务源文件并标记 cleanedAt(行 + /// 保留作历史记录)。先删文件后标记——中断重入安全(文件已删为 + /// no-op)。返回清理条数。候选集硬守护在 TaskStore.cleanupCandidates + /// (未完成任务永不出现)。 + Future cleanup({DateTime? now}) async { + final at = now ?? _now(); + final candidates = await _store.cleanupCandidates(keepCompletedDays, now: at); + var cleaned = 0; + for (final task in candidates) { + try { + final file = File(task.sourceFilePath); + if (await file.exists()) { + await file.delete(); + } + } on Object { + continue; // 单文件失败不阻断其余(下次唤醒重试)。 + } + await _store.markCleanable(task.id, now: at); + cleaned++; + } + return cleaned; + } + + /// 关闭(App 退出):停 timer、尽力断在途、释放 object Dio。 + void dispose() { + stop(); + for (final handle in _inFlight.values) { + handle.cancelToken.cancel(); + } + _objectDio.close(); + } +} + +/// 在途传输控制句柄。 +class _TransferHandle { + final CancelToken cancelToken = CancelToken(); + + /// 传输协程完成信号(settle 用;_launch 内同步赋值)。 + Future done = Future.value(); +} diff --git a/apps/mobile/lib/l10n/app_en.arb b/apps/mobile/lib/l10n/app_en.arb index c309fb1..589c403 100644 --- a/apps/mobile/lib/l10n/app_en.arb +++ b/apps/mobile/lib/l10n/app_en.arb @@ -107,5 +107,15 @@ "procUploaded": "Uploaded", "procProcessing": "Processing", "procReady": "Ready", - "procProcessingFailed": "Processing failed" + "procProcessingFailed": "Processing failed", + "queueStubHint": "Local upload queue and continuous capture arrive in Task 9", + "serverHistoryEntry": "View server upload history", + "serverHistoryTitle": "Server upload history", + "serverHistoryBanner": "Server-side history (cross-device) — for this device's pending uploads see the Queue tab", + "serverHistoryEmpty": "No upload sessions yet", + "uploadStatePending": "Pending", + "uploadStateConfirmed": "Confirmed", + "uploadStateCancelled": "Cancelled", + "uploadStateExpired": "Expired", + "thisDevice": "This device" } diff --git a/apps/mobile/lib/l10n/app_localizations.dart b/apps/mobile/lib/l10n/app_localizations.dart index 8b99f6e..f2e77fc 100644 --- a/apps/mobile/lib/l10n/app_localizations.dart +++ b/apps/mobile/lib/l10n/app_localizations.dart @@ -745,6 +745,66 @@ abstract class AppLocalizations { /// In zh, this message translates to: /// **'处理失败'** String get procProcessingFailed; + + /// No description provided for @queueStubHint. + /// + /// In zh, this message translates to: + /// **'本机待传队列与连拍管理将在任务 9 上线'** + String get queueStubHint; + + /// No description provided for @serverHistoryEntry. + /// + /// In zh, this message translates to: + /// **'查看服务端上传历史'** + String get serverHistoryEntry; + + /// No description provided for @serverHistoryTitle. + /// + /// In zh, this message translates to: + /// **'服务端上传历史'** + String get serverHistoryTitle; + + /// No description provided for @serverHistoryBanner. + /// + /// In zh, this message translates to: + /// **'服务端历史(跨设备)——本机待传队列请看「队列」页'** + String get serverHistoryBanner; + + /// No description provided for @serverHistoryEmpty. + /// + /// In zh, this message translates to: + /// **'暂无上传记录'** + String get serverHistoryEmpty; + + /// No description provided for @uploadStatePending. + /// + /// In zh, this message translates to: + /// **'待完成'** + String get uploadStatePending; + + /// No description provided for @uploadStateConfirmed. + /// + /// In zh, this message translates to: + /// **'已确认'** + String get uploadStateConfirmed; + + /// No description provided for @uploadStateCancelled. + /// + /// In zh, this message translates to: + /// **'已取消'** + String get uploadStateCancelled; + + /// No description provided for @uploadStateExpired. + /// + /// In zh, this message translates to: + /// **'已过期'** + String get uploadStateExpired; + + /// No description provided for @thisDevice. + /// + /// In zh, this message translates to: + /// **'本设备'** + String get thisDevice; } class _AppLocalizationsDelegate diff --git a/apps/mobile/lib/l10n/app_localizations_en.dart b/apps/mobile/lib/l10n/app_localizations_en.dart index 902475b..49547c7 100644 --- a/apps/mobile/lib/l10n/app_localizations_en.dart +++ b/apps/mobile/lib/l10n/app_localizations_en.dart @@ -338,4 +338,36 @@ class AppLocalizationsEn extends AppLocalizations { @override String get procProcessingFailed => 'Processing failed'; + + @override + String get queueStubHint => + 'Local upload queue and continuous capture arrive in Task 9'; + + @override + String get serverHistoryEntry => 'View server upload history'; + + @override + String get serverHistoryTitle => 'Server upload history'; + + @override + String get serverHistoryBanner => + 'Server-side history (cross-device) — for this device\'s pending uploads see the Queue tab'; + + @override + String get serverHistoryEmpty => 'No upload sessions yet'; + + @override + String get uploadStatePending => 'Pending'; + + @override + String get uploadStateConfirmed => 'Confirmed'; + + @override + String get uploadStateCancelled => 'Cancelled'; + + @override + String get uploadStateExpired => 'Expired'; + + @override + String get thisDevice => 'This device'; } diff --git a/apps/mobile/lib/l10n/app_localizations_zh.dart b/apps/mobile/lib/l10n/app_localizations_zh.dart index 35bd7cb..0d7f378 100644 --- a/apps/mobile/lib/l10n/app_localizations_zh.dart +++ b/apps/mobile/lib/l10n/app_localizations_zh.dart @@ -331,4 +331,34 @@ class AppLocalizationsZh extends AppLocalizations { @override String get procProcessingFailed => '处理失败'; + + @override + String get queueStubHint => '本机待传队列与连拍管理将在任务 9 上线'; + + @override + String get serverHistoryEntry => '查看服务端上传历史'; + + @override + String get serverHistoryTitle => '服务端上传历史'; + + @override + String get serverHistoryBanner => '服务端历史(跨设备)——本机待传队列请看「队列」页'; + + @override + String get serverHistoryEmpty => '暂无上传记录'; + + @override + String get uploadStatePending => '待完成'; + + @override + String get uploadStateConfirmed => '已确认'; + + @override + String get uploadStateCancelled => '已取消'; + + @override + String get uploadStateExpired => '已过期'; + + @override + String get thisDevice => '本设备'; } diff --git a/apps/mobile/lib/l10n/app_zh.arb b/apps/mobile/lib/l10n/app_zh.arb index 531d9bf..804e83a 100644 --- a/apps/mobile/lib/l10n/app_zh.arb +++ b/apps/mobile/lib/l10n/app_zh.arb @@ -107,5 +107,15 @@ "procUploaded": "已上传", "procProcessing": "处理中", "procReady": "就绪", - "procProcessingFailed": "处理失败" + "procProcessingFailed": "处理失败", + "queueStubHint": "本机待传队列与连拍管理将在任务 9 上线", + "serverHistoryEntry": "查看服务端上传历史", + "serverHistoryTitle": "服务端上传历史", + "serverHistoryBanner": "服务端历史(跨设备)——本机待传队列请看「队列」页", + "serverHistoryEmpty": "暂无上传记录", + "uploadStatePending": "待完成", + "uploadStateConfirmed": "已确认", + "uploadStateCancelled": "已取消", + "uploadStateExpired": "已过期", + "thisDevice": "本设备" } diff --git a/apps/mobile/lib/pages/home_page.dart b/apps/mobile/lib/pages/home_page.dart index f70048d..5d0bb34 100644 --- a/apps/mobile/lib/pages/home_page.dart +++ b/apps/mobile/lib/pages/home_page.dart @@ -54,19 +54,39 @@ class _HomePageState extends State { } } -/// 上传队列(桩)。Task 6/9:状态机展示、筛选与重试。 +/// 上传队列(桩)。Task 9:状态机展示、筛选与重试;当前仅提供 +/// 服务端上传历史入口(A19 跨设备记录)。 class QueueTab extends StatelessWidget { const QueueTab({super.key}); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); return Scaffold( appBar: AppBar(title: Text(l10n.queue)), body: Center( - child: Text( - l10n.queue, - style: Theme.of(context).textTheme.headlineSmall, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 32), + child: Text( + l10n.queueStubHint, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + const SizedBox(height: 16), + FilledButton.tonalIcon( + onPressed: () => + Navigator.of(context).pushNamed('/server-history'), + icon: const Icon(Icons.history_outlined), + label: Text(l10n.serverHistoryEntry), + ), + ], ), ), ); diff --git a/apps/mobile/lib/pages/server_history_page.dart b/apps/mobile/lib/pages/server_history_page.dart new file mode 100644 index 0000000..345fea6 --- /dev/null +++ b/apps/mobile/lib/pages/server_history_page.dart @@ -0,0 +1,340 @@ +/// 服务端上传历史页(P3 计划 Task 6,A19 语义)。 +/// +/// GET /uploads(operationId listUploads)游标分页展示**当前账号跨设备** +/// 的上传会话记录:会话状态 / photo.processingState / sourceDeviceId / +/// 创建时间。顶部横幅明确标注「本机待传队列不在此列」——本机队列是 +/// drift 任务库(TaskStore),两者数据源与生命周期不同(A19 标注要求)。 +/// +/// 记录不含 filename(契约 UploadSessionRecord 无该字段),行首展示 +/// clientTaskId 前 8 位作识别;sourceDeviceId 与本机 [DeviceId] 一致时 +/// 标记「本设备」。 +library; + +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../core/api_client.dart'; +import '../core/app_exception.dart'; +import '../core/device_id.dart'; +import '../core/error_codes.dart'; +import '../core/error_l10n.dart'; +import '../features/photos/models.dart'; +import '../features/uploads/models.dart'; +import '../l10n/app_localizations.dart'; + +/// 服务端上传历史(从队列页占位入口进入)。 +class ServerHistoryPage extends ConsumerStatefulWidget { + const ServerHistoryPage({super.key}); + + @override + ConsumerState createState() => _ServerHistoryPageState(); +} + +class _ServerHistoryPageState extends ConsumerState { + static const int _pageSize = 20; + + List _items = const []; + String? _nextCursor; + bool _hasMore = false; + bool _loading = true; + bool _loadingMore = false; + String? _errorCode; + + /// 本机设备标识(「本设备」标记比对,A19)。 + String? _ownDeviceId; + + @override + void initState() { + super.initState(); + _reload(); + unawaited( + ref.read(deviceIdProvider).value().then((id) { + if (mounted) setState(() => _ownDeviceId = id); + }), + ); + } + + Future _reload() async { + setState(() { + _loading = true; + _errorCode = null; + }); + try { + final page = await ref + .read(apiClientProvider) + .listUploads(limit: _pageSize); + if (mounted) { + setState(() { + _items = page.items; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loading = false; + }); + } + } on AppException catch (e) { + if (mounted) { + setState(() { + _errorCode = e.code; + _loading = false; + }); + } + } on Object { + if (mounted) { + setState(() { + _errorCode = ErrorCodes.internalError; + _loading = false; + }); + } + } + } + + Future _loadMore() async { + if (_loading || _loadingMore || !_hasMore) return; + setState(() => _loadingMore = true); + try { + final page = await ref.read(apiClientProvider).listUploads( + cursor: _nextCursor, + limit: _pageSize, + ); + if (mounted) { + setState(() { + _items = [..._items, ...page.items]; + _nextCursor = page.nextCursor; + _hasMore = page.hasMore; + _loadingMore = false; + }); + } + } on AppException { + if (mounted) { + setState(() => _loadingMore = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + AppLocalizations.of(context) + .messageForErrorCode(ErrorCodes.networkError), + ), + ), + ); + } + } on Object { + if (mounted) { + setState(() => _loadingMore = false); + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(AppLocalizations.of(context).errorGeneric), + ), + ); + } + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final theme = Theme.of(context); + return Scaffold( + appBar: AppBar(title: Text(l10n.serverHistoryTitle)), + body: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // A19 标注横幅:与「队列」页(本机任务库)的数据源区分。 + Card( + margin: const EdgeInsets.fromLTRB(12, 12, 12, 0), + child: Padding( + padding: const EdgeInsets.all(12), + child: Row( + children: [ + Icon( + Icons.devices_outlined, + size: 18, + color: theme.colorScheme.onSurfaceVariant, + ), + const SizedBox(width: 8), + Expanded( + child: Text( + l10n.serverHistoryBanner, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ], + ), + ), + ), + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _items.isEmpty && _errorCode != null + ? Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.cloud_off, size: 48), + const SizedBox(height: 12), + Text(l10n.messageForErrorCode(_errorCode!)), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _reload, + icon: const Icon(Icons.refresh), + label: Text(l10n.retry), + ), + ], + ), + ) + : ListView.builder( + itemCount: _items.length + + (_items.isEmpty || _hasMore || _loadingMore ? 1 : 0), + itemBuilder: (context, index) { + if (_items.isEmpty) { + return Padding( + padding: const EdgeInsets.all(32), + child: Text( + l10n.serverHistoryEmpty, + textAlign: TextAlign.center, + style: theme.textTheme.bodySmall, + ), + ); + } + if (index >= _items.length) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center( + child: SizedBox( + height: 24, + width: 24, + child: CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ); + } + if (index >= _items.length - 3 && + _hasMore && + !_loadingMore) { + WidgetsBinding.instance.addPostFrameCallback( + (_) => _loadMore(), + ); + } + return _sessionTile(l10n, theme, _items[index]); + }, + ), + ), + ], + ), + ); + } + + Widget _sessionTile( + AppLocalizations l10n, + ThemeData theme, + UploadSessionRecord record, + ) { + // createdAt 以本机时区展示(记录跨相册,无单一相册时区可依)。 + final local = record.createdAt.toLocal(); + final time = '${local.year}/${local.month}/${local.day} ' + '${local.hour.toString().padLeft(2, '0')}:' + '${local.minute.toString().padLeft(2, '0')}'; + final deviceShort = record.sourceDeviceId?.substring(0, 8); + final ownDevice = + _ownDeviceId != null && record.sourceDeviceId == _ownDeviceId; + return ListTile( + leading: const Icon(Icons.cloud_done_outlined), + title: Text( + '${record.clientTaskId.substring(0, 8)} · ' + '${(record.expectedBytes / 1024).round()} KB', + style: theme.textTheme.bodyMedium?.copyWith( + fontFeatures: const [FontFeature.tabularFigures()], + ), + ), + subtitle: Text( + deviceShort == null + ? time + : '$time · $deviceShort${ownDevice ? '(${l10n.thisDevice})' : ''}', + ), + trailing: Wrap( + spacing: 6, + runSpacing: 4, + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + _HistoryTag( + label: _sessionStateLabel(l10n, record.state), + highlight: record.state == UploadSessionState.confirmed, + ), + _HistoryTag( + label: _processingLabel(l10n, record.photo.processingState), + highlight: record.photo.processingState == + PhotoProcessingState.ready, + ), + ], + ), + ); + } + + static String _sessionStateLabel( + AppLocalizations l10n, + UploadSessionState state, + ) { + switch (state) { + case UploadSessionState.pending: + return l10n.uploadStatePending; + case UploadSessionState.confirmed: + return l10n.uploadStateConfirmed; + case UploadSessionState.cancelled: + return l10n.uploadStateCancelled; + case UploadSessionState.expired: + return l10n.uploadStateExpired; + } + } + + static String _processingLabel( + AppLocalizations l10n, + PhotoProcessingState s, + ) { + switch (s) { + case PhotoProcessingState.pendingUpload: + return l10n.procPendingUpload; + case PhotoProcessingState.uploaded: + return l10n.procUploaded; + case PhotoProcessingState.processing: + return l10n.procProcessing; + case PhotoProcessingState.ready: + return l10n.procReady; + case PhotoProcessingState.processingFailed: + return l10n.procProcessingFailed; + } + } +} + +/// 会话记录标签(会话状态/处理状态)。 +class _HistoryTag extends StatelessWidget { + const _HistoryTag({required this.label, required this.highlight}); + + final String label; + final bool highlight; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: highlight + ? theme.colorScheme.secondaryContainer + : theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(999), + ), + child: Text( + label, + style: theme.textTheme.labelSmall?.copyWith( + color: highlight + ? theme.colorScheme.onSecondaryContainer + : theme.colorScheme.onSurfaceVariant, + ), + ), + ); + } +} diff --git a/apps/mobile/pubspec.lock b/apps/mobile/pubspec.lock index 971b155..e380701 100644 --- a/apps/mobile/pubspec.lock +++ b/apps/mobile/pubspec.lock @@ -813,6 +813,62 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "3.4.3" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: "1e12aafe408aa50da80edfd679a2a6bf63ba7ab37c7fa98286da459a757b3399" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.28" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "2ec3934efa51e46117f23031cc141b8fc878e8525b94ec1ea4f7f586cf1b47ea" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.7" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: diff --git a/apps/mobile/pubspec.yaml b/apps/mobile/pubspec.yaml index cca2947..b86d087 100644 --- a/apps/mobile/pubspec.yaml +++ b/apps/mobile/pubspec.yaml @@ -22,6 +22,8 @@ dependencies: sqlite3_flutter_libs: ^0.6.0+eol # 会话与凭证存储 flutter_secure_storage: ^11.2.0 + # 设备标识持久(sourceDeviceId,非机密——shared_preferences 足够) + shared_preferences: ^2.5.3 # 选图 / 连拍 image_picker: ^1.2.3 camera: ^0.12.1 diff --git a/apps/mobile/test/core/device_id_test.dart b/apps/mobile/test/core/device_id_test.dart new file mode 100644 index 0000000..ab0b751 --- /dev/null +++ b/apps/mobile/test/core/device_id_test.dart @@ -0,0 +1,119 @@ +/// 设备标识(DeviceId)测试(P3 计划 Task 6)。 +library; + +import 'dart:math'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/device_id.dart'; + +class MemoryDeviceIdStore implements DeviceIdStore { + String? value; + int writes = 0; + + @override + Future read() async => value; + + @override + Future write(String v) async { + writes++; + value = v; + } +} + +void main() { + test('首次生成:32 位小写十六进制并持久化', () async { + final store = MemoryDeviceIdStore(); + final id = await DeviceId(store: store).value(); + + expect(DeviceId.kPattern.hasMatch(id), isTrue, reason: '形状:$id'); + expect(store.value, id); + expect(store.writes, 1); + }); + + test('懒缓存 + 跨实例稳定:同一存储只生成一次', () async { + final store = MemoryDeviceIdStore(); + final a = DeviceId(store: store); + final b = DeviceId(store: store); + + final first = await a.value(); + // 同实例二次调用走缓存(不再写存储)。 + expect(await a.value(), first); + expect(store.writes, 1); + // 新实例从存储读取同一值(per-install 语义)。 + expect(await b.value(), first); + expect(store.writes, 1); + }); + + test('并发首次调用共享同一次生成(不重复落盘)', () async { + final store = MemoryDeviceIdStore(); + final device = DeviceId(store: store); + final results = await Future.wait( + List.generate(5, (_) => device.value()), + ); + expect(results.toSet().length, 1); + expect(store.writes, 1); + }); + + test('不同安装(不同存储)生成不同标识', () async { + final a = await DeviceId(store: MemoryDeviceIdStore()).value(); + final b = await DeviceId(store: MemoryDeviceIdStore()).value(); + expect(a, isNot(b)); + }); + + test('存储中的非法值(长度/字符不符)重新生成', () async { + final store = MemoryDeviceIdStore()..value = 'short'; + final id = await DeviceId(store: store).value(); + expect(DeviceId.kPattern.hasMatch(id), isTrue); + expect(store.value, id); // 修正值写回 + }); + + test('存储读取失败(异常)按未生成处理', () async { + final throwing = _ThrowingStore(); + final id = await DeviceId(store: throwing).value(); + expect(DeviceId.kPattern.hasMatch(id), isTrue); + }); + + test('hexEncode:字节 → 小写两位十六进制', () { + expect( + DeviceId.hexEncode([0x00, 0x0f, 0x10, 0xff, 0xa5]), + '000f10ffa5', + ); + expect(DeviceId.hexEncode(List.filled(16, 0xab)).length, 32); + }); + + test('可注入 Random(确定性生成便于复现)', () async { + final fixed = _SequenceRandom(); + final id = await DeviceId(store: MemoryDeviceIdStore(), random: fixed) + .value(); + expect(id, '0102030405060708090a0b0c0d0e0f10'); + }); +} + +class _ThrowingStore implements DeviceIdStore { + @override + Future read() async => throw _Boom(); + + @override + Future write(String value) async {} +} + +class _Boom implements Exception {} + +/// 固定序列 Random(测试确定性注入)。 +class _SequenceRandom implements Random { + final List _values = List.generate(16, (i) => i + 1); + int _cursor = 0; + + @override + int nextInt(int max) { + final v = _values[_cursor % _values.length] % max; + _cursor++; + return v; + } + + @override + bool nextBool() => true; + + @override + double nextDouble() => .5; +} diff --git a/apps/mobile/test/features/uploads/preparer_test.dart b/apps/mobile/test/features/uploads/preparer_test.dart new file mode 100644 index 0000000..925eb61 --- /dev/null +++ b/apps/mobile/test/features/uploads/preparer_test.dart @@ -0,0 +1,258 @@ +/// 上传准备器(UploadPreparer)测试(P3 计划 Task 6)。 +/// +/// 真实临时目录驱动(Directory.systemTemp)——复制/stat/魔数嗅探均为 +/// 真实文件系统行为;任务库用内存 drift。 +library; + +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/features/uploads/preparer.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; + +/// JPEG/PNG 魔数最小字节。 +final List _jpgBytes = [ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, // JFIF 头片段 + 0x4A, 0x46, 0x49, 0x46, 0x00, +]; +final List _pngBytes = [ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG 签名 + 0x00, 0x00, 0x00, 0x0D, +]; + +void main() { + late TaskStore store; + late Directory tmpRoot; + late Directory sourceDir; + late Directory managedBase; + + setUp(() async { + store = TaskStore.memory(); + tmpRoot = await Directory.systemTemp.createTemp('preparer-test-'); + sourceDir = Directory('${tmpRoot.path}/src') + ..createSync(recursive: true); + managedBase = Directory('${tmpRoot.path}/managed') + ..createSync(recursive: true); + }); + + tearDown(() async { + await store.close(); + await tmpRoot.delete(recursive: true); + }); + + File sourceFile(String name, List bytes) { + final f = File('${sourceDir.path}/$name'); + f.writeAsBytesSync(bytes); + return f; + } + + UploadPreparer preparer({int maxBytes = UploadPreparer.kMaxUploadBytes}) => + UploadPreparer( + store, + baseDirProvider: () async => managedBase, + maxBytes: maxBytes, + ); + + group('prepare(快乐路径)', () { + test('jpg:受管副本存在、行 queued、魔数嗅探 contentType、bytes/filename 落库', + () async { + final src = sourceFile('IMG_0001.jpg', _jpgBytes); + + final task = await preparer().prepare( + accountId: 'alice', + albumId: 'alb-1', + categoryId: 'cat-1', + sourcePath: src.path, + ); + + expect(task.state, UploadTaskState.queued); + expect(task.contentType, 'image/jpeg'); + expect(task.bytes, _jpgBytes.length); + expect(task.filename, 'IMG_0001.jpg'); + expect(task.categoryId, 'cat-1'); + expect(task.lastError, isNull); + // 受管路径:{base}/uploads/{accountId}/{albumId}/{taskId}.jpg + expect( + task.sourceFilePath, + matches(RegExp( + r'[/\\]uploads[/\\]alice[/\\]alb-1[/\\][0-9a-f]{32}\.jpg$', + )), + ); + expect(File(task.sourceFilePath).existsSync(), isTrue); + expect(task.id, task.sourceFilePath.split(RegExp(r'[/\\]')).last.substring(0, 32)); + expect(File(task.sourceFilePath).readAsBytesSync(), _jpgBytes); + }); + + test('png 魔数覆盖错误扩展名(.png 伪装检查的反向:jpg 扩展实际 png)', + () async { + final src = sourceFile('photo.jpg', _pngBytes); + final task = await preparer().prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + ); + expect(task.contentType, 'image/png'); + // 扩展名沿用源(副本文件名带 .jpg)——服务端以 contentType 为准。 + expect(task.sourceFilePath, endsWith('.jpg')); + }); + + test('heic 无魔数走扩展名映射;filename 可覆盖缺省', () async { + final src = sourceFile('IMG_0002.heic', List.filled(64, 0x00)); + final task = await preparer().prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + filename: '连拍_0002.heic', + ); + expect(task.contentType, 'image/heic'); + expect(task.filename, '连拍_0002.heic'); + }); + + test('入队回调触发(引擎唤醒挂接点)', () async { + final src = sourceFile('a.jpg', _jpgBytes); + var enqueued = 0; + final p = UploadPreparer( + store, + baseDirProvider: () async => managedBase, + onEnqueued: () => enqueued++, + ); + await p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path); + expect(enqueued, 1); + }); + }); + + group('prepare(预检失败 → preparationFailed)', () { + test('未知类型(.txt 且无魔数):unsupportedType,且不产副本', () async { + final src = sourceFile('notes.txt', List.filled(16, 0x41)); + final task = await preparer().prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + ); + expect(task.state, UploadTaskState.preparationFailed); + expect(task.lastError, PrepErrorCodes.unsupportedType); + expect(File(task.sourceFilePath).existsSync(), isFalse); + }); + + test('源文件不存在:sourceUnreadable', () async { + final task = await preparer().prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: '${sourceDir.path}/missing.jpg', + ); + expect(task.state, UploadTaskState.preparationFailed); + expect(task.lastError, PrepErrorCodes.sourceUnreadable); + }); + + test('超本地尺寸上限:tooLarge(上限可注入小值)', () async { + final src = sourceFile('big.jpg', _jpgBytes + List.filled(200, 0x00)); + final task = await preparer(maxBytes: 100).prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + ); + expect(task.state, UploadTaskState.preparationFailed); + expect(task.lastError, PrepErrorCodes.tooLarge); + }); + + test('复制阶段失败(源在预检后、复制前被删):copyFailed', () async { + final src = sourceFile('race.jpg', _jpgBytes); + final p = preparer(); + // 竞态模拟:stat/嗅探通过后、copy 前删源——通过先跑一次拿路径再 + // 直接构造不可复制场景:用目录当源(copy 抛错)。 + final dirAsSource = Directory('${sourceDir.path}/adir') + ..createSync(); + final task = await p.prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: dirAsSource.path, + ); + // 目录 length() 在部分平台返回 stat——Windows 下非 0 会走到 copy 失败; + // Linux 下目录 stat 为 4096 同样走 copy 失败;若返回 0 则是 + // sourceUnreadable——两者都是 preparationFailed,断言状态为主。 + expect(task.state, UploadTaskState.preparationFailed); + expect( + task.lastError, + anyOf(PrepErrorCodes.copyFailed, PrepErrorCodes.sourceUnreadable), + ); + expect(src.existsSync(), isTrue, reason: '不相干文件不受影响'); + }); + }); + + group('retry(preparationFailed → queued)', () { + test('提供新源后重试成功并清除错误', () async { + final src = sourceFile('r1.jpg', _jpgBytes); + final p = preparer(); + final failed = await p.prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: '${sourceDir.path}/missing.jpg', + ); + expect(failed.state, UploadTaskState.preparationFailed); + + final retried = await p.retry( + accountId: 'alice', + taskId: failed.id, + sourcePath: src.path, + ); + expect(retried.state, UploadTaskState.queued); + expect(retried.lastError, isNull); + expect(File(retried.sourceFilePath).existsSync(), isTrue); + }); + + test('非 preparationFailed 状态拒绝重试', () async { + final src = sourceFile('r2.jpg', _jpgBytes); + final p = preparer(); + final queued = await p.prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + ); + await expectLater( + p.retry(accountId: 'alice', taskId: queued.id, sourcePath: src.path), + throwsA(isA()), + ); + }); + }); + + group('串行执行(连拍防 IO 风暴)', () { + test('并发 prepare 按提交顺序串行完成,各自独立成行', () async { + final p = preparer(); + final srcs = [ + for (var i = 0; i < 3; i++) + sourceFile('burst-$i.jpg', _jpgBytes + [i]), + ]; + final tasks = await Future.wait([ + for (final src in srcs) + p.prepare(accountId: 'alice', albumId: 'alb-1', sourcePath: src.path), + ]); + // 全部入队、ID/幂等键互不相同、副本都在。 + expect(tasks.map((t) => t.state), everyElement(UploadTaskState.queued)); + expect(tasks.map((t) => t.id).toSet().length, 3); + expect(tasks.map((t) => t.clientTaskId).toSet().length, 3); + for (final t in tasks) { + expect(File(t.sourceFilePath).existsSync(), isTrue); + } + // createdAt 严格递增(串行执行的间接证据:无交叉写库)。 + final stamps = tasks.map((t) => t.createdAt).toList(); + for (var i = 1; i < stamps.length; i++) { + expect(!stamps[i].isBefore(stamps[i - 1]), isTrue); + } + }); + }); + + test('账号/相册隔离的受管目录(不同账号不同目录树)', () async { + final s1 = sourceFile('u1.jpg', _jpgBytes); + final s2 = sourceFile('u2.jpg', _jpgBytes); + final p = preparer(); + final t1 = await p.prepare( + accountId: 'alice', albumId: 'alb-1', sourcePath: s1.path); + final t2 = await p.prepare( + accountId: 'bob', albumId: 'alb-2', sourcePath: s2.path); + expect(t1.sourceFilePath, contains('alice')); + expect(t1.sourceFilePath, contains('alb-1')); + expect(t2.sourceFilePath, contains('bob')); + expect(t2.sourceFilePath, contains('alb-2')); + }); +} diff --git a/apps/mobile/test/features/uploads/task_store_test.dart b/apps/mobile/test/features/uploads/task_store_test.dart index 77e5f0d..b7b0258 100644 --- a/apps/mobile/test/features/uploads/task_store_test.dart +++ b/apps/mobile/test/features/uploads/task_store_test.dart @@ -251,6 +251,59 @@ void main() { throwsA(isA()), ); }); + + test('patch 未提供的列保持不变(文档契约,引擎重试依赖)', () async { + // 场景(Task 6 引擎):失败写入 serverUploadId/attempt/lastError 后, + // 后续不带这些字段的迁移不得清空它们。 + await store.create(draft()); + await store.updateState('task-0001', UploadTaskState.queued); + await store.updateState( + 'task-0001', + UploadTaskState.uploading, + patch: const UploadTaskPatch( + serverUploadId: 'upl-1', + serverPhotoId: 'pho-1', + ), + ); + await store.updateState( + 'task-0001', + UploadTaskState.waitingNetwork, + patch: const UploadTaskPatch( + attempt: 2, + lastError: 'NETWORK_ERROR', + ), + ); + final kept = await store.updateState( + 'task-0001', + UploadTaskState.uploading, + now: DateTime.utc(2026, 9, 19, 10), + ); + expect(kept.serverUploadId, 'upl-1', reason: '未提供不得清空'); + expect(kept.serverPhotoId, 'pho-1'); + expect(kept.attempt, 2); + expect(kept.lastError, 'NETWORK_ERROR'); + }); + + test('updateMeta:旁路回填不迁移状态(引擎 init 后回填会话 ID)', () async { + await store.create(draft()); + await store.updateState('task-0001', UploadTaskState.queued); + await store.updateState('task-0001', UploadTaskState.uploading); + final at = DateTime.utc(2026, 9, 19, 10); + final updated = await store.updateMeta( + 'task-0001', + serverUploadId: 'upl-9', + serverPhotoId: 'pho-9', + now: at, + ); + expect(updated.state, UploadTaskState.uploading, reason: '状态不变'); + expect(updated.serverUploadId, 'upl-9'); + expect(updated.serverPhotoId, 'pho-9'); + expect(updated.updatedAt, at); + // 未提供的字段保持不变(二次回填只更新一个)。 + final again = await store.updateMeta('task-0001', serverUploadId: 'upl-x'); + expect(again.serverUploadId, 'upl-x'); + expect(again.serverPhotoId, 'pho-9'); + }); }); group('账号隔离(硬守护)', () { diff --git a/apps/mobile/test/features/uploads/upload_engine_test.dart b/apps/mobile/test/features/uploads/upload_engine_test.dart new file mode 100644 index 0000000..1540fe5 --- /dev/null +++ b/apps/mobile/test/features/uploads/upload_engine_test.dart @@ -0,0 +1,875 @@ +/// 上传引擎(UploadEngine)测试(P3 计划 Task 6)。 +/// +/// 打桩面:FakeAdapter ×2(API 侧 /v1/* 与对象侧 presign PUT)+ 内存 +/// TaskStore + 真实临时文件 + 可变时钟。覆盖:快乐路径调用序列、幂等 +/// 重试、断网恢复、退避与自动重试上限、409/401/403 分类、取消、账号 +/// 隔离、A15 归属不变、并行上限、崩溃恢复、进度回调、清理执行器。 +library; + +import 'dart:async'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/device_id.dart'; +import 'package:photo_live/core/error_codes.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/features/uploads/preparer.dart'; +import 'package:photo_live/features/uploads/task_state.dart'; +import 'package:photo_live/features/uploads/task_store.dart'; +import 'package:photo_live/features/uploads/upload_engine.dart'; + +import '../../helpers/fakes.dart'; + +const String _kDeviceId = '0123456789abcdef0123456789abcdef'; + +/// 引擎测试装配:双适配器假服务 + 内存库 + 临时文件 + 可变时钟。 +class Harness { + Harness() { + apiAdapter = FakeAdapter(_apiRouter); + objectAdapter = FakeAdapter(_objectRouter); + api = ApiClient( + sessionStore: sessionStore, + dio: Dio(BaseOptions(baseUrl: 'http://api.test.local')) + ..httpClientAdapter = apiAdapter, + ); + objectDio = Dio()..httpClientAdapter = objectAdapter; + store = TaskStore.memory(); + deviceId = DeviceId(store: _FixedDeviceIdStore(_kDeviceId)); + } + + late final FakeAdapter apiAdapter; + late final FakeAdapter objectAdapter; + late final ApiClient api; + late final Dio objectDio; + late final TaskStore store; + late final DeviceId deviceId; + final FakeSessionStore sessionStore = FakeSessionStore() + ..session = Session(token: 'tok-1', account: 'alice'); + + /// 可变时钟(测试推进)。 + DateTime clock = DateTime.utc(2026, 9, 19, 12, 0, 0); + + /// 服务端调用事件序列(跨适配器统一断言)。 + final List events = []; + + /// clientTaskId → init 响应体(幂等:同键回原会话)。 + final Map> sessionsByClientTaskId = + >{}; + + /// uploadId → init 响应体(renew/complete 回显)。 + final Map> sessionsByUploadId = + >{}; + + final List> initBodies = >[]; + int renewCalls = 0; + + // ── 故障注入旋钮(次数制)── + int initNetworkFailures = 0; + int initUnauthorized = 0; + int initAlbumDisabled = 0; + int completeVerifyFailures = 0; + int completeNetworkFailures = 0; + int putNetworkFailures = 0; + int put403Count = 0; + + /// PUT 闸门(并行/取消测试:挂起全部 PUT 直到放开)。 + Completer? putGate; + + UploadEngine buildEngine({ + int maxParallel = 2, + int keepCompletedDays = 14, + void Function(String taskId, int sent, int total)? onProgress, + }) => + UploadEngine( + store, + api, + deviceId, + objectDio: objectDio, + now: () => clock, + maxParallel: maxParallel, + keepCompletedDays: keepCompletedDays, + onProgress: onProgress, + ); + + Map _initBodyFor(String uploadId, String photoId, + {bool renewed = false}) { + return { + 'uploadId': uploadId, + 'photoId': photoId, + 'objectKey': 'photo-live/dev/staging/$uploadId/$photoId.jpg', + 'credential': { + 'mode': 'presigned', + 'url': + 'http://object.test.local/staging/$uploadId/$photoId.jpg' + '${renewed ? '?renewed=1' : ''}', + 'method': 'PUT', + 'headers': {'Content-Type': 'image/jpeg'}, + 'expiresAt': '2026-09-19T13:00:00.000Z', + }, + }; + } + + FutureOr<(int, Object?)> _apiRouter(RequestOptions o) { + if (o.method == 'POST' && o.path == '/v1/uploads') { + final body = Map.from(o.data as Map); + initBodies.add(body); + final ct = body['clientTaskId'] as String; + if (initAlbumDisabled > 0) { + initAlbumDisabled--; + return (409, errorBody(ErrorCodes.albumUploadDisabled)); + } + if (initUnauthorized > 0) { + initUnauthorized--; + return (401, errorBody(ErrorCodes.authTokenExpired)); + } + if (initNetworkFailures > 0) { + initNetworkFailures--; + throw DioException( + requestOptions: o, + type: DioExceptionType.connectionError, + ); + } + final existing = sessionsByClientTaskId[ct]; + if (existing != null) { + events.add('init:$ct(idem)'); + return (201, existing); + } + final n = sessionsByClientTaskId.length + 1; + final uploadId = 'upl$n'; + final photoId = 'pho$n'; + final initBody = _initBodyFor(uploadId, photoId); + sessionsByClientTaskId[ct] = initBody; + sessionsByUploadId[uploadId] = initBody; + events.add('init:$ct'); + return (201, initBody); + } + final renew = + RegExp(r'^/v1/uploads/([^/]+)/renew$').firstMatch(o.path); + if (renew != null) { + renewCalls++; + final uploadId = renew.group(1)!; + events.add('renew:$uploadId'); + final original = sessionsByUploadId[uploadId]!; + return ( + 200, + _initBodyFor(uploadId, original['photoId'] as String, renewed: true), + ); + } + final complete = + RegExp(r'^/v1/uploads/([^/]+)/complete$').firstMatch(o.path); + if (complete != null) { + final uploadId = complete.group(1)!; + if (completeVerifyFailures > 0) { + completeVerifyFailures--; + events.add('complete-fail:$uploadId'); + return (400, errorBody(ErrorCodes.uploadVerifyFailed)); + } + if (completeNetworkFailures > 0) { + completeNetworkFailures--; + throw DioException( + requestOptions: o, + type: DioExceptionType.connectionError, + ); + } + events.add('complete:$uploadId'); + final original = sessionsByUploadId[uploadId]!; + return ( + 200, + { + 'photoId': original['photoId'], + 'processingState': 'processing', + }, + ); + } + throw StateError('意外的 API 请求:${o.method} ${o.path}'); + } + + FutureOr<(int, Object?)> _objectRouter(RequestOptions o) async { + events.add('put'); + final gate = putGate; + if (gate != null) { + await gate.future; + } + if (put403Count > 0) { + put403Count--; + return (403, null); + } + if (putNetworkFailures > 0) { + putNetworkFailures--; + throw DioException( + requestOptions: o, + type: DioExceptionType.connectionError, + ); + } + return (200, null); + } + + /// 种子一条 queued 任务(真实临时文件做源)。 + Future seedTask( + File file, { + String accountId = 'alice', + String albumId = 'alb-1', + String? categoryId, + String clientTaskId = 'ct-default', + DateTime? createdAt, + }) async { + final draft = UploadTaskDraft( + id: 'task-$clientTaskId', + accountId: accountId, + albumId: albumId, + categoryId: categoryId, + clientTaskId: clientTaskId, + sourceFilePath: file.path, + filename: file.uri.pathSegments.last, + bytes: await file.length(), + contentType: 'image/jpeg', + ); + final task = await store.create(draft, now: createdAt); + return store.updateState( + task.id, + UploadTaskState.queued, + now: createdAt, + ); + } +} + +class _FixedDeviceIdStore implements DeviceIdStore { + _FixedDeviceIdStore(this._value); + final String _value; + + @override + Future read() async => _value; + + @override + Future write(String value) async {} +} + +void main() { + late Harness h; + late Directory tmp; + late UploadEngine engine; + + setUp(() async { + h = Harness(); + tmp = await Directory.systemTemp.createTemp('engine-test-'); + }); + + tearDown(() async { + engine.dispose(); + await h.store.close(); + // Windows:取消竞态下 dio 会孤儿化适配器 fetch(CancelableOperation + // 置 cancel),被孤儿化的 drain 仍短暂持有文件句柄(errno 32)—— + // 短重试兜底等待句柄释放。 + for (var i = 0; i < 20; i++) { + try { + await tmp.delete(recursive: true); + break; + } on FileSystemException { + await Future.delayed(const Duration(milliseconds: 50)); + } + } + }); + + File photoFile(String name, [int size = 1024]) { + final f = File('${tmp.path}/$name'); + f.writeAsBytesSync(List.generate(size, (i) => i % 251)); + return f; + } + + /// 驱动一轮完整调度并等待收敛(drain 折叠并发调用;双循环兜底 + /// 完成回调触发的补位 drain)。 + Future cycle() async { + for (var i = 0; i < 3; i++) { + await engine.drain(); + await engine.settle(); + } + await engine.drain(); + } + + /// 泵事件循环直到条件成立(真实 IO/微任务链推进用)。 + Future until(bool Function() cond) async { + for (var i = 0; i < 100 && !cond(); i++) { + await pumpEventQueue(); + } + expect(cond(), isTrue, reason: '等待条件在泵内未成立'); + } + + Future task(String id) async { + final t = await h.store.get('alice', id); + if (t == null) { + throw StateError('任务不存在:$id'); + } + return t; + } + + group('快乐路径', () { + test('全序列 init → PUT → complete → completed;服务端调用与请求体断言', + () async { + final file = photoFile('a.jpg'); + final progress = >{}; + engine = h.buildEngine(onProgress: (id, sent, total) => progress + .putIfAbsent(id, () => []) + .add(sent)); + engine.start('alice', period: const Duration(hours: 1)); + + final seeded = await h.seedTask(file, clientTaskId: 'ct-1'); + await cycle(); + + final t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + expect(t.serverUploadId, 'upl1'); + expect(t.serverPhotoId, 'pho1'); + expect(t.lastError, isNull); + expect(t.nextRetryAt, isNull); + expect(h.events, ['init:ct-1', 'put', 'complete:upl1']); + + // init 请求体:幂等键 + sourceDeviceId(A19)+ 元数据。 + final body = h.initBodies.single; + expect(body['albumId'], 'alb-1'); + expect(body['clientTaskId'], 'ct-1'); + expect(body['filename'], 'a.jpg'); + expect(body['bytes'], 1024); + expect(body['contentType'], 'image/jpeg'); + expect(body['sourceDeviceId'], _kDeviceId); + + // PUT:预签名 URL + 凭证头 + Content-Length;无 Authorization + // (裸 Dio——Bearer 会破坏 MinIO 预签名)。 + final put = h.objectAdapter.requests.single; + expect(put.method, 'PUT'); + expect(put.uri.toString(), + 'http://object.test.local/staging/upl1/pho1.jpg'); + expect(put.headers['Content-Type'], 'image/jpeg'); + // dio 以原始键位(小写 const Headers.contentLengthHeader)落请求头。 + expect(put.headers['content-length'], 1024); + expect(put.headers.containsKey('Authorization'), isFalse); + + // 源文件即时保留(清理执行器按保留期处理,不随完成删除)。 + expect(file.existsSync(), isTrue); + // 进度回调按真实字节计数上报(计满 1024)。 + expect(progress[seeded.id]?.last, 1024); + }); + + test('与准备器集成:preparing → queued → completed(受管副本上传)', + () async { + final src = photoFile('src.jpg'); + final managed = Directory('${tmp.path}/managed')..createSync(); + final preparer = UploadPreparer( + h.store, + baseDirProvider: () async => managed, + ); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + final queued = await preparer.prepare( + accountId: 'alice', + albumId: 'alb-1', + sourcePath: src.path, + ); + expect(queued.state, UploadTaskState.queued); + await cycle(); + + final t = await task(queued.id); + expect(t.state, UploadTaskState.completed); + // PUT 的 body 是受管副本(大小一致,路径在受管目录下)。 + expect(t.sourceFilePath, contains('managed')); + expect(File(t.sourceFilePath).lengthSync(), 1024); + expect(h.events.last, 'complete:upl1'); + }); + }); + + group('幂等重试', () { + test('PUT 中途网络失败 → waitingNetwork → 到期重试:init 复用 ' + 'clientTaskId、服务端同会话同照片,最终单照片 completed', () async { + final file = photoFile('b.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.putNetworkFailures = 1; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-b'); + await cycle(); + + var t = await task(seeded.id); + expect(t.state, UploadTaskState.waitingNetwork); + expect(t.attempt, 1); + expect(t.serverUploadId, 'upl1', reason: 'init 已成功,会话 ID 保留'); + expect(h.events, ['init:ct-b', 'put']); + + // 未到退避时间(5s·2^0):不重试。 + h.clock = h.clock.add(const Duration(seconds: 2)); + await cycle(); + expect(h.events, ['init:ct-b', 'put'], reason: '退避未到不重试'); + + // 到期重试:init 幂等(同 clientTaskId 返回原会话)→ 单照片完成。 + h.clock = h.clock.add(const Duration(seconds: 10)); + await cycle(); + t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + expect(t.serverPhotoId, 'pho1'); + expect(h.sessionsByClientTaskId, hasLength(1), reason: '服务端只建了一个会话'); + expect(h.events, ['init:ct-b', 'put', 'init:ct-b(idem)', 'put', 'complete:upl1']); + expect(h.initBodies, hasLength(2)); + expect(h.initBodies.map((b) => b['clientTaskId']).toSet(), ['ct-b']); + }); + + test('complete 网络失败 → waitingNetwork;重试只补 complete(PUT 已达)', + () async { + final file = photoFile('c.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.completeNetworkFailures = 1; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-c'); + await cycle(); + + var t = await task(seeded.id); + expect(t.state, UploadTaskState.waitingNetwork); + // 网络失败(非核验失败)不产生服务端事件:序列止于 PUT。 + expect(h.events, ['init:ct-c', 'put']); + expect(h.events.where((e) => e.startsWith('complete')), isEmpty); + + h.clock = h.clock.add(const Duration(seconds: 30)); + await cycle(); + t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + // 重试路径:init 幂等 + PUT 重传(服务端对象覆盖写)+ complete。 + expect(h.events, ['init:ct-c', 'put', 'init:ct-c(idem)', 'put', 'complete:upl1']); + }); + }); + + group('断网恢复', () { + test('网络失败退避等待;onNetworkRestored 立即绕过退避完成', () async { + final file = photoFile('d.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.initNetworkFailures = 1; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-d'); + await cycle(); + + var t = await task(seeded.id); + expect(t.state, UploadTaskState.waitingNetwork); + expect(t.lastError, ErrorCodes.networkError); + expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 5))); + + // 时钟未推进(退避未到),但网络恢复信号触发立即重试。 + engine.onNetworkRestored(); + await cycle(); + + t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + expect(h.initBodies, hasLength(2)); + }); + }); + + group('退避曲线与自动重试上限', () { + test('backoffFor:5s·2^(n-1) 封顶 10min', () { + expect(UploadEngine.backoffFor(1), const Duration(seconds: 5)); + expect(UploadEngine.backoffFor(2), const Duration(seconds: 10)); + expect(UploadEngine.backoffFor(3), const Duration(seconds: 20)); + expect(UploadEngine.backoffFor(7), const Duration(seconds: 320)); + // 5s·2^7=640s 越过封顶 → 600s。 + expect(UploadEngine.backoffFor(8), const Duration(minutes: 10)); + expect(UploadEngine.backoffFor(100), const Duration(minutes: 10)); + expect(UploadEngine.backoffFor(0), const Duration(seconds: 5)); + }); + + test('UPLOAD_VERIFY_FAILED 三次后停止自动重试(nextRetryAt=null 终止符)', + () async { + final file = photoFile('e.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.completeVerifyFailures = 3; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-e'); + await cycle(); + + var t = await task(seeded.id); + expect(t.state, UploadTaskState.retryableFailed); + expect(t.attempt, 1); + expect(t.lastError, ErrorCodes.uploadVerifyFailed); + expect(t.nextRetryAt, isNotNull); + + h.clock = h.clock.add(const Duration(seconds: 10)); + await cycle(); + t = await task(seeded.id); + expect(t.attempt, 2); + + h.clock = h.clock.add(const Duration(seconds: 30)); + await cycle(); + t = await task(seeded.id); + expect(t.state, UploadTaskState.retryableFailed); + expect(t.attempt, 3); + expect(t.nextRetryAt, isNull, reason: '达上限:不再自动重试'); + + final initCount = h.initBodies.length; + h.clock = h.clock.add(const Duration(hours: 1)); + await cycle(); + expect(h.initBodies.length, initCount, reason: '终止符后不再出队'); + expect((await task(seeded.id)).state, UploadTaskState.retryableFailed); + }); + + test('retryNow 手动重试可复活终止符任务', () async { + final file = photoFile('e2.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.completeVerifyFailures = 3; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-e2'); + await cycle(); + h.clock = h.clock.add(const Duration(seconds: 10)); + await cycle(); + h.clock = h.clock.add(const Duration(seconds: 30)); + await cycle(); + var t = await task(seeded.id); + expect(t.nextRetryAt, isNull); + + h.completeVerifyFailures = 0; // 服务端修复。 + await engine.retryNow('alice', seeded.id); + await cycle(); + t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + }); + }); + + group('服务端状态分类', () { + test('409 ALBUM_UPLOAD_DISABLED → retryableFailed 不自动重试', () async { + final file = photoFile('f.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.initAlbumDisabled = 1; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-f'); + await cycle(); + + final t = await task(seeded.id); + expect(t.state, UploadTaskState.retryableFailed); + expect(t.lastError, ErrorCodes.albumUploadDisabled); + expect(t.nextRetryAt, isNull); + expect(t.attempt, 0, reason: '客户端重试无意义,不烧次数'); + + final initCount = h.initBodies.length; + h.clock = h.clock.add(const Duration(hours: 1)); + await cycle(); + expect(h.initBodies.length, initCount, reason: '无自动重试'); + }); + + test('401 会话过期 → waitingNetwork 短退避;重登录后(重试)完成,' + 'attempt 不增长', () async { + final file = photoFile('g.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.initUnauthorized = 1; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-g'); + await cycle(); + + var t = await task(seeded.id); + expect(t.state, UploadTaskState.waitingNetwork); + expect(t.lastError, ErrorCodes.authTokenExpired); + expect(t.attempt, 0); + expect(t.nextRetryAt, h.clock.add(const Duration(seconds: 30))); + + h.clock = h.clock.add(const Duration(seconds: 31)); + await cycle(); + t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + }); + }); + + group('凭证续期(presign 403)', () { + test('403 → renewUpload → 新 URL 重传成功', () async { + final file = photoFile('h.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.put403Count = 1; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-h'); + await cycle(); + + final t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + expect(h.renewCalls, 1); + expect(h.events, + ['init:ct-h', 'put', 'renew:upl1', 'put', 'complete:upl1']); + // 第二次 PUT 用续期 URL。 + expect(h.objectAdapter.requests.last.uri.query, 'renewed=1'); + }); + + test('403 持续超元数上限 → 分类失败(retryableFailed)', () async { + final file = photoFile('h2.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + h.put403Count = 99; // 永远 403。 + + final seeded = await h.seedTask(file, clientTaskId: 'ct-h2'); + await cycle(); + + final t = await task(seeded.id); + expect(t.state, UploadTaskState.retryableFailed); + expect(h.renewCalls, kMaxCredentialRenewals); + expect(t.attempt, 1, reason: '第 4 次 403 落入通用分类桶'); + }); + }); + + group('取消', () { + test('未开始(queued):删行 + 删受管文件', () async { + final file = photoFile('i.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + final seeded = await h.seedTask(file, clientTaskId: 'ct-i'); + final cancelled = await engine.cancel('alice', seeded.id); + expect(cancelled, isTrue); + expect(await h.store.get('alice', seeded.id), isNull, reason: '行已删'); + expect(file.existsSync(), isFalse, reason: '受管文件已删'); + expect(h.events, isEmpty, reason: '从未发起传输'); + }); + + test('进行中(uploading):尽力取消——置 cancelled、文件保留、PUT 收敛后不再迁移', + () async { + final file = photoFile('j.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + final gate = Completer(); + h.putGate = gate; + + final seeded = await h.seedTask(file, clientTaskId: 'ct-j'); + // 只启动(PUT 挂起),不 cycle 收敛。泵事件循环直到 PUT 真正发出。 + await engine.drain(); + await until(() => h.objectAdapter.requests.isNotEmpty); + expect(h.objectAdapter.requests, isNotEmpty, reason: 'PUT 已发出'); + + final cancelled = await engine.cancel('alice', seeded.id); + expect(cancelled, isTrue); + expect((await task(seeded.id)).state, UploadTaskState.cancelled); + + gate.complete(); + await engine.settle(); + await cycle(); + + final t = await task(seeded.id); + expect(t.state, UploadTaskState.cancelled, reason: '取消是终态,PUT 结果不复活'); + expect(file.existsSync(), isTrue, reason: '进行中取消:文件保留给清理执行器'); + expect(h.events.where((e) => e.startsWith('complete')), isEmpty); + }); + + test('已终态(completed):取消无效返回 false', () async { + final file = photoFile('i2.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + final seeded = await h.seedTask(file, clientTaskId: 'ct-i2'); + await cycle(); + expect(await engine.cancel('alice', seeded.id), isFalse); + }); + }); + + group('账号隔离与 A15', () { + test('引擎只出队当前账号任务(他人队列不动)', () async { + final mine = photoFile('k1.jpg'); + final theirs = photoFile('k2.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + final aliceTask = await h.seedTask(mine, clientTaskId: 'ct-k1'); + final bobTask = await h.seedTask( + theirs, + accountId: 'bob', + clientTaskId: 'ct-k2', + ); + await cycle(); + + expect((await task(aliceTask.id)).state, UploadTaskState.completed); + final bobRow = await h.store.get('bob', bobTask.id); + expect(bobRow!.state, UploadTaskState.queued, reason: 'bob 的任务不被触碰'); + expect(h.initBodies.map((b) => b['clientTaskId']), ['ct-k1']); + }); + + test('A15:连拍中途切换相册/分类不影响既有任务(引擎只读行内目标)', + () async { + final f1 = photoFile('l1.jpg'); + final f2 = photoFile('l2.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + // 第一张:相册 A 分类 X;随后"切到"相册 B 分类 Y 拍第二张。 + final t1 = await h.seedTask( + f1, + albumId: 'alb-A', + categoryId: 'cat-X', + clientTaskId: 'ct-l1', + ); + final t2 = await h.seedTask( + f2, + albumId: 'alb-B', + categoryId: 'cat-Y', + clientTaskId: 'ct-l2', + ); + await cycle(); + + expect((await task(t1.id)).state, UploadTaskState.completed); + expect((await task(t2.id)).state, UploadTaskState.completed); + // 各自 init 携带各自入队时的目标(归属不变)。 + expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l1') + ['albumId'], 'alb-A'); + expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l1') + ['categoryId'], 'cat-X'); + expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l2') + ['albumId'], 'alb-B'); + expect(h.initBodies.firstWhere((b) => b['clientTaskId'] == 'ct-l2') + ['categoryId'], 'cat-Y'); + }); + }); + + group('并行上限', () { + test('maxParallel=2:三任务闸门下同时只有 2 个 PUT,放开后全部完成', + () async { + final files = [ + photoFile('m1.jpg'), + photoFile('m2.jpg'), + photoFile('m3.jpg'), + ]; + engine = h.buildEngine(maxParallel: 2); + engine.start('alice', period: const Duration(hours: 1)); + final gate = Completer(); + h.putGate = gate; + + for (var i = 0; i < files.length; i++) { + await h.seedTask(files[i], clientTaskId: 'ct-m${i + 1}'); + } + await engine.drain(); + // 泵到两个 PUT 都被适配器记录(协程 init 异步在途;第三个不发出)。 + await until(() => h.objectAdapter.requests.length == 2); + + expect(h.objectAdapter.requests, hasLength(2), + reason: '并行上限 2:第三个 PUT 不发出'); + expect((await h.store.listByAccount('alice')) + .where((t) => t.state == UploadTaskState.uploading) + .length, 2); + + gate.complete(); + await cycle(); + + final states = await h.store.listByAccount('alice'); + expect(states.map((t) => t.state), + everyElement(UploadTaskState.completed)); + expect(h.objectAdapter.requests, hasLength(3)); + }); + }); + + group('崩溃恢复', () { + test('卡在 uploading 的任务(进程被杀痕迹)drain 前归位并完成', () async { + final file = photoFile('n.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + // 摆出"被杀时"的僵死状态:queued → uploading 后无 in-flight。 + final seeded = await h.seedTask(file, clientTaskId: 'ct-n'); + await h.store.updateState(seeded.id, UploadTaskState.uploading); + + await cycle(); + final t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + expect(h.events, ['init:ct-n', 'put', 'complete:upl1']); + }); + + test('卡在 uploadedConfirming 的任务同样归位重试(complete 幂等)', + () async { + final file = photoFile('n2.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + final seeded = await h.seedTask(file, clientTaskId: 'ct-n2'); + await h.store.updateState(seeded.id, UploadTaskState.uploading); + await h.store.updateState( + seeded.id, + UploadTaskState.uploadedConfirming, + ); + + await cycle(); + expect((await task(seeded.id)).state, UploadTaskState.completed); + }); + }); + + group('清理执行器', () { + test('超过保留期的 completed:文件删除 + cleanedAt 标记 + 行保留为记录', + () async { + final file = photoFile('o.jpg'); + engine = h.buildEngine(keepCompletedDays: 14); + engine.start('alice', period: const Duration(hours: 1)); + + final seeded = await h.seedTask(file, clientTaskId: 'ct-o'); + await cycle(); + expect((await task(seeded.id)).state, UploadTaskState.completed); + + // 保留期内不清理。 + expect(await engine.cleanup(), 0); + expect(file.existsSync(), isTrue); + + // 20 天后(回写 updatedAt 早于截止)。 + final future = h.clock.add(const Duration(days: 20)); + expect(await engine.cleanup(now: future), 1); + expect(file.existsSync(), isFalse); + final t = await task(seeded.id); + expect(t.state, UploadTaskState.completed); + expect(t.cleanedAt, future); + + // 幂等:已清理不再重复。 + expect( + await engine.cleanup(now: future.add(const Duration(days: 1))), + 0, + ); + }); + + test('未完成任务源文件永不清理(硬守护在引擎侧同样成立)', () async { + final queuedFile = photoFile('p1.jpg'); + final waitingFile = photoFile('p2.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + + final ancient = h.clock.subtract(const Duration(days: 100)); + // queued 任务(seedTask 落库即 queued,updatedAt=ancient)。 + await h.seedTask( + queuedFile, + clientTaskId: 'ct-p1', + createdAt: ancient, + ); + // waitingNetwork 的古老任务(updatedAt 同样古老)。 + final w = await h.seedTask( + waitingFile, + clientTaskId: 'ct-p2', + createdAt: ancient, + ); + await h.store.updateState( + w.id, + UploadTaskState.waitingNetwork, + patch: UploadTaskPatch( + nextRetryAt: h.clock.add(const Duration(hours: 1)), + ), + now: ancient, + ); + // q 的 updatedAt 已是 ancient(seedTask 落库时写入)。 + + expect(await engine.cleanup(), 0); + expect(queuedFile.existsSync(), isTrue); + expect(waitingFile.existsSync(), isTrue); + }); + }); + + test('源文件缺失:分类为不自动重试的 retryableFailed', () async { + final file = photoFile('q.jpg'); + engine = h.buildEngine(); + engine.start('alice', period: const Duration(hours: 1)); + final seeded = await h.seedTask(file, clientTaskId: 'ct-q'); + await file.delete(); // 受管副本被系统清理。 + + await cycle(); + final t = await task(seeded.id); + expect(t.state, UploadTaskState.retryableFailed); + expect(t.lastError, UploadEngine.sourceMissingCode); + expect(t.nextRetryAt, isNull); + // 文件检查在 PUT 阶段(init 已发生、无 PUT/complete)。 + expect(h.events, ['init:ct-q']); + }); +} diff --git a/apps/mobile/test/features/uploads_models_test.dart b/apps/mobile/test/features/uploads_models_test.dart index 9b07e31..6d638b9 100644 --- a/apps/mobile/test/features/uploads_models_test.dart +++ b/apps/mobile/test/features/uploads_models_test.dart @@ -31,6 +31,22 @@ final Map initResponseFixture = { 'credential': credentialFixture, }; +/// UploadSessionRecord 最小形状(sourceDeviceId 测试基底)。 +const Map _recordFixture = { + 'uploadId': 'upl0000002', + 'albumId': 'cktragt000001', + 'clientTaskId': 'task-uuid-0002', + 'state': 'confirmed', + 'expectedBytes': 8192000, + 'contentType': 'image/png', + 'createdAt': '2026-09-19T09:15:30.000Z', + 'photo': { + 'photoId': 'pho0000002', + 'processingState': 'uploaded', + 'visibility': 'unpublished', + }, +}; + void main() { test('UploadCredential fromJson/toJson 往返(e2e 凭证形状)', () { final model = UploadCredential.fromJson(credentialFixture); @@ -76,6 +92,27 @@ void main() { expect(withCategory.toJson()['categoryId'], 'ckucat0000001'); }); + test('UploadInitRequest sourceDeviceId(P3 Task 3 契约增补):' + '提供时下发、缺省不下发', () { + const withDevice = UploadInitRequest( + albumId: 'cktragt000001', + clientTaskId: 'task-uuid-0003', + filename: 'IMG_20260919_0003.jpg', + bytes: 1024, + contentType: 'image/jpeg', + sourceDeviceId: '0123456789abcdef0123456789abcdef', + ); + expect(withDevice.toJson()['sourceDeviceId'], isNotNull); + const withoutDevice = UploadInitRequest( + albumId: 'cktragt000001', + clientTaskId: 'task-uuid-0004', + filename: 'IMG_20260919_0004.jpg', + bytes: 1024, + contentType: 'image/jpeg', + ); + expect(withoutDevice.toJson().containsKey('sourceDeviceId'), isFalse); + }); + test('UploadCompleteResponse fromJson/toJson 往返', () { final fixture = { 'photoId': 'pho0000001', @@ -108,6 +145,19 @@ void main() { expect(model.toJson(), equals(fixture)); }); + test('UploadSessionRecord sourceDeviceId:可空回显(A19 设备来源)', () { + final withDevice = UploadSessionRecord.fromJson({ + ..._recordFixture, + 'sourceDeviceId': '0123456789abcdef0123456789abcdef', + }); + expect(withDevice.sourceDeviceId, '0123456789abcdef0123456789abcdef'); + expect(withDevice.toJson()['sourceDeviceId'], isNotNull); + // init 未携带时为 null(契约 type: [string, 'null'])。 + final withoutDevice = UploadSessionRecord.fromJson(_recordFixture); + expect(withoutDevice.sourceDeviceId, isNull); + expect(withoutDevice.toJson().containsKey('sourceDeviceId'), isFalse); + }); + test('UploadSessionState 全枚举解析(pending/confirmed/cancelled/expired)', () { for (final value in const ['pending', 'confirmed', 'cancelled', 'expired']) { diff --git a/apps/mobile/test/helpers/fakes.dart b/apps/mobile/test/helpers/fakes.dart index 16b2c62..154e099 100644 --- a/apps/mobile/test/helpers/fakes.dart +++ b/apps/mobile/test/helpers/fakes.dart @@ -70,6 +70,10 @@ class FakeAdapter implements HttpClientAdapter { body: options.data, ), ); + if (requestStream != null) { + // 消费请求流(真实服务端会读 body;上传引擎的进度计数流由此驱动)。 + await requestStream.drain(); + } final (status, body) = await router(options); if (body == null) { // 204 等:空体且不带 JSON content-type(transformer 不解码)。 diff --git a/apps/mobile/test/pages/server_history_page_test.dart b/apps/mobile/test/pages/server_history_page_test.dart new file mode 100644 index 0000000..8396200 --- /dev/null +++ b/apps/mobile/test/pages/server_history_page_test.dart @@ -0,0 +1,199 @@ +/// 服务端上传历史页 widget 测试(P3 计划 Task 6,A19 语义)。 +/// +/// 覆盖:A19 数据源区分横幅、行内容(clientTaskId 短码/KB/状态标签/ +/// 设备短码 + 「本设备」标记)、游标加载更多、错误重试。 +library; + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:photo_live/core/api_client.dart'; +import 'package:photo_live/core/device_id.dart'; +import 'package:photo_live/core/session_store.dart'; +import 'package:photo_live/l10n/app_localizations.dart'; +import 'package:photo_live/pages/server_history_page.dart'; + +import '../helpers/fakes.dart'; + +const String _kOwnDevice = 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'; +const String _kOtherDevice = 'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb'; + +class _FixedDeviceIdStore implements DeviceIdStore { + _FixedDeviceIdStore(this._value); + final String _value; + + @override + Future read() async => _value; + + @override + Future write(String value) async {} +} + +/// 契约 `UploadSessionRecord` 响应体(GET /uploads 条目)。 +Map sessionJson({ + String uploadId = 'upl-0001', + String albumId = 'alb-1', + String clientTaskId = 'clienttask000000000000000000000001', + String state = 'confirmed', + int expectedBytes = 2048, + String createdAt = '2026-09-19T08:00:00Z', + String photoId = 'pho-0001', + String processingState = 'ready', + String? sourceDeviceId = _kOwnDevice, +}) => + { + 'uploadId': uploadId, + 'albumId': albumId, + 'clientTaskId': clientTaskId, + 'state': state, + 'expectedBytes': expectedBytes, + 'contentType': 'image/jpeg', + 'createdAt': createdAt, + 'sourceDeviceId': sourceDeviceId, + 'photo': { + 'photoId': photoId, + 'processingState': processingState, + 'visibility': 'published', + }, + }; + +void main() { + late FakeSessionStore sessionStore; + + setUp(() { + sessionStore = FakeSessionStore() + ..session = Session(token: 'tok-1', account: 'alice'); + }); + + Future pumpPage(WidgetTester tester, ApiClient client) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + apiClientProvider.overrideWith((ref) => client), + deviceIdProvider.overrideWith( + (ref) => DeviceId(store: _FixedDeviceIdStore(_kOwnDevice)), + ), + ], + child: MaterialApp( + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const ServerHistoryPage(), + ), + ), + ); + await tester.pumpAndSettle(); + } + + ApiClient clientOf(FakeAdapter adapter) => ApiClient( + sessionStore: sessionStore, + dio: Dio(BaseOptions(baseUrl: 'http://test.local')) + ..httpClientAdapter = adapter, + ); + + testWidgets('A19 横幅 + 行内容(短码/KB/状态标签/设备短码/本设备标记)', + (tester) async { + final adapter = FakeAdapter((o) { + if (o.method == 'GET' && o.path == '/v1/uploads') { + return ( + 200, + cursorPageBody([ + sessionJson(clientTaskId: 'owndevice0000000000000000000001'), + sessionJson( + clientTaskId: 'otherdevice0000000000000000000002', + state: 'pending', + processingState: 'processing', + expectedBytes: 5 * 1024 * 1024, + sourceDeviceId: _kOtherDevice, + ), + ]), + ); + } + throw StateError('unexpected ${o.method} ${o.path}'); + }); + + await pumpPage(tester, clientOf(adapter)); + + // A19 数据源区分横幅。 + expect(find.textContaining('cross-device'), findsOneWidget); + expect(find.textContaining('Queue tab'), findsOneWidget); + + // 行:clientTaskId 前 8 位 + KB(2048B → 2 KB;5MB → 5120 KB)。 + expect(find.textContaining('owndevic'), findsOneWidget); + expect(find.textContaining('2 KB'), findsOneWidget); + expect(find.textContaining('otherdev'), findsOneWidget); + expect(find.textContaining('5120 KB'), findsOneWidget); + + // 状态标签(会话状态 + 处理状态)。 + expect(find.text('Confirmed'), findsOneWidget); + expect(find.text('Pending'), findsOneWidget); + expect(find.text('Ready'), findsOneWidget); + expect(find.text('Processing'), findsOneWidget); + + // 设备短码(本机设备aaaaaaaa + 「本设备」;他机无标记)。 + expect(find.textContaining(_kOwnDevice.substring(0, 8)), findsOneWidget); + expect(find.textContaining(_kOtherDevice.substring(0, 8)), findsOneWidget); + expect(find.textContaining('(This device)'), findsOneWidget); + + // listUploads 请求带分页 limit=20。 + expect(adapter.requests.single.uri.queryParameters['limit'], '20'); + }); + + testWidgets('游标加载更多(滚动触底 → 第二页带 cursor)', (tester) async { + final page1 = List>.generate( + 20, + (i) => sessionJson( + uploadId: 'upl-p1-$i', + clientTaskId: 'page1row$i.padLeft-000000000000000000001', + ), + ); + final adapter = FakeAdapter((o) { + final cursor = o.uri.queryParameters['cursor']; + if (cursor == null) { + return (200, cursorPageBody(page1, nextCursor: 'hc1')); + } + if (cursor == 'hc1') { + return ( + 200, + cursorPageBody( + [sessionJson(clientTaskId: 'page2item000000000000000000000001')], + ), + ); + } + throw StateError('unexpected cursor $cursor'); + }); + + await pumpPage(tester, clientOf(adapter)); + expect(adapter.requests, hasLength(1), reason: '首页无 cursor'); + + await tester.drag(find.byType(ListView), const Offset(0, -6000)); + await tester.pumpAndSettle(); + + expect(adapter.requests.last.uri.queryParameters['cursor'], 'hc1'); + expect(find.textContaining('page2ite'), findsOneWidget); + }); + + testWidgets('加载失败展示错误与重试;恢复后重试成功', (tester) async { + var failFirst = true; + final adapter = FakeAdapter((o) { + if (failFirst) { + throw DioException( + requestOptions: RequestOptions(path: o.path), + type: DioExceptionType.connectionError, + ); + } + return (200, cursorPageBody([sessionJson()])); + }); + + await pumpPage(tester, clientOf(adapter)); + expect(find.textContaining('Network error'), findsOneWidget); + expect(find.byIcon(Icons.cloud_off), findsOneWidget); + + failFirst = false; + await tester.tap(find.text('Retry')); + await tester.pumpAndSettle(); + + expect(find.textContaining('Network error'), findsNothing); + expect(find.textContaining('clientta'), findsOneWidget, reason: '重试后行渲染'); + }); +}