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>
This commit is contained in:
浅念
2026-09-19 00:40:02 +08:00
parent 8bc50e07ef
commit f975321b67
18 changed files with 3430 additions and 0 deletions
+612
View File
@@ -0,0 +1,612 @@
/// 相册域模型(P3 计划 Task 2):相册、分类、成员、账号查询与请求体。
///
/// 契约来源:`packages/contracts/openapi.yaml` tag `albums`/`categories`/
/// `members`/`admin`。
library;
/// 相册多语言条目(创建/更新请求与响应共用)。
///
/// 契约 `components.schemas.AlbumTranslation`:未填的可选字段存储为 null
/// (回读时 null 而非缺省键)。
class AlbumTranslation {
const AlbumTranslation({
required this.locale,
required this.title,
this.description,
this.venue,
this.address,
});
factory AlbumTranslation.fromJson(Map<String, dynamic> json) =>
AlbumTranslation(
locale: json['locale'] as String,
title: json['title'] as String,
description: json['description'] as String?,
venue: json['venue'] as String?,
address: json['address'] as String?,
);
/// 语言(契约 Locale enum:zh-CN | en;Dart 标识符不兼容连字符,
/// 按 String 透传)。
final String locale;
/// 相册标题。
final String title;
/// 相册描述;未填为 null。
final String? description;
/// 场地名称;未填为 null。
final String? venue;
/// 场地地址;未填为 null。
final String? address;
Map<String, dynamic> toJson() => {
'locale': locale,
'title': title,
'description': description,
'venue': venue,
'address': address,
};
}
/// 相册状态。
///
/// 契约 `AlbumSummary.status` enum:active=正常;offline=下线
/// (停止公开访问与新授权签发)。
enum AlbumStatus {
active('active'),
offline('offline');
const AlbumStatus(this.wire);
/// 线上(JSON)取值。
final String wire;
static AlbumStatus fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 AlbumStatus: $value'),
);
}
/// 相册列表条目(完整开关与多语言标题,管理列表无需二次请求详情)。
///
/// 契约 `components.schemas.AlbumSummary`(GET /albums,
/// operationId `listAlbums` 的条目)。
class AlbumSummary {
const AlbumSummary({
required this.id,
required this.publicToken,
required this.ownerId,
required this.status,
required this.defaultLocale,
required this.timezone,
required this.startAt,
required this.endAt,
required this.uploadEnabled,
required this.publicEnabled,
required this.downloadEnabled,
required this.originalDownloadEnabled,
required this.watermarkVersion,
required this.translations,
required this.createdAt,
required this.updatedAt,
required this.statsSyncedAt,
});
factory AlbumSummary.fromJson(Map<String, dynamic> json) => AlbumSummary(
id: json['id'] as String,
publicToken: json['publicToken'] as String,
ownerId: json['ownerId'] as String,
status: AlbumStatus.fromJson(json['status'] as String),
defaultLocale: json['defaultLocale'] as String,
timezone: json['timezone'] as String,
startAt: DateTime.parse(json['startAt'] as String),
endAt: json['endAt'] == null
? null
: DateTime.parse(json['endAt'] as String),
uploadEnabled: json['uploadEnabled'] as bool,
publicEnabled: json['publicEnabled'] as bool,
downloadEnabled: json['downloadEnabled'] as bool,
originalDownloadEnabled: json['originalDownloadEnabled'] as bool,
watermarkVersion: json['watermarkVersion'] as int,
translations: (json['translations'] as List<dynamic>)
.map(
(dynamic e) => AlbumTranslation.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
statsSyncedAt: json['statsSyncedAt'] == null
? null
: DateTime.parse(json['statsSyncedAt'] as String),
);
/// 相册 ID。
final String id;
/// 公开访问令牌(不可枚举,pattern ^[0-9a-z]{20,32}$)。
final String publicToken;
/// 创建者用户 ID。
final String ownerId;
/// 相册状态。
final AlbumStatus status;
/// 缺省语言(契约 Locale enum:zh-CN | en)。
final String defaultLocale;
/// 活动 IANA 时区。
final String timezone;
/// 活动开始时间(UTC 存储,展示按 timezone)。
final DateTime startAt;
/// 活动结束时间;null 表示未定。
final DateTime? endAt;
/// 是否允许新上传任务。
final bool uploadEnabled;
/// 是否公开可见。
final bool publicEnabled;
/// 允许下载浏览版本。
final bool downloadEnabled;
/// 允许下载原图。
final bool originalDownloadEnabled;
/// 当前水印配置版本。
final int watermarkVersion;
/// 各语言内容。
final List<AlbumTranslation> translations;
/// 创建时间。
final DateTime createdAt;
/// 最近更新时间。
final DateTime updatedAt;
/// 统计副本最近同步时间(P2 A27);null 表示尚无回传应用。
final DateTime? statsSyncedAt;
Map<String, dynamic> toJson() => {
'id': id,
'publicToken': publicToken,
'ownerId': ownerId,
'status': status.wire,
'defaultLocale': defaultLocale,
'timezone': timezone,
'startAt': startAt.toUtc().toIso8601String(),
'endAt': endAt?.toUtc().toIso8601String(),
'uploadEnabled': uploadEnabled,
'publicEnabled': publicEnabled,
'downloadEnabled': downloadEnabled,
'originalDownloadEnabled': originalDownloadEnabled,
'watermarkVersion': watermarkVersion,
'translations': translations
.map((AlbumTranslation t) => t.toJson())
.toList(),
'createdAt': createdAt.toUtc().toIso8601String(),
'updatedAt': updatedAt.toUtc().toIso8601String(),
'statsSyncedAt': statsSyncedAt?.toUtc().toIso8601String(),
};
}
/// 相册详情。
///
/// 契约 `components.schemas.Album`(= AlbumSummary + categories,按
/// sortOrder 升序);POST /albums、GET/PATCH /albums/{albumId}
/// (operationId `createAlbum` / `getAlbum` / `updateAlbum`)的响应。
class Album extends AlbumSummary {
const Album({
required this.categories,
required super.id,
required super.publicToken,
required super.ownerId,
required super.status,
required super.defaultLocale,
required super.timezone,
required super.startAt,
required super.endAt,
required super.uploadEnabled,
required super.publicEnabled,
required super.downloadEnabled,
required super.originalDownloadEnabled,
required super.watermarkVersion,
required super.translations,
required super.createdAt,
required super.updatedAt,
required super.statsSyncedAt,
});
factory Album.fromJson(Map<String, dynamic> json) => Album(
categories: (json['categories'] as List<dynamic>)
.map(
(dynamic e) => Category.fromJson(e as Map<String, dynamic>),
)
.toList(),
id: json['id'] as String,
publicToken: json['publicToken'] as String,
ownerId: json['ownerId'] as String,
status: AlbumStatus.fromJson(json['status'] as String),
defaultLocale: json['defaultLocale'] as String,
timezone: json['timezone'] as String,
startAt: DateTime.parse(json['startAt'] as String),
endAt: json['endAt'] == null
? null
: DateTime.parse(json['endAt'] as String),
uploadEnabled: json['uploadEnabled'] as bool,
publicEnabled: json['publicEnabled'] as bool,
downloadEnabled: json['downloadEnabled'] as bool,
originalDownloadEnabled: json['originalDownloadEnabled'] as bool,
watermarkVersion: json['watermarkVersion'] as int,
translations: (json['translations'] as List<dynamic>)
.map(
(dynamic e) => AlbumTranslation.fromJson(
e as Map<String, dynamic>,
),
)
.toList(),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
statsSyncedAt: json['statsSyncedAt'] == null
? null
: DateTime.parse(json['statsSyncedAt'] as String),
);
/// 相册分类(按 sortOrder 升序;新相册为空数组)。
final List<Category> categories;
@override
Map<String, dynamic> toJson() =>
{...super.toJson(), 'categories': categories.map((c) => c.toJson()).toList()};
}
/// 分类多语言名称条目。
///
/// 契约 `components.schemas.CategoryTranslation`。
class CategoryTranslation {
const CategoryTranslation({required this.locale, required this.name});
factory CategoryTranslation.fromJson(Map<String, dynamic> json) =>
CategoryTranslation(
locale: json['locale'] as String,
name: json['name'] as String,
);
/// 语言(契约 Locale enum:zh-CN | en)。
final String locale;
/// 分类名称。
final String name;
Map<String, dynamic> toJson() => {'locale': locale, 'name': name};
}
/// 相册分类。
///
/// 契约 `components.schemas.Category`;POST /albums/{albumId}/categories、
/// PATCH /categories/{categoryId}(operationId `createCategory` /
/// `updateCategory`)的响应,亦内嵌于 [Album.categories]。
class Category {
const Category({
required this.id,
required this.albumId,
required this.sortOrder,
required this.translations,
});
factory Category.fromJson(Map<String, dynamic> json) => Category(
id: json['id'] as String,
albumId: json['albumId'] as String,
sortOrder: json['sortOrder'] as int,
translations: (json['translations'] as List<dynamic>)
.map(
(dynamic e) =>
CategoryTranslation.fromJson(e as Map<String, dynamic>),
)
.toList(),
);
/// 分类 ID。
final String id;
/// 所属相册 ID。
final String albumId;
/// 排序序号(升序)。
final int sortOrder;
/// 各语言名称。
final List<CategoryTranslation> translations;
Map<String, dynamic> toJson() => {
'id': id,
'albumId': albumId,
'sortOrder': sortOrder,
'translations':
translations.map((CategoryTranslation t) => t.toJson()).toList(),
};
}
/// 成员角色。
///
/// 契约 `Member.role` enum:owner 由创建流程产生,P1a 仅 collaborator 可添加。
enum MemberRole {
owner('owner'),
collaborator('collaborator');
const MemberRole(this.wire);
/// 线上(JSON)取值。
final String wire;
static MemberRole fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 MemberRole: $value'),
);
}
/// 成员状态。
///
/// 契约 `Member.status` enum:P1a 仅 active,预留扩展。
enum MemberStatus {
active('active');
const MemberStatus(this.wire);
/// 线上(JSON)取值。
final String wire;
static MemberStatus fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 MemberStatus: $value'),
);
}
/// 相册成员。
///
/// 契约 `components.schemas.Member`;POST /albums/{albumId}/members
/// (operationId `addAlbumMember`)的响应、GET 同路径
/// (`listAlbumMembers`)的条目。
class Member {
const Member({
required this.userId,
required this.account,
required this.displayName,
required this.role,
required this.status,
});
factory Member.fromJson(Map<String, dynamic> json) => Member(
userId: json['userId'] as String,
account: json['account'] as String,
displayName: json['displayName'] as String,
role: MemberRole.fromJson(json['role'] as String),
status: MemberStatus.fromJson(json['status'] as String),
);
/// 用户 ID。
final String userId;
/// 登录账号。
final String account;
/// 展示姓名(来自用户资料同步)。
final String displayName;
/// 相册角色。
final MemberRole role;
/// 成员状态。
final MemberStatus status;
Map<String, dynamic> toJson() => {
'userId': userId,
'account': account,
'displayName': displayName,
'role': role.wire,
'status': status.wire,
};
}
/// 账号前缀查询条目(脱敏)。
///
/// 契约 `components.schemas.AdminUserLookupItem`;GET /admin/users
/// (operationId `lookupUsersByAccount`)的条目,用于邀请协作者的
/// 前置搜索。无游标(前缀搜索 + 小上限即取即用)。
class AdminUserLookupItem {
const AdminUserLookupItem({
required this.id,
required this.account,
required this.displayName,
});
factory AdminUserLookupItem.fromJson(Map<String, dynamic> json) =>
AdminUserLookupItem(
id: json['id'] as String,
account: json['account'] as String,
displayName: json['displayName'] as String,
);
/// 用户 ID(用于 POST /albums/{albumId}/members 的 userId)。
final String id;
/// 登录账号。
final String account;
/// 展示姓名。
final String displayName;
Map<String, dynamic> toJson() => {
'id': id,
'account': account,
'displayName': displayName,
};
}
/// 创建相册请求体。
///
/// 契约 `components.schemas.AlbumCreateRequest`(operationId
/// `createAlbum`)。初始默认值(服务端):uploadEnabled/publicEnabled=
/// downloadEnabled=true、originalDownloadEnabled=false、status=active、
/// watermarkVersion=0——故请求体不含 uploadEnabled/publicEnabled/status。
class AlbumCreateRequest {
const AlbumCreateRequest({
required this.translations,
required this.defaultLocale,
required this.startAt,
this.endAt,
this.timezone = 'Asia/Shanghai',
this.downloadEnabled = true,
this.originalDownloadEnabled = false,
});
/// 多语言内容;必须包含 defaultLocale 对应条目(minItems 1)。
final List<AlbumTranslation> translations;
/// 缺省语言(契约 Locale enum:zh-CN | en)。
final String defaultLocale;
/// 活动开始时间。
final DateTime startAt;
/// 活动结束时间;null/缺省表示未定。
final DateTime? endAt;
/// 活动 IANA 时区(契约缺省 Asia/Shanghai)。
final String timezone;
/// 允许下载浏览版本(契约缺省 true)。
final bool downloadEnabled;
/// 允许下载原图(契约缺省 false)。
final bool originalDownloadEnabled;
Map<String, dynamic> toJson() => {
'translations':
translations.map((AlbumTranslation t) => t.toJson()).toList(),
'defaultLocale': defaultLocale,
'startAt': startAt.toUtc().toIso8601String(),
'endAt': endAt?.toUtc().toIso8601String(),
'timezone': timezone,
'downloadEnabled': downloadEnabled,
'originalDownloadEnabled': originalDownloadEnabled,
};
}
/// 更新相册请求体(部分更新:仅提交的字段被修改)。
///
/// 契约 `components.schemas.AlbumUpdateRequest`(operationId
/// `updateAlbum`,minProperties 1)。translations 为全量替换(须包含
/// defaultLocale 对应条目);[endAt] 传 null 清除——「未提供」与
/// 「显式清除」经 [clearEndAt] 区分(Dart 可空字段无法自带该语义)。
class AlbumUpdateRequest {
const AlbumUpdateRequest({
this.translations,
this.defaultLocale,
this.startAt,
this.endAt,
this.clearEndAt = false,
this.timezone,
this.uploadEnabled,
this.publicEnabled,
this.downloadEnabled,
this.originalDownloadEnabled,
});
/// 全量替换多语言内容(须包含 defaultLocale 对应条目)。
final List<AlbumTranslation>? translations;
/// 缺省语言。
final String? defaultLocale;
/// 活动开始时间。
final DateTime? startAt;
/// 活动结束时间;与 [clearEndAt] 互斥使用。
final DateTime? endAt;
/// 显式清除 endAt(序列化为 `"endAt": null`)。
final bool clearEndAt;
/// 活动 IANA 时区。
final String? timezone;
/// 是否允许新上传任务。
final bool? uploadEnabled;
/// 是否公开可见。
final bool? publicEnabled;
/// 允许下载浏览版本。
final bool? downloadEnabled;
/// 允许下载原图。
final bool? originalDownloadEnabled;
Map<String, dynamic> toJson() => {
if (translations != null)
'translations':
translations!.map((AlbumTranslation t) => t.toJson()).toList(),
if (defaultLocale != null) 'defaultLocale': defaultLocale,
if (startAt != null) 'startAt': startAt!.toUtc().toIso8601String(),
if (endAt != null) 'endAt': endAt!.toUtc().toIso8601String(),
if (clearEndAt) 'endAt': null,
if (timezone != null) 'timezone': timezone,
if (uploadEnabled != null) 'uploadEnabled': uploadEnabled,
if (publicEnabled != null) 'publicEnabled': publicEnabled,
if (downloadEnabled != null) 'downloadEnabled': downloadEnabled,
if (originalDownloadEnabled != null)
'originalDownloadEnabled': originalDownloadEnabled,
};
}
/// 创建分类请求体。
///
/// 契约 `components.schemas.CategoryCreateRequest`(operationId
/// `createCategory`)。sortOrder 契约缺省 0——null 表示不提交(走服务端
/// 缺省)。
class CategoryCreateRequest {
const CategoryCreateRequest({this.sortOrder, required this.translations});
/// 排序序号(升序);null 表示不提交(服务端缺省 0)。
final int? sortOrder;
/// 分类多语言名称(minItems 1)。
final List<CategoryTranslation> translations;
Map<String, dynamic> toJson() => {
if (sortOrder != null) 'sortOrder': sortOrder,
'translations':
translations.map((CategoryTranslation t) => t.toJson()).toList(),
};
}
/// 更新分类请求体(部分更新;translations 为全量替换)。
///
/// 契约 `components.schemas.CategoryUpdateRequest`(operationId
/// `updateCategory`,minProperties 1)。
class CategoryUpdateRequest {
const CategoryUpdateRequest({this.sortOrder, this.translations});
/// 排序序号(升序)。
final int? sortOrder;
/// 全量替换多语言名称。
final List<CategoryTranslation>? translations;
Map<String, dynamic> toJson() => {
if (sortOrder != null) 'sortOrder': sortOrder,
if (translations != null)
'translations':
translations!.map((CategoryTranslation t) => t.toJson()).toList(),
};
}
+45
View File
@@ -0,0 +1,45 @@
/// 认证域模型(P3 计划 Task 2)。
///
/// 契约来源:`packages/contracts/openapi.yaml` tag `auth`。
library;
/// 密码登录响应。
///
/// 契约 `components.schemas.LoginResponse`(operationId `loginWithPassword`
/// ,POST /auth/password → 200)。
class LoginResponse {
const LoginResponse({required this.sessionToken, required this.expiresAt});
factory LoginResponse.fromJson(Map<String, dynamic> json) => LoginResponse(
sessionToken: json['sessionToken'] as String,
expiresAt: DateTime.parse(json['expiresAt'] as String),
);
/// 会话令牌(Bearer)。
final String sessionToken;
/// 会话过期时间(date-time)。
final DateTime expiresAt;
Map<String, dynamic> toJson() => {
'sessionToken': sessionToken,
'expiresAt': expiresAt.toUtc().toIso8601String(),
};
}
/// 会话滑动续期响应。
///
/// 契约 `components.schemas.RefreshResponse`(operationId `refreshSession`
/// ,POST /auth/refresh → 200):令牌不变,仅返回新的过期时间。
class RefreshResponse {
const RefreshResponse({required this.expiresAt});
factory RefreshResponse.fromJson(Map<String, dynamic> json) =>
RefreshResponse(expiresAt: DateTime.parse(json['expiresAt'] as String));
/// 续期后的过期时间(date-time)。
final DateTime expiresAt;
Map<String, dynamic> toJson() =>
{'expiresAt': expiresAt.toUtc().toIso8601String()};
}
+234
View File
@@ -0,0 +1,234 @@
/// 照片域模型(P3 计划 Task 2):管理端照片记录与照片枚举。
///
/// 契约来源:`packages/contracts/openapi.yaml` tag `photos`。
library;
/// 照片对外可见性。
///
/// 契约 `components.schemas.PhotoVisibility`:unpublished/published/hidden/
/// deleted;deleted 为软删除。管理端 PATCH /photos/{photoId} 仅允许在
/// hidden 与 published 间切换。
enum PhotoVisibility {
unpublished('unpublished'),
published('published'),
hidden('hidden'),
deleted('deleted');
const PhotoVisibility(this.wire);
/// 线上(JSON)取值。
final String wire;
static PhotoVisibility fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 PhotoVisibility: $value'),
);
}
/// 照片服务端处理状态。
///
/// 契约 `components.schemas.PhotoProcessingState`(技术实施方案 §5.5
/// 「服务端处理」链路)。
enum PhotoProcessingState {
pendingUpload('pending_upload'),
uploaded('uploaded'),
processing('processing'),
ready('ready'),
processingFailed('processing_failed');
const PhotoProcessingState(this.wire);
/// 线上(JSON)取值。
final String wire;
static PhotoProcessingState fromJson(String value) => values.firstWhere(
(v) => v.wire == value,
orElse: () => throw FormatException('未知 PhotoProcessingState: $value'),
);
}
/// 管理端照片记录。
///
/// 契约 `components.schemas.PhotoRecord`(技术实施方案 §4 photos 表字段)。
/// 作为 GET /albums/{albumId}/photos 条目时经 [AdminPhoto] 内嵌 uploader;
/// PATCH /photos/{photoId}(operationId `updatePhoto`)的响应为裸
/// PhotoRecord。
class PhotoRecord {
const PhotoRecord({
required this.photoId,
required this.albumId,
required this.categoryId,
required this.uploaderId,
required this.visibility,
required this.processingState,
required this.bytes,
required this.width,
required this.height,
required this.shotAt,
required this.uploadedAt,
required this.publishedAt,
required this.likeCount,
});
factory PhotoRecord.fromJson(Map<String, dynamic> json) => PhotoRecord(
photoId: json['photoId'] as String,
albumId: json['albumId'] as String,
categoryId: json['categoryId'] as String?,
uploaderId: json['uploaderId'] as String,
visibility: PhotoVisibility.fromJson(json['visibility'] as String),
processingState:
PhotoProcessingState.fromJson(json['processingState'] as String),
bytes: json['bytes'] as int,
width: json['width'] as int?,
height: json['height'] as int?,
shotAt: json['shotAt'] == null
? null
: DateTime.parse(json['shotAt'] as String),
uploadedAt: DateTime.parse(json['uploadedAt'] as String),
publishedAt: json['publishedAt'] == null
? null
: DateTime.parse(json['publishedAt'] as String),
likeCount: json['likeCount'] as int,
);
/// 照片 ID。
final String photoId;
/// 所属相册 ID。
final String albumId;
/// 分类 ID;null 表示未分类。
final String? categoryId;
/// 上传人用户 ID。
final String uploaderId;
/// 对外可见性。
final PhotoVisibility visibility;
/// 服务端处理状态。
final PhotoProcessingState processingState;
/// 原图字节数。
final int bytes;
/// 原图宽度(像素);处理前为 null。
final int? width;
/// 原图高度(像素);处理前为 null。
final int? height;
/// 拍摄时间(EXIF);无有效值时为 null。
final DateTime? shotAt;
/// 上传完成时间。
final DateTime uploadedAt;
/// 发布时间;未发布为 null。
final DateTime? publishedAt;
/// 累计点赞数。
final int likeCount;
Map<String, dynamic> toJson() => {
'photoId': photoId,
'albumId': albumId,
'categoryId': categoryId,
'uploaderId': uploaderId,
'visibility': visibility.wire,
'processingState': processingState.wire,
'bytes': bytes,
'width': width,
'height': height,
'shotAt': shotAt?.toUtc().toIso8601String(),
'uploadedAt': uploadedAt.toUtc().toIso8601String(),
'publishedAt': publishedAt?.toUtc().toIso8601String(),
'likeCount': likeCount,
};
}
/// 管理列表条目内嵌的上传人脱敏信息。
///
/// 契约 `components.schemas.AdminPhotoUploader`(不含邮箱、手机等凭据字段)。
class AdminPhotoUploader {
const AdminPhotoUploader({
required this.userId,
required this.account,
required this.displayName,
});
factory AdminPhotoUploader.fromJson(Map<String, dynamic> json) =>
AdminPhotoUploader(
userId: json['userId'] as String,
account: json['account'] as String,
displayName: json['displayName'] as String,
);
/// 上传人用户 ID(与 PhotoRecord.uploaderId 一致)。
final String userId;
/// 登录账号。
final String account;
/// 展示姓名(来自用户资料)。
final String displayName;
Map<String, dynamic> toJson() => {
'userId': userId,
'account': account,
'displayName': displayName,
};
}
/// 管理端照片列表条目。
///
/// 契约 `components.schemas.AdminPhoto`(= PhotoRecord + uploader 内嵌),
/// GET /albums/{albumId}/photos(operationId `listAlbumPhotos`)的条目。
class AdminPhoto extends PhotoRecord {
const AdminPhoto({
required this.uploader,
required super.photoId,
required super.albumId,
required super.categoryId,
required super.uploaderId,
required super.visibility,
required super.processingState,
required super.bytes,
required super.width,
required super.height,
required super.shotAt,
required super.uploadedAt,
required super.publishedAt,
required super.likeCount,
});
factory AdminPhoto.fromJson(Map<String, dynamic> json) => AdminPhoto(
uploader: AdminPhotoUploader.fromJson(
json['uploader'] as Map<String, dynamic>,
),
photoId: json['photoId'] as String,
albumId: json['albumId'] as String,
categoryId: json['categoryId'] as String?,
uploaderId: json['uploaderId'] as String,
visibility: PhotoVisibility.fromJson(json['visibility'] as String),
processingState:
PhotoProcessingState.fromJson(json['processingState'] as String),
bytes: json['bytes'] as int,
width: json['width'] as int?,
height: json['height'] as int?,
shotAt: json['shotAt'] == null
? null
: DateTime.parse(json['shotAt'] as String),
uploadedAt: DateTime.parse(json['uploadedAt'] as String),
publishedAt: json['publishedAt'] == null
? null
: DateTime.parse(json['publishedAt'] as String),
likeCount: json['likeCount'] as int,
);
/// 上传人脱敏信息。
final AdminPhotoUploader uploader;
@override
Map<String, dynamic> toJson() => {...super.toJson(), 'uploader': uploader.toJson()};
}
@@ -0,0 +1,296 @@
/// 上传域模型(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(),
};
}