新增:媒体上传客户端——MediaUploader 编排状态机 + 预签名直传 + 进度组件,修正 media 端点误挂 community 服务(T3-13)
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:
2026-09-09 12:21:20 +08:00
parent 19bd8c1810
commit 1441f0148f
21 changed files with 2017 additions and 33 deletions
+146
View File
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:patbond_flutter/core/network/api_client.dart';
import 'package:patbond_flutter/core/network/token_refresher.dart';
import 'package:patbond_flutter/features/auth/auth_models.dart';
import 'package:patbond_flutter/features/auth/session_manager.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_picking.dart';
import 'package:patbond_flutter/features/community/media_uploader.dart';
import 'package:uuid/uuid.dart';
import '../helpers/auth_test_helpers.dart';
/// T3-13 compose 真链路冒烟(默认跳过,不计入常规测试套件):
///
/// ```bash
/// # 先起后端六容器(patbond-api 仓库根):
/// # ./deploy/init-secrets.sh
/// # JAVA_HOME=<JDK17> ./mvnw -DskipTests package && docker compose up -d --build
/// PATBOND_MEDIA_SMOKE=1 flutter test test/smoke/media_upload_smoke_test.dart
/// ```
///
/// 驱动**真实 MediaUploader** 走完整链路:注册取 token → createUpload
/// user :8082)→ 预签名 PUT 直传 MinIO:9000)→ confirm → ready
/// assetId → 引用发帖(community :8084)→ 预签名 GET 取回字节一致。
/// 压缩层用透传实现(flutter test VM 无原生编解码通道),其余全为生产实现。
void main() {
final enabled = Platform.environment['PATBOND_MEDIA_SMOKE'] == '1';
final env = Platform.environment;
final authBase = env['PATBOND_SMOKE_AUTH_BASE'] ?? 'http://127.0.0.1:8081';
final userBase = env['PATBOND_SMOKE_USER_BASE'] ?? 'http://127.0.0.1:8082';
final communityBase =
env['PATBOND_SMOKE_COMMUNITY_BASE'] ?? 'http://127.0.0.1:8084';
// 1x1 PNG67 字节,合法图片本体;mime 声明与直传 Content-Type 一致)。
final pngBytes = base64Decode(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8'
'z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==',
);
test(
'选图→压缩→createUpload→直传→confirm→ready→引用发帖→GET 回读',
() async {
// ---- 注册测试账号(一次性,随机凭据,不落任何持久化)----
final dio = Dio(BaseOptions(validateStatus: (_) => true));
final seed = DateTime.now().millisecondsSinceEpoch;
final register = await dio.post<Map<String, dynamic>>(
'$authBase/api/v1/auth/register',
data: {
'username': 'smoke$seed',
'phone': '+86139${(seed % 100000000).toString().padLeft(8, '0')}',
'password': 'Smoke1234!$seed',
},
options: Options(
headers: {
'Idempotency-Key': const Uuid().v4(),
'X-Device-Id': const Uuid().v4(),
},
),
);
expect(register.data?['code'], 0, reason: '注册失败:${register.data}');
final session = SessionManager(store: InMemoryTokenStore());
await session.updateTokens(
AuthTokens.fromJson(register.data!['data'] as Map<String, dynamic>),
);
final refresher = TokenRefresher(
dio: buildPatbondDio(session: session, baseUrl: authBase),
session: session,
);
final repository = ApiCommunityRepository(
api: ApiClient(
dio: buildPatbondDio(session: session, baseUrl: communityBase),
session: session,
refresher: refresher,
),
mediaApi: ApiClient(
dio: buildPatbondDio(session: session, baseUrl: userBase),
session: session,
refresher: refresher,
),
);
// ---- 真实 MediaUploader 全链路 ----
final uploader = MediaUploader(
repository: repository,
compressor: _PassthroughPngCompressor(),
);
final done = Completer<void>();
uploader.addListener(() {
if (done.isCompleted) return;
if (uploader.allReady) done.complete();
if (uploader.hasFailure) {
done.completeError(
StateError('上传失败:${uploader.items.single.errorMessage}'),
);
}
});
uploader.addImages([
PickedMediaImage(bytes: pngBytes, name: 'smoke.png'),
]);
await done.future.timeout(const Duration(seconds: 60));
final item = uploader.items.single;
expect(item.phase, MediaItemPhase.ready);
expect(item.assetId, isNotEmpty);
// ---- ready assetId 引用发帖(孤儿防护出口)+ 预签名 GET 回读 ----
final post = await repository.createPost(
CreatePostRequest(
content: 'T3-13 媒体上传冒烟 $seed',
status: PostStatus.published,
media: uploader.buildAttachRequests(),
),
);
expect(post.media.single.assetId, item.assetId);
final fetched = await dio.get<List<int>>(
post.media.single.url,
options: Options(responseType: ResponseType.bytes),
);
expect(fetched.statusCode, 200);
expect(fetched.data, pngBytes, reason: '预签名 GET 回读字节应与上传一致');
// 收尾:删除冒烟帖(asset 服务端软删语义随帖处理)。
await repository.deletePost(post.id);
},
skip: enabled ? false : '需 compose 后端在位,PATBOND_MEDIA_SMOKE=1 时执行',
timeout: const Timeout(Duration(minutes: 3)),
);
}
/// 透传压缩器:原样输出字节并声明 image/png(仅冒烟用——测试 VM 无
/// flutter_image_compress 平台通道;生产走 NativeMediaImageCompressor)。
class _PassthroughPngCompressor implements MediaImageCompressor {
@override
Future<CompressedMediaImage> compress(
PickedMediaImage source, {
required int quality,
}) async => CompressedMediaImage(bytes: source.bytes, mimeType: 'image/png');
}