新增:媒体上传客户端——MediaUploader 编排状态机 + 预签名直传 + 进度组件,修正 media 端点误挂 community 服务(T3-13)
CI / flutter-gates (push) Successful in 2m30s
CI / flutter-gates (push) Successful in 2m30s
- MediaUploader:选图→压缩(80→60 降质阶梯,10 MiB 超限拒绝)→createUpload →预签名 PUT 直传(进度回调)→confirm→ready assetId 交付;并发槽位 2、 顺序即 position、单图失败不拖垮整批;凭据过期预检与存储侧 403 各自动 换新凭据一次;失败重试复用压缩产物、从 createUpload 全新开始 - 孤儿防护:未 confirm 的 assetId 只以管线局部变量存在,快照 assetId 与 ready 态断言绑定,buildAttachRequests 非全员 ready 即抛 StateError - 线路修正:media 两步上传端点由 user 服务(:8082 MediaController)提供, T3-12 误挂 community 客户端(:8084 无 media 路由),补 mediaApi 分端口 直连,compose 真链路实测证实 - UploadProgressOverlay 四态进度层(05 号规范 §3.3);直传层本地 HttpServer 三线路测试(200/403/断连);compose 冒烟测试 (PATBOND_MEDIA_SMOKE=1 启用,默认跳过) - 新依赖:image_picker、flutter_image_compress(03 号评估选型) - flutter test 379 全绿(基线 347,+32)、analyze 0、format 无 diff Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,138 @@
|
||||
import 'dart:async';
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:patbond_flutter/features/community/community_models.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';
|
||||
|
||||
/// media 上传链路测试件(T3-13):假选择器/压缩器/直传客户端,
|
||||
/// 全部支持 Completer 控时序(helpers 既有先例)。
|
||||
|
||||
PickedMediaImage pickedImage({int seed = 1, int size = 64}) => PickedMediaImage(
|
||||
bytes: Uint8List.fromList(List.filled(size, seed)),
|
||||
name: 'img-$seed.jpg',
|
||||
);
|
||||
|
||||
MediaUploadCredentials credentials({
|
||||
String assetId = 'a-1',
|
||||
DateTime? expiresAt,
|
||||
}) => MediaUploadCredentials(
|
||||
assetId: assetId,
|
||||
uploadUrl:
|
||||
'http://minio.local/patbond-media/post_image/$assetId?X-Amz-Signature=sig',
|
||||
method: 'PUT',
|
||||
requiredHeaders: const {'Content-Type': 'image/jpeg'},
|
||||
expiresAt: expiresAt ?? DateTime.now().add(const Duration(minutes: 10)),
|
||||
);
|
||||
|
||||
MediaAsset readyAsset({String assetId = 'a-1'}) => MediaAsset(
|
||||
id: assetId,
|
||||
kind: MediaKind.image,
|
||||
purpose: 'post_image',
|
||||
mimeType: 'image/jpeg',
|
||||
byteSize: 1024,
|
||||
widthPx: 1080,
|
||||
heightPx: 810,
|
||||
status: MediaAssetStatus.ready,
|
||||
url: 'http://minio.local/p.jpg?X-Amz-Signature=sig',
|
||||
readyAt: DateTime.utc(2026, 9, 9),
|
||||
createdAt: DateTime.utc(2026, 9, 9),
|
||||
);
|
||||
|
||||
class FakeMediaImagePicker implements MediaImagePicker {
|
||||
FakeMediaImagePicker(this.results);
|
||||
|
||||
final List<PickedMediaImage> results;
|
||||
final List<int> limits = [];
|
||||
|
||||
/// 非 null 时 pickImages 挂起等待(picking 态观测用)。
|
||||
Completer<void>? gate;
|
||||
|
||||
@override
|
||||
Future<List<PickedMediaImage>> pickImages({required int limit}) async {
|
||||
limits.add(limit);
|
||||
await gate?.future;
|
||||
return results.take(limit).toList();
|
||||
}
|
||||
}
|
||||
|
||||
/// 假压缩器:默认原样透传出 image/jpeg;[sizePerQuality] 指定各降质档
|
||||
/// 的产物大小(超限阶梯测试用)。
|
||||
class FakeMediaCompressor implements MediaImageCompressor {
|
||||
final List<int> qualities = [];
|
||||
Map<int, int>? sizePerQuality;
|
||||
Exception? error;
|
||||
|
||||
@override
|
||||
Future<CompressedMediaImage> compress(
|
||||
PickedMediaImage source, {
|
||||
required int quality,
|
||||
}) async {
|
||||
qualities.add(quality);
|
||||
if (error != null) throw error!;
|
||||
final size = sizePerQuality?[quality];
|
||||
return CompressedMediaImage(
|
||||
bytes: size == null
|
||||
? source.bytes
|
||||
: Uint8List.fromList(List.filled(size, 0)),
|
||||
mimeType: 'image/jpeg',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 一次直传调用的观测句柄(进度回调驱动 + 结局手动控制)。
|
||||
class DirectUploadCall {
|
||||
DirectUploadCall({
|
||||
required this.url,
|
||||
required this.headers,
|
||||
required this.bytes,
|
||||
required this.onProgress,
|
||||
});
|
||||
|
||||
final String url;
|
||||
final Map<String, String> headers;
|
||||
final Uint8List bytes;
|
||||
final void Function(int sent, int total)? onProgress;
|
||||
final Completer<void> completer = Completer<void>();
|
||||
|
||||
void emitProgress(int sent, int total) => onProgress?.call(sent, total);
|
||||
void succeed() => completer.complete();
|
||||
void fail(MediaDirectUploadException error) => completer.completeError(error);
|
||||
}
|
||||
|
||||
class FakeDirectUploadClient implements MediaDirectUploadClient {
|
||||
final List<DirectUploadCall> calls = [];
|
||||
|
||||
/// 非 null 时每次 put 自动以该结果收尾(null = 自动成功);
|
||||
/// 设为 manual 后由测试经 [calls] 手动驱动。
|
||||
bool manual = false;
|
||||
final List<MediaDirectUploadException?> scriptedOutcomes = [];
|
||||
|
||||
@override
|
||||
Future<void> put({
|
||||
required String url,
|
||||
required Map<String, String> headers,
|
||||
required Uint8List bytes,
|
||||
void Function(int sent, int total)? onProgress,
|
||||
}) {
|
||||
final call = DirectUploadCall(
|
||||
url: url,
|
||||
headers: headers,
|
||||
bytes: bytes,
|
||||
onProgress: onProgress,
|
||||
);
|
||||
calls.add(call);
|
||||
if (!manual) {
|
||||
final outcome = scriptedOutcomes.isEmpty
|
||||
? null
|
||||
: scriptedOutcomes.removeAt(0);
|
||||
if (outcome == null) {
|
||||
call.succeed();
|
||||
} else {
|
||||
call.fail(outcome);
|
||||
}
|
||||
}
|
||||
return call.completer.future;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user