Files
photo-live/apps/mobile/lib/features/uploads/models.dart
T
浅念 f975321b67 feat(mobile): api client, session storage and contract-aligned models
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>
2026-09-19 00:40:02 +08:00

297 lines
8.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 上传域模型(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(),
};
}