/// 上传域模型(P3 计划 Task 2):直传会话、凭证与请求体。 /// /// 契约来源:`packages/contracts/openapi.yaml` tag `uploads`。 library; import '../photos/models.dart'; /// 上传会话状态(服务端视角;本地任务状态机见技术实施方案 §5.5、 /// P3 计划 Task 5)。 /// /// 契约 `components.schemas.UploadSessionState`:会话没有 failed 状态—— /// 完成核验失败属于单次请求错误(UPLOAD_VERIFY_FAILED),会话保持 /// pending 可重试。 enum UploadSessionState { pending('pending'), confirmed('confirmed'), cancelled('cancelled'), expired('expired'); const UploadSessionState(this.wire); /// 线上(JSON)取值。 final String wire; static UploadSessionState fromJson(String value) => values.firstWhere( (v) => v.wire == value, orElse: () => throw FormatException('未知 UploadSessionState: $value'), ); } /// staging 对象的临时直传凭证。 /// /// 契约 `components.schemas.UploadCredential`:仅可写入指定 objectKey, /// 不可访问 originals/variants;过期后续签(renew)再重试。 class UploadCredential { const UploadCredential({ required this.mode, required this.url, required this.method, required this.headers, required this.expiresAt, }); factory UploadCredential.fromJson(Map json) => UploadCredential( mode: json['mode'] as String, url: json['url'] as String, method: json['method'] as String, headers: (json['headers'] as Map) .map((dynamic k, dynamic v) => MapEntry(k as String, v as String)), expiresAt: DateTime.parse(json['expiresAt'] as String), ); /// 凭证模式;P1a 仅 presigned,预留 STS 扩展。 final String mode; /// 预签名上传 URL。 final String url; /// 上传 HTTP 方法(PUT)。 final String method; /// 上传时必须携带的请求头(如 Content-Type)。 final Map headers; /// 凭证过期时间。 final DateTime expiresAt; Map toJson() => { 'mode': mode, 'url': url, 'method': method, 'headers': headers, 'expiresAt': expiresAt.toUtc().toIso8601String(), }; } /// 初始化上传任务请求体。 /// /// 契约 `components.schemas.UploadInitRequest`(operationId /// `initUpload`,POST /uploads):幂等初始化——相同 clientTaskId 且参数 /// 一致时返回原会话,参数不一致 409(UPLOAD_SESSION_CONFLICT)。 /// /// sourceDeviceId 为 P3 Task 3 契约增补(已落地):客户端首次生成并 /// 持久的设备标识(A19 设备来源语义,见 core/device_id.dart);可选, /// 同一 clientTaskId 幂等重放时服务端保留首次落库的值。 class UploadInitRequest { const UploadInitRequest({ required this.albumId, required this.clientTaskId, required this.filename, required this.bytes, required this.contentType, this.categoryId, this.sourceDeviceId, }); /// 目标相册 ID。 final String albumId; /// 目标分类 ID;缺省为相册默认分类。 final String? categoryId; /// 客户端任务幂等键;同一任务的重试必须复用(maxLength 128)。 final String clientTaskId; /// 原始文件名(仅元数据,maxLength 255)。 final String filename; /// 申明文件字节数;完成核验时逐一比对。 final int bytes; /// MIME 类型(白名单 image/jpeg | image/png | image/heic)。 final String contentType; /// 发起设备标识(可选,minLength 8 / maxLength 64;A19)。 final String? sourceDeviceId; Map toJson() => { 'albumId': albumId, if (categoryId != null) 'categoryId': categoryId, 'clientTaskId': clientTaskId, 'filename': filename, 'bytes': bytes, 'contentType': contentType, if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId, }; } /// 初始化上传任务响应。 /// /// 契约 `components.schemas.UploadInitResponse`(operationId `initUpload` /// → 201;`renewUploadCredential` → 200 结构相同,credential 为新值、 /// 其余字段回显原会话)。 class UploadInitResponse { const UploadInitResponse({ required this.uploadId, required this.photoId, required this.objectKey, required this.credential, }); factory UploadInitResponse.fromJson(Map json) => UploadInitResponse( uploadId: json['uploadId'] as String, photoId: json['photoId'] as String, objectKey: json['objectKey'] as String, credential: UploadCredential.fromJson( json['credential'] as Map, ), ); /// 上传会话 ID。 final String uploadId; /// 预创建的照片 ID(重试复用,不重复建照片)。 final String photoId; /// staging 对象键 /// (photo-live/{env}/staging/{uploadId}/{photoId}.{ext})。 final String objectKey; /// staging 对象的临时直传凭证。 final UploadCredential credential; Map toJson() => { 'uploadId': uploadId, 'photoId': photoId, 'objectKey': objectKey, 'credential': credential.toJson(), }; } /// 完成核验响应。 /// /// 契约 `components.schemas.UploadCompleteResponse`(operationId /// `completeUpload`,POST /uploads/{uploadId}/complete → 200;幂等)。 class UploadCompleteResponse { const UploadCompleteResponse({ required this.photoId, required this.processingState, }); factory UploadCompleteResponse.fromJson(Map json) => UploadCompleteResponse( photoId: json['photoId'] as String, processingState: PhotoProcessingState.fromJson(json['processingState'] as String), ); /// 照片 ID。 final String photoId; /// 核验通过后的处理状态(进入处理流水线)。 final PhotoProcessingState processingState; Map toJson() => { 'photoId': photoId, 'processingState': processingState.wire, }; } /// 会话关联照片的当前状态(会话记录内嵌,客户端据此展示服务端进度)。 /// /// 契约 `components.schemas.UploadSessionPhoto`。 class UploadSessionPhoto { const UploadSessionPhoto({ required this.photoId, required this.processingState, required this.visibility, }); factory UploadSessionPhoto.fromJson(Map json) => UploadSessionPhoto( photoId: json['photoId'] as String, processingState: PhotoProcessingState.fromJson(json['processingState'] as String), visibility: PhotoVisibility.fromJson(json['visibility'] as String), ); /// 照片 ID。 final String photoId; /// 服务端处理状态。 final PhotoProcessingState processingState; /// 对外可见性。 final PhotoVisibility visibility; Map toJson() => { 'photoId': photoId, 'processingState': processingState.wire, 'visibility': visibility.wire, }; } /// 上传会话记录。 /// /// 契约 `components.schemas.UploadSessionRecord`(GET /uploads, /// operationId `listUploads` 的条目):当前用户的上传会话历史(新在前), /// 用于任务队列恢复与跨设备状态展示(A19)。 class UploadSessionRecord { const UploadSessionRecord({ required this.uploadId, required this.albumId, required this.clientTaskId, required this.state, required this.expectedBytes, required this.contentType, required this.createdAt, required this.photo, this.sourceDeviceId, }); factory UploadSessionRecord.fromJson(Map json) => UploadSessionRecord( uploadId: json['uploadId'] as String, albumId: json['albumId'] as String, clientTaskId: json['clientTaskId'] as String, state: UploadSessionState.fromJson(json['state'] as String), expectedBytes: json['expectedBytes'] as int, contentType: json['contentType'] as String, createdAt: DateTime.parse(json['createdAt'] as String), photo: UploadSessionPhoto.fromJson( json['photo'] as Map, ), sourceDeviceId: json['sourceDeviceId'] as String?, ); /// 上传会话 ID。 final String uploadId; /// 目标相册 ID。 final String albumId; /// 客户端任务幂等键。 final String clientTaskId; /// 会话状态。 final UploadSessionState state; /// 申明文件字节数。 final int expectedBytes; /// 申明 MIME 类型。 final String contentType; /// 会话创建时间。 final DateTime createdAt; /// 发起设备标识(init 时未携带为 null;P3 Task 3 契约增补,A19)。 final String? sourceDeviceId; /// 会话关联照片的当前状态。 final UploadSessionPhoto photo; Map toJson() => { 'uploadId': uploadId, 'albumId': albumId, 'clientTaskId': clientTaskId, 'state': state.wire, 'expectedBytes': expectedBytes, 'contentType': contentType, 'createdAt': createdAt.toUtc().toIso8601String(), if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId, 'photo': photo.toJson(), }; }