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