Files
photo-live/apps/mobile/lib/core/api_client.dart
T
浅念 f975321b67 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>
2026-09-19 00:40:02 +08:00

505 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 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;
});