1441f0148f
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>
83 lines
2.7 KiB
Dart
83 lines
2.7 KiB
Dart
import 'dart:typed_data';
|
||
|
||
import 'package:dio/dio.dart';
|
||
|
||
/// 预签名直传失败。
|
||
///
|
||
/// - [statusCode] 为 null 表示网络层失败(断连/超时),可原凭据重试;
|
||
/// - 403 为存储侧拒绝签名(凭据过期/被改动),须重新 createUpload 换新凭据;
|
||
/// - 其余状态码按可重试处理(重试走完整重传)。
|
||
class MediaDirectUploadException implements Exception {
|
||
const MediaDirectUploadException({this.statusCode, required this.message});
|
||
|
||
final int? statusCode;
|
||
final String message;
|
||
|
||
/// 存储侧拒绝了凭据(签名过期/不符),重试前必须换新凭据。
|
||
bool get isCredentialRejected => statusCode == 403;
|
||
|
||
@override
|
||
String toString() => 'MediaDirectUploadException($statusCode): $message';
|
||
}
|
||
|
||
/// 预签名 PUT 直传抽象(两步上传第二步的传输层)。
|
||
///
|
||
/// 不走业务信封、不带 Bearer 鉴权——鉴权就是 URL 里的签名本身;
|
||
/// [headers] 即 createUpload 返回的 requiredHeaders,必须原样携带
|
||
/// (Content-Type 已签进签名,改动即 403)。
|
||
abstract class MediaDirectUploadClient {
|
||
Future<void> put({
|
||
required String url,
|
||
required Map<String, String> headers,
|
||
required Uint8List bytes,
|
||
void Function(int sent, int total)? onProgress,
|
||
});
|
||
}
|
||
|
||
/// 基于裸 Dio 的实现:独立实例,无 AuthInterceptor(预签名 URL 携带
|
||
/// Authorization 头会破坏 SigV4 校验),sendTimeout 按 10 MiB 弱网上限放宽。
|
||
class DioMediaDirectUploadClient implements MediaDirectUploadClient {
|
||
DioMediaDirectUploadClient({Dio? dio})
|
||
: _dio =
|
||
dio ??
|
||
Dio(
|
||
BaseOptions(
|
||
connectTimeout: const Duration(seconds: 5),
|
||
sendTimeout: const Duration(seconds: 120),
|
||
receiveTimeout: const Duration(seconds: 30),
|
||
validateStatus: (_) => true,
|
||
),
|
||
);
|
||
|
||
final Dio _dio;
|
||
|
||
@override
|
||
Future<void> put({
|
||
required String url,
|
||
required Map<String, String> headers,
|
||
required Uint8List bytes,
|
||
void Function(int sent, int total)? onProgress,
|
||
}) async {
|
||
final Response<Object?> response;
|
||
try {
|
||
response = await _dio.put<Object?>(
|
||
url,
|
||
data: Stream<Uint8List>.value(bytes),
|
||
options: Options(
|
||
headers: {...headers, Headers.contentLengthHeader: bytes.length},
|
||
),
|
||
onSendProgress: onProgress,
|
||
);
|
||
} on DioException catch (error) {
|
||
throw MediaDirectUploadException(message: '直传网络失败:${error.type.name}');
|
||
}
|
||
final status = response.statusCode ?? 0;
|
||
if (status < 200 || status >= 300) {
|
||
throw MediaDirectUploadException(
|
||
statusCode: status,
|
||
message: '存储侧拒绝直传:HTTP $status',
|
||
);
|
||
}
|
||
}
|
||
}
|