Files
photo-live/apps/mobile/lib/features/uploads/models.dart
T

309 lines
9.2 KiB
Dart
Raw Normal View History

/// 上传域模型(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(),
};
}