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,
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user