feat(mobile): android background upload bridge via workmanager
Kotlin 侧(MethodChannel photolive/uploads + EventChannel photolive/uploads/events):
- UploadPlugin:registerTask 入队 WorkManager OneTimeWorkRequest(unique
work=upload-task-{taskId},expedited + OutOfQuotaPolicy 回退、CONNECTED
约束、指数退避 10s 起)、cancelTask / queryTask / ackEvents
- UploadWorker(CoroutineWorker):dataSync 前台服务 + 进度通知,
OkHttp PUT(ETag 捕获、64KB 分片进度),结局分类:2xx→completed、
403→needsRenew、4xx→failed{HTTP_4XX}、网络/5xx→Result.retry(上限
5 次后 failed{NETWORK|SERVER_ERROR})、缺文件→failed{SOURCE_MISSING}
- UploadEventBus:终态事件单调 seq + 未 ack 缓存重放(上限 200),
重注册/取消作废旧缓存——进程重启/切后台恢复不丢完成信号
- Manifest:FOREGROUND_SERVICE(_DATA_SYNC)/POST_NOTIFICATIONS 权限 +
SystemForegroundService tools:node=merge 补 dataSync 类型(已验证
合并产物)
Dart 侧:Transport 接口(init/complete/renew 留引擎,仅 PUT 委托)——
DartHttpTransport(Task 6 逻辑抽出,403→renew 异常归一)+ NativeTransport
(registerTask→事件流终态/进度映射、终态 ack、取消联动);引擎传输选择:
Android 缺省原生,dart-define FORCE_DART_TRANSPORT=true 强制 Dart 路径;
失败分类与 dio 路径语义对齐(401 短退避/429 网络桶/SOURCE_MISSING 不
自动重试等)
测试:gradle testDebugUnitTest 29 例(executor MockWebServer 分类×10、
协议解析×11、事件总线×8);flutter test 219 全绿(新增 NativeTransport
事件映射/取消/ack/并发路由、引擎×Transport 委托与 needsRenew 重注册、
传输选择矩阵);flutter analyze 0;debug apk 构建通过
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,421 @@
|
||||
/// 上传传输抽象与双实现(P3 计划 Task 7)。
|
||||
///
|
||||
/// 引擎的传输步骤(presign PUT)经 [Transport] 接口委托:
|
||||
/// - **[DartHttpTransport]**(Task 6 逻辑原样抽出):裸 dio 流式 PUT +
|
||||
/// 进度计数流 + 取消令牌——iOS / 桌面 / 调试路径(dart-define
|
||||
/// `FORCE_DART_TRANSPORT=true` 强制启用);
|
||||
/// - **[NativeTransport]**(Task 7 新增,Android):MethodChannel
|
||||
/// `photolive/uploads` 注册 WorkManager 后台工作(expedited + dataSync
|
||||
/// 前台服务),EventChannel `photolive/uploads/events` 回流
|
||||
/// progress/completed/failed/needsRenew 事件——App 切后台/锁屏后续传
|
||||
/// 由系统调度承担。
|
||||
///
|
||||
/// 失败语义(两实现归一为 [TransportException] 族,引擎分类见
|
||||
/// upload_engine.dart `_classifyFailure`):
|
||||
/// | 情形 | 异常 |
|
||||
/// |---|---|
|
||||
/// | 网络不可达/超时/取消令牌触发前的连接层失败 | `TransportException(network)` |
|
||||
/// | HTTP 403(presign 凭证过期) | [TransportRenewRequiredException](引擎 renew 后重发) |
|
||||
/// | 其余 HTTP 非 2xx | `TransportException(http, statusCode)` |
|
||||
/// | 源文件缺失(原生 worker 发现) | `TransportException(sourceMissing)` |
|
||||
/// | 平台通道调用失败 | `TransportException(channel)` |
|
||||
/// | 取消令牌触发 | `TransportException(cancelled)`(引擎静默收敛) |
|
||||
library;
|
||||
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
/// presign PUT 传输接口(引擎只委托文件 PUT;init/complete/renew 仍在
|
||||
/// Dart 引擎内,保持服务端交互语义单源)。
|
||||
abstract interface class Transport {
|
||||
/// 单次文件 PUT。
|
||||
///
|
||||
/// - 成功返回 [TransportResult](ETag 供诊断;完成核验以服务端为准);
|
||||
/// - 失败抛 [TransportException] 族(见库级注释分类表);
|
||||
/// - [cancelToken] 取消时抛 `TransportException(cancelled)`。
|
||||
Future<TransportResult> send({
|
||||
required String taskId,
|
||||
required String url,
|
||||
required String method,
|
||||
required Map<String, String> headers,
|
||||
required String filePath,
|
||||
Map<String, Object?>? metadata,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
});
|
||||
|
||||
/// 释放底层资源(引擎 dispose 调用;注入方自持传输时自行管理)。
|
||||
void dispose();
|
||||
}
|
||||
|
||||
/// PUT 成功结果。
|
||||
class TransportResult {
|
||||
const TransportResult({this.etag});
|
||||
|
||||
/// 服务端返回的 ETag 响应头(可空;仅诊断用途)。
|
||||
final String? etag;
|
||||
}
|
||||
|
||||
/// 传输失败码(原生侧事件码与本侧异常码的归一层)。
|
||||
abstract final class TransportErrorCodes {
|
||||
/// 网络不可达/超时/连接层失败。
|
||||
static const String network = 'NETWORK';
|
||||
|
||||
/// HTTP 层失败(statusCode 保留在异常上)。
|
||||
static const String http = 'HTTP';
|
||||
|
||||
/// 取消令牌触发(非失败分类;引擎静默收敛)。
|
||||
static const String cancelled = 'CANCELLED';
|
||||
|
||||
/// 源文件缺失(受管副本被清理,不可恢复)。
|
||||
static const String sourceMissing = 'SOURCE_MISSING';
|
||||
|
||||
/// 平台通道调用失败(桥接层异常)。
|
||||
static const String channel = 'CHANNEL';
|
||||
}
|
||||
|
||||
/// 传输失败(两实现统一的异常类型)。
|
||||
class TransportException implements Exception {
|
||||
const TransportException(this.code, {this.statusCode, this.cause});
|
||||
|
||||
/// 失败分类([TransportErrorCodes])。
|
||||
final String code;
|
||||
|
||||
/// HTTP 状态码(http 类失败携带)。
|
||||
final int? statusCode;
|
||||
|
||||
/// 底层异常(诊断)。
|
||||
final Object? cause;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TransportException($code${statusCode != null ? ', status=$statusCode' : ''}'
|
||||
'${cause != null ? ', cause=$cause' : ''})';
|
||||
}
|
||||
|
||||
/// HTTP 403(presign 凭证过期):引擎 renewUpload 换新凭证后重发。
|
||||
class TransportRenewRequiredException extends TransportException {
|
||||
const TransportRenewRequiredException({super.cause})
|
||||
: super(TransportErrorCodes.http, statusCode: 403);
|
||||
}
|
||||
|
||||
// ── Dart(dio)实现 ──────────────────────────────────────────────────────
|
||||
|
||||
/// Task 6 的裸 dio 直传实现(iOS / 桌面 / FORCE_DART_TRANSPORT 调试)。
|
||||
///
|
||||
/// 严格携带 credential.headers(+ Content-Length——流式发送 dio 无法自动
|
||||
/// 推断,显式声明);进度经计数流上报(不依赖适配器对 onSendProgress 的
|
||||
/// 实现)。
|
||||
class DartHttpTransport implements Transport {
|
||||
DartHttpTransport({Dio? dio})
|
||||
: _dio = dio,
|
||||
_ownedDio = dio == null;
|
||||
|
||||
final Dio? _dio;
|
||||
final bool _ownedDio;
|
||||
|
||||
/// 自建实例惰性创建(注入 dio 时不建)。
|
||||
Dio? _created;
|
||||
Dio get _client => _dio ?? (_created ??= Dio());
|
||||
|
||||
@override
|
||||
Future<TransportResult> send({
|
||||
required String taskId,
|
||||
required String url,
|
||||
required String method,
|
||||
required Map<String, String> headers,
|
||||
required String filePath,
|
||||
Map<String, Object?>? metadata,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
final file = File(filePath);
|
||||
final length = await file.length();
|
||||
var sent = 0;
|
||||
final progressStream = file.openRead().map((List<int> chunk) {
|
||||
sent += chunk.length;
|
||||
onProgress?.call(sent, length);
|
||||
return chunk;
|
||||
});
|
||||
try {
|
||||
final resp = await _client.put<void>(
|
||||
url,
|
||||
data: progressStream,
|
||||
cancelToken: cancelToken,
|
||||
options: Options(
|
||||
method: method,
|
||||
headers: {
|
||||
...headers,
|
||||
Headers.contentLengthHeader: length,
|
||||
},
|
||||
// 响应体非 JSON(MinIO XML/空),防 transformer 解析报错。
|
||||
responseType: ResponseType.plain,
|
||||
receiveDataWhenStatusError: true,
|
||||
),
|
||||
);
|
||||
final etag = resp.headers.value(HttpHeaders.etagHeader);
|
||||
return TransportResult(etag: etag);
|
||||
} on DioException catch (e) {
|
||||
throw _mapDioException(e);
|
||||
}
|
||||
}
|
||||
|
||||
/// dio 异常 → 传输异常归一(403 → renew;连接层 → network;取消 →
|
||||
/// cancelled;其余 badResponse → http)。
|
||||
static TransportException _mapDioException(DioException e) {
|
||||
if (e.type == DioExceptionType.cancel) {
|
||||
return const TransportException(TransportErrorCodes.cancelled);
|
||||
}
|
||||
final status = e.response?.statusCode;
|
||||
if (status != null) {
|
||||
if (status == 403) return const TransportRenewRequiredException();
|
||||
return TransportException(TransportErrorCodes.http,
|
||||
statusCode: status, cause: e);
|
||||
}
|
||||
if (e.type == DioExceptionType.connectionTimeout ||
|
||||
e.type == DioExceptionType.sendTimeout ||
|
||||
e.type == DioExceptionType.receiveTimeout ||
|
||||
e.type == DioExceptionType.transformTimeout ||
|
||||
e.type == DioExceptionType.connectionError ||
|
||||
e.type == DioExceptionType.badCertificate ||
|
||||
e.type == DioExceptionType.unknown) {
|
||||
return TransportException(TransportErrorCodes.network, cause: e);
|
||||
}
|
||||
// badResponse 无响应必经上方分支,防御性兜底为网络类。
|
||||
return TransportException(TransportErrorCodes.network, cause: e);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_ownedDio) {
|
||||
_client.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 原生(WorkManager)实现 ──────────────────────────────────────────────
|
||||
|
||||
/// 平台通道名(与 Android UploadPlugin 一致;Kotlin 侧常量见
|
||||
/// UploadProtocol.kt)。
|
||||
abstract final class UploadBridgeChannels {
|
||||
/// 命令通道:registerTask / cancelTask / queryTask / ackEvents。
|
||||
static const String methods = 'photolive/uploads';
|
||||
|
||||
/// 事件流:progress / completed / failed / needsRenew(带 seq,
|
||||
/// 终态事件未 ack 前原生侧缓存重放——生命周期安全)。
|
||||
static const String events = 'photolive/uploads/events';
|
||||
}
|
||||
|
||||
/// 平台通道绑定薄层(默认真实通道;测试注入替身)。
|
||||
class NativeUploadChannel {
|
||||
const NativeUploadChannel();
|
||||
|
||||
static const MethodChannel _methods = MethodChannel(
|
||||
UploadBridgeChannels.methods,
|
||||
);
|
||||
static const EventChannel _events = EventChannel(
|
||||
UploadBridgeChannels.events,
|
||||
);
|
||||
|
||||
Future<Object?> invoke(String method, Map<String, Object?> args) =>
|
||||
_methods.invokeMethod<Object?>(method, args);
|
||||
|
||||
Stream<dynamic> get events => _events.receiveBroadcastStream();
|
||||
}
|
||||
|
||||
/// 原生事件等待句柄。
|
||||
class _NativeWaiter {
|
||||
_NativeWaiter(this.onProgress);
|
||||
|
||||
final void Function(int sent, int total)? onProgress;
|
||||
final Completer<TransportResult> completer = Completer<TransportResult>();
|
||||
|
||||
void complete(TransportResult result) {
|
||||
if (!completer.isCompleted) completer.complete(result);
|
||||
}
|
||||
|
||||
void completeError(Object error) {
|
||||
if (!completer.isCompleted) completer.completeError(error);
|
||||
}
|
||||
}
|
||||
|
||||
/// Android 原生传输:registerTask 入队 WorkManager(expedited + dataSync
|
||||
/// 前台服务),事件流回流终态/进度。
|
||||
///
|
||||
/// 生命周期安全:事件带单调 seq;终态事件在 Dart ack 前由原生侧缓存,
|
||||
/// 重新 listen 时重放——进程重启/切后台恢复后不丢完成信号。进度事件不
|
||||
/// 重放(瞬态)。
|
||||
class NativeTransport implements Transport {
|
||||
NativeTransport({NativeUploadChannel? channel})
|
||||
: _channel = channel ?? const NativeUploadChannel();
|
||||
|
||||
final NativeUploadChannel _channel;
|
||||
|
||||
StreamSubscription<dynamic>? _eventsSub;
|
||||
final Map<String, _NativeWaiter> _waiters = <String, _NativeWaiter>{};
|
||||
|
||||
void _ensureSubscribed() {
|
||||
if (_eventsSub != null) return;
|
||||
_eventsSub = _channel.events.listen(
|
||||
_onEvent,
|
||||
onError: (Object _) {
|
||||
// 事件流出错:丢弃订阅,下次 send 重建(注册重试语义兜底)。
|
||||
_eventsSub?.cancel();
|
||||
_eventsSub = null;
|
||||
},
|
||||
onDone: () => _eventsSub = null,
|
||||
);
|
||||
}
|
||||
|
||||
void _onEvent(dynamic raw) {
|
||||
if (raw is! Map) return;
|
||||
final event = Map<String, Object?>.from(raw);
|
||||
final taskId = event['taskId'] as String?;
|
||||
final waiter = taskId == null ? null : _waiters[taskId];
|
||||
switch (event['event']) {
|
||||
case 'progress':
|
||||
final sent = (event['sent'] as num?)?.toInt();
|
||||
final total = (event['total'] as num?)?.toInt();
|
||||
if (waiter != null && sent != null && total != null) {
|
||||
waiter.onProgress?.call(sent, total);
|
||||
}
|
||||
case 'completed':
|
||||
waiter?.complete(
|
||||
TransportResult(etag: event['etag'] as String?),
|
||||
);
|
||||
_ackTerminal(event);
|
||||
case 'needsRenew':
|
||||
waiter?.completeError(const TransportRenewRequiredException());
|
||||
_ackTerminal(event);
|
||||
case 'failed':
|
||||
waiter?.completeError(_mapFailedEvent(event));
|
||||
_ackTerminal(event);
|
||||
default:
|
||||
break; // 未知事件:前向兼容忽略。
|
||||
}
|
||||
}
|
||||
|
||||
/// 原生失败码 → 传输异常。
|
||||
static TransportException _mapFailedEvent(Map<String, Object?> event) {
|
||||
final status = (event['status'] as num?)?.toInt();
|
||||
switch (event['code']) {
|
||||
case 'NETWORK':
|
||||
return const TransportException(TransportErrorCodes.network);
|
||||
case 'SOURCE_MISSING':
|
||||
return const TransportException(TransportErrorCodes.sourceMissing);
|
||||
case 'HTTP_4XX':
|
||||
case 'SERVER_ERROR':
|
||||
return TransportException(
|
||||
TransportErrorCodes.http,
|
||||
statusCode: status,
|
||||
);
|
||||
default:
|
||||
return TransportException(
|
||||
event['code'] as String? ?? TransportErrorCodes.channel,
|
||||
statusCode: status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _ackTerminal(Map<String, Object?> event) {
|
||||
final seq = (event['seq'] as num?)?.toInt();
|
||||
if (seq == null) return;
|
||||
unawaited(
|
||||
_channel
|
||||
.invoke('ackEvents', <String, Object?>{'seq': seq})
|
||||
.catchError((Object _) => null),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<TransportResult> send({
|
||||
required String taskId,
|
||||
required String url,
|
||||
required String method,
|
||||
required Map<String, String> headers,
|
||||
required String filePath,
|
||||
Map<String, Object?>? metadata,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
CancelToken? cancelToken,
|
||||
}) async {
|
||||
_ensureSubscribed();
|
||||
final waiter = _NativeWaiter(onProgress);
|
||||
_waiters[taskId] = waiter;
|
||||
// 取消令牌联动:cancelTask(原生尽力停止 worker)+ 等待句柄立即失败。
|
||||
cancelToken?.whenCancel.then((_) {
|
||||
// 已终结则 no-op(isCompleted 守护)。
|
||||
waiter.completeError(
|
||||
const TransportException(TransportErrorCodes.cancelled),
|
||||
);
|
||||
unawaited(
|
||||
_channel
|
||||
.invoke('cancelTask', <String, Object?>{'taskId': taskId})
|
||||
.catchError((Object _) => null),
|
||||
);
|
||||
});
|
||||
try {
|
||||
await _channel.invoke('registerTask', <String, Object?>{
|
||||
'taskId': taskId,
|
||||
'url': url,
|
||||
'method': method,
|
||||
'headers': headers,
|
||||
'filePath': filePath,
|
||||
'metadata': metadata ?? const <String, Object?>{},
|
||||
});
|
||||
} on PlatformException catch (e) {
|
||||
// 注册失败:摘除等待句柄后归一为通道异常。
|
||||
if (_waiters[taskId] == waiter) _waiters.remove(taskId);
|
||||
throw TransportException(TransportErrorCodes.channel, cause: e);
|
||||
}
|
||||
try {
|
||||
return await waiter.completer.future;
|
||||
} finally {
|
||||
if (_waiters[taskId] == waiter) _waiters.remove(taskId);
|
||||
}
|
||||
}
|
||||
|
||||
/// 查询原生侧工作状态(WorkInfo 概览;Task 9/11 校准用)。
|
||||
Future<Map<String, Object?>?> queryTask(String taskId) async {
|
||||
try {
|
||||
final result = await _channel
|
||||
.invoke('queryTask', <String, Object?>{'taskId': taskId});
|
||||
return result == null
|
||||
? null
|
||||
: Map<String, Object?>.from(result as Map<dynamic, dynamic>);
|
||||
} on PlatformException {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_eventsSub?.cancel();
|
||||
_eventsSub = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 传输选择 ─────────────────────────────────────────────────────────────
|
||||
|
||||
/// dart-define FORCE_DART_TRANSPORT=true:Android 也强制 Dart dio 路径
|
||||
/// (桥接层问题排查用)。
|
||||
const bool kForceDartTransport = bool.fromEnvironment('FORCE_DART_TRANSPORT');
|
||||
|
||||
/// 传输选择纯函数(可测):Android 且未强制 Dart → 原生。
|
||||
bool preferNativeTransport({
|
||||
required bool isAndroid,
|
||||
bool forceDartTransport = kForceDartTransport,
|
||||
}) =>
|
||||
isAndroid && !forceDartTransport;
|
||||
|
||||
/// 默认传输解析:Android → [NativeTransport](WorkManager 后台续传);
|
||||
/// 其余平台 / 强制覆盖 → [DartHttpTransport]。
|
||||
Transport defaultUploadTransport() {
|
||||
if (preferNativeTransport(isAndroid: !kIsWeb && Platform.isAndroid)) {
|
||||
return NativeTransport();
|
||||
}
|
||||
return DartHttpTransport();
|
||||
}
|
||||
@@ -10,8 +10,8 @@
|
||||
/// 介入,永不自动出队);网络恢复([onNetworkRestored])对
|
||||
/// waitingNetwork 立即绕过退避;
|
||||
/// - **传输三步**:initUpload(幂等,clientTaskId + sourceDeviceId)→
|
||||
/// presign PUT(dio 流式 + 进度回调 + 取消令牌;**裸 Dio**——不挂
|
||||
/// ApiClient 拦截器,避免 Bearer 头污染 MinIO 预签名签名)→
|
||||
/// presign PUT(**经 [Transport] 委托**——Android 走原生 WorkManager
|
||||
/// 后台桥接(Task 7),其余平台裸 dio 流式;进度回调 + 取消令牌)→
|
||||
/// completeUpload(幂等核验)→ completed;
|
||||
/// - **失败分类**([UploadEngineSourceMissing] 之外见 [_classifyFailure]):
|
||||
/// | 错误 | 目标状态 | 自动重试 |
|
||||
@@ -46,6 +46,7 @@ import '../../core/app_exception.dart';
|
||||
import '../../core/device_id.dart';
|
||||
import '../../core/error_codes.dart';
|
||||
import 'models.dart';
|
||||
import 'native_transport.dart';
|
||||
import 'task_state.dart';
|
||||
import 'task_store.dart';
|
||||
|
||||
@@ -75,27 +76,41 @@ const int kMaxCredentialRenewals = 2;
|
||||
/// 传输引擎。
|
||||
///
|
||||
/// 事件驱动 + 周期兜底;不 own isolate(传输多为 IO 等待,UI isolate
|
||||
/// 足够;Android 后台延续由 Task 7 原生桥接承担)。
|
||||
/// 足够;Android 后台延续由 [Transport] 原生实现承担——Task 7)。
|
||||
class UploadEngine {
|
||||
UploadEngine(
|
||||
this._store,
|
||||
this._apiClient,
|
||||
this._deviceId, {
|
||||
Dio? objectDio,
|
||||
Transport? transport,
|
||||
DateTime Function()? now,
|
||||
this.maxParallel = kMaxParallelUploads,
|
||||
this.keepCompletedDays = kKeepCompletedDays,
|
||||
this.onProgress,
|
||||
this.onError,
|
||||
}) : _objectDio = objectDio ?? Dio(),
|
||||
}) : _objectDio = objectDio,
|
||||
_transport = transport ??
|
||||
(objectDio != null
|
||||
? DartHttpTransport(dio: objectDio)
|
||||
: defaultUploadTransport()),
|
||||
_ownsTransport = transport == null,
|
||||
_now = now ?? DateTime.now;
|
||||
|
||||
final TaskStore _store;
|
||||
final ApiClient _apiClient;
|
||||
final DeviceId _deviceId;
|
||||
|
||||
/// presign PUT 专用裸 Dio(无鉴权拦截器,见库级注释)。
|
||||
final Dio _objectDio;
|
||||
/// presign PUT 专用裸 Dio(无鉴权拦截器,见库级注释;[objectDio] 注入
|
||||
/// 时持有引用供 dispose 关闭——传输经 [DartHttpTransport] 使用)。
|
||||
final Dio? _objectDio;
|
||||
|
||||
/// PUT 传输委托(Android 原生 WorkManager 桥接 / 其余平台 Dart dio;
|
||||
/// 选择逻辑见 native_transport.dart)。
|
||||
final Transport _transport;
|
||||
|
||||
/// dispose 时是否释放 [_transport](注入传输由注入方管理生命周期)。
|
||||
final bool _ownsTransport;
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// 并行传输上限(运行时 <1 钳位为 1,见 [_effectiveParallel])。
|
||||
@@ -294,9 +309,13 @@ class UploadEngine {
|
||||
now: _now(),
|
||||
);
|
||||
await _transferSteps(current, handle);
|
||||
} on TransportException catch (e) {
|
||||
// 传输层失败(两实现归一;取消已在 _putObject 内短路,到达这里的
|
||||
// 均为待分类失败)。
|
||||
await _classifyFailure(task, e);
|
||||
} on DioException catch (e) {
|
||||
// 仅来自 PUT(api 调用已归一 AppException);取消已在
|
||||
// _transferSteps 内短路,到达这里的均为待分类失败。
|
||||
// 防御性兜底(api 调用已归一 AppException;dio 异常仅可能自传输
|
||||
// 层漏网),按原语义分类。
|
||||
await _classifyFailure(task, e);
|
||||
} on AppException catch (e) {
|
||||
await _classifyFailure(task, e);
|
||||
@@ -333,20 +352,17 @@ class UploadEngine {
|
||||
now: _now(),
|
||||
);
|
||||
|
||||
// b. presign PUT(流式 + 进度 + 取消令牌;403 → renew 换凭证重传)。
|
||||
// b. presign PUT(transport 委托 + 进度 + 取消令牌;403 → renew 换
|
||||
// 凭证重传——原生路径 re-register 新 URL,Dart 路径重发 dio PUT)。
|
||||
var credential = init.credential;
|
||||
var renewals = 0;
|
||||
while (true) {
|
||||
try {
|
||||
await _putObject(task, credential, handle.cancelToken);
|
||||
await _putObject(task, credential, handle.cancelToken,
|
||||
serverUploadId: init.uploadId);
|
||||
break;
|
||||
} on DioException catch (e) {
|
||||
if (handle.cancelToken.isCancelled) {
|
||||
// 用户取消:状态已置 cancelled(cancel() 内),终止协程。
|
||||
return;
|
||||
}
|
||||
if (e.response?.statusCode == 403 &&
|
||||
renewals < kMaxCredentialRenewals) {
|
||||
} on TransportRenewRequiredException {
|
||||
if (renewals < kMaxCredentialRenewals) {
|
||||
renewals++;
|
||||
final renewed = await _apiClient.renewUpload(init.uploadId);
|
||||
credential = renewed.credential;
|
||||
@@ -375,14 +391,15 @@ class UploadEngine {
|
||||
);
|
||||
}
|
||||
|
||||
/// presign PUT:裸 Dio 直传 credential.url,严格携带 credential.headers
|
||||
/// (+ Content-Length——流式发送 dio 无法自动推断,显式声明)。进度经
|
||||
/// 计数流上报(引擎自持,不依赖适配器对 onSendProgress 的实现)。
|
||||
/// presign PUT:委托 [_transport](Android 原生 WorkManager 桥接 /
|
||||
/// 其余 Dart dio 流式),严格携带 credential.headers。源文件存在性前置
|
||||
/// 检查保留在引擎侧(两路径共用的 sourceMissing 分类依据)。
|
||||
Future<void> _putObject(
|
||||
UploadTask task,
|
||||
UploadCredential credential,
|
||||
CancelToken cancelToken,
|
||||
) async {
|
||||
CancelToken cancelToken, {
|
||||
String? serverUploadId,
|
||||
}) async {
|
||||
final file = File(task.sourceFilePath);
|
||||
if (!await file.exists()) {
|
||||
// 源文件缺失:不可恢复,分类按「不自动重试」处理。
|
||||
@@ -391,37 +408,43 @@ class UploadEngine {
|
||||
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(
|
||||
try {
|
||||
await _transport.send(
|
||||
taskId: task.id,
|
||||
url: credential.url,
|
||||
method: credential.method,
|
||||
headers: {
|
||||
...credential.headers,
|
||||
Headers.contentLengthHeader: length,
|
||||
headers: credential.headers,
|
||||
filePath: task.sourceFilePath,
|
||||
metadata: <String, Object?>{
|
||||
'uploadId': serverUploadId,
|
||||
'attempt': task.attempt,
|
||||
},
|
||||
// 响应体非 JSON(MinIO XML/空),防 transformer 解析报错。
|
||||
responseType: ResponseType.plain,
|
||||
receiveDataWhenStatusError: true,
|
||||
),
|
||||
);
|
||||
onProgress: (sent, total) => onProgress?.call(task.id, sent, total),
|
||||
cancelToken: cancelToken,
|
||||
);
|
||||
} on TransportException catch (e) {
|
||||
if (_isCancelledTransfer(e, cancelToken)) {
|
||||
// 用户取消:状态已置 cancelled(cancel() 内),终止协程。
|
||||
return;
|
||||
}
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// 取消判定:取消令牌已触发或传输层报告 cancelled。
|
||||
static bool _isCancelledTransfer(Object error, CancelToken cancelToken) =>
|
||||
cancelToken.isCancelled ||
|
||||
(error is TransportException &&
|
||||
error.code == TransportErrorCodes.cancelled);
|
||||
|
||||
/// 失败分类与状态落库(分类表见库级注释;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 TransportException) {
|
||||
app = _normalizeTransportFailure(error);
|
||||
} else if (error is DioException) {
|
||||
app = ApiClient.mapDioException(error);
|
||||
} else {
|
||||
@@ -488,6 +511,64 @@ class UploadEngine {
|
||||
);
|
||||
}
|
||||
|
||||
/// 传输层异常 → AppException 归一(与 dio 路径 mapDioException 语义
|
||||
/// 对齐:网络类/429 → 网络桶;401 → 短退避桶;sourceMissing → 不自动
|
||||
/// 重试桶;其余按 HTTP 状态归桶 4)。
|
||||
static AppException _normalizeTransportFailure(TransportException e) {
|
||||
switch (e.code) {
|
||||
case TransportErrorCodes.network:
|
||||
return const AppException(
|
||||
code: ErrorCodes.networkError,
|
||||
message: '传输失败(网络)',
|
||||
);
|
||||
case TransportErrorCodes.sourceMissing:
|
||||
return const AppException(
|
||||
code: UploadEngine.sourceMissingCode,
|
||||
message: 'source file missing',
|
||||
);
|
||||
case TransportErrorCodes.cancelled:
|
||||
return const AppException(
|
||||
code: ErrorCodes.cancelled,
|
||||
message: '传输已取消',
|
||||
);
|
||||
case TransportErrorCodes.channel:
|
||||
return AppException(
|
||||
code: ErrorCodes.internalError,
|
||||
message: '原生桥接通道错误: ${e.cause}',
|
||||
);
|
||||
case TransportErrorCodes.http:
|
||||
default:
|
||||
final status = e.statusCode;
|
||||
if (status == 401) {
|
||||
return const AppException(
|
||||
code: ErrorCodes.authTokenExpired,
|
||||
statusCode: 401,
|
||||
message: '未认证或会话已过期',
|
||||
);
|
||||
}
|
||||
if (status == 429 || status == 408) {
|
||||
return const AppException(
|
||||
code: ErrorCodes.rateLimited,
|
||||
statusCode: 429,
|
||||
message: '传输限速',
|
||||
);
|
||||
}
|
||||
if (status != null) {
|
||||
return AppException(
|
||||
code: status >= 500
|
||||
? ErrorCodes.internalError
|
||||
: ErrorCodes.httpError,
|
||||
statusCode: status,
|
||||
message: '传输 HTTP $status',
|
||||
);
|
||||
}
|
||||
return AppException(
|
||||
code: ErrorCodes.internalError,
|
||||
message: '传输失败: ${e.cause ?? e}',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 分类落地(并发取消致 StateError 时静默——cancelled 已是终态)。
|
||||
Future<void> _fail(
|
||||
UploadTask task,
|
||||
@@ -583,13 +664,17 @@ class UploadEngine {
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
/// 关闭(App 退出):停 timer、尽力断在途、释放 object Dio。
|
||||
/// 关闭(App 退出):停 timer、尽力断在途、释放注入的 object Dio 与
|
||||
/// 自建传输。
|
||||
void dispose() {
|
||||
stop();
|
||||
for (final handle in _inFlight.values) {
|
||||
handle.cancelToken.cancel();
|
||||
}
|
||||
_objectDio.close();
|
||||
_objectDio?.close();
|
||||
if (_ownsTransport) {
|
||||
_transport.dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user