2026-09-19 00:40:02 +08:00
|
|
|
|
/// 上传域模型(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)。
|
|
|
|
|
|
///
|
2026-09-19 02:50:40 +08:00
|
|
|
|
/// sourceDeviceId 为 P3 Task 3 契约增补(已落地):客户端首次生成并
|
|
|
|
|
|
/// 持久的设备标识(A19 设备来源语义,见 core/device_id.dart);可选,
|
|
|
|
|
|
/// 同一 clientTaskId 幂等重放时服务端保留首次落库的值。
|
2026-09-19 00:40:02 +08:00
|
|
|
|
class UploadInitRequest {
|
|
|
|
|
|
const UploadInitRequest({
|
|
|
|
|
|
required this.albumId,
|
|
|
|
|
|
required this.clientTaskId,
|
|
|
|
|
|
required this.filename,
|
|
|
|
|
|
required this.bytes,
|
|
|
|
|
|
required this.contentType,
|
|
|
|
|
|
this.categoryId,
|
2026-09-19 02:50:40 +08:00
|
|
|
|
this.sourceDeviceId,
|
2026-09-19 00:40:02 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
/// 目标相册 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;
|
|
|
|
|
|
|
2026-09-19 02:50:40 +08:00
|
|
|
|
/// 发起设备标识(可选,minLength 8 / maxLength 64;A19)。
|
|
|
|
|
|
final String? sourceDeviceId;
|
|
|
|
|
|
|
2026-09-19 00:40:02 +08:00
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
|
|
|
|
'albumId': albumId,
|
|
|
|
|
|
if (categoryId != null) 'categoryId': categoryId,
|
|
|
|
|
|
'clientTaskId': clientTaskId,
|
|
|
|
|
|
'filename': filename,
|
|
|
|
|
|
'bytes': bytes,
|
|
|
|
|
|
'contentType': contentType,
|
2026-09-19 02:50:40 +08:00
|
|
|
|
if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId,
|
2026-09-19 00:40:02 +08:00
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/// 初始化上传任务响应。
|
|
|
|
|
|
///
|
|
|
|
|
|
/// 契约 `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,
|
2026-09-19 02:50:40 +08:00
|
|
|
|
this.sourceDeviceId,
|
2026-09-19 00:40:02 +08:00
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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>,
|
|
|
|
|
|
),
|
2026-09-19 02:50:40 +08:00
|
|
|
|
sourceDeviceId: json['sourceDeviceId'] as String?,
|
2026-09-19 00:40:02 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/// 上传会话 ID。
|
|
|
|
|
|
final String uploadId;
|
|
|
|
|
|
|
|
|
|
|
|
/// 目标相册 ID。
|
|
|
|
|
|
final String albumId;
|
|
|
|
|
|
|
|
|
|
|
|
/// 客户端任务幂等键。
|
|
|
|
|
|
final String clientTaskId;
|
|
|
|
|
|
|
|
|
|
|
|
/// 会话状态。
|
|
|
|
|
|
final UploadSessionState state;
|
|
|
|
|
|
|
|
|
|
|
|
/// 申明文件字节数。
|
|
|
|
|
|
final int expectedBytes;
|
|
|
|
|
|
|
|
|
|
|
|
/// 申明 MIME 类型。
|
|
|
|
|
|
final String contentType;
|
|
|
|
|
|
|
|
|
|
|
|
/// 会话创建时间。
|
|
|
|
|
|
final DateTime createdAt;
|
|
|
|
|
|
|
2026-09-19 02:50:40 +08:00
|
|
|
|
/// 发起设备标识(init 时未携带为 null;P3 Task 3 契约增补,A19)。
|
|
|
|
|
|
final String? sourceDeviceId;
|
|
|
|
|
|
|
2026-09-19 00:40:02 +08:00
|
|
|
|
/// 会话关联照片的当前状态。
|
|
|
|
|
|
final UploadSessionPhoto photo;
|
|
|
|
|
|
|
|
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
|
|
|
|
'uploadId': uploadId,
|
|
|
|
|
|
'albumId': albumId,
|
|
|
|
|
|
'clientTaskId': clientTaskId,
|
|
|
|
|
|
'state': state.wire,
|
|
|
|
|
|
'expectedBytes': expectedBytes,
|
|
|
|
|
|
'contentType': contentType,
|
|
|
|
|
|
'createdAt': createdAt.toUtc().toIso8601String(),
|
2026-09-19 02:50:40 +08:00
|
|
|
|
if (sourceDeviceId != null) 'sourceDeviceId': sourceDeviceId,
|
2026-09-19 00:40:02 +08:00
|
|
|
|
'photo': photo.toJson(),
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|