3ddb0ea2d4
Co-Authored-By: Claude Code <noreply@anthropic.com>
309 lines
9.2 KiB
Dart
309 lines
9.2 KiB
Dart
/// 上传域模型(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<String, dynamic> json) =>
|
||
UploadCredential(
|
||
mode: json['mode'] as String,
|
||
url: json['url'] as String,
|
||
method: json['method'] as String,
|
||
headers: (json['headers'] as Map<dynamic, dynamic>)
|
||
.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<String, String> headers;
|
||
|
||
/// 凭证过期时间。
|
||
final DateTime expiresAt;
|
||
|
||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> json) =>
|
||
UploadInitResponse(
|
||
uploadId: json['uploadId'] as String,
|
||
photoId: json['photoId'] as String,
|
||
objectKey: json['objectKey'] as String,
|
||
credential: UploadCredential.fromJson(
|
||
json['credential'] as Map<String, dynamic>,
|
||
),
|
||
);
|
||
|
||
/// 上传会话 ID。
|
||
final String uploadId;
|
||
|
||
/// 预创建的照片 ID(重试复用,不重复建照片)。
|
||
final String photoId;
|
||
|
||
/// staging 对象键
|
||
/// (photo-live/{env}/staging/{uploadId}/{photoId}.{ext})。
|
||
final String objectKey;
|
||
|
||
/// staging 对象的临时直传凭证。
|
||
final UploadCredential credential;
|
||
|
||
Map<String, dynamic> 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<String, dynamic> json) =>
|
||
UploadCompleteResponse(
|
||
photoId: json['photoId'] as String,
|
||
processingState:
|
||
PhotoProcessingState.fromJson(json['processingState'] as String),
|
||
);
|
||
|
||
/// 照片 ID。
|
||
final String photoId;
|
||
|
||
/// 核验通过后的处理状态(进入处理流水线)。
|
||
final PhotoProcessingState processingState;
|
||
|
||
Map<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic>,
|
||
),
|
||
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<String, dynamic> 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(),
|
||
};
|
||
}
|