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
+504
View File
@@ -0,0 +1,504 @@
/// cn-api 客户端(P3 计划 Task 2)。
///
/// - 契约权威:`packages/contracts/openapi.yaml`(servers.url=/v1)。
/// - Bearer 注入:请求拦截器从 [SessionStore] 读取令牌(登录端点豁免)。
/// - 401 处理:清空会话并触发 [onUnauthorized](App 壳注册的监听回调,
/// 负责跳转 /login);并发的后续 401 只通知一次,直至新会话写入。
/// 登录端点自身的 401(凭据错误)不触发会话驱逐。
/// - 错误归一:HTTP 错误体 `{code, message, requestId}` 与网络层失败一律
/// 抛 [AppException](码表见 [ErrorCodes];l10n 文案解析留在 UI 层)。
library;
import 'package:dio/dio.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/albums/models.dart';
import '../features/auth/models.dart';
import '../features/photos/models.dart';
import '../features/uploads/models.dart';
import 'app_exception.dart';
import 'config.dart';
import 'error_codes.dart';
import 'models/cursor_page.dart';
import 'session_store.dart';
class ApiClient {
/// [dio] 可注入测试替身(自定义 HttpClientAdapter);[baseUrl] 缺省
/// 取 [ApiConfig.baseUrl](--dart-define API_BASE 覆盖)。
ApiClient({
required SessionStore sessionStore,
String baseUrl = ApiConfig.baseUrl,
Dio? dio,
}) : _session = sessionStore {
_dio = dio ??
Dio(
BaseOptions(
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 60),
receiveTimeout: const Duration(seconds: 60),
),
);
_dio.interceptors.add(
InterceptorsWrapper(onRequest: _attachSession, onError: _handleError),
);
}
late final Dio _dio;
final SessionStore _session;
/// 会话失效(401 驱逐)通知。App 壳启动时注册,例如:
///
/// ```dart
/// ref.read(apiClientProvider).onUnauthorized = () =>
/// navigatorKey.currentState
/// ?.pushNamedAndRemoveUntil('/login', (route) => false);
/// ```
void Function()? onUnauthorized;
/// 去重标记:并发 401 只通知一次;登录/续期成功写新会话后复位。
bool _unauthorizedNotified = false;
/// 暴露内部 Dio(presign PUT 直传等需要绕过统一封装的场景)。
Dio get dio => _dio;
void close({bool force = false}) => _dio.close(force: force);
/// 登录端点:401 = 凭据错误而非会话过期,不驱逐会话、不附加令牌。
static bool _isAuthExemptPath(String path) =>
path.endsWith('/v1/auth/password');
// ── 拦截器 ──
Future<void> _attachSession(
RequestOptions options,
RequestInterceptorHandler handler,
) async {
try {
if (!_isAuthExemptPath(options.path)) {
final session = await _session.read();
if (session != null &&
session.token.isNotEmpty &&
!options.headers.containsKey('Authorization')) {
options.headers['Authorization'] = 'Bearer ${session.token}';
}
}
} on Object {
// 存储读取失败不阻断请求:按未登录裸发,由服务端 401 裁决。
} finally {
handler.next(options);
}
}
Future<void> _handleError(
DioException e,
ErrorInterceptorHandler handler,
) async {
if (e.response?.statusCode == 401 &&
!_isAuthExemptPath(e.requestOptions.path)) {
try {
// 先同步置位去重标记再做异步清除:并发 401 下仅首个触发通知。
final shouldNotify = !_unauthorizedNotified;
_unauthorizedNotified = true;
await _session.clear();
if (shouldNotify) {
onUnauthorized?.call();
}
} on Object {
// 会话清除/通知失败不阻断错误传播(下次 401 仍会重试清除)。
}
}
handler.next(e);
}
// ── 错误映射 ──
/// DioException → [AppException](公开静态,供表驱动单测直接调用)。
///
/// 优先取错误响应体 `{code, message, requestId}`(契约
/// `components.schemas.Error`);无错误体的响应按契约通用兜底
/// (openapi.yaml info.description:401→AUTH_TOKEN_EXPIRED、
/// 500→INTERNAL_ERROR);网络层失败映射客户端伪码(NETWORK_* 等)。
static AppException mapDioException(DioException e) {
final response = e.response;
if (response != null) {
final status = response.statusCode ?? 0;
final data = response.data;
if (data is Map) {
final Object? code = data['code'];
final Object? message = data['message'];
final Object? requestId = data['requestId'];
if (code is String && message is String) {
return AppException(
code: code,
message: message,
requestId: requestId is String ? requestId : null,
statusCode: status,
);
}
}
if (status == 401) {
return const AppException(
code: ErrorCodes.authTokenExpired,
message: '未认证或会话已过期',
statusCode: 401,
);
}
if (status >= 500) {
return AppException(
code: ErrorCodes.internalError,
message: '服务器内部错误',
statusCode: status,
);
}
return AppException(
code: ErrorCodes.httpError,
message: 'HTTP $status 且无契约错误体',
statusCode: status,
);
}
switch (e.type) {
case DioExceptionType.connectionTimeout:
case DioExceptionType.sendTimeout:
case DioExceptionType.receiveTimeout:
case DioExceptionType.transformTimeout:
return const AppException(
code: ErrorCodes.networkTimeout,
message: '网络超时',
);
case DioExceptionType.cancel:
return const AppException(
code: ErrorCodes.cancelled,
message: '请求已取消',
);
case DioExceptionType.connectionError:
return const AppException(
code: ErrorCodes.networkError,
message: '网络连接失败',
);
case DioExceptionType.badCertificate:
return const AppException(
code: ErrorCodes.networkError,
message: '证书校验失败',
);
case DioExceptionType.badResponse: // 有响应必经上方分支,防御性兜底。
case DioExceptionType.unknown:
return AppException(
code: ErrorCodes.networkError,
message: e.message ?? '未知网络错误',
);
}
}
/// 统一发送:DioException 归一为 AppException 后抛出。
Future<T> _send<T>(Future<T> Function() run) async {
try {
return await run();
} on DioException catch (e) {
throw mapDioException(e);
}
}
// ── auth(契约 tag: auth)──
/// operationId `loginWithPassword`(POST /auth/password)。
///
/// 成功后将会话(令牌/过期时间/账号回显)写入 [SessionStore]。
Future<LoginResponse> login(String account, String password) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/auth/password',
data: {'account': account, 'password': password},
),
);
final result = LoginResponse.fromJson(resp.data!);
await _session.save(
Session(
token: result.sessionToken,
expiresAt: result.expiresAt,
account: account,
),
);
_unauthorizedNotified = false;
return result;
}
/// operationId `refreshSession`(POST /auth/refresh):令牌不变,
/// 滑动续期过期时间——同步回写 [SessionStore]。
Future<RefreshResponse> refresh() async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/auth/refresh'),
);
final result = RefreshResponse.fromJson(resp.data!);
final current = await _session.read();
if (current != null) {
await _session.save(
Session(
token: current.token,
expiresAt: result.expiresAt,
account: current.account,
),
);
}
return result;
}
/// operationId `logout`(POST /auth/logout)。尽力而为:服务端撤销
/// 失败不阻塞本地退出,本地会话恒被清除。
Future<void> logout() async {
try {
await _send(() => _dio.post<void>('/v1/auth/logout'));
} on AppException {
// 尽力而为(计划 Task 3 语义):错误吞掉,本地照常登出。
} finally {
await _session.clear();
_unauthorizedNotified = false;
}
}
// ── albums / members / categories / admin ──
/// operationId `listAlbums`(GET /albums,游标分页)。
Future<CursorPage<AlbumSummary>> listAlbums({String? cursor, int? limit}) =>
_getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: limit);
/// operationId `createAlbum`(POST /albums → 201)。
Future<Album> createAlbum(AlbumCreateRequest req) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/albums', data: req.toJson()),
);
return Album.fromJson(resp.data!);
}
/// operationId `getAlbum`(GET /albums/{albumId})。
Future<Album> getAlbum(String albumId) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>('/v1/albums/$albumId'),
);
return Album.fromJson(resp.data!);
}
/// operationId `updateAlbum`(PATCH /albums/{albumId},部分更新)。
Future<Album> updateAlbum(String albumId, AlbumUpdateRequest patch) async {
final resp = await _send(
() => _dio.patch<Map<String, dynamic>>(
'/v1/albums/$albumId',
data: patch.toJson(),
),
);
return Album.fromJson(resp.data!);
}
/// operationId `addAlbumMember`(POST /albums/{albumId}/members → 201)。
///
/// role 省略:契约缺省 collaborator(P1a 唯一可添加角色)。
Future<Member> addMember(String albumId, String userId) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/albums/$albumId/members',
data: {'userId': userId},
),
);
return Member.fromJson(resp.data!);
}
/// operationId `listAlbumMembers`(GET /albums/{albumId}/members)。
Future<CursorPage<Member>> listMembers(
String albumId, {
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/albums/$albumId/members',
Member.fromJson,
cursor: cursor,
limit: limit,
);
/// operationId `removeAlbumMember`
/// (DELETE /albums/{albumId}/members/{userId} → 204,目标不存在幂等)。
Future<void> removeMember(String albumId, String userId) =>
_send(() => _dio.delete<void>('/v1/albums/$albumId/members/$userId'));
/// operationId `lookupUsersByAccount`
/// (GET /admin/users?accountPrefix=,无游标,按账号升序)。
Future<List<AdminUserLookupItem>> lookupUsers(
String accountPrefix, {
int? limit,
}) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>(
'/v1/admin/users',
queryParameters: {
'accountPrefix': accountPrefix,
'limit': ?limit,
},
),
);
final items = resp.data!['items'] as List<dynamic>;
return items
.map(
(dynamic e) => AdminUserLookupItem.fromJson(
e as Map<String, dynamic>,
),
)
.toList();
}
/// operationId `createCategory`
/// (POST /albums/{albumId}/categories → 201)。
Future<Category> createCategory(
String albumId,
CategoryCreateRequest req,
) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/albums/$albumId/categories',
data: req.toJson(),
),
);
return Category.fromJson(resp.data!);
}
/// operationId `updateCategory`(PATCH /categories/{categoryId})。
Future<Category> updateCategory(
String categoryId,
CategoryUpdateRequest patch,
) async {
final resp = await _send(
() => _dio.patch<Map<String, dynamic>>(
'/v1/categories/$categoryId',
data: patch.toJson(),
),
);
return Category.fromJson(resp.data!);
}
/// operationId `deleteCategory`(DELETE /categories/{categoryId} → 204)。
Future<void> deleteCategory(String categoryId) =>
_send(() => _dio.delete<void>('/v1/categories/$categoryId'));
// ── uploads(契约 tag: uploads)──
/// operationId `initUpload`(POST /uploads → 201,clientTaskId 幂等)。
Future<UploadInitResponse> initUpload(UploadInitRequest req) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/uploads', data: req.toJson()),
);
return UploadInitResponse.fromJson(resp.data!);
}
/// operationId `renewUploadCredential`
/// (POST /uploads/{uploadId}/renew,结构同初始化响应)。
Future<UploadInitResponse> renewUpload(String uploadId) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/uploads/$uploadId/renew'),
);
return UploadInitResponse.fromJson(resp.data!);
}
/// operationId `completeUpload`
/// (POST /uploads/{uploadId}/complete,幂等核验)。
Future<UploadCompleteResponse> completeUpload(String uploadId) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>('/v1/uploads/$uploadId/complete'),
);
return UploadCompleteResponse.fromJson(resp.data!);
}
/// operationId `listUploads`(GET /uploads,跨设备会话历史,新在前)。
Future<CursorPage<UploadSessionRecord>> listUploads({
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/uploads',
UploadSessionRecord.fromJson,
cursor: cursor,
limit: limit,
);
// ── photos(契约 tag: photos)──
/// operationId `listAlbumPhotos`
/// (GET /albums/{albumId}/photos,管理列表,全状态可见)。
Future<CursorPage<AdminPhoto>> listAlbumPhotos(
String albumId, {
PhotoVisibility? visibility,
String? categoryId,
String? cursor,
int? limit,
}) =>
_getPage(
'/v1/albums/$albumId/photos',
AdminPhoto.fromJson,
cursor: cursor,
limit: limit,
extraQuery: {
if (visibility != null) 'visibility': visibility.wire,
'categoryId': ?categoryId,
},
);
/// operationId `updatePhoto`(PATCH /photos/{photoId}):仅允许
/// hidden ↔ published 切换,非法取值本地即抛 ArgumentError。
Future<PhotoRecord> updatePhotoVisibility(
String photoId,
PhotoVisibility visibility,
) async {
if (visibility != PhotoVisibility.hidden &&
visibility != PhotoVisibility.published) {
throw ArgumentError(
'updatePhotoVisibility 仅允许 hidden/published,收到 '
'${visibility.wire}',
);
}
final resp = await _send(
() => _dio.patch<Map<String, dynamic>>(
'/v1/photos/$photoId',
data: {'visibility': visibility.wire},
),
);
return PhotoRecord.fromJson(resp.data!);
}
/// operationId `deletePhoto`(DELETE /photos/{photoId} → 204 软删除)。
Future<void> deletePhoto(String photoId) =>
_send(() => _dio.delete<void>('/v1/photos/$photoId'));
// ── 通用游标分页 ──
/// 通用游标页请求:cursor/limit 仅在提供时下发,[extraQuery] 携带
/// 领域过滤参数。
Future<CursorPage<T>> _getPage<T>(
String path,
T Function(Map<String, dynamic>) fromJson, {
String? cursor,
int? limit,
Map<String, dynamic>? extraQuery,
}) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>(
path,
queryParameters: {
'cursor': ?cursor,
'limit': ?limit,
...?extraQuery,
},
),
);
return CursorPage.parse(resp.data!, fromJson);
}
}
/// 会话存储 Provider(生产实现:flutter_secure_storage)。
final sessionStoreProvider =
Provider<SessionStore>((ref) => SecureSessionStore());
/// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized]
/// 跳转 /login(命名路由,见 lib/app/app.dart)。
final apiClientProvider = Provider<ApiClient>((ref) {
final client = ApiClient(sessionStore: ref.watch(sessionStoreProvider));
ref.onDispose(client.close);
return client;
});
+35
View File
@@ -0,0 +1,35 @@
import 'error_codes.dart';
/// 统一 API 异常:HTTP 错误响应体 `{code, message, requestId}`(契约
/// `components.schemas.Error`)与网络层失败一律归一为本类型,映射逻辑见
/// `ApiClient.mapDioException`。
///
/// UI 层按 [code]([ErrorCodes])解析本地化文案;[message] 为服务器/
/// 客户端生成的诊断消息,不作为展示文案依据。
class AppException implements Exception {
const AppException({
required this.code,
required this.message,
this.requestId,
this.statusCode,
});
/// 稳定错误码(契约码或客户端伪码,见 [ErrorCodes])。
final String code;
/// 可读消息(诊断用)。
final String message;
/// 请求追踪 ID(服务端错误体携带时非空)。
final String? requestId;
/// HTTP 状态码(网络层失败/取消时为 null)。
final int? statusCode;
/// 会话过期/未认证(401 语义:令牌失效需重新登录)。
bool get isAuthExpired => code == ErrorCodes.authTokenExpired;
@override
String toString() => 'AppException(code: $code, statusCode: $statusCode, '
'requestId: $requestId, message: $message)';
}
+26
View File
@@ -0,0 +1,26 @@
/// 环境入口配置(P3 计划 Task 2)。
///
/// 值经 `--dart-define=KEY=value` 覆盖;缺省指向 Android 模拟器宿主回环
/// (10.0.2.2 = 模拟器内看到的宿主机 localhost,见计划「模拟器网络回环」
/// 偏差登记)。`String.fromEnvironment` 为编译期常量,不可运行时切换。
class ApiConfig {
const ApiConfig._();
/// cn-api 基地址(不含 `/v1` 前缀——契约 servers.url 为 `/v1`,
/// 由 [ApiClient] 统一拼接到各端点路径)。
static const String baseUrl = String.fromEnvironment(
'API_BASE',
defaultValue: 'http://10.0.2.2:8080',
);
/// viewer 观众端基地址(Task 4 分享二维码链接用)。
static const String viewerBase = String.fromEnvironment(
'VIEWER_BASE',
defaultValue: 'http://10.0.2.2:5175',
);
/// 观众端相册链接:`{VIEWER_BASE}/a/{token}`(Task 4 qr 分享用,
/// viewer 路由约定)。
static String viewerAlbumUrl(String publicToken) =>
'$viewerBase/a/$publicToken';
}
+77
View File
@@ -0,0 +1,77 @@
/// 服务端稳定错误码镜像(P3 计划 Task 2)。
///
/// 1:1 对照 `packages/contracts/src/errors.ts` 的 `ErrorCodes` 与
/// `packages/contracts/openapi.yaml` `components.schemas.Error` 的 enum
/// (17 项)。契约约定:客户端按 code 映射多语言文案(l10n 解析留在 UI
/// 层),`message` 仅供诊断。
///
/// 文件末尾的 [networkError] / [networkTimeout] / [cancelled] /
/// [httpError] 为 **客户端本地伪码**(契约外,仅存在于 AppException,
/// 服务端永不返回)。
abstract final class ErrorCodes {
/// 账号或密码错误(401)。
static const String authInvalidCredentials = 'AUTH_INVALID_CREDENTIALS';
/// 未认证:令牌缺失、无效或已过期(401)。
static const String authTokenExpired = 'AUTH_TOKEN_EXPIRED';
/// 登录失败次数过多(429;Task 13 契约修订,区别于凭据错误)。
static const String authRateLimited = 'AUTH_RATE_LIMITED';
/// 公开互动端点(likes/visits)基础限速 429(P2 Task 1 增补的通用码)。
static const String rateLimited = 'RATE_LIMITED';
/// 已认证但无操作权限(403)。
static const String authForbidden = 'AUTH_FORBIDDEN';
/// 框架/路由层面的资源不存在(404;业务 404 一律携带域码,P1a 增补)。
static const String notFound = 'NOT_FOUND';
/// 相册不存在或当前用户不可见(404,避免枚举)。
static const String albumNotFound = 'ALBUM_NOT_FOUND';
/// 相册不存在或未公开(404,观众端,不区分以避免枚举)。
static const String albumNotPublic = 'ALBUM_NOT_PUBLIC';
/// 相册已关闭上传(409)。
static const String albumUploadDisabled = 'ALBUM_UPLOAD_DISABLED';
/// 该用户已是相册成员(409)。
static const String memberConflict = 'MEMBER_CONFLICT';
/// 上传会话冲突:同 clientTaskId 参数不一致 / 状态不允许续签或完成(409)。
static const String uploadSessionConflict = 'UPLOAD_SESSION_CONFLICT';
/// 完成核验失败:对象缺失、字节数不符或完整性信息不可用(400)。
static const String uploadVerifyFailed = 'UPLOAD_VERIFY_FAILED';
/// 照片不存在(404)。
static const String photoNotFound = 'PHOTO_NOT_FOUND';
/// 照片不存在、未发布或不属于该相册(404,观众端)。
static const String photoNotPublic = 'PHOTO_NOT_PUBLIC';
/// 参数校验失败(400,经全局校验管道)。
static const String validationFailed = 'VALIDATION_FAILED';
/// 服务间 /internal/** HMAC 鉴权失败(401,P2;App 侧永不收到,
/// 仅为与 errors.ts 全量 1:1 对齐而镜像)。
static const String syncAuthFailed = 'SYNC_AUTH_FAILED';
/// 服务器内部错误(500)。
static const String internalError = 'INTERNAL_ERROR';
// ── 以下为客户端本地伪码(契约外,见类注释) ──
/// 网络连接失败(DNS/握手/断网等连接层错误)。
static const String networkError = 'NETWORK_ERROR';
/// 网络超时(连接/发送/接收任一阶段)。
static const String networkTimeout = 'NETWORK_TIMEOUT';
/// 请求被取消。
static const String cancelled = 'CANCELLED';
/// 收到无契约错误体的非 2xx 响应(statusCode 保留在 AppException 上)。
static const String httpError = 'HTTP_ERROR';
}
@@ -0,0 +1,49 @@
/// 游标分页页(P3 计划 Task 2 的唯一通用分页解析助手)。
///
/// 契约 `components.schemas.CursorPageMeta` + 各列表响应的 `items` 数组
/// (AlbumList / MemberList / UploadSessionList / AdminPhotoList 均为
/// allOf 内嵌本结构)。不变式(openapi.yaml):hasMore=true 当且仅当
/// nextCursor≠null,两者必须成对填充。
///
/// [parse] 对违约响应做容错:hasMore=true 但 nextCursor 缺失时按末页处理
/// (游标置 null),避免上层「还有下一页却拿不到游标」的死循环。
class CursorPage<T> {
const CursorPage({
required this.items,
required this.nextCursor,
required this.hasMore,
});
/// 从列表端点响应体解析一页。
///
/// [fromJson] 为条目模型的解析函数(如 `AlbumSummary.fromJson`)。
static CursorPage<T> parse<T>(
Map<String, dynamic> json,
T Function(Map<String, dynamic>) fromJson,
) {
final items = (json['items'] as List<dynamic>? ?? const <dynamic>[])
.map((dynamic e) => fromJson(e as Map<String, dynamic>))
.toList();
final rawCursor = json['nextCursor'];
final cursor = rawCursor is String && rawCursor.isNotEmpty
? rawCursor
: null;
final declaredMore = json['hasMore'] as bool? ?? false;
// 违约容错:无游标即视为末页(见类注释)。
final effectiveMore = declaredMore && cursor != null;
return CursorPage<T>(
items: items,
nextCursor: effectiveMore ? cursor : null,
hasMore: effectiveMore,
);
}
/// 本页条目。
final List<T> items;
/// 下一页游标;null 表示没有更多数据。
final String? nextCursor;
/// 是否还有下一页(与 [nextCursor] 成对)。
final bool hasMore;
}
+106
View File
@@ -0,0 +1,106 @@
/// 会话存储(P3 计划 Task 2):Bearer 令牌 + 过期时间 + 账号。
///
/// 契约来源:`components.schemas.LoginResponse`(sessionToken/expiresAt);
/// account 为登录请求回显(LoginResponse 不含,客户端自持以便界面展示
/// 与账号隔离键使用)。
library;
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
/// 会话值对象(不可变)。
class Session {
const Session({required this.token, this.expiresAt, this.account});
/// Bearer 令牌。
final String token;
/// 会话过期时间(服务端 date-time)。
final DateTime? expiresAt;
/// 登录账号(回显)。
final String? account;
/// 令牌是否已过期(无过期时间时视为未过期,交由服务端 401 裁决)。
bool get isExpired =>
expiresAt != null && DateTime.now().isAfter(expiresAt!);
}
/// 会话存储抽象:[ApiClient] 依赖本接口,测试注入内存实现。
abstract class SessionStore {
/// 读取会话;从未登录(或已清除)返回 null。
Future<Session?> read();
/// 写入会话(全量覆盖,含清空可空字段)。
Future<void> save(Session session);
/// 清除会话(登出/401 驱逐)。
Future<void> clear();
}
/// [SessionStore] 的 flutter_secure_storage 生产实现。
///
/// 序列化逻辑独立为静态 [encode]/[decode](纯函数,可单测);平台通道
/// 调用保持最薄。
class SecureSessionStore implements SessionStore {
SecureSessionStore({FlutterSecureStorage? storage})
: _storage = storage ?? FlutterSecureStorage();
/// 安全存储键(App 私有命名空间内)。
static const String kTokenKey = 'session.token';
static const String kExpiresAtKey = 'session.expiresAt';
static const String kAccountKey = 'session.account';
final FlutterSecureStorage _storage;
/// Session → 扁平键值(可空字段缺省不落键)。
static Map<String, String> encode(Session session) => {
kTokenKey: session.token,
if (session.expiresAt != null)
kExpiresAtKey: session.expiresAt!.toUtc().toIso8601String(),
if (session.account != null) kAccountKey: session.account!,
};
/// 扁平键值 → Session;令牌缺失/为空返回 null(无会话语义)。
static Session? decode(Map<String?, String?> values) {
final token = values[kTokenKey];
if (token == null || token.isEmpty) return null;
final expiresAt = values[kExpiresAtKey];
return Session(
token: token,
expiresAt:
expiresAt == null ? null : DateTime.tryParse(expiresAt),
account: values[kAccountKey],
);
}
@override
Future<Session?> read() async {
final values = await _storage.readAll();
return decode(values);
}
@override
Future<void> save(Session session) async {
final encoded = encode(session);
// 先写后删:可空字段缺省时移除旧值,避免残留上一会话的账号/过期时间。
for (final entry in encoded.entries) {
await _storage.write(key: entry.key, value: entry.value);
}
for (final key in _allKeys.where((k) => !encoded.containsKey(k))) {
await _storage.delete(key: key);
}
}
@override
Future<void> clear() async {
for (final key in _allKeys) {
await _storage.delete(key: key);
}
}
static const List<String> _allKeys = [
kTokenKey,
kExpiresAtKey,
kAccountKey,
];
}
+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(),
};
}
+512
View File
@@ -0,0 +1,512 @@
/// ApiClient 拦截器与端点行为测试(P3 计划 Task 2)。
///
/// 网络层经手写 FakeAdapter(dio HttpClientAdapter)打桩——响应体为
/// 依 openapi.yaml 各端点 schema 手工转写的 JSON;未引入
/// http_mock_adapter(零额外 dev 依赖,打桩面完全可控)。
library;
import 'dart:convert';
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/app_exception.dart';
import 'package:photo_live/core/error_codes.dart';
import 'package:photo_live/core/models/cursor_page.dart';
import 'package:photo_live/core/session_store.dart';
import 'package:photo_live/features/albums/models.dart';
import 'package:photo_live/features/auth/models.dart';
import 'package:photo_live/features/photos/models.dart';
/// 内存会话存储(测试替身)。
class FakeSessionStore implements SessionStore {
Session? session;
@override
Future<Session?> read() async => session;
@override
Future<void> save(Session value) async => session = value;
@override
Future<void> clear() async => session = null;
}
/// 记录到适配器的请求快照。
class RecordedRequest {
const RecordedRequest({
required this.method,
required this.path,
required this.query,
required this.headers,
required this.body,
});
final String method;
final String path;
final Map<String, dynamic> query;
final Map<String, dynamic> headers;
final Object? body;
}
/// 手写 dio 打桩适配器:按路由分发预设响应并记录全部请求。
class FakeAdapter implements HttpClientAdapter {
FakeAdapter(this.router);
/// 路由器:返回 (status, body);body 为 null 表示无响应体(204)。
final (int, Object?) Function(RequestOptions options) router;
final List<RecordedRequest> requests = <RecordedRequest>[];
@override
Future<ResponseBody> fetch(
RequestOptions options,
Stream? requestStream,
Future? cancelFuture,
) async {
requests.add(
RecordedRequest(
method: options.method,
path: options.path,
query: Map<String, dynamic>.from(options.queryParameters),
headers: Map<String, dynamic>.from(options.headers),
body: options.data,
),
);
final (status, body) = router(options);
if (body == null) {
// 204 等:空体且不带 JSON content-type(transformer 不解码)。
return ResponseBody.fromString('', status);
}
return ResponseBody.fromString(
jsonEncode(body),
status,
headers: {
Headers.contentTypeHeader: [Headers.jsonContentType],
},
);
}
@override
void close({bool force = false}) {}
}
/// 捕获异步错误(不冒泡),返回异常对象;正常完成返回 null。
Future<Object?> captureError(Future<void> Function() run) async {
try {
await run();
return null;
} on Object catch (e) {
return e;
}
}
/// openapi.yaml components.schemas.AlbumSummary 形状的最小相册体。
Map<String, dynamic> albumBody() => <String, dynamic>{
'id': 'alb-1',
'publicToken': 'a1b2c3d4e5f6g7h8i9j0',
'ownerId': 'usr-1',
'status': 'active',
'defaultLocale': 'zh-CN',
'timezone': 'Asia/Shanghai',
'startAt': '2026-10-01T02:00:00Z',
'endAt': null,
'uploadEnabled': true,
'publicEnabled': true,
'downloadEnabled': true,
'originalDownloadEnabled': false,
'watermarkVersion': 0,
'translations': [
{
'locale': 'zh-CN',
'title': '测试相册',
'description': null,
'venue': null,
'address': null,
},
],
'createdAt': '2026-09-10T08:00:00Z',
'updatedAt': '2026-09-10T08:00:00Z',
'statsSyncedAt': null,
'categories': [],
};
ApiClient _client(FakeSessionStore store, FakeAdapter adapter) {
final dio = Dio(BaseOptions(baseUrl: 'http://test.local'))
..httpClientAdapter = adapter;
return ApiClient(sessionStore: store, dio: dio);
}
void main() {
late FakeSessionStore store;
late int unauthorizedCalls;
setUp(() {
store = FakeSessionStore();
unauthorizedCalls = 0;
});
void seedSession() {
store.session = Session(
token: 'tok-live-1',
expiresAt: DateTime.utc(2099, 1, 1),
account: 'staff01',
);
}
ApiClient wired(FakeAdapter adapter) => _client(store, adapter)
..onUnauthorized = () => unauthorizedCalls++;
test('会话存在时请求注入 Authorization: Bearer', () async {
seedSession();
final adapter = FakeAdapter((_) => (
200,
{
'items': <String>[],
'nextCursor': null,
'hasMore': false,
}
));
await wired(adapter).listAlbums();
expect(adapter.requests.single.path, '/v1/albums');
expect(
adapter.requests.single.headers['Authorization'],
'Bearer tok-live-1',
);
});
test('无会话时不注入 Authorization(裸发,由服务端 401 裁决)', () async {
final adapter = FakeAdapter((_) => (
200,
{
'items': <String>[],
'nextCursor': null,
'hasMore': false,
}
));
await wired(adapter).listAlbums();
expect(adapter.requests.single.headers.containsKey('Authorization'),
isFalse);
});
test('login:不携带令牌、回写会话(token+expiresAt+account)', () async {
final adapter = FakeAdapter(
(o) => o.path == '/v1/auth/password'
? (
200,
{
'sessionToken': 'tok-new',
'expiresAt': '2026-09-20T12:00:00Z',
}
)
: throw StateError('unexpected ${o.path}'),
);
final resp = await wired(adapter).login('staff01', 'pw-123');
expect(resp, isA<LoginResponse>());
expect(resp.sessionToken, 'tok-new');
final req = adapter.requests.single;
expect(req.method, 'POST');
expect(req.headers.containsKey('Authorization'), isFalse);
expect(req.body, {'account': 'staff01', 'password': 'pw-123'});
expect(store.session?.token, 'tok-new');
expect(store.session?.account, 'staff01');
expect(store.session?.expiresAt, DateTime.utc(2026, 9, 20, 12));
});
test('业务 401:清空会话 + 通知一次 + 抛 AUTH_TOKEN_EXPIRED', () async {
seedSession();
final adapter = FakeAdapter((_) => (
401,
{
'code': 'AUTH_TOKEN_EXPIRED',
'message': 'token expired',
'requestId': 'req-401',
}
));
final err = await captureError(() => wired(adapter).listAlbums());
expect(
err,
isA<AppException>()
.having((e) => e.code, 'code', ErrorCodes.authTokenExpired)
.having((e) => e.requestId, 'requestId', 'req-401'),
);
expect(store.session, isNull, reason: '401 必须清空本地会话');
expect(unauthorizedCalls, 1, reason: '驱逐通知恰好一次');
});
test('并发 401:通知去重(新会话写入前只通知一次)', () async {
seedSession();
final adapter = FakeAdapter((_) => (
401,
{'code': 'AUTH_TOKEN_EXPIRED', 'message': 'x', 'requestId': 'r'},
));
final client = wired(adapter);
final results = await Future.wait([
captureError(() => client.listAlbums()),
captureError(() => client.listUploads()),
captureError(() => client.getAlbum('alb-1')),
]);
expect(results.every((e) => e is AppException), isTrue);
expect(store.session, isNull);
expect(unauthorizedCalls, 1);
});
test('登录端点 401(凭据错误):不驱逐、不通知、不附加旧令牌', () async {
seedSession();
final adapter = FakeAdapter(
(o) => o.path == '/v1/auth/password'
? (
401,
{
'code': 'AUTH_INVALID_CREDENTIALS',
'message': 'bad credentials',
'requestId': 'req-1',
}
)
: throw StateError('unexpected ${o.path}'),
);
final err =
await captureError(() => wired(adapter).login('staff01', 'wrong-pw'));
expect(
err,
isA<AppException>().having(
(e) => e.code,
'code',
ErrorCodes.authInvalidCredentials,
),
);
expect(store.session?.token, 'tok-live-1', reason: '登录失败不清会话');
expect(unauthorizedCalls, 0);
expect(adapter.requests.single.headers.containsKey('Authorization'),
isFalse);
});
test('新会话写入后,后续 401 再次通知(标志复位)', () async {
seedSession();
var tokenDead = true;
final adapter = FakeAdapter((o) {
if (o.path == '/v1/auth/password') {
return (
200,
{'sessionToken': 'tok-2', 'expiresAt': '2026-09-21T00:00:00Z'}
);
}
return tokenDead
? (
401,
{'code': 'AUTH_TOKEN_EXPIRED', 'message': 'x', 'requestId': 'r'}
)
: (
200,
{
'items': <String>[],
'nextCursor': null,
'hasMore': false,
}
);
});
final client = wired(adapter);
await captureError(() => client.listAlbums());
expect(unauthorizedCalls, 1);
await client.login('staff01', 'pw');
tokenDead = false;
await client.listAlbums(); // 新会话下正常
expect(unauthorizedCalls, 1);
tokenDead = true;
await captureError(() => client.listAlbums());
expect(unauthorizedCalls, 2, reason: '重新登录后 401 应再次通知');
});
test('错误体经端点方法映射为 AppException(getAlbum 404)', () async {
final adapter = FakeAdapter((_) => (
404,
{
'code': 'ALBUM_NOT_FOUND',
'message': 'album missing',
'requestId': 'req-404',
}
));
final err = await captureError(() => wired(adapter).getAlbum('ghost'));
expect(
err,
isA<AppException>()
.having((e) => e.code, 'code', ErrorCodes.albumNotFound)
.having((e) => e.statusCode, 'statusCode', 404)
.having((e) => e.requestId, 'requestId', 'req-404'),
);
});
test('listAlbums:游标/条数透传 + 分页字段解析', () async {
final adapter = FakeAdapter((_) => (
200,
{
'items': [albumBody()],
'nextCursor': 'cur-2',
'hasMore': true,
}
));
final page = await wired(adapter).listAlbums(cursor: 'cur-1', limit: 50);
expect(
adapter.requests.single.query,
containsPair('cursor', 'cur-1'),
);
expect(adapter.requests.single.query, containsPair('limit', 50));
expect(page, isA<CursorPage<AlbumSummary>>());
expect(page.items.single.id, 'alb-1');
expect(page.nextCursor, 'cur-2');
expect(page.hasMore, isTrue);
});
test('listAlbumPhotos:可见性/分类过滤参数透传', () async {
final adapter = FakeAdapter((_) => (
200,
{
'items': <String>[],
'nextCursor': null,
'hasMore': false,
}
));
await wired(adapter).listAlbumPhotos(
'alb-1',
visibility: PhotoVisibility.hidden,
categoryId: 'cat-9',
);
final query = adapter.requests.single.query;
expect(query, containsPair('visibility', 'hidden'));
expect(query, containsPair('categoryId', 'cat-9'));
expect(query.containsKey('cursor'), isFalse);
expect(query.containsKey('limit'), isFalse);
});
test('createAlbum:请求体透传 + 响应解析为 Album', () async {
final adapter = FakeAdapter((_) => (201, albumBody()));
final album = await wired(adapter).createAlbum(
AlbumCreateRequest(
translations: const [
AlbumTranslation(locale: 'zh-CN', title: '测试相册'),
],
defaultLocale: 'zh-CN',
startAt: DateTime.utc(2026, 10, 1, 2),
),
);
expect(adapter.requests.single.method, 'POST');
expect(adapter.requests.single.body, isA<Map<String, dynamic>>());
expect(album, isA<Album>());
expect(album.id, 'alb-1');
expect(album.translations.single.title, '测试相册');
});
test('updatePhotoVisibility:非法目标本地即拒绝(不走网络)', () async {
final adapter = FakeAdapter((_) => throw StateError('不应发起请求'));
final client = wired(adapter);
expect(
() => client.updatePhotoVisibility('pho-1', PhotoVisibility.unpublished),
throwsArgumentError,
);
expect(
() => client.updatePhotoVisibility('pho-1', PhotoVisibility.deleted),
throwsArgumentError,
);
expect(adapter.requests, isEmpty);
});
test('updatePhotoVisibility:合法目标 PATCH 正确体', () async {
final adapter = FakeAdapter((_) => (200, {
'photoId': 'pho-1',
'albumId': 'alb-1',
'categoryId': null,
'uploaderId': 'usr-1',
'visibility': 'hidden',
'processingState': 'ready',
'bytes': 1,
'width': null,
'height': null,
'shotAt': null,
'uploadedAt': '2026-09-12T08:05:00Z',
'publishedAt': null,
'likeCount': 0,
}));
final photo = await wired(adapter)
.updatePhotoVisibility('pho-1', PhotoVisibility.hidden);
expect(adapter.requests.single.method, 'PATCH');
expect(adapter.requests.single.path, '/v1/photos/pho-1');
expect(adapter.requests.single.body, {'visibility': 'hidden'});
expect(photo.visibility, PhotoVisibility.hidden);
expect(photo, isA<PhotoRecord>());
});
test('refresh:令牌/账号保留,仅滑动更新过期时间', () async {
seedSession();
final adapter = FakeAdapter(
(o) => (
200,
{'expiresAt': '2026-10-01T00:00:00Z'},
),
);
final resp = await wired(adapter).refresh();
expect(adapter.requests.single.path, '/v1/auth/refresh');
expect(
adapter.requests.single.headers['Authorization'],
'Bearer tok-live-1',
);
expect(resp.expiresAt, DateTime.utc(2026, 10, 1));
expect(store.session?.token, 'tok-live-1', reason: '续期不换令牌');
expect(store.session?.account, 'staff01');
expect(store.session?.expiresAt, DateTime.utc(2026, 10, 1));
});
test('logout:服务端 500 也照样清除本地会话且不抛错(尽力而为)',
() async {
seedSession();
final adapter = FakeAdapter((_) => (
500,
{'code': 'INTERNAL_ERROR', 'message': 'boom', 'requestId': 'r'},
));
await wired(adapter).logout();
expect(adapter.requests.single.path, '/v1/auth/logout');
expect(adapter.requests.single.method, 'POST');
expect(store.session, isNull);
expect(unauthorizedCalls, 0, reason: 'logout 不触发驱逐通知');
});
test('removeMember / deletePhoto:DELETE 且容忍 204 空响应体', () async {
final adapter = FakeAdapter((_) => (204, null));
final client = wired(adapter);
await client.removeMember('alb-1', 'usr-2');
await client.deletePhoto('pho-1');
expect(adapter.requests[0].method, 'DELETE');
expect(adapter.requests[0].path, '/v1/albums/alb-1/members/usr-2');
expect(adapter.requests[1].method, 'DELETE');
expect(adapter.requests[1].path, '/v1/photos/pho-1');
});
}
@@ -0,0 +1,90 @@
/// CursorPage 游标解析边界用例(P3 计划 Task 2)。
///
/// 契约来源:openapi.yaml components.schemas.CursorPageMeta——
/// 「hasMore=true 当且仅当 nextCursor≠null,两者必须成对填充」。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/core/models/cursor_page.dart';
/// 最小条目模型(仅用于分页解析验证)。
Map<String, dynamic> _item(String id) => {'id': id};
String _parseItem(Map<String, dynamic> json) => json['id'] as String;
void main() {
test('常规页:hasMore=true 与 nextCursor 成对保留', () {
final page = CursorPage.parse<String>(
{
'items': [_item('a'), _item('b')],
'nextCursor': 'cur-2',
'hasMore': true,
},
_parseItem,
);
expect(page.items, ['a', 'b']);
expect(page.nextCursor, 'cur-2');
expect(page.hasMore, isTrue);
});
test('末页:nextCursor=null 且 hasMore=false', () {
final page = CursorPage.parse<String>(
{
'items': [_item('c')],
'nextCursor': null,
'hasMore': false,
},
_parseItem,
);
expect(page.items, ['c']);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
});
test('违约容错:hasMore=true 但游标缺失 → 按末页处理(防死循环)', () {
final page = CursorPage.parse<String>(
{
'items': [_item('a')],
'nextCursor': null,
'hasMore': true, // 违反 CursorPageMeta 不变式
},
_parseItem,
);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
});
test('违约容错:hasMore=false 但携带游标 → 游标丢弃', () {
final page = CursorPage.parse<String>(
{
'items': <Map<String, dynamic>>[],
'nextCursor': 'stale',
'hasMore': false,
},
_parseItem,
);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
expect(page.items, isEmpty);
});
test('空字符串游标视为 null', () {
final page = CursorPage.parse<String>(
{
'items': <Map<String, dynamic>>[],
'nextCursor': '',
'hasMore': true,
},
_parseItem,
);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
});
test('缺省字段:无 hasMore 默认 false,无 items 默认空数组', () {
final page = CursorPage.parse<String>(<String, dynamic>{}, _parseItem);
expect(page.items, isEmpty);
expect(page.nextCursor, isNull);
expect(page.hasMore, isFalse);
});
}
+208
View File
@@ -0,0 +1,208 @@
/// 错误码映射表驱动测试(P3 计划 Task 2)。
///
/// 码表来源:packages/contracts/src/errors.ts(17 项,1:1 镜像到
/// lib/core/error_codes.dart)与 openapi.yaml
/// components.schemas.Error 的 enum;状态码配对取 openapi.yaml 各端点
/// responses 标注(如 429 AUTH_RATE_LIMITED)。
library;
import 'package:dio/dio.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/core/api_client.dart';
import 'package:photo_live/core/app_exception.dart';
import 'package:photo_live/core/error_codes.dart';
/// 构造「服务端错误体」型 DioException(badResponse)。
DioException _httpError(int status, Map<String, dynamic> body) {
final options = RequestOptions(path: '/v1/albums');
return DioException(
type: DioExceptionType.badResponse,
requestOptions: options,
response: Response(
requestOptions: options,
statusCode: status,
data: body,
),
);
}
/// 构造「无契约错误体」型 DioException。
DioException _bareError(int status) {
final options = RequestOptions(path: '/v1/albums');
return DioException(
type: DioExceptionType.badResponse,
requestOptions: options,
response: Response(requestOptions: options, statusCode: status),
);
}
void main() {
group('mapDioException:契约 17 码全量透传(errors.ts 1:1)', () {
// (code, 典型状态码) —— 状态码取自 openapi.yaml 各响应 description。
const cases = <(String, int)>[
(ErrorCodes.authInvalidCredentials, 401),
(ErrorCodes.authTokenExpired, 401),
(ErrorCodes.authRateLimited, 429), // Task 13 契约修订
(ErrorCodes.rateLimited, 429), // P2 Task 1 增补(观众互动限速)
(ErrorCodes.authForbidden, 403),
(ErrorCodes.notFound, 404), // P1a 增补(框架级 404)
(ErrorCodes.albumNotFound, 404),
(ErrorCodes.albumNotPublic, 404),
(ErrorCodes.albumUploadDisabled, 409),
(ErrorCodes.memberConflict, 409),
(ErrorCodes.uploadSessionConflict, 409),
(ErrorCodes.uploadVerifyFailed, 400),
(ErrorCodes.photoNotFound, 404),
(ErrorCodes.photoNotPublic, 404),
(ErrorCodes.validationFailed, 400),
(ErrorCodes.syncAuthFailed, 401), // App 侧不消费,仅为镜像完整性
(ErrorCodes.internalError, 500),
];
test('错误体 {code,message,requestId} 逐码映射为 AppException', () {
for (final (code, status) in cases) {
final ex = ApiClient.mapDioException(
_httpError(status, {
'code': code,
'message': 'diagnostic message',
'requestId': 'req-001',
}),
);
expect(ex.code, code, reason: '错误体 code=$code 应原样透传');
expect(ex.message, 'diagnostic message', reason: 'code=$code');
expect(ex.requestId, 'req-001', reason: 'code=$code');
expect(ex.statusCode, status, reason: 'code=$code');
expect(ex.isAuthExpired, code == ErrorCodes.authTokenExpired);
}
});
test('码表与 errors.ts 字面值逐项一致(防漂移)', () {
// 顺序即 packages/contracts/src/errors.ts 的声明顺序。
expect(
[
ErrorCodes.authInvalidCredentials,
ErrorCodes.authTokenExpired,
ErrorCodes.authRateLimited,
ErrorCodes.rateLimited,
ErrorCodes.authForbidden,
ErrorCodes.notFound,
ErrorCodes.albumNotFound,
ErrorCodes.albumNotPublic,
ErrorCodes.albumUploadDisabled,
ErrorCodes.memberConflict,
ErrorCodes.uploadSessionConflict,
ErrorCodes.uploadVerifyFailed,
ErrorCodes.photoNotFound,
ErrorCodes.photoNotPublic,
ErrorCodes.validationFailed,
ErrorCodes.syncAuthFailed,
ErrorCodes.internalError,
],
everyElement(isNotNull),
);
expect(ErrorCodes.authRateLimited, 'AUTH_RATE_LIMITED');
expect(ErrorCodes.notFound, 'NOT_FOUND');
expect(ErrorCodes.rateLimited, 'RATE_LIMITED');
});
});
group('mapDioException:网络层(客户端伪码)', () {
test('连接失败 → NETWORK_ERROR', () {
final ex = ApiClient.mapDioException(
DioException(
type: DioExceptionType.connectionError,
requestOptions: RequestOptions(path: '/v1/albums'),
),
);
expect(ex.code, ErrorCodes.networkError);
expect(ex.statusCode, isNull);
});
test('连接/发送/接收/转换超时 → NETWORK_TIMEOUT', () {
const types = [
DioExceptionType.connectionTimeout,
DioExceptionType.sendTimeout,
DioExceptionType.receiveTimeout,
DioExceptionType.transformTimeout,
];
for (final type in types) {
final ex = ApiClient.mapDioException(
DioException(
type: type,
requestOptions: RequestOptions(path: '/v1/albums'),
),
);
expect(ex.code, ErrorCodes.networkTimeout, reason: '$type');
}
});
test('取消 → CANCELLED', () {
final ex = ApiClient.mapDioException(
DioException(
type: DioExceptionType.cancel,
requestOptions: RequestOptions(path: '/v1/albums'),
),
);
expect(ex.code, ErrorCodes.cancelled);
});
});
group('mapDioException:无契约错误体的兜底', () {
// openapi.yaml info.description:状态码通用映射 401→AUTH_TOKEN_EXPIRED、
// 500→INTERNAL_ERROR;其余无体状态归 HTTP_ERROR(statusCode 保留)。
test('裸 401 → AUTH_TOKEN_EXPIRED', () {
final ex = ApiClient.mapDioException(_bareError(401));
expect(ex.code, ErrorCodes.authTokenExpired);
expect(ex.statusCode, 401);
});
test('裸 500 / 503 → INTERNAL_ERROR', () {
expect(ApiClient.mapDioException(_bareError(500)).code,
ErrorCodes.internalError);
expect(ApiClient.mapDioException(_bareError(503)).code,
ErrorCodes.internalError);
});
test('裸 403 → HTTP_ERROR(保留状态码)', () {
final ex = ApiClient.mapDioException(_bareError(403));
expect(ex.code, ErrorCodes.httpError);
expect(ex.statusCode, 403);
});
test('错误体缺 code 字段 → 按状态码兜底', () {
final ex = ApiClient.mapDioException(
_httpError(400, {'message': 'no code here'}),
);
expect(ex.code, ErrorCodes.httpError);
expect(ex.statusCode, 400);
});
test('错误体 code 非 String → 按状态码兜底', () {
final ex = ApiClient.mapDioException(
_httpError(500, {'code': 42, 'message': 'numeric code'}),
);
expect(ex.code, ErrorCodes.internalError);
});
test('requestId 缺失时为 null', () {
final ex = ApiClient.mapDioException(
_httpError(404, {'code': 'ALBUM_NOT_FOUND', 'message': 'x'}),
);
expect(ex.requestId, isNull);
});
});
test('AppException.toString 含诊断要素', () {
const ex = AppException(
code: 'RATE_LIMITED',
message: 'too many',
requestId: 'req-9',
statusCode: 429,
);
final s = ex.toString();
expect(s, contains('RATE_LIMITED'));
expect(s, contains('req-9'));
expect(s, contains('429'));
expect(s, contains('too many'));
});
}
@@ -0,0 +1,85 @@
/// SecureSessionStore 序列化与会话语义测试(P3 计划 Task 2)。
///
/// 平台通道(flutter_secure_storage)调用保持最薄、不经平台通道单测;
/// 编解码逻辑为纯静态函数在此锁定。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/core/session_store.dart';
void main() {
group('SecureSessionStore.encode/decode', () {
test('全字段往返', () {
const session = Session(
token: 'tok-abc',
expiresAt: null, // 以动态值覆盖,见下
account: 'staff01',
);
// DateTime 非 const,单独构造完整会话。
final full = Session(
token: session.token,
expiresAt: DateTime.utc(2026, 9, 19, 12, 30),
account: session.account,
);
final decoded = SecureSessionStore.decode(
SecureSessionStore.encode(full),
);
expect(decoded, isNotNull);
expect(decoded!.token, 'tok-abc');
expect(decoded.account, 'staff01');
expect(decoded.expiresAt, DateTime.utc(2026, 9, 19, 12, 30));
});
test('可空字段缺省不落键', () {
const session = Session(token: 'tok-only');
final encoded = SecureSessionStore.encode(session);
expect(encoded.containsKey(SecureSessionStore.kTokenKey), isTrue);
expect(
encoded.containsKey(SecureSessionStore.kExpiresAtKey),
isFalse,
);
expect(encoded.containsKey(SecureSessionStore.kAccountKey), isFalse);
final decoded = SecureSessionStore.decode(encoded);
expect(decoded!.expiresAt, isNull);
expect(decoded.account, isNull);
});
test('令牌缺失/为空 → null(无会话语义)', () {
expect(SecureSessionStore.decode(<String?, String?>{}), isNull);
expect(
SecureSessionStore.decode({SecureSessionStore.kTokenKey: ''}),
isNull,
);
});
test('过期时间不可解析 → expiresAt 为 null(会话仍有效持有)', () {
final decoded = SecureSessionStore.decode({
SecureSessionStore.kTokenKey: 'tok-x',
SecureSessionStore.kExpiresAtKey: 'not-a-date',
});
expect(decoded, isNotNull);
expect(decoded!.expiresAt, isNull);
});
});
group('Session.isExpired', () {
test('过期时间为过去 → true', () {
final s = Session(token: 't', expiresAt: DateTime.utc(2000, 1, 1));
expect(s.isExpired, isTrue);
});
test('过期时间为未来 → false', () {
final s = Session(
token: 't',
expiresAt: DateTime.now().add(const Duration(hours: 1)),
);
expect(s.isExpired, isFalse);
});
test('无过期时间 → false(交由服务端 401 裁决)', () {
const s = Session(token: 't');
expect(s.isExpired, isFalse);
});
});
}
@@ -0,0 +1,258 @@
/// 相册域模型往返测试(P3 计划 Task 2)。
///
/// fixture 来源(字段逐一转写):
/// - openapi.yaml components.schemas.AlbumSummary / Album /
/// AlbumTranslation / Category / CategoryTranslation / Member /
/// AdminUserLookupItem / AlbumCreateRequest / AlbumUpdateRequest /
/// CategoryCreateRequest / CategoryUpdateRequest;
/// - services/api/test/albums.e2e-spec.ts `validCreateBody`(zh-CN 完整 +
/// en 仅 title、未填可选字段回读 null)与 201 响应形状断言(status=
/// active、四开关默认值、watermarkVersion=0、categories=[]、
/// publicToken ^[0-9a-z]{20,32}$)。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/albums/models.dart';
// openapi.yaml:AlbumSummary 必返字段全集(+ Album 增量 categories)。
final Map<String, dynamic> albumSummaryFixture = <String, dynamic>{
'id': 'cktragt000001',
'publicToken': 'a1b2c3d4e5f6g7h8i9j0',
'ownerId': 'usr-owner-01',
'status': 'active',
'defaultLocale': 'zh-CN',
'timezone': 'Asia/Shanghai',
'startAt': '2026-10-01T02:00:00.000Z',
'endAt': null,
'uploadEnabled': true,
'publicEnabled': true,
'downloadEnabled': true,
'originalDownloadEnabled': false,
'watermarkVersion': 0,
'translations': [
{
'locale': 'zh-CN',
'title': '2026 公司年会',
'description': '年度盛会',
'venue': '大宴会厅',
'address': '上海市浦东新区世纪大道 100 号',
},
{
'locale': 'en',
'title': 'Annual Party 2026',
'description': null,
'venue': null,
'address': null,
},
],
'createdAt': '2026-09-10T08:00:00.000Z',
'updatedAt': '2026-09-10T08:00:00.000Z',
'statsSyncedAt': null,
};
final Map<String, dynamic> categoryFixture = <String, dynamic>{
'id': 'ckucat0000001',
'albumId': 'cktragt000001',
'sortOrder': 0,
'translations': [
{'locale': 'zh-CN', 'name': '开幕式'},
{'locale': 'en', 'name': 'Opening'},
],
};
void main() {
test('AlbumSummary fromJson/toJson 往返(e2e 201 形状)', () {
final model = AlbumSummary.fromJson(albumSummaryFixture);
expect(model.status, AlbumStatus.active);
expect(model.publicToken, matches(RegExp('^[0-9a-z]{20,32}\$')));
expect(model.translations, hasLength(2));
// e2e:未填可选字段回读 null(契约「存储为 null」)。
expect(model.translations[1].description, isNull);
expect(model.translations[1].venue, isNull);
expect(model.translations[1].address, isNull);
expect(model.toJson(), equals(albumSummaryFixture));
});
test('Album fromJson/toJson 往返(AlbumSummary + categories)', () {
final fixture = {...albumSummaryFixture, 'categories': [categoryFixture]};
final model = Album.fromJson(fixture);
expect(model.categories, hasLength(1));
expect(model.categories.first.id, 'ckucat0000001');
expect(model.categories.first.translations.first.name, '开幕式');
expect(model.toJson(), equals(fixture));
});
test('AlbumSummary 非空 endAt/statsSyncedAt 往返', () {
final fixture = {
...albumSummaryFixture,
'endAt': '2026-10-01T14:00:00.000Z',
'status': 'offline',
'statsSyncedAt': '2026-09-18T01:02:03.500Z',
};
final model = AlbumSummary.fromJson(fixture);
expect(model.endAt, DateTime.utc(2026, 10, 1, 14));
expect(model.status, AlbumStatus.offline);
expect(model.statsSyncedAt!.millisecond, 500);
expect(model.toJson(), equals(fixture));
});
test('Category fromJson/toJson 往返', () {
final model = Category.fromJson(categoryFixture);
expect(model.sortOrder, 0);
expect(model.toJson(), equals(categoryFixture));
});
test('Member fromJson/toJson 往返(members-categories.e2e 形状)', () {
final fixture = <String, dynamic>{
'userId': 'usr-owner-01',
'account': 'album-owner',
'displayName': '张三',
'role': 'owner',
'status': 'active',
};
final model = Member.fromJson(fixture);
expect(model.role, MemberRole.owner);
expect(model.status, MemberStatus.active);
expect(model.toJson(), equals(fixture));
final collaborator = Member.fromJson({...fixture, 'role': 'collaborator'});
expect(collaborator.role, MemberRole.collaborator);
});
test('AdminUserLookupItem fromJson/toJson 往返(脱敏三字段)', () {
final fixture = <String, dynamic>{
'id': 'usr-col-02',
'account': 'collab01',
'displayName': '李四',
};
final model = AdminUserLookupItem.fromJson(fixture);
expect(model.toJson(), equals(fixture));
});
test('AlbumCreateRequest toJson(albums.e2e validCreateBody 同构)', () {
final req = AlbumCreateRequest(
translations: const [
AlbumTranslation(
locale: 'zh-CN',
title: '2026 公司年会',
description: '年度盛会',
venue: '大宴会厅',
address: '上海市浦东新区世纪大道 100 号',
),
AlbumTranslation(locale: 'en', title: 'Annual Party 2026'),
],
defaultLocale: 'zh-CN',
startAt: DateTime.utc(2026, 10, 1, 2),
);
expect(req.toJson(), {
'translations': [
{
'locale': 'zh-CN',
'title': '2026 公司年会',
'description': '年度盛会',
'venue': '大宴会厅',
'address': '上海市浦东新区世纪大道 100 号',
},
{
'locale': 'en',
'title': 'Annual Party 2026',
'description': null,
'venue': null,
'address': null,
},
],
'defaultLocale': 'zh-CN',
// Dart toIso8601String 恒带毫秒——与 e2e 体(.000Z,Date.toISOString
// 毫秒形)逐字节一致。
'startAt': '2026-10-01T02:00:00.000Z',
'endAt': null,
'timezone': 'Asia/Shanghai',
'downloadEnabled': true,
'originalDownloadEnabled': false,
});
});
test('AlbumUpdateRequest toJson 仅序列化提交字段(部分更新语义)', () {
expect(
const AlbumUpdateRequest(uploadEnabled: false).toJson(),
{'uploadEnabled': false},
);
expect(
AlbumUpdateRequest(
startAt: DateTime.utc(2026, 10, 2, 9, 0, 0),
endAt: DateTime.utc(2026, 10, 2, 18),
).toJson(),
{
'startAt': '2026-10-02T09:00:00.000Z',
'endAt': '2026-10-02T18:00:00.000Z',
},
);
// endAt 显式清除:序列化为 "endAt": null(契约:null 清除)。
expect(
const AlbumUpdateRequest(clearEndAt: true).toJson(),
{'endAt': null},
);
// 端提交与显式清除同时设置时,清除语义(null)胜出。
expect(
AlbumUpdateRequest(
endAt: DateTime.utc(2026, 10, 2, 18),
clearEndAt: true,
).toJson(),
{'endAt': null},
);
});
test('CategoryCreateRequest / CategoryUpdateRequest toJson', () {
const translations = [
CategoryTranslation(locale: 'zh-CN', name: '晚宴'),
CategoryTranslation(locale: 'en', name: 'Banquet'),
];
expect(
const CategoryCreateRequest(translations: translations).toJson(),
{
'translations': [
{'locale': 'zh-CN', 'name': '晚宴'},
{'locale': 'en', 'name': 'Banquet'},
],
},
);
expect(
const CategoryCreateRequest(sortOrder: 5, translations: translations)
.toJson()['sortOrder'],
5,
);
expect(
const CategoryUpdateRequest(sortOrder: 2).toJson(),
{'sortOrder': 2},
);
expect(
const CategoryUpdateRequest(translations: translations).toJson(),
{
'translations': [
{'locale': 'zh-CN', 'name': '晚宴'},
{'locale': 'en', 'name': 'Banquet'},
],
},
);
});
test('未知枚举值解析抛 FormatException(fail-fast 防脏数据静默)', () {
expect(
() => AlbumSummary.fromJson({
...albumSummaryFixture,
'status': 'archived',
}),
throwsFormatException,
);
expect(
() => Member.fromJson(const {
'userId': 'u',
'account': 'a',
'displayName': 'd',
'role': 'viewer',
'status': 'active',
}),
throwsFormatException,
);
});
}
@@ -0,0 +1,41 @@
/// 认证域模型往返测试(P3 计划 Task 2)。
///
/// fixture 来源:openapi.yaml components.schemas.LoginResponse /
/// RefreshResponse(必返字段 sessionToken+expiresAt / expiresAt);
/// 时间形态取 auth.e2e-spec.ts 直种会话的「Date.now()+3_600_000」毫秒精度,
/// 此处用 Dart 规范形(整秒无小数)保证 toJson 往返逐字节一致,
/// 毫秒形(.000Z)解析另有冒烟断言。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/auth/models.dart';
void main() {
test('LoginResponse fromJson/toJson 往返', () {
final fixture = <String, dynamic>{
'sessionToken': 'f2a9c8b17d3e4f5a6b7c8d9e0f1a2b3c',
'expiresAt': '2026-09-19T12:30:00.000Z',
};
final model = LoginResponse.fromJson(fixture);
expect(model.sessionToken, 'f2a9c8b17d3e4f5a6b7c8d9e0f1a2b3c');
expect(model.expiresAt, DateTime.utc(2026, 9, 19, 12, 30));
expect(model.toJson(), equals(fixture));
});
test('LoginResponse 解析毫秒精度时间(服务端 Date.toISOString 形态)', () {
final model = LoginResponse.fromJson({
'sessionToken': 't',
'expiresAt': '2026-09-19T12:30:00.500Z',
});
expect(model.expiresAt.millisecond, 500);
// toJson 规范化为 Dart ISO 输出(同值不同形,服务端 Date.parse 均可)。
expect(model.toJson()['expiresAt'], '2026-09-19T12:30:00.500Z');
});
test('RefreshResponse fromJson/toJson 往返', () {
final fixture = <String, dynamic>{'expiresAt': '2026-09-20T08:00:00.000Z'};
final model = RefreshResponse.fromJson(fixture);
expect(model.expiresAt, DateTime.utc(2026, 9, 20, 8));
expect(model.toJson(), equals(fixture));
});
}
@@ -0,0 +1,110 @@
/// 照片域模型往返测试(P3 计划 Task 2)。
///
/// fixture 来源(字段逐一转写):
/// - openapi.yaml components.schemas.PhotoRecord / AdminPhoto /
/// AdminPhotoUploader / PhotoVisibility / PhotoProcessingState;
/// - services/api/test/photos-admin.e2e-spec.ts 的 ADMIN_PHOTO_KEYS
/// (PhotoRecord 必返字段 + uploader 内嵌)与种子形态
/// (visibility=published / processingState=ready、categoryId 可空、
/// displayName 取资料字段而非账号)。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/photos/models.dart';
final Map<String, dynamic> photoRecordFixture = <String, dynamic>{
'photoId': 'pa-photo-p1',
'albumId': 'cktragt000001',
'categoryId': 'ckucat0000001',
'uploaderId': 'usr-col-02',
'visibility': 'published',
'processingState': 'ready',
'bytes': 4096000,
'width': 4032,
'height': 3024,
'shotAt': '2026-09-12T07:55:00.000Z',
'uploadedAt': '2026-09-12T08:05:00.000Z',
'publishedAt': '2026-09-12T08:06:00.000Z',
'likeCount': 3,
};
void main() {
test('PhotoRecord fromJson/toJson 往返(管理端全字段)', () {
final model = PhotoRecord.fromJson(photoRecordFixture);
expect(model.visibility, PhotoVisibility.published);
expect(model.processingState, PhotoProcessingState.ready);
expect(model.likeCount, 3);
expect(model.toJson(), equals(photoRecordFixture));
});
test('PhotoRecord 可空字段:处理前宽高为 null、未发布为 null', () {
final fixture = {
...photoRecordFixture,
'categoryId': null,
'width': null,
'height': null,
'shotAt': null,
'publishedAt': null,
'visibility': 'unpublished',
'processingState': 'uploaded',
};
final model = PhotoRecord.fromJson(fixture);
expect(model.categoryId, isNull);
expect(model.width, isNull);
expect(model.height, isNull);
expect(model.shotAt, isNull);
expect(model.publishedAt, isNull);
expect(model.visibility, PhotoVisibility.unpublished);
expect(model.processingState, PhotoProcessingState.uploaded);
expect(model.toJson(), equals(fixture));
});
test('PhotoRecord 解析毫秒精度时间(服务端 Date.toISOString 形态)', () {
final model = PhotoRecord.fromJson({
...photoRecordFixture,
'uploadedAt': '2026-09-12T08:05:00.000Z',
});
expect(model.uploadedAt, DateTime.utc(2026, 9, 12, 8, 5));
});
test('AdminPhoto fromJson/toJson 往返(PhotoRecord + uploader 内嵌)', () {
final fixture = {
...photoRecordFixture,
'uploader': {
'userId': 'usr-col-02',
'account': 'collab01',
'displayName': '李四',
},
};
final model = AdminPhoto.fromJson(fixture);
// e2e:uploader 映射取的是资料 displayName,而非账号。
expect(model.uploader.displayName, '李四');
expect(model.uploader.userId, model.uploaderId);
expect(model.toJson(), equals(fixture));
});
test('PhotoVisibility / PhotoProcessingState 全枚举解析', () {
for (final value in const [
'unpublished',
'published',
'hidden',
'deleted',
]) {
expect(PhotoVisibility.fromJson(value).wire, value);
}
for (final value in const [
'pending_upload',
'uploaded',
'processing',
'ready',
'processing_failed',
]) {
expect(PhotoProcessingState.fromJson(value).wire, value);
}
expect(() => PhotoVisibility.fromJson('archived'), throwsFormatException);
expect(
() => PhotoProcessingState.fromJson('failed'),
throwsFormatException,
);
});
}
@@ -0,0 +1,142 @@
/// 上传域模型往返测试(P3 计划 Task 2)。
///
/// fixture 来源(字段逐一转写):
/// - openapi.yaml components.schemas.UploadCredential / UploadInitRequest /
/// UploadInitResponse / UploadCompleteResponse / UploadSessionRecord /
/// UploadSessionPhoto / UploadSessionState;
/// - services/api/test/uploads.e2e-spec.ts 的凭证形状断言
/// (mode=presigned、method=PUT、headers={'Content-Type':'image/jpeg'}、
/// credential.expiresAt > now)与 staging objectKey 形态
/// (photo-live/{env}/staging/{uploadId}/{photoId}.{ext})。
library;
import 'package:flutter_test/flutter_test.dart';
import 'package:photo_live/features/photos/models.dart';
import 'package:photo_live/features/uploads/models.dart';
final Map<String, dynamic> credentialFixture = <String, dynamic>{
'mode': 'presigned',
'url':
'http://127.0.0.1:9000/photo-live-dev/staging/upl0000001/pho0000001.jpg'
'?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Expires=600',
'method': 'PUT',
'headers': {'Content-Type': 'image/jpeg'},
'expiresAt': '2026-09-19T13:00:00.000Z',
};
final Map<String, dynamic> initResponseFixture = <String, dynamic>{
'uploadId': 'upl0000001',
'photoId': 'pho0000001',
'objectKey': 'photo-live/dev/staging/upl0000001/pho0000001.jpg',
'credential': credentialFixture,
};
void main() {
test('UploadCredential fromJson/toJson 往返(e2e 凭证形状)', () {
final model = UploadCredential.fromJson(credentialFixture);
expect(model.mode, 'presigned');
expect(model.method, 'PUT');
expect(model.headers['Content-Type'], 'image/jpeg');
expect(model.expiresAt.isAfter(DateTime.utc(2026, 9, 19)), isTrue);
expect(model.toJson(), equals(credentialFixture));
});
test('UploadInitResponse fromJson/toJson 往返', () {
final model = UploadInitResponse.fromJson(initResponseFixture);
expect(model.uploadId, 'upl0000001');
expect(model.photoId, 'pho0000001');
expect(model.objectKey, 'photo-live/dev/staging/upl0000001/pho0000001.jpg');
expect(model.toJson(), equals(initResponseFixture));
});
test('UploadInitRequest toJson:categoryId 缺省不下发', () {
const req = UploadInitRequest(
albumId: 'cktragt000001',
clientTaskId: 'task-uuid-0001',
filename: 'IMG_20260919_0001.jpg',
bytes: 4096000,
contentType: 'image/jpeg',
);
expect(req.toJson(), {
'albumId': 'cktragt000001',
'clientTaskId': 'task-uuid-0001',
'filename': 'IMG_20260919_0001.jpg',
'bytes': 4096000,
'contentType': 'image/jpeg',
});
const withCategory = UploadInitRequest(
albumId: 'cktragt000001',
categoryId: 'ckucat0000001',
clientTaskId: 'task-uuid-0002',
filename: 'IMG_20260919_0002.png',
bytes: 8192000,
contentType: 'image/png',
);
expect(withCategory.toJson()['categoryId'], 'ckucat0000001');
});
test('UploadCompleteResponse fromJson/toJson 往返', () {
final fixture = <String, dynamic>{
'photoId': 'pho0000001',
'processingState': 'processing',
};
final model = UploadCompleteResponse.fromJson(fixture);
expect(model.processingState, PhotoProcessingState.processing);
expect(model.toJson(), equals(fixture));
});
test('UploadSessionRecord fromJson/toJson 往返(photo 内嵌)', () {
final fixture = <String, dynamic>{
'uploadId': 'upl0000002',
'albumId': 'cktragt000001',
'clientTaskId': 'task-uuid-0002',
'state': 'confirmed',
'expectedBytes': 8192000,
'contentType': 'image/png',
'createdAt': '2026-09-19T09:15:30.000Z',
'photo': {
'photoId': 'pho0000002',
'processingState': 'uploaded',
'visibility': 'unpublished',
},
};
final model = UploadSessionRecord.fromJson(fixture);
expect(model.state, UploadSessionState.confirmed);
expect(model.photo.processingState, PhotoProcessingState.uploaded);
expect(model.photo.visibility, PhotoVisibility.unpublished);
expect(model.toJson(), equals(fixture));
});
test('UploadSessionState 全枚举解析(pending/confirmed/cancelled/expired)',
() {
for (final value in const ['pending', 'confirmed', 'cancelled', 'expired']) {
expect(UploadSessionState.fromJson(value).wire, value);
}
expect(() => UploadSessionState.fromJson('failed'), throwsFormatException);
});
test('未知凭证/状态字段值 fail-fast', () {
expect(
() => UploadCredential.fromJson({...credentialFixture, 'mode': 'sts'}),
returnsNormally, // mode 为 String 透传(P1a 仅 presigned,预留扩展)
);
expect(
() => UploadSessionRecord.fromJson({
'uploadId': 'u',
'albumId': 'a',
'clientTaskId': 'c',
'state': 'failed', // 契约无 failed 状态(核验失败是请求级错误)
'expectedBytes': 1,
'contentType': 'image/jpeg',
'createdAt': '2026-09-19T09:15:30.000Z',
'photo': {
'photoId': 'p',
'processingState': 'uploaded',
'visibility': 'unpublished',
},
}),
throwsFormatException,
);
});
}