feat(mobile): upload engine with preparation, batching states and retries
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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<PhotoLiveApp> {
|
||||
'/login': (context) => const LoginPage(),
|
||||
'/home': (context) => const HomePage(),
|
||||
'/password-change': (context) => const PasswordChangePage(),
|
||||
'/server-history': (context) => const ServerHistoryPage(),
|
||||
},
|
||||
home: const LoginPage(),
|
||||
);
|
||||
|
||||
@@ -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<String?> read();
|
||||
Future<void> write(String value);
|
||||
}
|
||||
|
||||
/// shared_preferences 生产实现(单键)。
|
||||
class SharedPrefsDeviceIdStore implements DeviceIdStore {
|
||||
const SharedPrefsDeviceIdStore();
|
||||
|
||||
static const String kKey = 'device.id';
|
||||
|
||||
@override
|
||||
Future<String?> read() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
return prefs.getString(kKey);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> 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<String>? _pending;
|
||||
|
||||
/// 合法取值形状:32 位小写十六进制。
|
||||
static final RegExp kPattern = RegExp(r'^[0-9a-f]{32}$');
|
||||
|
||||
/// 当前设备标识(首次调用生成并持久化;后续读缓存)。
|
||||
Future<String> value() => _pending ??= _loadOrCreate();
|
||||
|
||||
Future<String> _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<int>.generate(16, (_) => _random.nextInt(256));
|
||||
return hexEncode(bytes);
|
||||
}
|
||||
|
||||
/// 字节 → 小写十六进制(独立函数,单测直接覆盖)。
|
||||
static String hexEncode(List<int> bytes) =>
|
||||
bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
|
||||
}
|
||||
|
||||
/// 设备标识 Provider(全局单例语义:整个 App 共享同一 per-install 值)。
|
||||
final deviceIdProvider = Provider<DeviceId>((ref) => DeviceId());
|
||||
@@ -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<String, dynamic> 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<String, dynamic> json) =>
|
||||
@@ -257,6 +264,7 @@ class UploadSessionRecord {
|
||||
photo: UploadSessionPhoto.fromJson(
|
||||
json['photo'] as Map<String, dynamic>,
|
||||
),
|
||||
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(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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<Directory> Function()? baseDirProvider,
|
||||
Random? random,
|
||||
this.maxBytes = kMaxUploadBytes,
|
||||
this.onEnqueued,
|
||||
}) : _baseDirProvider = baseDirProvider ?? getApplicationSupportDirectory,
|
||||
_random = random ?? Random.secure();
|
||||
|
||||
final TaskStore _store;
|
||||
final Future<Directory> 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<void> _tail = Future<void>.value();
|
||||
|
||||
/// 准备一张图片并入队。
|
||||
///
|
||||
/// [sourcePath] 为 image_picker/camera 返回的源文件路径(App 沙箱内
|
||||
/// 可直接 File 读取);[filename] 缺省取源文件 basename。
|
||||
///
|
||||
/// 返回入队后的任务(state=queued);失败返回 state=preparationFailed
|
||||
/// 的任务(不抛异常——准备失败是业务状态而非调用错误)。
|
||||
Future<UploadTask> 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<UploadTask> retry({
|
||||
required String accountId,
|
||||
required String taskId,
|
||||
required String sourcePath,
|
||||
}) {
|
||||
return _serialize(() => _retry(
|
||||
accountId: accountId,
|
||||
taskId: taskId,
|
||||
sourcePath: sourcePath,
|
||||
));
|
||||
}
|
||||
|
||||
/// 串行化:所有准备操作首尾相接(错误吞掉以不断链,错误经返回值上抛)。
|
||||
Future<T> _serialize<T>(Future<T> Function() op) {
|
||||
final result = _tail.then((_) => op());
|
||||
_tail = result.then<void>((_) {}, onError: (Object _) {});
|
||||
return result;
|
||||
}
|
||||
|
||||
Future<UploadTask> _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<UploadTask> _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<UploadTask> _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<String?> _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<int> _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<Directory> _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<int>.generate(16, (_) => _random.nextInt(256));
|
||||
return DeviceId.hexEncode(bytes);
|
||||
}
|
||||
}
|
||||
@@ -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<TaskStore>((ref) {
|
||||
final store = TaskStore.app();
|
||||
ref.onDispose(store.close);
|
||||
return store;
|
||||
});
|
||||
|
||||
/// 传输引擎(裸 Dio 直传 presign;并行/保留期配置见 upload_engine.dart
|
||||
/// 的 dart-define 常量)。
|
||||
final uploadEngineProvider = Provider<UploadEngine>((ref) {
|
||||
final engine = UploadEngine(
|
||||
ref.watch(taskStoreProvider),
|
||||
ref.watch(apiClientProvider),
|
||||
ref.watch(deviceIdProvider),
|
||||
);
|
||||
ref.onDispose(engine.dispose);
|
||||
return engine;
|
||||
});
|
||||
|
||||
/// 准备器(入队回调唤醒引擎立即出队)。
|
||||
final uploadPreparerProvider = Provider<UploadPreparer>((ref) {
|
||||
final engine = ref.watch(uploadEngineProvider);
|
||||
return UploadPreparer(
|
||||
ref.watch(taskStoreProvider),
|
||||
onEnqueued: engine.notifyWorkAvailable,
|
||||
);
|
||||
});
|
||||
@@ -292,6 +292,10 @@ class TaskStore {
|
||||
|
||||
/// 状态迁移(带合法迁移守护 + updatedAt 刷新),返回迁移后的任务。
|
||||
///
|
||||
/// patch 语义(与 [UploadTaskPatch] 文档一致):**未提供的列保持不变**,
|
||||
/// 显式清除走 clear* 布尔;serverUploadId/serverPhotoId 仅在提供时
|
||||
/// 写入(write-once 回填,无清除通道)。
|
||||
///
|
||||
/// 抛 [StateError]:任务不存在,或 `当前 → to` 不在合法迁移表
|
||||
/// ([assertTransition])。
|
||||
Future<UploadTask> 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<UploadTask> 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)),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<String, _TransferHandle> _inFlight = <String, _TransferHandle>{};
|
||||
|
||||
/// drain 进行中Future(并发调用折叠为同轮——所有 await 方都等到完整
|
||||
/// 调度结束;结束后清空)。
|
||||
Future<void>? _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<void> settle() => Future.wait(
|
||||
[for (final handle in _inFlight.values) handle.done],
|
||||
).then((_) {});
|
||||
|
||||
/// 一轮调度:崩溃恢复 → 补足并行配额。并发调用折叠为同轮(见
|
||||
/// [_drainPending]),所有 await 方等到完整调度结束。
|
||||
Future<void> drain() {
|
||||
final pending = _drainPending;
|
||||
if (pending != null) return pending;
|
||||
final future = _doDrain().whenComplete(() => _drainPending = null);
|
||||
_drainPending = future;
|
||||
return future;
|
||||
}
|
||||
|
||||
Future<void> _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<UploadTask?> _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<void> _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<void> _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<void> _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<void> _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<int> chunk) {
|
||||
sent += chunk.length;
|
||||
onProgress?.call(task.id, sent, length);
|
||||
return chunk;
|
||||
});
|
||||
await _objectDio.put<void>(
|
||||
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<void> _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<void> _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<bool> 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<void> 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<int> 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<void> done = Future<void>.value();
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
|
||||
@@ -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 => '本设备';
|
||||
}
|
||||
|
||||
@@ -107,5 +107,15 @@
|
||||
"procUploaded": "已上传",
|
||||
"procProcessing": "处理中",
|
||||
"procReady": "就绪",
|
||||
"procProcessingFailed": "处理失败"
|
||||
"procProcessingFailed": "处理失败",
|
||||
"queueStubHint": "本机待传队列与连拍管理将在任务 9 上线",
|
||||
"serverHistoryEntry": "查看服务端上传历史",
|
||||
"serverHistoryTitle": "服务端上传历史",
|
||||
"serverHistoryBanner": "服务端历史(跨设备)——本机待传队列请看「队列」页",
|
||||
"serverHistoryEmpty": "暂无上传记录",
|
||||
"uploadStatePending": "待完成",
|
||||
"uploadStateConfirmed": "已确认",
|
||||
"uploadStateCancelled": "已取消",
|
||||
"uploadStateExpired": "已过期",
|
||||
"thisDevice": "本设备"
|
||||
}
|
||||
|
||||
@@ -54,19 +54,39 @@ class _HomePageState extends State<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// 上传队列(桩)。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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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<ServerHistoryPage> createState() => _ServerHistoryPageState();
|
||||
}
|
||||
|
||||
class _ServerHistoryPageState extends ConsumerState<ServerHistoryPage> {
|
||||
static const int _pageSize = 20;
|
||||
|
||||
List<UploadSessionRecord> _items = const <UploadSessionRecord>[];
|
||||
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<void> _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<void> _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 = <UploadSessionRecord>[..._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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String?> read() async => value;
|
||||
|
||||
@override
|
||||
Future<void> 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<String?> read() async => throw _Boom();
|
||||
|
||||
@override
|
||||
Future<void> write(String value) async {}
|
||||
}
|
||||
|
||||
class _Boom implements Exception {}
|
||||
|
||||
/// 固定序列 Random(测试确定性注入)。
|
||||
class _SequenceRandom implements Random {
|
||||
final List<int> _values = List<int>.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;
|
||||
}
|
||||
@@ -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<int> _jpgBytes = [
|
||||
0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, // JFIF 头片段
|
||||
0x4A, 0x46, 0x49, 0x46, 0x00,
|
||||
];
|
||||
final List<int> _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<int> 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<StateError>()),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
}
|
||||
@@ -251,6 +251,59 @@ void main() {
|
||||
throwsA(isA<StateError>()),
|
||||
);
|
||||
});
|
||||
|
||||
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('账号隔离(硬守护)', () {
|
||||
|
||||
@@ -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<String> events = <String>[];
|
||||
|
||||
/// clientTaskId → init 响应体(幂等:同键回原会话)。
|
||||
final Map<String, Map<String, dynamic>> sessionsByClientTaskId =
|
||||
<String, Map<String, dynamic>>{};
|
||||
|
||||
/// uploadId → init 响应体(renew/complete 回显)。
|
||||
final Map<String, Map<String, dynamic>> sessionsByUploadId =
|
||||
<String, Map<String, dynamic>>{};
|
||||
|
||||
final List<Map<String, dynamic>> initBodies = <Map<String, dynamic>>[];
|
||||
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<void>? 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<String, dynamic> _initBodyFor(String uploadId, String photoId,
|
||||
{bool renewed = false}) {
|
||||
return <String, dynamic>{
|
||||
'uploadId': uploadId,
|
||||
'photoId': photoId,
|
||||
'objectKey': 'photo-live/dev/staging/$uploadId/$photoId.jpg',
|
||||
'credential': <String, dynamic>{
|
||||
'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<String, dynamic>.from(o.data as Map<dynamic, dynamic>);
|
||||
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,
|
||||
<String, dynamic>{
|
||||
'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<UploadTask> 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<String?> read() async => _value;
|
||||
|
||||
@override
|
||||
Future<void> 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<void>.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
File photoFile(String name, [int size = 1024]) {
|
||||
final f = File('${tmp.path}/$name');
|
||||
f.writeAsBytesSync(List<int>.generate(size, (i) => i % 251));
|
||||
return f;
|
||||
}
|
||||
|
||||
/// 驱动一轮完整调度并等待收敛(drain 折叠并发调用;双循环兜底
|
||||
/// 完成回调触发的补位 drain)。
|
||||
Future<void> cycle() async {
|
||||
for (var i = 0; i < 3; i++) {
|
||||
await engine.drain();
|
||||
await engine.settle();
|
||||
}
|
||||
await engine.drain();
|
||||
}
|
||||
|
||||
/// 泵事件循环直到条件成立(真实 IO/微任务链推进用)。
|
||||
Future<void> until(bool Function() cond) async {
|
||||
for (var i = 0; i < 100 && !cond(); i++) {
|
||||
await pumpEventQueue();
|
||||
}
|
||||
expect(cond(), isTrue, reason: '等待条件在泵内未成立');
|
||||
}
|
||||
|
||||
Future<UploadTask> 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 = <String, List<int>>{};
|
||||
engine = h.buildEngine(onProgress: (id, sent, total) => progress
|
||||
.putIfAbsent(id, () => <int>[])
|
||||
.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<void>();
|
||||
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<void>();
|
||||
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']);
|
||||
});
|
||||
}
|
||||
@@ -31,6 +31,22 @@ final Map<String, dynamic> initResponseFixture = <String, dynamic>{
|
||||
'credential': credentialFixture,
|
||||
};
|
||||
|
||||
/// UploadSessionRecord 最小形状(sourceDeviceId 测试基底)。
|
||||
const Map<String, dynamic> _recordFixture = <String, dynamic>{
|
||||
'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 = <String, dynamic>{
|
||||
'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']) {
|
||||
|
||||
@@ -70,6 +70,10 @@ class FakeAdapter implements HttpClientAdapter {
|
||||
body: options.data,
|
||||
),
|
||||
);
|
||||
if (requestStream != null) {
|
||||
// 消费请求流(真实服务端会读 body;上传引擎的进度计数流由此驱动)。
|
||||
await requestStream.drain<Object?>();
|
||||
}
|
||||
final (status, body) = await router(options);
|
||||
if (body == null) {
|
||||
// 204 等:空体且不带 JSON content-type(transformer 不解码)。
|
||||
|
||||
@@ -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<String?> read() async => _value;
|
||||
|
||||
@override
|
||||
Future<void> write(String value) async {}
|
||||
}
|
||||
|
||||
/// 契约 `UploadSessionRecord` 响应体(GET /uploads 条目)。
|
||||
Map<String, dynamic> 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,
|
||||
}) =>
|
||||
<String, dynamic>{
|
||||
'uploadId': uploadId,
|
||||
'albumId': albumId,
|
||||
'clientTaskId': clientTaskId,
|
||||
'state': state,
|
||||
'expectedBytes': expectedBytes,
|
||||
'contentType': 'image/jpeg',
|
||||
'createdAt': createdAt,
|
||||
'sourceDeviceId': sourceDeviceId,
|
||||
'photo': <String, dynamic>{
|
||||
'photoId': photoId,
|
||||
'processingState': processingState,
|
||||
'visibility': 'published',
|
||||
},
|
||||
};
|
||||
|
||||
void main() {
|
||||
late FakeSessionStore sessionStore;
|
||||
|
||||
setUp(() {
|
||||
sessionStore = FakeSessionStore()
|
||||
..session = Session(token: 'tok-1', account: 'alice');
|
||||
});
|
||||
|
||||
Future<void> 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<Map<String, dynamic>>.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: '重试后行渲染');
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user