import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:patbond_flutter/core/network/api_exception.dart'; import 'package:patbond_flutter/features/community/community_models.dart'; import 'package:patbond_flutter/features/community/community_repository.dart'; import 'package:patbond_flutter/features/community/media_compression.dart'; import 'package:patbond_flutter/features/community/media_direct_upload.dart'; import 'package:patbond_flutter/features/community/media_picking.dart'; import 'package:patbond_flutter/features/community/post_analytics.dart'; /// 单张图的上传阶段(05 号规范 §3.3 四视觉态的底层状态模型)。 /// /// 生命周期:queued → compressing → uploading(progress) → confirming /// → ready | failed(retryable?)。ready 是唯一可交付态——**assetId 只在 /// ready 态对外可见**(孤儿防护:未 confirm 的 asset 不得被引用)。 enum MediaItemPhase { queued, compressing, uploading, confirming, ready, failed, } /// 单张图的不可变状态快照(UI 消费;内部任务状态见 _UploadTask)。 @immutable class MediaUploadItem { const MediaUploadItem({ required this.localId, required this.phase, required this.previewBytes, this.progress = 0, this.assetId, this.errorMessage, this.retryable = false, }) : assert( (assetId != null) == (phase == MediaItemPhase.ready), 'assetId 与 ready 态严格绑定(孤儿防护)', ); /// 本地稳定标识(重试/删除寻址用,与服务端无关)。 final int localId; final MediaItemPhase phase; /// 缩略预览字节(原图,UI 直接 Image.memory 渲染)。 final Uint8List previewBytes; /// 直传进度 0..1(uploading 有意义;confirming 视作 1.0)。 final double progress; /// ready 态的可引用 asset 标识;其余态恒为 null(构造期断言兜底)。 final String? assetId; /// failed 态的用户可读原因。 final String? errorMessage; /// failed 态是否可重试(false = 终态,如压缩后仍超限)。 final bool retryable; bool get isReady => phase == MediaItemPhase.ready; bool get isFailed => phase == MediaItemPhase.failed; bool get isBusy => !isReady && !isFailed; } /// 内部任务:可变状态 + 生命周期旗标(cancelled 后一切在途结果作废)。 class _UploadTask { _UploadTask({required this.localId, required this.source}); final int localId; final PickedMediaImage source; MediaItemPhase phase = MediaItemPhase.queued; double progress = 0; String? errorMessage; bool retryable = false; bool cancelled = false; /// 本图第几次上传尝试(媒体三段埋点 attemptSeq,从 1 起;retry 递增)。 int attemptSeq = 1; /// 本次尝试的 started 时刻(succeeded 的 durationMs 口径)。 DateTime? attemptStartedAt; /// 压缩产物缓存(重试跳过重压缩)。 CompressedMediaImage? compressed; /// confirm 成功前的服务端 assetId 只以管线局部变量存在,**不落任务 /// 状态、不出现在 [MediaUploadItem] 快照**——孤儿防护的结构保证; /// confirm 通过后才写入 [readyAssetId]。 String? readyAssetId; MediaUploadItem snapshot() => MediaUploadItem( localId: localId, phase: phase, previewBytes: source.bytes, progress: progress, assetId: readyAssetId, errorMessage: errorMessage, retryable: retryable, ); } /// 媒体上传编排器(T3-13,03 号评估 §4.3 冻结接口的定稿实现)。 /// /// 职责:选图 → 压缩(降质阶梯 80→60,仍超 10 MiB 拒绝为终态失败)→ /// createUpload → 预签名 PUT 直传(进度回调;Content-Type 按 /// requiredHeaders 原样携带)→ confirm → ready assetId 交付。 /// /// 语义要点: /// - **顺序保持**:items 顺序 = 加入顺序 = position 语义;并发上传的 /// 完成先后不影响顺序([buildAttachRequests] 按当前列表序发号)。 /// - **单飞槽位**:至多 [maxConcurrentUploads] 张同时占用网络管线, /// 单图失败不拖垮整批。 /// - **凭据过期**:PUT 前过期预检、存储侧 403 各触发一次自动 /// re-createUpload(换新 assetId 新凭据);再失败交给手动重试。 /// - **失败重试**:retry 复用压缩产物、从 createUpload 全新开始 /// (统一覆盖「对象未上传保持 uploading」与「内容不符置 failed 终态」 /// 两种服务端分支——旧 asset 弃引用,由服务端超时清理兜底)。 /// - **孤儿防护**:未 confirm 的 assetId 只以管线局部变量存在; /// 对外可见的 [MediaUploadItem.assetId] 与 ready 态严格绑定(断言), /// [buildAttachRequests] 仅在全员 ready 时可用。 /// - 预签名凭据只存内存、用完即弃,不持久化(既有纪律)。 /// - **媒体三段埋点**(T3-17):每次尝试恰一条 started,收敛为 /// succeeded / failed 各一条;`sizeBucket` 统一取原图字节数。 class MediaUploader extends ChangeNotifier { MediaUploader({ required this._repository, MediaImagePicker? picker, MediaImageCompressor? compressor, MediaDirectUploadClient? directUpload, this._analytics, this.purpose = MediaPurpose.postImage, this.maxImages = 9, this.maxConcurrentUploads = 2, this.maxByteSize = 10 * 1024 * 1024, DateTime Function()? now, }) : _picker = picker ?? SystemMediaImagePicker(), _compressor = compressor ?? const NativeMediaImageCompressor(), _directUpload = directUpload ?? DioMediaDirectUploadClient(), _now = now ?? DateTime.now, _slots = maxConcurrentUploads; final CommunityRepository _repository; final MediaImagePicker _picker; final MediaImageCompressor _compressor; final MediaDirectUploadClient _directUpload; /// 媒体上传三段埋点(T3-17 接入;未注入即不上报)。 final PostAnalytics? _analytics; final DateTime Function() _now; /// 上传用途(决定服务端 objectKey 前缀,也是引用侧的类型检查依据): /// 发布页 `post_image`、资料页头像 `user_avatar`、宠物头像 `pet_avatar`。 /// 用途不符的 asset 在引用时被答 404/40405,所以这个值必须由调用方 /// 按场景显式给对(M3.5-08/09 起本类不再只服务发布页)。 final MediaPurpose purpose; /// 九宫格上限(05 号规范 §3.2);头像场景传 1。 final int maxImages; final int maxConcurrentUploads; /// 与服务端 byteSize 上限一致(10 MiB,13 号报告偏差 #7)。 final int maxByteSize; /// 凭据过期预检安全边距:距 expiresAt 不足此值即视为过期,直接换新。 static const credentialsSafetyMargin = Duration(seconds: 30); /// 压缩降质阶梯(80 常规 → 60 兜底;仍超限即终态拒绝)。 static const qualityLadder = [80, 60]; final List<_UploadTask> _tasks = []; int _nextLocalId = 1; bool _picking = false; int _slots; final List> _slotWaiters = []; // ---- 对外状态 ---- /// 选择器是否拉起中(uploader 级 picking 态)。 bool get isPicking => _picking; List get items => List.unmodifiable(_tasks.map((task) => task.snapshot())); bool get isEmpty => _tasks.isEmpty; int get remainingSlots => maxImages - _tasks.length; int get readyCount => _tasks.where((t) => t.phase == MediaItemPhase.ready).length; bool get allReady => _tasks.isNotEmpty && readyCount == _tasks.length; bool get hasFailure => _tasks.any((t) => t.phase == MediaItemPhase.failed); bool get hasBusyItem => _picking || _tasks.any((t) => t.snapshot().isBusy); /// 页级汇总进度(05 号规范 §3.3 线性进度条):各图等权。 double get overallProgress { if (_tasks.isEmpty) return 0; var sum = 0.0; for (final task in _tasks) { sum += switch (task.phase) { MediaItemPhase.ready => 1.0, MediaItemPhase.uploading => task.progress, MediaItemPhase.confirming => 1.0, _ => 0.0, }; } return sum / _tasks.length; } /// 交付口(T3-17 发布页组 CreatePostRequest.media 用): /// 仅全员 ready 时可用——**任何非 ready 项在场即抛 [StateError]**, /// 从类型上杜绝未 confirm asset 被引用。position 按当前列表序 0..n-1。 List buildAttachRequests({int coverIndex = 0}) { if (!allReady) { throw StateError('存在未就绪的上传项,不得引用(孤儿防护)'); } return [ for (final (index, task) in _tasks.indexed) PostMediaAttachRequest( assetId: task.readyAssetId!, position: index, isCover: index == coverIndex, ), ]; } // ---- 操作 ---- /// 拉起系统选择器并把所选图片加入上传管线(剩余槽位自动截断)。 Future pickAndAdd() async { if (_picking || remainingSlots <= 0) return; _picking = true; notifyListeners(); try { final images = await _picker.pickImages(limit: remainingSlots); _picking = false; addImages(images); } catch (_) { _picking = false; notifyListeners(); } } /// 直接加入图片(选择器旁路,测试与分享外链场景用)。 void addImages(List images) { for (final image in images.take(remainingSlots)) { final task = _UploadTask(localId: _nextLocalId++, source: image); _tasks.add(task); unawaited(_run(task)); } notifyListeners(); } /// 重试一张可重试失败图:复用压缩产物,从 createUpload 全新开始。 void retry(int localId) { final task = _taskOrNull(localId); if (task == null || task.phase != MediaItemPhase.failed || !task.retryable) { return; } task.errorMessage = null; task.retryable = false; task.progress = 0; task.attemptSeq += 1; task.phase = MediaItemPhase.queued; notifyListeners(); unawaited(_run(task)); } /// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的 /// 服务端 asset 弃引用(服务端超时清理兜底)。在途任务被移除按 /// `cancelled` 上报一条上传失败(06 §1.4「用户取消」口径)。 void remove(int localId) { final task = _taskOrNull(localId); if (task == null) return; _reportCancelled(task); task.cancelled = true; _tasks.remove(task); notifyListeners(); } /// 清空全部(发布成功/离开页面时调用);在途任务同 [remove] 记 cancelled。 void reset() { for (final task in _tasks) { _reportCancelled(task); task.cancelled = true; } _tasks.clear(); _picking = false; notifyListeners(); } _UploadTask? _taskOrNull(int localId) { for (final task in _tasks) { if (task.localId == localId) return task; } return null; } // ---- 管线 ---- Future _run(_UploadTask task) async { await _acquireSlot(); try { if (task.cancelled) return; // 一次尝试恰一条 started(含压缩段:压缩失败也在漏斗内可见)。 task.attemptStartedAt = _now(); _analytics?.mediaUploadStarted( mediaType: MediaType.image, byteSize: task.source.bytes.length, ); final compressed = await _compress(task); if (compressed == null || task.cancelled) return; await _uploadAndConfirm(task, compressed); } finally { _releaseSlot(); } } /// 压缩(降质阶梯);超限终态失败返回 null。 Future _compress(_UploadTask task) async { final cached = task.compressed; if (cached != null) return cached; _transition(task, MediaItemPhase.compressing); try { for (final quality in qualityLadder) { final result = await _compressor.compress( task.source, quality: quality, ); if (task.cancelled) return null; if (result.byteSize <= maxByteSize) { task.compressed = result; return result; } } } catch (_) { _fail( task, message: '图片处理失败', retryable: true, reason: MediaUploadFailureReason.unsupportedFormat, ); return null; } _fail( task, message: '图片过大,压缩后仍超过 10 MB', retryable: false, reason: MediaUploadFailureReason.mediaTooLarge, ); return null; } Future _uploadAndConfirm( _UploadTask task, CompressedMediaImage compressed, ) async { // createUpload:登记 mime/byteSize,取预签名 PUT 凭据(内存态,不持久化)。 MediaUploadCredentials credentials; try { credentials = await _createUpload(compressed); } on Exception catch (error) { if (!task.cancelled) _failFromApi(task, error); return; } if (task.cancelled) return; // 直传 PUT:过期预检与存储侧 403 各允许一次自动换新凭据。 _transition(task, MediaItemPhase.uploading); var renewed = false; while (true) { if (_credentialsExpired(credentials)) { if (renewed) { _fail( task, message: '上传凭据已过期', retryable: true, reason: MediaUploadFailureReason.serverError, ); return; } renewed = true; try { credentials = await _createUpload(compressed); } on Exception catch (error) { if (!task.cancelled) _failFromApi(task, error); return; } if (task.cancelled) return; // 回到循环顶复检新凭据(服务端时钟异常仍过期即失败,不无限重取)。 continue; } try { await _directUpload.put( url: credentials.uploadUrl, headers: credentials.requiredHeaders, bytes: compressed.bytes, onProgress: (sent, total) { if (task.cancelled || total <= 0) return; task.progress = sent / total; notifyListeners(); }, ); break; } on MediaDirectUploadException catch (error) { if (task.cancelled) return; if (error.isCredentialRejected && !renewed) { renewed = true; try { credentials = await _createUpload(compressed); } on Exception catch (creationError) { _failFromApi(task, creationError); return; } if (task.cancelled) return; task.progress = 0; notifyListeners(); continue; } _fail( task, message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝', retryable: true, reason: error.statusCode == null ? MediaUploadFailureReason.networkError : MediaUploadFailureReason.serverError, ); return; } } if (task.cancelled) return; // confirm:42205 对象未上传保持 uploading / 内容不符置 failed 终态, // 客户端统一按「可重试 + 重试换新 asset」处理,两分支都正确收敛。 _transition(task, MediaItemPhase.confirming); final MediaAsset asset; try { asset = await _repository.completeMediaUpload(credentials.assetId); } on Exception catch (error) { if (!task.cancelled) _failFromApi(task, error); return; } if (task.cancelled) return; if (asset.status != MediaAssetStatus.ready) { _fail( task, message: '上传确认未通过', retryable: true, reason: MediaUploadFailureReason.serverError, ); return; } task.readyAssetId = asset.id; task.progress = 1; _transition(task, MediaItemPhase.ready); final startedAt = task.attemptStartedAt; _analytics?.mediaUploadSucceeded( mediaType: MediaType.image, byteSize: task.source.bytes.length, durationMs: startedAt == null ? 0 : _now().difference(startedAt).inMilliseconds, ); } Future _createUpload( CompressedMediaImage compressed, ) { return _repository.createMediaUpload( CreateMediaUploadRequest( kind: MediaKind.image, purpose: purpose, mimeType: compressed.mimeType, byteSize: compressed.byteSize, ), ); } bool _credentialsExpired(MediaUploadCredentials credentials) => !_now().add(credentialsSafetyMargin).isBefore(credentials.expiresAt); void _failFromApi(_UploadTask task, Exception error) { // 参数被服务端拒绝(40000:mime/byteSize 白名单外)重试无意义,终态。 final isParamError = error is ApiBusinessException && error.code == ApiCodes.paramError; _fail( task, message: error is ApiBusinessException ? error.message : '网络异常,请重试', retryable: !isParamError, // 客户端已本地保证 ≤10 MiB,故 40000 归因为格式白名单外; // 会话失效不上报(reason 传 null),其余业务/限流并入 server_error。 reason: switch (error) { ApiBusinessException _ when isParamError => MediaUploadFailureReason.unsupportedFormat, SessionExpiredException _ => null, ApiNetworkException _ => MediaUploadFailureReason.networkError, _ => MediaUploadFailureReason.serverError, }, errorCode: error is ApiBusinessException ? error.code : null, ); } void _fail( _UploadTask task, { required String message, required bool retryable, required MediaUploadFailureReason? reason, int? errorCode, }) { if (task.cancelled) return; task.phase = MediaItemPhase.failed; task.errorMessage = message; task.retryable = retryable; if (reason != null) { _analytics?.mediaUploadFailed( mediaType: MediaType.image, byteSize: task.source.bytes.length, reason: reason, attemptSeq: task.attemptSeq, errorCode: errorCode, ); } notifyListeners(); } /// 在途任务被删格/清空作废 → `cancelled`(已 ready / 已 failed 不报)。 void _reportCancelled(_UploadTask task) { if (task.cancelled || !task.snapshot().isBusy) return; _analytics?.mediaUploadFailed( mediaType: MediaType.image, byteSize: task.source.bytes.length, reason: MediaUploadFailureReason.cancelled, attemptSeq: task.attemptSeq, ); } void _transition(_UploadTask task, MediaItemPhase phase) { if (task.cancelled) return; task.phase = phase; notifyListeners(); } Future _acquireSlot() { if (_slots > 0) { _slots--; return Future.value(); } final waiter = Completer(); _slotWaiters.add(waiter); return waiter.future; } void _releaseSlot() { if (_slotWaiters.isNotEmpty) { _slotWaiters.removeAt(0).complete(); } else { _slots++; } } } /// [MediaUploader] 的构造口(发布页每次进入建一个,退出即 dispose)。 /// /// 生产缺省即 `MediaUploader(repository: ..., analytics: ...)`;注入点为 /// **测试与桌面实测专用**——Linux 桌面既无 image_picker 也无 /// flutter_image_compress 的原生实现,桌面真链路只替换选图与压缩两层, /// 其余(createUpload / 直传 PUT / confirm)全为生产实现。 typedef MediaUploaderFactory = MediaUploader Function( CommunityRepository repository, PostAnalytics? analytics, );