diff --git a/apps/mobile/lib/core/api_client.dart b/apps/mobile/lib/core/api_client.dart new file mode 100644 index 0000000..e660305 --- /dev/null +++ b/apps/mobile/lib/core/api_client.dart @@ -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 _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 _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 _send(Future Function() run) async { + try { + return await run(); + } on DioException catch (e) { + throw mapDioException(e); + } + } + + // ── auth(契约 tag: auth)── + + /// operationId `loginWithPassword`(POST /auth/password)。 + /// + /// 成功后将会话(令牌/过期时间/账号回显)写入 [SessionStore]。 + Future login(String account, String password) async { + final resp = await _send( + () => _dio.post>( + '/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 refresh() async { + final resp = await _send( + () => _dio.post>('/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 logout() async { + try { + await _send(() => _dio.post('/v1/auth/logout')); + } on AppException { + // 尽力而为(计划 Task 3 语义):错误吞掉,本地照常登出。 + } finally { + await _session.clear(); + _unauthorizedNotified = false; + } + } + + // ── albums / members / categories / admin ── + + /// operationId `listAlbums`(GET /albums,游标分页)。 + Future> listAlbums({String? cursor, int? limit}) => + _getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: limit); + + /// operationId `createAlbum`(POST /albums → 201)。 + Future createAlbum(AlbumCreateRequest req) async { + final resp = await _send( + () => _dio.post>('/v1/albums', data: req.toJson()), + ); + return Album.fromJson(resp.data!); + } + + /// operationId `getAlbum`(GET /albums/{albumId})。 + Future getAlbum(String albumId) async { + final resp = await _send( + () => _dio.get>('/v1/albums/$albumId'), + ); + return Album.fromJson(resp.data!); + } + + /// operationId `updateAlbum`(PATCH /albums/{albumId},部分更新)。 + Future updateAlbum(String albumId, AlbumUpdateRequest patch) async { + final resp = await _send( + () => _dio.patch>( + '/v1/albums/$albumId', + data: patch.toJson(), + ), + ); + return Album.fromJson(resp.data!); + } + + /// operationId `addAlbumMember`(POST /albums/{albumId}/members → 201)。 + /// + /// role 省略:契约缺省 collaborator(P1a 唯一可添加角色)。 + Future addMember(String albumId, String userId) async { + final resp = await _send( + () => _dio.post>( + '/v1/albums/$albumId/members', + data: {'userId': userId}, + ), + ); + return Member.fromJson(resp.data!); + } + + /// operationId `listAlbumMembers`(GET /albums/{albumId}/members)。 + Future> 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 removeMember(String albumId, String userId) => + _send(() => _dio.delete('/v1/albums/$albumId/members/$userId')); + + /// operationId `lookupUsersByAccount` + /// (GET /admin/users?accountPrefix=,无游标,按账号升序)。 + Future> lookupUsers( + String accountPrefix, { + int? limit, + }) async { + final resp = await _send( + () => _dio.get>( + '/v1/admin/users', + queryParameters: { + 'accountPrefix': accountPrefix, + 'limit': ?limit, + }, + ), + ); + final items = resp.data!['items'] as List; + return items + .map( + (dynamic e) => AdminUserLookupItem.fromJson( + e as Map, + ), + ) + .toList(); + } + + /// operationId `createCategory` + /// (POST /albums/{albumId}/categories → 201)。 + Future createCategory( + String albumId, + CategoryCreateRequest req, + ) async { + final resp = await _send( + () => _dio.post>( + '/v1/albums/$albumId/categories', + data: req.toJson(), + ), + ); + return Category.fromJson(resp.data!); + } + + /// operationId `updateCategory`(PATCH /categories/{categoryId})。 + Future updateCategory( + String categoryId, + CategoryUpdateRequest patch, + ) async { + final resp = await _send( + () => _dio.patch>( + '/v1/categories/$categoryId', + data: patch.toJson(), + ), + ); + return Category.fromJson(resp.data!); + } + + /// operationId `deleteCategory`(DELETE /categories/{categoryId} → 204)。 + Future deleteCategory(String categoryId) => + _send(() => _dio.delete('/v1/categories/$categoryId')); + + // ── uploads(契约 tag: uploads)── + + /// operationId `initUpload`(POST /uploads → 201,clientTaskId 幂等)。 + Future initUpload(UploadInitRequest req) async { + final resp = await _send( + () => _dio.post>('/v1/uploads', data: req.toJson()), + ); + return UploadInitResponse.fromJson(resp.data!); + } + + /// operationId `renewUploadCredential` + /// (POST /uploads/{uploadId}/renew,结构同初始化响应)。 + Future renewUpload(String uploadId) async { + final resp = await _send( + () => _dio.post>('/v1/uploads/$uploadId/renew'), + ); + return UploadInitResponse.fromJson(resp.data!); + } + + /// operationId `completeUpload` + /// (POST /uploads/{uploadId}/complete,幂等核验)。 + Future completeUpload(String uploadId) async { + final resp = await _send( + () => _dio.post>('/v1/uploads/$uploadId/complete'), + ); + return UploadCompleteResponse.fromJson(resp.data!); + } + + /// operationId `listUploads`(GET /uploads,跨设备会话历史,新在前)。 + Future> listUploads({ + String? cursor, + int? limit, + }) => + _getPage( + '/v1/uploads', + UploadSessionRecord.fromJson, + cursor: cursor, + limit: limit, + ); + + // ── photos(契约 tag: photos)── + + /// operationId `listAlbumPhotos` + /// (GET /albums/{albumId}/photos,管理列表,全状态可见)。 + Future> 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 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>( + '/v1/photos/$photoId', + data: {'visibility': visibility.wire}, + ), + ); + return PhotoRecord.fromJson(resp.data!); + } + + /// operationId `deletePhoto`(DELETE /photos/{photoId} → 204 软删除)。 + Future deletePhoto(String photoId) => + _send(() => _dio.delete('/v1/photos/$photoId')); + + // ── 通用游标分页 ── + + /// 通用游标页请求:cursor/limit 仅在提供时下发,[extraQuery] 携带 + /// 领域过滤参数。 + Future> _getPage( + String path, + T Function(Map) fromJson, { + String? cursor, + int? limit, + Map? extraQuery, + }) async { + final resp = await _send( + () => _dio.get>( + path, + queryParameters: { + 'cursor': ?cursor, + 'limit': ?limit, + ...?extraQuery, + }, + ), + ); + return CursorPage.parse(resp.data!, fromJson); + } +} + +/// 会话存储 Provider(生产实现:flutter_secure_storage)。 +final sessionStoreProvider = + Provider((ref) => SecureSessionStore()); + +/// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized] +/// 跳转 /login(命名路由,见 lib/app/app.dart)。 +final apiClientProvider = Provider((ref) { + final client = ApiClient(sessionStore: ref.watch(sessionStoreProvider)); + ref.onDispose(client.close); + return client; +}); diff --git a/apps/mobile/lib/core/app_exception.dart b/apps/mobile/lib/core/app_exception.dart new file mode 100644 index 0000000..3b7f267 --- /dev/null +++ b/apps/mobile/lib/core/app_exception.dart @@ -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)'; +} diff --git a/apps/mobile/lib/core/config.dart b/apps/mobile/lib/core/config.dart new file mode 100644 index 0000000..a63c034 --- /dev/null +++ b/apps/mobile/lib/core/config.dart @@ -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'; +} diff --git a/apps/mobile/lib/core/error_codes.dart b/apps/mobile/lib/core/error_codes.dart new file mode 100644 index 0000000..d921372 --- /dev/null +++ b/apps/mobile/lib/core/error_codes.dart @@ -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'; +} diff --git a/apps/mobile/lib/core/models/cursor_page.dart b/apps/mobile/lib/core/models/cursor_page.dart new file mode 100644 index 0000000..0b6d8b1 --- /dev/null +++ b/apps/mobile/lib/core/models/cursor_page.dart @@ -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 { + const CursorPage({ + required this.items, + required this.nextCursor, + required this.hasMore, + }); + + /// 从列表端点响应体解析一页。 + /// + /// [fromJson] 为条目模型的解析函数(如 `AlbumSummary.fromJson`)。 + static CursorPage parse( + Map json, + T Function(Map) fromJson, + ) { + final items = (json['items'] as List? ?? const []) + .map((dynamic e) => fromJson(e as Map)) + .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( + items: items, + nextCursor: effectiveMore ? cursor : null, + hasMore: effectiveMore, + ); + } + + /// 本页条目。 + final List items; + + /// 下一页游标;null 表示没有更多数据。 + final String? nextCursor; + + /// 是否还有下一页(与 [nextCursor] 成对)。 + final bool hasMore; +} diff --git a/apps/mobile/lib/core/session_store.dart b/apps/mobile/lib/core/session_store.dart new file mode 100644 index 0000000..d4e8796 --- /dev/null +++ b/apps/mobile/lib/core/session_store.dart @@ -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 read(); + + /// 写入会话(全量覆盖,含清空可空字段)。 + Future save(Session session); + + /// 清除会话(登出/401 驱逐)。 + Future 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 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 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 read() async { + final values = await _storage.readAll(); + return decode(values); + } + + @override + Future 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 clear() async { + for (final key in _allKeys) { + await _storage.delete(key: key); + } + } + + static const List _allKeys = [ + kTokenKey, + kExpiresAtKey, + kAccountKey, + ]; +} diff --git a/apps/mobile/lib/features/albums/models.dart b/apps/mobile/lib/features/albums/models.dart new file mode 100644 index 0000000..b0d5aed --- /dev/null +++ b/apps/mobile/lib/features/albums/models.dart @@ -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 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 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 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) + .map( + (dynamic e) => AlbumTranslation.fromJson( + e as Map, + ), + ) + .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 translations; + + /// 创建时间。 + final DateTime createdAt; + + /// 最近更新时间。 + final DateTime updatedAt; + + /// 统计副本最近同步时间(P2 A27);null 表示尚无回传应用。 + final DateTime? statsSyncedAt; + + Map 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 json) => Album( + categories: (json['categories'] as List) + .map( + (dynamic e) => Category.fromJson(e as Map), + ) + .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) + .map( + (dynamic e) => AlbumTranslation.fromJson( + e as Map, + ), + ) + .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 categories; + + @override + Map 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 json) => + CategoryTranslation( + locale: json['locale'] as String, + name: json['name'] as String, + ); + + /// 语言(契约 Locale enum:zh-CN | en)。 + final String locale; + + /// 分类名称。 + final String name; + + Map 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 json) => Category( + id: json['id'] as String, + albumId: json['albumId'] as String, + sortOrder: json['sortOrder'] as int, + translations: (json['translations'] as List) + .map( + (dynamic e) => + CategoryTranslation.fromJson(e as Map), + ) + .toList(), + ); + + /// 分类 ID。 + final String id; + + /// 所属相册 ID。 + final String albumId; + + /// 排序序号(升序)。 + final int sortOrder; + + /// 各语言名称。 + final List translations; + + Map 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 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 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 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 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 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 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? 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 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 translations; + + Map 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? translations; + + Map toJson() => { + if (sortOrder != null) 'sortOrder': sortOrder, + if (translations != null) + 'translations': + translations!.map((CategoryTranslation t) => t.toJson()).toList(), + }; +} diff --git a/apps/mobile/lib/features/auth/models.dart b/apps/mobile/lib/features/auth/models.dart new file mode 100644 index 0000000..10fca52 --- /dev/null +++ b/apps/mobile/lib/features/auth/models.dart @@ -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 json) => LoginResponse( + sessionToken: json['sessionToken'] as String, + expiresAt: DateTime.parse(json['expiresAt'] as String), + ); + + /// 会话令牌(Bearer)。 + final String sessionToken; + + /// 会话过期时间(date-time)。 + final DateTime expiresAt; + + Map 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 json) => + RefreshResponse(expiresAt: DateTime.parse(json['expiresAt'] as String)); + + /// 续期后的过期时间(date-time)。 + final DateTime expiresAt; + + Map toJson() => + {'expiresAt': expiresAt.toUtc().toIso8601String()}; +} diff --git a/apps/mobile/lib/features/photos/models.dart b/apps/mobile/lib/features/photos/models.dart new file mode 100644 index 0000000..195d640 --- /dev/null +++ b/apps/mobile/lib/features/photos/models.dart @@ -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 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 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 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 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 json) => AdminPhoto( + uploader: AdminPhotoUploader.fromJson( + json['uploader'] as Map, + ), + 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 toJson() => {...super.toJson(), 'uploader': uploader.toJson()}; +} diff --git a/apps/mobile/lib/features/uploads/models.dart b/apps/mobile/lib/features/uploads/models.dart new file mode 100644 index 0000000..993953a --- /dev/null +++ b/apps/mobile/lib/features/uploads/models.dart @@ -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 json) => + UploadCredential( + mode: json['mode'] as String, + url: json['url'] as String, + method: json['method'] as String, + headers: (json['headers'] as Map) + .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 headers; + + /// 凭证过期时间。 + final DateTime expiresAt; + + Map 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 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 json) => + UploadInitResponse( + uploadId: json['uploadId'] as String, + photoId: json['photoId'] as String, + objectKey: json['objectKey'] as String, + credential: UploadCredential.fromJson( + json['credential'] as Map, + ), + ); + + /// 上传会话 ID。 + final String uploadId; + + /// 预创建的照片 ID(重试复用,不重复建照片)。 + final String photoId; + + /// staging 对象键 + /// (photo-live/{env}/staging/{uploadId}/{photoId}.{ext})。 + final String objectKey; + + /// staging 对象的临时直传凭证。 + final UploadCredential credential; + + Map 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 json) => + UploadCompleteResponse( + photoId: json['photoId'] as String, + processingState: + PhotoProcessingState.fromJson(json['processingState'] as String), + ); + + /// 照片 ID。 + final String photoId; + + /// 核验通过后的处理状态(进入处理流水线)。 + final PhotoProcessingState processingState; + + Map 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 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 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 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, + ), + ); + + /// 上传会话 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 toJson() => { + 'uploadId': uploadId, + 'albumId': albumId, + 'clientTaskId': clientTaskId, + 'state': state.wire, + 'expectedBytes': expectedBytes, + 'contentType': contentType, + 'createdAt': createdAt.toUtc().toIso8601String(), + 'photo': photo.toJson(), + }; +} diff --git a/apps/mobile/test/core/api_client_test.dart b/apps/mobile/test/core/api_client_test.dart new file mode 100644 index 0000000..fbf8393 --- /dev/null +++ b/apps/mobile/test/core/api_client_test.dart @@ -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 read() async => session; + + @override + Future save(Session value) async => session = value; + + @override + Future 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 query; + final Map 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 requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add( + RecordedRequest( + method: options.method, + path: options.path, + query: Map.from(options.queryParameters), + headers: Map.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 captureError(Future Function() run) async { + try { + await run(); + return null; + } on Object catch (e) { + return e; + } +} + +/// openapi.yaml components.schemas.AlbumSummary 形状的最小相册体。 +Map albumBody() => { + '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': [], + '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': [], + '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()); + 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() + .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().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': [], + '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() + .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>()); + expect(page.items.single.id, 'alb-1'); + expect(page.nextCursor, 'cur-2'); + expect(page.hasMore, isTrue); + }); + + test('listAlbumPhotos:可见性/分类过滤参数透传', () async { + final adapter = FakeAdapter((_) => ( + 200, + { + 'items': [], + '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>()); + expect(album, isA()); + 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()); + }); + + 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'); + }); +} diff --git a/apps/mobile/test/core/cursor_page_test.dart b/apps/mobile/test/core/cursor_page_test.dart new file mode 100644 index 0000000..44413eb --- /dev/null +++ b/apps/mobile/test/core/cursor_page_test.dart @@ -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 _item(String id) => {'id': id}; + +String _parseItem(Map json) => json['id'] as String; + +void main() { + test('常规页:hasMore=true 与 nextCursor 成对保留', () { + final page = CursorPage.parse( + { + '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( + { + '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( + { + 'items': [_item('a')], + 'nextCursor': null, + 'hasMore': true, // 违反 CursorPageMeta 不变式 + }, + _parseItem, + ); + expect(page.nextCursor, isNull); + expect(page.hasMore, isFalse); + }); + + test('违约容错:hasMore=false 但携带游标 → 游标丢弃', () { + final page = CursorPage.parse( + { + 'items': >[], + 'nextCursor': 'stale', + 'hasMore': false, + }, + _parseItem, + ); + expect(page.nextCursor, isNull); + expect(page.hasMore, isFalse); + expect(page.items, isEmpty); + }); + + test('空字符串游标视为 null', () { + final page = CursorPage.parse( + { + 'items': >[], + 'nextCursor': '', + 'hasMore': true, + }, + _parseItem, + ); + expect(page.nextCursor, isNull); + expect(page.hasMore, isFalse); + }); + + test('缺省字段:无 hasMore 默认 false,无 items 默认空数组', () { + final page = CursorPage.parse({}, _parseItem); + expect(page.items, isEmpty); + expect(page.nextCursor, isNull); + expect(page.hasMore, isFalse); + }); +} diff --git a/apps/mobile/test/core/error_codes_test.dart b/apps/mobile/test/core/error_codes_test.dart new file mode 100644 index 0000000..9277fc5 --- /dev/null +++ b/apps/mobile/test/core/error_codes_test.dart @@ -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 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')); + }); +} diff --git a/apps/mobile/test/core/session_store_test.dart b/apps/mobile/test/core/session_store_test.dart new file mode 100644 index 0000000..50e486e --- /dev/null +++ b/apps/mobile/test/core/session_store_test.dart @@ -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({}), 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); + }); + }); +} diff --git a/apps/mobile/test/features/albums_models_test.dart b/apps/mobile/test/features/albums_models_test.dart new file mode 100644 index 0000000..8a5128c --- /dev/null +++ b/apps/mobile/test/features/albums_models_test.dart @@ -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 albumSummaryFixture = { + '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 categoryFixture = { + '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 = { + '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 = { + '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, + ); + }); +} diff --git a/apps/mobile/test/features/auth_models_test.dart b/apps/mobile/test/features/auth_models_test.dart new file mode 100644 index 0000000..86e7f56 --- /dev/null +++ b/apps/mobile/test/features/auth_models_test.dart @@ -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 = { + '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 = {'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)); + }); +} diff --git a/apps/mobile/test/features/photos_models_test.dart b/apps/mobile/test/features/photos_models_test.dart new file mode 100644 index 0000000..d7e6742 --- /dev/null +++ b/apps/mobile/test/features/photos_models_test.dart @@ -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 photoRecordFixture = { + '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, + ); + }); +} diff --git a/apps/mobile/test/features/uploads_models_test.dart b/apps/mobile/test/features/uploads_models_test.dart new file mode 100644 index 0000000..9b07e31 --- /dev/null +++ b/apps/mobile/test/features/uploads_models_test.dart @@ -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 credentialFixture = { + '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 initResponseFixture = { + '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 = { + '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 = { + '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, + ); + }); +}