f975321b67
P3 计划 Task 2(apps/mobile):
- core/config.dart:API_BASE/VIEWER_BASE 经 --dart-define 覆盖,缺省
10.0.2.2 模拟器宿主回环;viewer 相册链接构造 {VIEWER_BASE}/a/{token}。
- core/error_codes.dart:errors.ts 17 码 1:1 镜像(含注释),另设 4 个
客户端本地伪码(NETWORK_ERROR/NETWORK_TIMEOUT/CANCELLED/HTTP_ERROR)。
- core/api_client.dart:dio 封装——Bearer 注入(登录端点豁免)、401
驱逐(清会话 + onUnauthorized 回调去重,App 壳注册跳 /login)、
DioException/错误体 {code,message,requestId} 归一 AppException、
通用游标分页助手;auth/albums/members/categories/admin/uploads/photos
全端点方法;riverpod provider 出口。
- core/session_store.dart:SessionStore 抽象 + flutter_secure_storage
实现(token/expiresAt/account 三键,encode/decode 纯函数可测)。
- features/{auth,albums,uploads,photos}/models.dart:手写 fromJson/
toJson,逐字段对齐 openapi.yaml(类注释标注契约 schema/operationId);
locale 按字符串透传(zh-CN 标识符不兼容),业务枚举 Dart 化并在
未知值 fail-fast。
- 测试 66 项:17 码表驱动 + 网络/超时/取消 + 兜底映射、游标边界
(含 CursorPageMeta 不变式违约容错)、模型往返(fixture 转写自
openapi.yaml schema 与 api e2e 断言,来源在测试头注释)、拦截器
行为(手写 FakeAdapter:Bearer 注入/401 驱逐/登录豁免/并发去重/
尽力而为 logout/refresh 滑动续期)。flutter analyze 零告警。
Co-Authored-By: Claude Code <noreply@anthropic.com>
297 lines
8.7 KiB
Dart
297 lines
8.7 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)。
|
||
///
|
||
/// 注:契约增补(P3 计划 Task 3)将顺带加入可选 sourceDeviceId
|
||
/// (A19 设备来源语义);当前以契约现状为准,不含该字段。
|
||
class UploadInitRequest {
|
||
const UploadInitRequest({
|
||
required this.albumId,
|
||
required this.clientTaskId,
|
||
required this.filename,
|
||
required this.bytes,
|
||
required this.contentType,
|
||
this.categoryId,
|
||
});
|
||
|
||
/// 目标相册 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;
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'albumId': albumId,
|
||
if (categoryId != null) 'categoryId': categoryId,
|
||
'clientTaskId': clientTaskId,
|
||
'filename': filename,
|
||
'bytes': bytes,
|
||
'contentType': contentType,
|
||
};
|
||
}
|
||
|
||
/// 初始化上传任务响应。
|
||
///
|
||
/// 契约 `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,
|
||
});
|
||
|
||
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>,
|
||
),
|
||
);
|
||
|
||
/// 上传会话 ID。
|
||
final String uploadId;
|
||
|
||
/// 目标相册 ID。
|
||
final String albumId;
|
||
|
||
/// 客户端任务幂等键。
|
||
final String clientTaskId;
|
||
|
||
/// 会话状态。
|
||
final UploadSessionState state;
|
||
|
||
/// 申明文件字节数。
|
||
final int expectedBytes;
|
||
|
||
/// 申明 MIME 类型。
|
||
final String contentType;
|
||
|
||
/// 会话创建时间。
|
||
final DateTime createdAt;
|
||
|
||
/// 会话关联照片的当前状态。
|
||
final UploadSessionPhoto photo;
|
||
|
||
Map<String, dynamic> toJson() => {
|
||
'uploadId': uploadId,
|
||
'albumId': albumId,
|
||
'clientTaskId': clientTaskId,
|
||
'state': state.wire,
|
||
'expectedBytes': expectedBytes,
|
||
'contentType': contentType,
|
||
'createdAt': createdAt.toUtc().toIso8601String(),
|
||
'photo': photo.toJson(),
|
||
};
|
||
}
|