Files
patbond-flutter/lib/features/community/media_uploader.dart
T
lixi 9892b65a19
CI / flutter-gates (push) Successful in 3m9s
新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
- 新增 PostComposePage(P3 发布页,push 路由 post_form):正文/类目
  (general·help)/九宫格选图(组装 MediaUploader + UploadProgressOverlay,
  删格按列表序重发 position)/发布 gating(正文非空 + 在场媒体全 ready)
- 发布走「createPost(draft) → PATCH status=published」两步:迁移失败时草稿
  已在服务端,UI 明确提示「草稿已保存」;40905 重置幂等键、42203 提示等图、
  网络失败同键重放不重复建帖;40902 自动取新 version 重提一次
- 草稿:「存草稿」(manual) 与「取消 → 保留」(on_exit) 两路径 + 进页恢复最新
  一条草稿(提示条/清空);「不保留」软删服务端草稿(post_deleted 触点)
- 埋点:新增 post_analytics.dart(发布漏斗五事件 + 媒体三段,键集对齐字典
  v3);MediaUploader 挂接 started/succeeded/failed(含 cancelled) 与 attemptSeq;
  pageName 枚举补 post_form
- PostMediaEditGrid(同文件编辑态):3 列九宫格 + 虚线「+」格 + 删除角标 +
  进度覆盖层 + 失败整格重试;页级上传汇总条
- create 页只余 AI 生成模拟(M4 原样保留),demo 发布流与 AppState.posts /
  publishPost / updatePost 及其持久化一并退役;首页 story「发布」与 Feed 空态
  CTA 改为 push 真实发布页
- 测试 458 → 502(+44):发布页 widget 22、发布埋点 11、媒体三段 7、编辑态
  九宫格 4、主壳发布闭环 1;另加桌面真链路 integration_test(env 门控)
- flutter analyze 0 问题、dart format 无 diff;compose 六容器真链路实测通过
  (选图上传 → 发布 → Feed 置顶 → 第二客户端可见)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-10 14:42:13 +08:00

571 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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..1uploading 有意义;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.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;
/// 九宫格上限(05 号规范 §3.2)。
final int maxImages;
final int maxConcurrentUploads;
/// 与服务端 byteSize 上限一致(10 MiB13 号报告偏差 #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<Completer<void>> _slotWaiters = [];
// ---- 对外状态 ----
/// 选择器是否拉起中(uploader 级 picking 态)。
bool get isPicking => _picking;
List<MediaUploadItem> 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<PostMediaAttachRequest> 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<void> 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<PickedMediaImage> 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<void> _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<CompressedMediaImage?> _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<void> _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;
// confirm42205 对象未上传保持 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<MediaUploadCredentials> _createUpload(
CompressedMediaImage compressed,
) {
return _repository.createMediaUpload(
CreateMediaUploadRequest(
kind: MediaKind.image,
purpose: MediaPurpose.postImage,
mimeType: compressed.mimeType,
byteSize: compressed.byteSize,
),
);
}
bool _credentialsExpired(MediaUploadCredentials credentials) =>
!_now().add(credentialsSafetyMargin).isBefore(credentials.expiresAt);
void _failFromApi(_UploadTask task, Exception error) {
// 参数被服务端拒绝(40000mime/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<void> _acquireSlot() {
if (_slots > 0) {
_slots--;
return Future.value();
}
final waiter = Completer<void>();
_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,
);