feat(mobile): drift upload task store with state machine
- upload_tasks 表(accountId 隔离 + 三个复合索引)、状态列枚举转换器、 datetime 文本落库(UTC 归一,亚秒精度) - 纯状态机(task_state.dart):合法迁移表 + canTransition/assertTransition, completed/cancelled 终态 - TaskStore 仓储:create(clientTaskId 同账号同相册应用侧唯一)、 updateState(迁移守护+旁路字段)、账号隔离读路径(硬守护)、 countsByState 徽章、cleanupCandidates/markCleanable(仅终态可清理—— 评审缺口 1 硬守护,未完成源文件永不清理) - 测试 26 例(状态机表驱动全边 + 守护/隔离/筛选/保留策略); sqlite3 3.x build hooks 在 flutter test 宿主与 Android APK 构建均验证 (libsqlite3.so 已打包全 ABI,运行时验证留 Task 11) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# drift_dev 代码生成选项(P3 计划 Task 5)。
|
||||||
|
#
|
||||||
|
# store_date_time_values_as_text:datetime 列以 ISO-8601 文本落库(默认为
|
||||||
|
# 秒级整数),保住 createdAt/updatedAt/nextRetryAt 的亚秒精度——退避计算
|
||||||
|
# 与保留策略的年龄比较不丢精度。
|
||||||
|
targets:
|
||||||
|
$default:
|
||||||
|
builders:
|
||||||
|
drift_dev:
|
||||||
|
options:
|
||||||
|
store_date_time_values_as_text: true
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/// 本地上传任务状态机(P3 计划 Task 5):纯迁移表,无 drift 依赖。
|
||||||
|
///
|
||||||
|
/// 状态取值与合法迁移对齐技术实施方案 §5.5 与 P3 计划 Task 5/6:
|
||||||
|
/// - 准备:preparing → queued(入队)/ preparationFailed(准备失败,可回
|
||||||
|
/// preparing 重试);
|
||||||
|
/// - 传输主干:queued → uploading → uploadedConfirming → completed;
|
||||||
|
/// - 网络等待与重试:waitingNetwork / retryableFailed 可回 queued(重新
|
||||||
|
/// 出队)或直接 uploading(续传/重试);
|
||||||
|
/// - 终态:completed / cancelled,不接受任何后续迁移(completed 的源文件
|
||||||
|
/// 仅按保留策略清理,见 task_store.dart 的 cleanupCandidates 硬守护)。
|
||||||
|
///
|
||||||
|
/// 取消语义(方案 §5.5):未开始(preparing/preparationFailed/queued/
|
||||||
|
/// waitingNetwork/retryableFailed)→ cancelled 为确定语义;uploading 进行中
|
||||||
|
/// → cancelled 为"尽力"(写入标记,实际中断由引擎在下一个检查点兑现)。
|
||||||
|
library;
|
||||||
|
|
||||||
|
/// 上传任务状态(drift 表 `upload_tasks.state` 的取值)。
|
||||||
|
///
|
||||||
|
/// 注意与服务端会话状态(UploadSessionState:pending/confirmed/...)区分:
|
||||||
|
/// 本枚举是客户端本地任务生命周期,二者在 uploadedConfirming 阶段交汇。
|
||||||
|
enum UploadTaskState {
|
||||||
|
/// 准备中:选图/拍摄后复制源文件到受管目录、空间检查(Task 6)。
|
||||||
|
preparing('preparing'),
|
||||||
|
|
||||||
|
/// 准备失败(独立状态,可重试回 preparing):复制失败/磁盘不足等。
|
||||||
|
preparationFailed('preparation_failed'),
|
||||||
|
|
||||||
|
/// 已入队,等待传输引擎出队。
|
||||||
|
queued('queued'),
|
||||||
|
|
||||||
|
/// 传输中:POST /uploads → presign PUT → complete 的任一环节。
|
||||||
|
uploading('uploading'),
|
||||||
|
|
||||||
|
/// 字节已传完,等待/重试完成核验(POST complete)。
|
||||||
|
uploadedConfirming('uploaded_confirming'),
|
||||||
|
|
||||||
|
/// 终态:核验通过,服务端已受理(cleanup 仅针对此态与 cancelled)。
|
||||||
|
completed('completed'),
|
||||||
|
|
||||||
|
/// 网络不可达,指数退避等待恢复(nextRetryAt 记录下次尝试时间)。
|
||||||
|
waitingNetwork('waiting_network'),
|
||||||
|
|
||||||
|
/// 可重试失败(服务端 5xx/超时等),按退避策略回 queued 重试。
|
||||||
|
retryableFailed('retryable_failed'),
|
||||||
|
|
||||||
|
/// 终态:用户取消(uploading 态下为尽力取消)。
|
||||||
|
cancelled('cancelled');
|
||||||
|
|
||||||
|
const UploadTaskState(this.wire);
|
||||||
|
|
||||||
|
/// 落库(drift state 列)取值。
|
||||||
|
final String wire;
|
||||||
|
|
||||||
|
/// 解析落库取值;未知值 fail-fast(与 models.dart 的枚举解析约定一致)。
|
||||||
|
static UploadTaskState fromWire(String value) => values.firstWhere(
|
||||||
|
(v) => v.wire == value,
|
||||||
|
orElse: () => throw FormatException('未知 UploadTaskState: $value'),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 是否终态(不接受任何后续迁移)。
|
||||||
|
bool get isTerminal => this == completed || this == cancelled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 合法迁移表(键=当前状态,值=可直接迁入的目标集合)。
|
||||||
|
const Map<UploadTaskState, Set<UploadTaskState>> kUploadTaskTransitions = {
|
||||||
|
UploadTaskState.preparing: {
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.preparationFailed,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.preparationFailed: {
|
||||||
|
UploadTaskState.preparing,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.queued: {
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
UploadTaskState.waitingNetwork,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.uploading: {
|
||||||
|
UploadTaskState.uploadedConfirming,
|
||||||
|
UploadTaskState.waitingNetwork,
|
||||||
|
UploadTaskState.retryableFailed,
|
||||||
|
// 尽力取消:见库级文档(进行中取消由引擎在检查点兑现)。
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.uploadedConfirming: {
|
||||||
|
UploadTaskState.completed,
|
||||||
|
UploadTaskState.retryableFailed,
|
||||||
|
UploadTaskState.waitingNetwork,
|
||||||
|
},
|
||||||
|
UploadTaskState.waitingNetwork: {
|
||||||
|
UploadTaskState.queued,
|
||||||
|
// 网络恢复直接续传(重试)。
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.retryableFailed: {
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
// 终态:无出边(completed 的清理走 cleanedAt 标记,不是状态迁移)。
|
||||||
|
UploadTaskState.completed: {},
|
||||||
|
UploadTaskState.cancelled: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
/// `from → to` 是否合法迁移(自迁移一律非法,状态未变无需写库)。
|
||||||
|
bool canTransition(UploadTaskState from, UploadTaskState to) =>
|
||||||
|
kUploadTaskTransitions[from]!.contains(to);
|
||||||
|
|
||||||
|
/// 断言 `from → to` 合法,否则抛 [StateError](携带合法目标集,便于排障)。
|
||||||
|
void assertTransition(UploadTaskState from, UploadTaskState to) {
|
||||||
|
if (!canTransition(from, to)) {
|
||||||
|
final legal = kUploadTaskTransitions[from]!;
|
||||||
|
final hint = legal.isEmpty ? '(终态,无合法后继)' : ',合法目标:$legal';
|
||||||
|
throw StateError('非法上传任务状态迁移:$from → $to$hint');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,442 @@
|
|||||||
|
/// 本地上传任务库(P3 计划 Task 5):drift schema + [TaskStore] 仓储。
|
||||||
|
///
|
||||||
|
/// 设计要点(对齐技术实施方案 §5.5 / P3 计划 Task 5/6):
|
||||||
|
/// - **账号隔离硬守护**:所有任务读路径([TaskStore.get]/
|
||||||
|
/// [TaskStore.listByAccount]/[TaskStore.countsByState])无条件附加
|
||||||
|
/// `accountId` 过滤——账号切换后不可能读到他人任务(A15 连拍归属、
|
||||||
|
/// 队列 UI 均经由这些入口);
|
||||||
|
/// - **状态机守护**:[TaskStore.updateState] 走 [assertTransition],非法
|
||||||
|
/// 迁移抛 [StateError](合法表见 task_state.dart);
|
||||||
|
/// - **clientTaskId 幂等键**:同账号同相册内唯一,**应用侧**在
|
||||||
|
/// [TaskStore.create] 时校验(不加数据库唯一约束,保留引擎重排/重建的
|
||||||
|
/// 灵活性,幂等语义由服务端 POST /uploads 409 兜底);
|
||||||
|
/// - **源文件保留策略硬守护**:[TaskStore.cleanupCandidates] 只返回
|
||||||
|
/// completed/cancelled 且超过保留期、未清理(cleanedAt IS NULL)的任务,
|
||||||
|
/// **未完成任务永不进入清理候选**;[TaskStore.markCleanable] 同样拒绝
|
||||||
|
/// 非终态任务(评审缺口 1,单测覆盖)。
|
||||||
|
///
|
||||||
|
/// 字段增补说明:`cleanedAt`(源文件已清理时间)为 P3 计划 Task 5 字段
|
||||||
|
/// 清单之外的实现增补,用于 [TaskStore.markCleanable] 的幂等(清理执行器
|
||||||
|
/// 重启后不重复删文件);服务 markCleanable/cleanupCandidates 语义。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:drift/drift.dart';
|
||||||
|
import 'package:drift/native.dart';
|
||||||
|
import 'package:path/path.dart' as p;
|
||||||
|
import 'package:path_provider/path_provider.dart';
|
||||||
|
|
||||||
|
import 'task_state.dart';
|
||||||
|
|
||||||
|
part 'task_store.g.dart';
|
||||||
|
|
||||||
|
/// `state` 列的枚举转换器(落库取值见 [UploadTaskState.wire])。
|
||||||
|
class _UploadTaskStateConverter extends TypeConverter<UploadTaskState, String> {
|
||||||
|
const _UploadTaskStateConverter();
|
||||||
|
|
||||||
|
@override
|
||||||
|
UploadTaskState fromSql(String fromDb) =>
|
||||||
|
UploadTaskState.fromWire(fromDb);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toSql(UploadTaskState value) => value.wire;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 本地上传任务表(表名 upload_tasks)。
|
||||||
|
///
|
||||||
|
/// 三个复合索引支撑账号隔离后的高频查询:队列列表(accountId)、状态
|
||||||
|
/// 徽章(accountId+state)、相册内筛选(accountId+albumId+state,需求 4.3
|
||||||
|
/// 三维筛选)。
|
||||||
|
@TableIndex(name: 'upload_tasks_account_idx', columns: {#accountId})
|
||||||
|
@TableIndex(
|
||||||
|
name: 'upload_tasks_account_state_idx',
|
||||||
|
columns: {#accountId, #state},
|
||||||
|
)
|
||||||
|
@TableIndex(
|
||||||
|
name: 'upload_tasks_account_album_state_idx',
|
||||||
|
columns: {#accountId, #albumId, #state},
|
||||||
|
)
|
||||||
|
class UploadTasks extends Table {
|
||||||
|
/// 任务 ID(客户端生成 uuid,PK;与服务端 uploadId 无关)。
|
||||||
|
TextColumn get id => text()();
|
||||||
|
|
||||||
|
/// 账号隔离键(登录账号,见 Session.account)。
|
||||||
|
TextColumn get accountId => text()();
|
||||||
|
|
||||||
|
/// 目标相册 ID。
|
||||||
|
TextColumn get albumId => text()();
|
||||||
|
|
||||||
|
/// 目标分类 ID;缺省为相册默认分类(契约 UploadInitRequest.categoryId)。
|
||||||
|
TextColumn get categoryId => text().nullable()();
|
||||||
|
|
||||||
|
/// 客户端幂等键(同账号同相册内唯一,应用侧校验,见库级文档)。
|
||||||
|
TextColumn get clientTaskId => text()();
|
||||||
|
|
||||||
|
/// 源文件绝对路径(受管目录副本:app 支持目录/uploads/{albumId}/)。
|
||||||
|
TextColumn get sourceFilePath => text()();
|
||||||
|
|
||||||
|
/// 原始文件名(仅元数据, maxLength 255,契约对齐)。
|
||||||
|
TextColumn get filename => text()();
|
||||||
|
|
||||||
|
/// 申明文件字节数(完成核验时与服务端逐一比对)。
|
||||||
|
IntColumn get bytes => integer()();
|
||||||
|
|
||||||
|
/// MIME 类型(白名单 image/jpeg | image/png | image/heic)。
|
||||||
|
TextColumn get contentType => text()();
|
||||||
|
|
||||||
|
/// 任务状态(状态机见 task_state.dart)。
|
||||||
|
TextColumn get state =>
|
||||||
|
text().map(const _UploadTaskStateConverter())();
|
||||||
|
|
||||||
|
/// 已尝试传输次数(退避与上限判断依据)。
|
||||||
|
IntColumn get attempt => integer().withDefault(const Constant(0))();
|
||||||
|
|
||||||
|
/// 下次可重试时间(waitingNetwork/retryableFailed 的退避闹钟);null=无
|
||||||
|
/// 待重试。
|
||||||
|
DateTimeColumn get nextRetryAt => dateTime().nullable()();
|
||||||
|
|
||||||
|
/// 服务端上传会话 ID(initUpload 成功后回填,重试复用)。
|
||||||
|
TextColumn get serverUploadId => text().nullable()();
|
||||||
|
|
||||||
|
/// 服务端预创建照片 ID(initUpload 成功后回填)。
|
||||||
|
TextColumn get serverPhotoId => text().nullable()();
|
||||||
|
|
||||||
|
/// 最近一次失败原因(错误码文本,见 core/error_codes.dart;成功迁移时
|
||||||
|
/// 清除)。
|
||||||
|
TextColumn get lastError => text().nullable()();
|
||||||
|
|
||||||
|
/// 源文件已清理时间(保留策略执行器写入;null=未清理)。实现增补字段,
|
||||||
|
/// 见库级文档。
|
||||||
|
DateTimeColumn get cleanedAt => dateTime().nullable()();
|
||||||
|
|
||||||
|
/// 创建时间(入 preparing 时刻)。
|
||||||
|
DateTimeColumn get createdAt => dateTime()();
|
||||||
|
|
||||||
|
/// 最近更新时间(每次状态迁移刷新;保留策略的年龄基准)。
|
||||||
|
DateTimeColumn get updatedAt => dateTime()();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Set<Column> get primaryKey => {id};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// drift 数据库(生产=app documents dir 文件;测试=[UploadTaskDb.memory])。
|
||||||
|
@DriftDatabase(tables: [UploadTasks])
|
||||||
|
class UploadTaskDb extends _$UploadTaskDb {
|
||||||
|
UploadTaskDb(super.e);
|
||||||
|
|
||||||
|
/// 测试用内存库(drift NativeDatabase.memory,随 close 丢弃)。
|
||||||
|
UploadTaskDb.memory() : super(NativeDatabase.memory());
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get schemaVersion => 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 打开生产库:app documents 目录下 upload_tasks.sqlite,后台 isolate 执行
|
||||||
|
/// SQL(避免大结果集阻塞 UI)。
|
||||||
|
LazyDatabase openAppUploadTaskDb() {
|
||||||
|
return LazyDatabase(() async {
|
||||||
|
final dir = await getApplicationDocumentsDirectory();
|
||||||
|
final file = File(p.join(dir.path, 'upload_tasks.sqlite'));
|
||||||
|
return NativeDatabase.createInBackground(file);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 新任务的创建参数(初始状态固定 preparing,见状态机)。
|
||||||
|
class UploadTaskDraft {
|
||||||
|
const UploadTaskDraft({
|
||||||
|
required this.id,
|
||||||
|
required this.accountId,
|
||||||
|
required this.albumId,
|
||||||
|
this.categoryId,
|
||||||
|
required this.clientTaskId,
|
||||||
|
required this.sourceFilePath,
|
||||||
|
required this.filename,
|
||||||
|
required this.bytes,
|
||||||
|
required this.contentType,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 任务 ID(uuid,调用方生成)。
|
||||||
|
final String id;
|
||||||
|
|
||||||
|
/// 账号隔离键。
|
||||||
|
final String accountId;
|
||||||
|
|
||||||
|
/// 目标相册 ID。
|
||||||
|
final String albumId;
|
||||||
|
|
||||||
|
/// 目标分类 ID(可缺省)。
|
||||||
|
final String? categoryId;
|
||||||
|
|
||||||
|
/// 客户端幂等键。
|
||||||
|
final String clientTaskId;
|
||||||
|
|
||||||
|
/// 源文件路径(受管目录副本)。
|
||||||
|
final String sourceFilePath;
|
||||||
|
|
||||||
|
/// 原始文件名。
|
||||||
|
final String filename;
|
||||||
|
|
||||||
|
/// 字节数。
|
||||||
|
final int bytes;
|
||||||
|
|
||||||
|
/// MIME 类型。
|
||||||
|
final String contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态迁移时一并写入的旁路字段(未提供的列保持不变;`null` 与"清除"通过
|
||||||
|
/// clear* 布尔区分)。
|
||||||
|
class UploadTaskPatch {
|
||||||
|
const UploadTaskPatch({
|
||||||
|
this.attempt,
|
||||||
|
this.nextRetryAt,
|
||||||
|
this.serverUploadId,
|
||||||
|
this.serverPhotoId,
|
||||||
|
this.lastError,
|
||||||
|
this.clearNextRetryAt = false,
|
||||||
|
this.clearLastError = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// 新的尝试次数(失败分类时 +1 后写入)。
|
||||||
|
final int? attempt;
|
||||||
|
|
||||||
|
/// 新的下一次重试时间(退避计算结果)。
|
||||||
|
final DateTime? nextRetryAt;
|
||||||
|
|
||||||
|
/// 服务端上传会话 ID(initUpload 成功时写入)。
|
||||||
|
final String? serverUploadId;
|
||||||
|
|
||||||
|
/// 服务端照片 ID(initUpload 成功时写入)。
|
||||||
|
final String? serverPhotoId;
|
||||||
|
|
||||||
|
/// 失败原因错误码。
|
||||||
|
final String? lastError;
|
||||||
|
|
||||||
|
/// 显式清除 nextRetryAt(成功出队/完成后)。
|
||||||
|
final bool clearNextRetryAt;
|
||||||
|
|
||||||
|
/// 显式清除 lastError(迁移到成功路径时)。
|
||||||
|
final bool clearLastError;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传任务仓储:引擎(Task 6)与队列 UI(Task 9)的唯一入口。
|
||||||
|
///
|
||||||
|
/// 读路径全部账号隔离(硬守护);按 id 的写路径(updateState/markCleanable/
|
||||||
|
/// delete)为引擎内部操作,id 是 uuid PK,不构成跨账号读取面。
|
||||||
|
class TaskStore {
|
||||||
|
TaskStore(this._db);
|
||||||
|
|
||||||
|
/// 生产实例(app documents dir 持久库)。
|
||||||
|
factory TaskStore.app() => TaskStore(UploadTaskDb(openAppUploadTaskDb()));
|
||||||
|
|
||||||
|
/// 测试实例(内存库)。
|
||||||
|
factory TaskStore.memory() => TaskStore(UploadTaskDb.memory());
|
||||||
|
|
||||||
|
final UploadTaskDb _db;
|
||||||
|
|
||||||
|
/// 关闭底层数据库(App 退出/测试 tearDown)。
|
||||||
|
Future<void> close() => _db.close();
|
||||||
|
|
||||||
|
/// 时间戳统一 UTC 落库(datetime 为 ISO-8601 文本存储:UTC 值以 `Z`
|
||||||
|
/// 结尾、本地值带 `+hh:mm` 后缀,混存会让 SQL 文本比较错位一个时区
|
||||||
|
/// 偏移——全部归一 UTC 后字典序即时间序)。
|
||||||
|
static DateTime _utcAt(DateTime? at) => (at ?? DateTime.now()).toUtc();
|
||||||
|
|
||||||
|
/// 创建任务(初始状态 preparing、attempt=0)。
|
||||||
|
///
|
||||||
|
/// 抛 [StateError]:同账号同相册内 clientTaskId 已存在(幂等键不可
|
||||||
|
/// 复用——应用侧唯一性校验,见库级文档)。
|
||||||
|
Future<UploadTask> create(UploadTaskDraft draft, {DateTime? now}) {
|
||||||
|
return _db.transaction(() async {
|
||||||
|
final conflict = await (_db.select(_db.uploadTasks)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.accountId.equals(draft.accountId) &
|
||||||
|
t.albumId.equals(draft.albumId) &
|
||||||
|
t.clientTaskId.equals(draft.clientTaskId),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
if (conflict.isNotEmpty) {
|
||||||
|
throw StateError(
|
||||||
|
'clientTaskId 在同账号同相册内已存在:${draft.clientTaskId}'
|
||||||
|
'(幂等键不可复用)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final timestamp = _utcAt(now);
|
||||||
|
await _db.into(_db.uploadTasks).insert(
|
||||||
|
UploadTasksCompanion.insert(
|
||||||
|
id: draft.id,
|
||||||
|
accountId: draft.accountId,
|
||||||
|
albumId: draft.albumId,
|
||||||
|
categoryId: Value(draft.categoryId),
|
||||||
|
clientTaskId: draft.clientTaskId,
|
||||||
|
sourceFilePath: draft.sourceFilePath,
|
||||||
|
filename: draft.filename,
|
||||||
|
bytes: draft.bytes,
|
||||||
|
contentType: draft.contentType,
|
||||||
|
state: UploadTaskState.preparing,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return await _byId(draft.id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 按 id 读取(**账号隔离硬守护**:accountId 不匹配视同不存在)。
|
||||||
|
Future<UploadTask?> get(String accountId, String id) {
|
||||||
|
return (_db.select(_db.uploadTasks)
|
||||||
|
..where((t) => t.id.equals(id) & t.accountId.equals(accountId)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 状态迁移(带合法迁移守护 + updatedAt 刷新),返回迁移后的任务。
|
||||||
|
///
|
||||||
|
/// 抛 [StateError]:任务不存在,或 `当前 → to` 不在合法迁移表
|
||||||
|
/// ([assertTransition])。
|
||||||
|
Future<UploadTask> updateState(
|
||||||
|
String id,
|
||||||
|
UploadTaskState to, {
|
||||||
|
UploadTaskPatch patch = const UploadTaskPatch(),
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
return _db.transaction(() async {
|
||||||
|
final current = await _byId(id);
|
||||||
|
assertTransition(current.state, to);
|
||||||
|
await (_db.update(_db.uploadTasks)
|
||||||
|
..where((t) => t.id.equals(id)))
|
||||||
|
.write(
|
||||||
|
UploadTasksCompanion(
|
||||||
|
state: Value(to),
|
||||||
|
attempt: patch.attempt == null
|
||||||
|
? const Value.absent()
|
||||||
|
: Value(patch.attempt!),
|
||||||
|
nextRetryAt: patch.clearNextRetryAt
|
||||||
|
? const Value(null)
|
||||||
|
: Value(patch.nextRetryAt?.toUtc()),
|
||||||
|
serverUploadId: Value(patch.serverUploadId),
|
||||||
|
serverPhotoId: Value(patch.serverPhotoId),
|
||||||
|
lastError: patch.clearLastError
|
||||||
|
? const Value(null)
|
||||||
|
: Value(patch.lastError),
|
||||||
|
updatedAt: Value(_utcAt(now)),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return await _byId(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 账号任务列表(**accountId 过滤无条件附加——硬守护**)。
|
||||||
|
///
|
||||||
|
/// 可选筛选(需求 4.3 三维):albumId 相册、states 状态集合、since 创建
|
||||||
|
/// 时间下限。按 createdAt 升序(队列 FIFO 语义)。
|
||||||
|
Future<List<UploadTask>> listByAccount(
|
||||||
|
String accountId, {
|
||||||
|
String? albumId,
|
||||||
|
Set<UploadTaskState>? states,
|
||||||
|
DateTime? since,
|
||||||
|
int? limit,
|
||||||
|
}) {
|
||||||
|
final query = _db.select(_db.uploadTasks)
|
||||||
|
..where((t) => t.accountId.equals(accountId));
|
||||||
|
if (albumId != null) {
|
||||||
|
query.where((t) => t.albumId.equals(albumId));
|
||||||
|
}
|
||||||
|
if (states != null && states.isNotEmpty) {
|
||||||
|
query.where((t) => t.state.isInValues(states.toList()));
|
||||||
|
}
|
||||||
|
if (since != null) {
|
||||||
|
query
|
||||||
|
.where((t) => t.createdAt.isBiggerOrEqualValue(since.toUtc()));
|
||||||
|
}
|
||||||
|
query.orderBy([(t) => OrderingTerm.asc(t.createdAt)]);
|
||||||
|
if (limit != null) {
|
||||||
|
query.limit(limit);
|
||||||
|
}
|
||||||
|
return query.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 账号内各状态任务数(队列 UI 徽章;全部状态都会出现,无任务计 0)。
|
||||||
|
Future<Map<UploadTaskState, int>> countsByState(String accountId) async {
|
||||||
|
final rows = await (_db.selectOnly(_db.uploadTasks)
|
||||||
|
..addColumns([_db.uploadTasks.state, _db.uploadTasks.id.count()])
|
||||||
|
..where(_db.uploadTasks.accountId.equals(accountId))
|
||||||
|
..groupBy([_db.uploadTasks.state]))
|
||||||
|
.map((row) => MapEntry(
|
||||||
|
row.readWithConverter(_db.uploadTasks.state)!,
|
||||||
|
row.read(_db.uploadTasks.id.count())!,
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
return {
|
||||||
|
for (final state in UploadTaskState.values)
|
||||||
|
state: rows.firstWhere((e) => e.key == state,
|
||||||
|
orElse: () => MapEntry(state, 0))
|
||||||
|
.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 源文件清理候选:**仅** completed/cancelled、updatedAt 早于
|
||||||
|
/// `now - keepDays` 且未清理过的任务(硬守护:未完成任务永不出现——
|
||||||
|
/// 评审缺口 1)。
|
||||||
|
///
|
||||||
|
/// 设备级维护例程(清理磁盘上的受管目录副本),跨账号扫描;不影响任务
|
||||||
|
/// 记录本身(UI/历史仍可见,cleanedAt 仅标记源文件已删)。
|
||||||
|
Future<List<UploadTask>> cleanupCandidates(
|
||||||
|
int keepDays, {
|
||||||
|
DateTime? now,
|
||||||
|
}) {
|
||||||
|
final cutoff = (now ?? DateTime.now())
|
||||||
|
.subtract(Duration(days: keepDays))
|
||||||
|
// UTC 归一:与落库取值同格式(Z 后缀),文本比较即时间比较。
|
||||||
|
.toUtc();
|
||||||
|
return (_db.select(_db.uploadTasks)
|
||||||
|
..where(
|
||||||
|
(t) =>
|
||||||
|
t.state.isInValues(const [
|
||||||
|
UploadTaskState.completed,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
]) &
|
||||||
|
t.cleanedAt.isNull() &
|
||||||
|
t.updatedAt.isSmallerOrEqualValue(cutoff),
|
||||||
|
))
|
||||||
|
.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 标记源文件已清理(cleanedAt=now;幂等——重复标记仅刷新时间)。
|
||||||
|
///
|
||||||
|
/// 抛 [StateError]:任务不存在或**非终态**(未完成源文件永不清理——
|
||||||
|
/// 与 [cleanupCandidates] 同一硬守护的另一面)。
|
||||||
|
Future<void> markCleanable(String id, {DateTime? now}) {
|
||||||
|
return _db.transaction(() async {
|
||||||
|
final task = await _byId(id);
|
||||||
|
if (!task.state.isTerminal) {
|
||||||
|
throw StateError(
|
||||||
|
'未完成任务不可标记清理(状态:${task.state})——源文件硬守护',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final timestamp = _utcAt(now);
|
||||||
|
await (_db.update(_db.uploadTasks)
|
||||||
|
..where((t) => t.id.equals(id)))
|
||||||
|
.write(
|
||||||
|
UploadTasksCompanion(cleanedAt: Value(timestamp)),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 删除任务记录(连拍未开始取消=直接删;清理策略不依赖删除)。
|
||||||
|
Future<int> delete(String id) {
|
||||||
|
return (_db.delete(_db.uploadTasks)..where((t) => t.id.equals(id))).go();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 事务内按 id 精确读取(不存在即 [StateError]——调用方均持有引擎下发
|
||||||
|
/// 的合法 id)。
|
||||||
|
Future<UploadTask> _byId(String id) async {
|
||||||
|
final row = await (_db.select(_db.uploadTasks)
|
||||||
|
..where((t) => t.id.equals(id)))
|
||||||
|
.getSingleOrNull();
|
||||||
|
if (row == null) {
|
||||||
|
throw StateError('上传任务不存在:$id');
|
||||||
|
}
|
||||||
|
return row;
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
|||||||
|
/// 上传任务状态机表驱动测试(P3 计划 Task 5)。
|
||||||
|
///
|
||||||
|
/// 覆盖:合法迁移表与方案 §5.5 逐边对齐、全量非法对拒绝、自迁移拒绝、
|
||||||
|
/// 终态无出边、wire 落库取值往返。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:photo_live/features/uploads/task_state.dart';
|
||||||
|
|
||||||
|
/// 方案 §5.5 / P3 计划 Task 5/6 的合法迁移表(人工誊写,作为对照基准)。
|
||||||
|
final Map<UploadTaskState, Set<UploadTaskState>> expectedTransitions = {
|
||||||
|
UploadTaskState.preparing: {
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.preparationFailed,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.preparationFailed: {
|
||||||
|
UploadTaskState.preparing,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.queued: {
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
UploadTaskState.waitingNetwork,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.uploading: {
|
||||||
|
UploadTaskState.uploadedConfirming,
|
||||||
|
UploadTaskState.waitingNetwork,
|
||||||
|
UploadTaskState.retryableFailed,
|
||||||
|
UploadTaskState.cancelled, // 尽力取消
|
||||||
|
},
|
||||||
|
UploadTaskState.uploadedConfirming: {
|
||||||
|
UploadTaskState.completed,
|
||||||
|
UploadTaskState.retryableFailed,
|
||||||
|
UploadTaskState.waitingNetwork,
|
||||||
|
},
|
||||||
|
UploadTaskState.waitingNetwork: {
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.uploading, // 网络恢复直接续传
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.retryableFailed: {
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
},
|
||||||
|
UploadTaskState.completed: {},
|
||||||
|
UploadTaskState.cancelled: {},
|
||||||
|
};
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('合法迁移表与方案基准逐状态一致(多边/少边都失败)', () {
|
||||||
|
for (final entry in expectedTransitions.entries) {
|
||||||
|
final actual = kUploadTaskTransitions[entry.key]!;
|
||||||
|
expect(actual, equals(entry.value),
|
||||||
|
reason: '${entry.key} 的合法目标集合与基准不一致');
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
kUploadTaskTransitions.keys,
|
||||||
|
equals(expectedTransitions.keys.toSet()),
|
||||||
|
reason: '迁移表的键必须覆盖全部状态',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全部合法边:canTransition=true 且 assertTransition 不抛', () {
|
||||||
|
var legalEdgeCount = 0;
|
||||||
|
for (final from in UploadTaskState.values) {
|
||||||
|
for (final to in kUploadTaskTransitions[from]!) {
|
||||||
|
legalEdgeCount++;
|
||||||
|
expect(canTransition(from, to), isTrue, reason: '$from → $to 应合法');
|
||||||
|
expect(() => assertTransition(from, to), returnsNormally,
|
||||||
|
reason: '$from → $to 断言不应抛');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(legalEdgeCount, 20, reason: '合法边总数(防意外增删边)');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('全量非法对(9x9 减合法边):canTransition=false 且 assertTransition 抛 StateError', () {
|
||||||
|
for (final from in UploadTaskState.values) {
|
||||||
|
for (final to in UploadTaskState.values) {
|
||||||
|
if (kUploadTaskTransitions[from]!.contains(to)) continue;
|
||||||
|
expect(canTransition(from, to), isFalse,
|
||||||
|
reason: '$from → $to 应非法');
|
||||||
|
expect(() => assertTransition(from, to), throwsA(isA<StateError>()),
|
||||||
|
reason: '$from → $to 断言应抛 StateError');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自迁移一律非法(状态未变不该写库)', () {
|
||||||
|
for (final state in UploadTaskState.values) {
|
||||||
|
expect(canTransition(state, state), isFalse, reason: '$state → 自身');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('终态(completed/cancelled)无出边,且 isTerminal 仅为这两个', () {
|
||||||
|
for (final state in UploadTaskState.values) {
|
||||||
|
if (state == UploadTaskState.completed ||
|
||||||
|
state == UploadTaskState.cancelled) {
|
||||||
|
expect(state.isTerminal, isTrue);
|
||||||
|
expect(kUploadTaskTransitions[state]!, isEmpty,
|
||||||
|
reason: '$state 是终态不应有出边');
|
||||||
|
} else {
|
||||||
|
expect(state.isTerminal, isFalse, reason: '$state 不是终态');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('典型非法路径被拒绝(状态机硬守护)', () {
|
||||||
|
// 不能跳过准备直接传。
|
||||||
|
expect(canTransition(UploadTaskState.preparing, UploadTaskState.uploading),
|
||||||
|
isFalse);
|
||||||
|
// 不能从 queued 直接完成。
|
||||||
|
expect(
|
||||||
|
canTransition(UploadTaskState.queued, UploadTaskState.completed),
|
||||||
|
isFalse);
|
||||||
|
// completed 是终态:不能复活。
|
||||||
|
expect(
|
||||||
|
canTransition(UploadTaskState.completed, UploadTaskState.queued),
|
||||||
|
isFalse);
|
||||||
|
// cancelled 是终态。
|
||||||
|
expect(
|
||||||
|
canTransition(UploadTaskState.cancelled, UploadTaskState.preparing),
|
||||||
|
isFalse);
|
||||||
|
// preparationFailed 只能回 preparing 或取消,不能直接入队。
|
||||||
|
expect(
|
||||||
|
canTransition(
|
||||||
|
UploadTaskState.preparationFailed, UploadTaskState.queued),
|
||||||
|
isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('wire 落库取值全枚举往返;未知值 fail-fast', () {
|
||||||
|
const wires = [
|
||||||
|
'preparing',
|
||||||
|
'preparation_failed',
|
||||||
|
'queued',
|
||||||
|
'uploading',
|
||||||
|
'uploaded_confirming',
|
||||||
|
'completed',
|
||||||
|
'waiting_network',
|
||||||
|
'retryable_failed',
|
||||||
|
'cancelled',
|
||||||
|
];
|
||||||
|
expect(
|
||||||
|
UploadTaskState.values.map((s) => s.wire).toSet(),
|
||||||
|
equals(wires.toSet()),
|
||||||
|
);
|
||||||
|
for (final wire in wires) {
|
||||||
|
expect(UploadTaskState.fromWire(wire).wire, wire);
|
||||||
|
}
|
||||||
|
expect(() => UploadTaskState.fromWire('failed'), throwsFormatException);
|
||||||
|
expect(() => UploadTaskState.fromWire('UPLOADING'), throwsFormatException);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,491 @@
|
|||||||
|
/// 上传任务库(TaskStore)测试(P3 计划 Task 5)。
|
||||||
|
///
|
||||||
|
/// 内存库(drift NativeDatabase.memory)驱动,覆盖:
|
||||||
|
/// - CRUD 与幂等键(clientTaskId 同账号同相册唯一,应用侧校验);
|
||||||
|
/// - 状态迁移守护(updateState 非法迁移拒绝且不落库);
|
||||||
|
/// - 账号隔离硬守护(读路径无条件 accountId 过滤);
|
||||||
|
/// - 列表筛选(相册/状态/时间)与 FIFO 排序;
|
||||||
|
/// - countsByState(队列 UI 徽章);
|
||||||
|
/// - 源文件保留策略(cleanupCandidates 仅终态+过期;未完成永不清理——
|
||||||
|
/// 评审缺口 1 的硬守护测试);
|
||||||
|
/// - datetime 文本落库的亚秒精度。
|
||||||
|
library;
|
||||||
|
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:photo_live/features/uploads/task_state.dart';
|
||||||
|
import 'package:photo_live/features/uploads/task_store.dart';
|
||||||
|
|
||||||
|
/// 可覆盖默认值的 draft 构造器。
|
||||||
|
UploadTaskDraft draft({
|
||||||
|
String id = 'task-0001',
|
||||||
|
String accountId = 'alice',
|
||||||
|
String albumId = 'alb-1',
|
||||||
|
String? categoryId,
|
||||||
|
String clientTaskId = 'ct-0001',
|
||||||
|
String sourceFilePath = '/managed/uploads/alb-1/task-0001.jpg',
|
||||||
|
String filename = 'IMG_0001.jpg',
|
||||||
|
int bytes = 1024,
|
||||||
|
String contentType = 'image/jpeg',
|
||||||
|
}) {
|
||||||
|
return UploadTaskDraft(
|
||||||
|
id: id,
|
||||||
|
accountId: accountId,
|
||||||
|
albumId: albumId,
|
||||||
|
categoryId: categoryId,
|
||||||
|
clientTaskId: clientTaskId,
|
||||||
|
sourceFilePath: sourceFilePath,
|
||||||
|
filename: filename,
|
||||||
|
bytes: bytes,
|
||||||
|
contentType: contentType,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 将任务沿合法路径推进到目标状态(供 cleanup/counts 测试摆数据)。
|
||||||
|
Future<UploadTask> advanceTo(
|
||||||
|
TaskStore store,
|
||||||
|
String id,
|
||||||
|
UploadTaskState target, {
|
||||||
|
DateTime? at,
|
||||||
|
}) async {
|
||||||
|
var current = (await store.get('alice', id))!;
|
||||||
|
const path = <UploadTaskState, List<UploadTaskState>>{
|
||||||
|
UploadTaskState.queued: [UploadTaskState.queued],
|
||||||
|
UploadTaskState.uploading: [UploadTaskState.queued, UploadTaskState.uploading],
|
||||||
|
UploadTaskState.completed: [
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
UploadTaskState.uploadedConfirming,
|
||||||
|
UploadTaskState.completed,
|
||||||
|
],
|
||||||
|
UploadTaskState.cancelled: [UploadTaskState.cancelled],
|
||||||
|
UploadTaskState.waitingNetwork: [
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.waitingNetwork,
|
||||||
|
],
|
||||||
|
UploadTaskState.retryableFailed: [
|
||||||
|
UploadTaskState.queued,
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
UploadTaskState.retryableFailed,
|
||||||
|
],
|
||||||
|
};
|
||||||
|
for (final state in path[target]!) {
|
||||||
|
current = await store.updateState(id, state, now: at);
|
||||||
|
}
|
||||||
|
expect(current.state, target);
|
||||||
|
return current;
|
||||||
|
}
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
late TaskStore store;
|
||||||
|
|
||||||
|
setUp(() {
|
||||||
|
store = TaskStore.memory();
|
||||||
|
});
|
||||||
|
|
||||||
|
tearDown(() async {
|
||||||
|
await store.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
group('create(初始态与幂等键)', () {
|
||||||
|
test('初始状态 preparing、attempt=0、可空字段空、时间戳落库', () async {
|
||||||
|
final createdAt = DateTime.utc(2026, 9, 19, 1, 2, 3, 456);
|
||||||
|
final task = await store.create(draft(), now: createdAt);
|
||||||
|
|
||||||
|
expect(task.state, UploadTaskState.preparing);
|
||||||
|
expect(task.attempt, 0);
|
||||||
|
expect(task.nextRetryAt, isNull);
|
||||||
|
expect(task.serverUploadId, isNull);
|
||||||
|
expect(task.serverPhotoId, isNull);
|
||||||
|
expect(task.lastError, isNull);
|
||||||
|
expect(task.cleanedAt, isNull);
|
||||||
|
expect(task.accountId, 'alice');
|
||||||
|
expect(task.albumId, 'alb-1');
|
||||||
|
expect(task.bytes, 1024);
|
||||||
|
expect(task.createdAt, createdAt);
|
||||||
|
expect(task.updatedAt, createdAt);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('datetime 文本落库保住亚秒/微秒精度,且本地输入统一 UTC 落库', () async {
|
||||||
|
final stamp = DateTime.utc(2026, 9, 19, 8, 30, 15, 123, 456);
|
||||||
|
await store.create(draft(), now: stamp);
|
||||||
|
final task = await store.get('alice', 'task-0001');
|
||||||
|
expect(task!.createdAt, stamp); // 完整相等而非秒级截断
|
||||||
|
expect(task.updatedAt, stamp);
|
||||||
|
|
||||||
|
// 本地时区输入(DateTime.now() 非 UTC):同一时刻、UTC 归一落库——
|
||||||
|
// 文本存储下字典序即时间序,SQL 时间比较(since/cutoff)不因
|
||||||
|
// `Z`/`+08:00` 后缀混存而错位。
|
||||||
|
final local = DateTime.now();
|
||||||
|
await store.create(draft(id: 'task-local', clientTaskId: 'ct-local'), now: local);
|
||||||
|
final normalized = await store.get('alice', 'task-local');
|
||||||
|
expect(normalized!.createdAt.isUtc, isTrue);
|
||||||
|
expect(normalized.createdAt, local.toUtc());
|
||||||
|
});
|
||||||
|
|
||||||
|
test('同账号同相册重复 clientTaskId 拒绝(应用侧唯一性)', () async {
|
||||||
|
await store.create(draft());
|
||||||
|
await expectLater(
|
||||||
|
store.create(draft(id: 'task-0002')),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
// 唯一存在的仍是第一条。
|
||||||
|
final tasks = await store.listByAccount('alice');
|
||||||
|
expect(tasks, hasLength(1));
|
||||||
|
expect(tasks.single.id, 'task-0001');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('幂等键作用域=账号+相册:换相册或换账号可复用同一 clientTaskId', () async {
|
||||||
|
await store.create(draft());
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-0002', albumId: 'alb-2'),
|
||||||
|
);
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-0003', accountId: 'bob'),
|
||||||
|
);
|
||||||
|
expect(await store.countsByState('alice'), containsPair(
|
||||||
|
UploadTaskState.preparing, 2));
|
||||||
|
expect(await store.countsByState('bob'), containsPair(
|
||||||
|
UploadTaskState.preparing, 1));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('updateState(迁移守护 + 旁路字段)', () {
|
||||||
|
test('全生命周期推进:patch 写入 serverId/attempt/nextRetryAt/lastError 并可在成功时清除', () async {
|
||||||
|
await store.create(draft());
|
||||||
|
final t0 = DateTime.utc(2026, 9, 19, 9);
|
||||||
|
|
||||||
|
final queued = await store.updateState(
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.queued,
|
||||||
|
now: t0,
|
||||||
|
);
|
||||||
|
expect(queued.state, UploadTaskState.queued);
|
||||||
|
expect(queued.updatedAt, t0);
|
||||||
|
|
||||||
|
final uploading = await store.updateState(
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
patch: UploadTaskPatch(
|
||||||
|
serverUploadId: 'upl-1',
|
||||||
|
serverPhotoId: 'pho-1',
|
||||||
|
),
|
||||||
|
now: t0.add(const Duration(minutes: 1)),
|
||||||
|
);
|
||||||
|
expect(uploading.serverUploadId, 'upl-1');
|
||||||
|
expect(uploading.serverPhotoId, 'pho-1');
|
||||||
|
|
||||||
|
// 核验失败 → 重试路径:attempt+1、退避时间、错误码。
|
||||||
|
final failed = await store.updateState(
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.retryableFailed,
|
||||||
|
patch: UploadTaskPatch(
|
||||||
|
attempt: 1,
|
||||||
|
nextRetryAt: t0.add(const Duration(minutes: 10)),
|
||||||
|
lastError: 'UPLOAD_VERIFY_FAILED',
|
||||||
|
),
|
||||||
|
now: t0.add(const Duration(minutes: 2)),
|
||||||
|
);
|
||||||
|
expect(failed.attempt, 1);
|
||||||
|
expect(failed.nextRetryAt, t0.add(const Duration(minutes: 10)));
|
||||||
|
expect(failed.lastError, 'UPLOAD_VERIFY_FAILED');
|
||||||
|
|
||||||
|
// 重试:retryableFailed → queued → uploading(合法路径)。
|
||||||
|
await store.updateState(
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.queued,
|
||||||
|
now: t0.add(const Duration(minutes: 10)),
|
||||||
|
);
|
||||||
|
await store.updateState(
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
now: t0.add(const Duration(minutes: 11)),
|
||||||
|
);
|
||||||
|
await store.updateState(
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.uploadedConfirming,
|
||||||
|
now: t0.add(const Duration(minutes: 12)),
|
||||||
|
);
|
||||||
|
final done = await store.updateState(
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.completed,
|
||||||
|
patch: const UploadTaskPatch(
|
||||||
|
clearNextRetryAt: true,
|
||||||
|
clearLastError: true,
|
||||||
|
),
|
||||||
|
now: t0.add(const Duration(minutes: 13)),
|
||||||
|
);
|
||||||
|
expect(done.state, UploadTaskState.completed);
|
||||||
|
expect(done.nextRetryAt, isNull);
|
||||||
|
expect(done.lastError, isNull);
|
||||||
|
expect(done.cleanedAt, isNull, reason: '完成不等于已清理');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('非法迁移抛 StateError 且不落库(守护)', () async {
|
||||||
|
final createdAt = DateTime.utc(2026, 9, 19, 9);
|
||||||
|
await store.create(draft(), now: createdAt);
|
||||||
|
await expectLater(
|
||||||
|
store.updateState('task-0001', UploadTaskState.completed),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
final task = await store.get('alice', 'task-0001');
|
||||||
|
expect(task!.state, UploadTaskState.preparing);
|
||||||
|
expect(task.updatedAt, createdAt, reason: '失败的迁移不得刷新 updatedAt');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('终态出边一律拒绝(completed 后不可复活)', () async {
|
||||||
|
await store.create(draft());
|
||||||
|
await advanceTo(store, 'task-0001', UploadTaskState.completed);
|
||||||
|
await expectLater(
|
||||||
|
store.updateState('task-0001', UploadTaskState.queued),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
(await store.get('alice', 'task-0001'))!.state,
|
||||||
|
UploadTaskState.completed,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('不存在的 id 抛 StateError', () async {
|
||||||
|
await expectLater(
|
||||||
|
store.updateState('nope', UploadTaskState.queued),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('账号隔离(硬守护)', () {
|
||||||
|
test('listByAccount/get/countsByState 均不可见他人任务', () async {
|
||||||
|
await store.create(draft(accountId: 'alice', clientTaskId: 'ct-a'));
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-b', accountId: 'bob', clientTaskId: 'ct-b'),
|
||||||
|
);
|
||||||
|
|
||||||
|
final aliceTasks = await store.listByAccount('alice');
|
||||||
|
expect(aliceTasks.map((t) => t.accountId), everyElement('alice'));
|
||||||
|
expect(aliceTasks, hasLength(1));
|
||||||
|
|
||||||
|
// get 跨账号视同不存在。
|
||||||
|
expect(await store.get('alice', 'task-b'), isNull);
|
||||||
|
expect(await store.get('bob', 'task-0001'), isNull);
|
||||||
|
|
||||||
|
// 徽章计数同样隔离。
|
||||||
|
final bobCounts = await store.countsByState('bob');
|
||||||
|
expect(
|
||||||
|
bobCounts.values.fold<int>(0, (a, b) => a + b),
|
||||||
|
1,
|
||||||
|
reason: 'bob 只应见到自己的 1 条',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('筛选组合不放松 accountId 过滤(相册/状态条件均在其内)', () async {
|
||||||
|
await store.create(draft(albumId: 'alb-1', clientTaskId: 'ct-1'));
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-bob', accountId: 'bob', albumId: 'alb-1', clientTaskId: 'ct-1'),
|
||||||
|
);
|
||||||
|
final onlyAlice = await store.listByAccount(
|
||||||
|
'alice',
|
||||||
|
albumId: 'alb-1',
|
||||||
|
states: const {UploadTaskState.preparing},
|
||||||
|
);
|
||||||
|
expect(onlyAlice, hasLength(1));
|
||||||
|
expect(onlyAlice.single.accountId, 'alice');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('listByAccount(三维筛选 + FIFO)', () {
|
||||||
|
test('albumId 筛选', () async {
|
||||||
|
await store.create(draft(albumId: 'alb-1', clientTaskId: 'ct-1'));
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-2', albumId: 'alb-2', clientTaskId: 'ct-2'),
|
||||||
|
);
|
||||||
|
final tasks =
|
||||||
|
await store.listByAccount('alice', albumId: 'alb-2');
|
||||||
|
expect(tasks.map((t) => t.albumId), everyElement('alb-2'));
|
||||||
|
expect(tasks, hasLength(1));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('states 筛选(集合内 IN)', () async {
|
||||||
|
await store.create(draft(clientTaskId: 'ct-1'));
|
||||||
|
await store.create(draft(id: 'task-2', clientTaskId: 'ct-2'));
|
||||||
|
await store.create(draft(id: 'task-3', clientTaskId: 'ct-3'));
|
||||||
|
await advanceTo(store, 'task-2', UploadTaskState.completed);
|
||||||
|
await advanceTo(store, 'task-3', UploadTaskState.cancelled);
|
||||||
|
|
||||||
|
final active = await store.listByAccount(
|
||||||
|
'alice',
|
||||||
|
states: {UploadTaskState.preparing, UploadTaskState.queued},
|
||||||
|
);
|
||||||
|
expect(active.map((t) => t.id), ['task-0001']);
|
||||||
|
|
||||||
|
final terminal = await store.listByAccount(
|
||||||
|
'alice',
|
||||||
|
states: {UploadTaskState.completed, UploadTaskState.cancelled},
|
||||||
|
);
|
||||||
|
expect(terminal.map((t) => t.id).toSet(), {'task-2', 'task-3'});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('since 时间筛选(含边界:createdAt == since 计入)', () async {
|
||||||
|
final t0 = DateTime.utc(2026, 9, 19, 10);
|
||||||
|
await store.create(draft(clientTaskId: 'ct-1'), now: t0);
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-2', clientTaskId: 'ct-2'),
|
||||||
|
now: t0.add(const Duration(hours: 1)),
|
||||||
|
);
|
||||||
|
final tasks = await store.listByAccount(
|
||||||
|
'alice',
|
||||||
|
since: t0.add(const Duration(hours: 1)),
|
||||||
|
);
|
||||||
|
expect(tasks.map((t) => t.id), ['task-2']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('按 createdAt 升序(队列 FIFO)+ limit', () async {
|
||||||
|
final t0 = DateTime.utc(2026, 9, 19, 10);
|
||||||
|
for (var i = 1; i <= 3; i++) {
|
||||||
|
await store.create(
|
||||||
|
draft(
|
||||||
|
id: 'task-$i',
|
||||||
|
clientTaskId: 'ct-$i',
|
||||||
|
),
|
||||||
|
now: t0.add(Duration(minutes: i)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final tasks = await store.listByAccount('alice');
|
||||||
|
expect(tasks.map((t) => t.id), ['task-1', 'task-2', 'task-3']);
|
||||||
|
expect(
|
||||||
|
(await store.listByAccount('alice', limit: 2)).map((t) => t.id),
|
||||||
|
['task-1', 'task-2'],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('countsByState(队列徽章)', () {
|
||||||
|
test('全状态键 + 无任务计 0 + 分组计数正确', () async {
|
||||||
|
await store.create(draft(clientTaskId: 'ct-1'));
|
||||||
|
await store.create(draft(id: 'task-2', clientTaskId: 'ct-2'));
|
||||||
|
await store.create(draft(id: 'task-3', clientTaskId: 'ct-3'));
|
||||||
|
await advanceTo(store, 'task-2', UploadTaskState.completed);
|
||||||
|
await advanceTo(store, 'task-3', UploadTaskState.waitingNetwork);
|
||||||
|
|
||||||
|
final counts = await store.countsByState('alice');
|
||||||
|
expect(counts.keys.toSet(), UploadTaskState.values.toSet(),
|
||||||
|
reason: '徽章需要全部状态键(含 0)');
|
||||||
|
expect(counts[UploadTaskState.preparing], 1);
|
||||||
|
expect(counts[UploadTaskState.completed], 1);
|
||||||
|
expect(counts[UploadTaskState.waitingNetwork], 1);
|
||||||
|
expect(counts[UploadTaskState.uploading], 0);
|
||||||
|
expect(counts[UploadTaskState.cancelled], 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
group('源文件保留策略(评审缺口 1 硬守护)', () {
|
||||||
|
final now = DateTime.utc(2026, 9, 19, 12);
|
||||||
|
final ancient = DateTime.utc(2026, 8, 30, 12); // now - 20 天(超过保留期)。
|
||||||
|
|
||||||
|
test('cleanupCandidates:仅过期终态(completed+cancelled),新鲜与已清理的排除', () async {
|
||||||
|
await store.create(
|
||||||
|
draft(clientTaskId: 'ct-old-done'),
|
||||||
|
now: ancient,
|
||||||
|
);
|
||||||
|
await advanceTo(
|
||||||
|
store,
|
||||||
|
'task-0001',
|
||||||
|
UploadTaskState.completed,
|
||||||
|
at: ancient.add(const Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-cancel', clientTaskId: 'ct-old-cancel'),
|
||||||
|
now: ancient,
|
||||||
|
);
|
||||||
|
await advanceTo(
|
||||||
|
store,
|
||||||
|
'task-cancel',
|
||||||
|
UploadTaskState.cancelled,
|
||||||
|
at: ancient.add(const Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
|
||||||
|
// 保留期内完成的任务。
|
||||||
|
await store.create(
|
||||||
|
draft(id: 'task-fresh', clientTaskId: 'ct-fresh'),
|
||||||
|
now: now.subtract(const Duration(days: 2)),
|
||||||
|
);
|
||||||
|
await advanceTo(
|
||||||
|
store,
|
||||||
|
'task-fresh',
|
||||||
|
UploadTaskState.completed,
|
||||||
|
at: now.subtract(const Duration(days: 2)).add(const Duration(minutes: 5)),
|
||||||
|
);
|
||||||
|
|
||||||
|
var candidates = await store.cleanupCandidates(14, now: now);
|
||||||
|
expect(candidates.map((t) => t.id).toSet(), {'task-0001', 'task-cancel'});
|
||||||
|
|
||||||
|
// 标记清理后不再出现(幂等)。
|
||||||
|
await store.markCleanable('task-0001', now: now);
|
||||||
|
candidates = await store.cleanupCandidates(14, now: now);
|
||||||
|
expect(candidates.map((t) => t.id), ['task-cancel']);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('硬守护:过期但未完成的任务永不出现在清理候选(全部非终态)', () async {
|
||||||
|
// 各非终态摆一条"古老"任务(updatedAt 均早于保留期界)。
|
||||||
|
await store.create(draft(clientTaskId: 'ct-p'), now: ancient);
|
||||||
|
await store.create(draft(id: 'task-q', clientTaskId: 'ct-q'), now: ancient);
|
||||||
|
await advanceTo(store, 'task-q', UploadTaskState.queued, at: ancient);
|
||||||
|
await store.create(draft(id: 'task-u', clientTaskId: 'ct-u'), now: ancient);
|
||||||
|
await advanceTo(store, 'task-u', UploadTaskState.uploading, at: ancient);
|
||||||
|
await store.create(draft(id: 'task-w', clientTaskId: 'ct-w'), now: ancient);
|
||||||
|
await advanceTo(
|
||||||
|
store, 'task-w', UploadTaskState.waitingNetwork, at: ancient);
|
||||||
|
await store.create(draft(id: 'task-r', clientTaskId: 'ct-r'), now: ancient);
|
||||||
|
await advanceTo(
|
||||||
|
store, 'task-r', UploadTaskState.retryableFailed, at: ancient);
|
||||||
|
await store.create(draft(id: 'task-pf', clientTaskId: 'ct-pf'), now: ancient);
|
||||||
|
await store.updateState(
|
||||||
|
'task-pf',
|
||||||
|
UploadTaskState.preparationFailed,
|
||||||
|
now: ancient,
|
||||||
|
);
|
||||||
|
// uploading 停在核验中的古老任务。
|
||||||
|
await store.create(draft(id: 'task-uc', clientTaskId: 'ct-uc'), now: ancient);
|
||||||
|
await advanceTo(
|
||||||
|
store,
|
||||||
|
'task-uc',
|
||||||
|
UploadTaskState.uploading,
|
||||||
|
at: ancient,
|
||||||
|
);
|
||||||
|
await store.updateState(
|
||||||
|
'task-uc',
|
||||||
|
UploadTaskState.uploadedConfirming,
|
||||||
|
now: ancient,
|
||||||
|
);
|
||||||
|
|
||||||
|
final candidates = await store.cleanupCandidates(14, now: now);
|
||||||
|
expect(candidates, isEmpty,
|
||||||
|
reason: '全部为未完成的古老任务,绝不进入清理候选');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markCleanable:非终态拒绝(守护另一面)且不落 cleanedAt', () async {
|
||||||
|
await store.create(draft());
|
||||||
|
await expectLater(
|
||||||
|
store.markCleanable('task-0001'),
|
||||||
|
throwsA(isA<StateError>()),
|
||||||
|
);
|
||||||
|
expect((await store.get('alice', 'task-0001'))!.cleanedAt, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('markCleanable:终态可标记且幂等;delete 移除记录', () async {
|
||||||
|
await store.create(draft());
|
||||||
|
await advanceTo(store, 'task-0001', UploadTaskState.completed);
|
||||||
|
final markAt = DateTime.utc(2026, 9, 20);
|
||||||
|
await store.markCleanable('task-0001', now: markAt);
|
||||||
|
expect((await store.get('alice', 'task-0001'))!.cleanedAt, markAt);
|
||||||
|
// 幂等:重复标记只刷新时间。
|
||||||
|
final markAgain = markAt.add(const Duration(days: 1));
|
||||||
|
await store.markCleanable('task-0001', now: markAgain);
|
||||||
|
expect((await store.get('alice', 'task-0001'))!.cleanedAt, markAgain);
|
||||||
|
|
||||||
|
expect(await store.delete('task-0001'), 1);
|
||||||
|
expect(await store.get('alice', 'task-0001'), isNull);
|
||||||
|
expect(await store.delete('task-0001'), 0, reason: '重复删除返回 0');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -44,8 +44,8 @@
|
|||||||
|
|
||||||
## Task 5: 上传域模型与本地任务库(drift)
|
## Task 5: 上传域模型与本地任务库(drift)
|
||||||
|
|
||||||
- [ ] drift schema:upload_tasks(id uuid、accountId 隔离键、albumId、categoryId、clientTaskId、源文件路径(受管目录 copy)、filename/bytes/contentType、state 状态机 preparing/preparation_failed/queued/uploading/uploaded-confirming/completed/waiting_network/retryable_failed/cancelled、attempt/nextRetryAt、serverSessionId/photoId、createdAt/updatedAt、账号切换过滤=accountId 索引)
|
- [x] drift schema:upload_tasks(id uuid、accountId 隔离键、albumId、categoryId、clientTaskId、源文件路径(受管目录 copy)、filename/bytes/contentType、state 状态机 preparing/preparation_failed/queued/uploading/uploaded-confirming/completed/waiting_network/retryable_failed/cancelled、attempt/nextRetryAt、serverSessionId/photoId、createdAt/updatedAt、账号切换过滤=accountId 索引)
|
||||||
- [ ] 状态机单测(合法迁移表驱动;账号隔离查询;任务保留策略=completed 仅可清理标记)
|
- [x] 状态机单测(合法迁移表驱动;账号隔离查询;任务保留策略=completed 仅可清理标记)
|
||||||
- [ ] Commit `feat(mobile): drift upload task store with state machine`
|
- [ ] Commit `feat(mobile): drift upload task store with state machine`
|
||||||
|
|
||||||
## Task 6: 上传引擎(Dart 侧)
|
## Task 6: 上传引擎(Dart 侧)
|
||||||
|
|||||||
Reference in New Issue
Block a user