新增:发布页真实化——媒体九宫格编辑态 + 建草稿/迁移发布两步 + 发布漏斗与媒体三段埋点,create 页 demo 发布流退役(T3-17)
CI / flutter-gates (push) Successful in 3m9s
CI / flutter-gates (push) Successful in 3m9s
- 新增 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>
This commit is contained in:
@@ -7,6 +7,7 @@ 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 四视觉态的底层状态模型)。
|
||||
///
|
||||
@@ -76,6 +77,12 @@ class _UploadTask {
|
||||
bool retryable = false;
|
||||
bool cancelled = false;
|
||||
|
||||
/// 本图第几次上传尝试(媒体三段埋点 attemptSeq,从 1 起;retry 递增)。
|
||||
int attemptSeq = 1;
|
||||
|
||||
/// 本次尝试的 started 时刻(succeeded 的 durationMs 口径)。
|
||||
DateTime? attemptStartedAt;
|
||||
|
||||
/// 压缩产物缓存(重试跳过重压缩)。
|
||||
CompressedMediaImage? compressed;
|
||||
|
||||
@@ -115,12 +122,15 @@ class _UploadTask {
|
||||
/// 对外可见的 [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,
|
||||
@@ -135,6 +145,10 @@ class MediaUploader extends ChangeNotifier {
|
||||
final MediaImagePicker _picker;
|
||||
final MediaImageCompressor _compressor;
|
||||
final MediaDirectUploadClient _directUpload;
|
||||
|
||||
/// 媒体上传三段埋点(T3-17 接入;未注入即不上报)。
|
||||
final PostAnalytics? _analytics;
|
||||
|
||||
final DateTime Function() _now;
|
||||
|
||||
/// 九宫格上限(05 号规范 §3.2)。
|
||||
@@ -244,24 +258,28 @@ class MediaUploader extends ChangeNotifier {
|
||||
task.errorMessage = null;
|
||||
task.retryable = false;
|
||||
task.progress = 0;
|
||||
task.attemptSeq += 1;
|
||||
task.phase = MediaItemPhase.queued;
|
||||
notifyListeners();
|
||||
unawaited(_run(task));
|
||||
}
|
||||
|
||||
/// 移除一张图(任意态可移除);在途请求结果一律作废,未 confirm 的
|
||||
/// 服务端 asset 弃引用(服务端超时清理兜底)。
|
||||
/// 服务端 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();
|
||||
@@ -282,6 +300,12 @@ class MediaUploader extends ChangeNotifier {
|
||||
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);
|
||||
@@ -308,10 +332,20 @@ class MediaUploader extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
_fail(task, message: '图片处理失败', retryable: true);
|
||||
_fail(
|
||||
task,
|
||||
message: '图片处理失败',
|
||||
retryable: true,
|
||||
reason: MediaUploadFailureReason.unsupportedFormat,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
_fail(task, message: '图片过大,压缩后仍超过 10 MB', retryable: false);
|
||||
_fail(
|
||||
task,
|
||||
message: '图片过大,压缩后仍超过 10 MB',
|
||||
retryable: false,
|
||||
reason: MediaUploadFailureReason.mediaTooLarge,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -335,7 +369,12 @@ class MediaUploader extends ChangeNotifier {
|
||||
while (true) {
|
||||
if (_credentialsExpired(credentials)) {
|
||||
if (renewed) {
|
||||
_fail(task, message: '上传凭据已过期', retryable: true);
|
||||
_fail(
|
||||
task,
|
||||
message: '上传凭据已过期',
|
||||
retryable: true,
|
||||
reason: MediaUploadFailureReason.serverError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
renewed = true;
|
||||
@@ -380,6 +419,9 @@ class MediaUploader extends ChangeNotifier {
|
||||
task,
|
||||
message: error.statusCode == null ? '网络中断,上传失败' : '上传被存储服务拒绝',
|
||||
retryable: true,
|
||||
reason: error.statusCode == null
|
||||
? MediaUploadFailureReason.networkError
|
||||
: MediaUploadFailureReason.serverError,
|
||||
);
|
||||
return;
|
||||
}
|
||||
@@ -398,12 +440,25 @@ class MediaUploader extends ChangeNotifier {
|
||||
}
|
||||
if (task.cancelled) return;
|
||||
if (asset.status != MediaAssetStatus.ready) {
|
||||
_fail(task, message: '上传确认未通过', retryable: true);
|
||||
_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(
|
||||
@@ -424,12 +479,22 @@ class MediaUploader extends ChangeNotifier {
|
||||
|
||||
void _failFromApi(_UploadTask task, Exception error) {
|
||||
// 参数被服务端拒绝(40000:mime/byteSize 白名单外)重试无意义,终态。
|
||||
final retryable =
|
||||
error is! ApiBusinessException || error.code != ApiCodes.paramError;
|
||||
final isParamError =
|
||||
error is ApiBusinessException && error.code == ApiCodes.paramError;
|
||||
_fail(
|
||||
task,
|
||||
message: error is ApiBusinessException ? error.message : '网络异常,请重试',
|
||||
retryable: retryable,
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -437,14 +502,36 @@ class MediaUploader extends ChangeNotifier {
|
||||
_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;
|
||||
@@ -469,3 +556,15 @@ class MediaUploader extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [MediaUploader] 的构造口(发布页每次进入建一个,退出即 dispose)。
|
||||
///
|
||||
/// 生产缺省即 `MediaUploader(repository: ..., analytics: ...)`;注入点为
|
||||
/// **测试与桌面实测专用**——Linux 桌面既无 image_picker 也无
|
||||
/// flutter_image_compress 的原生实现,桌面真链路只替换选图与压缩两层,
|
||||
/// 其余(createUpload / 直传 PUT / confirm)全为生产实现。
|
||||
typedef MediaUploaderFactory =
|
||||
MediaUploader Function(
|
||||
CommunityRepository repository,
|
||||
PostAnalytics? analytics,
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user