/// cn-api 客户端(P3 计划 Task 2)。 /// /// - 契约权威:`packages/contracts/openapi.yaml`(servers.url=/v1)。 /// - Bearer 注入:请求拦截器从 [SessionStore] 读取令牌(登录端点豁免)。 /// - 401 处理:清空会话并触发 [onUnauthorized](App 壳注册的监听回调, /// 负责跳转 /login);并发的后续 401 只通知一次,直至新会话写入。 /// 登录端点自身的 401(凭据错误)与修改密码端点的 401(旧密码错误) /// 不是会话过期语义,不触发会话驱逐。 /// - 错误归一:HTTP 错误体 `{code, message, requestId}` 与网络层失败一律 /// 抛 [AppException](码表见 [ErrorCodes];l10n 文案解析留在 UI 层)。 library; import 'dart:io' show File, Platform; 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); /// 登录端点:不附加令牌(未认证端点)。 static bool _isLoginPath(String path) => path.endsWith('/v1/auth/password'); /// 401 驱逐豁免:登录(凭据错误)与修改密码(旧密码错误)的 401 都不是 /// 会话过期语义,不得清会话/触发跳登录;其余端点 401 一律驱逐。 static bool _evictsSessionOn401(String path) => !_isLoginPath(path) && !path.endsWith('/v1/auth/password-change'); // ── 拦截器 ── Future _attachSession( RequestOptions options, RequestInterceptorHandler handler, ) async { try { if (!_isLoginPath(options.path)) { final session = await _session.read(); if (session != null && session.token.isNotEmpty && !options.headers.containsKey('Authorization')) { options.headers['Authorization'] = 'Bearer ${session.token}'; } } } on Object { // 存储读取失败不阻断请求:按未登录裸发,由服务端 401 裁决。 } finally { handler.next(options); } } Future _handleError( DioException e, ErrorInterceptorHandler handler, ) async { if (e.response?.statusCode == 401 && _evictsSessionOn401(e.requestOptions.path)) { try { // 先同步置位去重标记再做异步清除:并发 401 下仅首个触发通知。 final shouldNotify = !_unauthorizedNotified; _unauthorizedNotified = true; await _session.clear(); if (shouldNotify) { onUnauthorized?.call(); } } on Object { // 会话清除/通知失败不阻断错误传播(下次 401 仍会重试清除)。 } } handler.next(e); } // ── 错误映射 ── /// DioException → [AppException](公开静态,供表驱动单测直接调用)。 /// /// 优先取错误响应体 `{code, message, requestId}`(契约 /// `components.schemas.Error`);无错误体的响应按契约通用兜底 /// (openapi.yaml info.description:401→AUTH_TOKEN_EXPIRED、 /// 500→INTERNAL_ERROR);网络层失败映射客户端伪码(NETWORK_* 等)。 static AppException mapDioException(DioException e) { final response = e.response; if (response != null) { final status = response.statusCode ?? 0; final data = response.data; if (data is Map) { final Object? code = data['code']; final Object? message = data['message']; final Object? requestId = data['requestId']; if (code is String && message is String) { return AppException( code: code, message: message, requestId: requestId is String ? requestId : null, statusCode: status, ); } } if (status == 401) { return const AppException( code: ErrorCodes.authTokenExpired, message: '未认证或会话已过期', statusCode: 401, ); } if (status >= 500) { return AppException( code: ErrorCodes.internalError, message: '服务器内部错误', statusCode: status, ); } return AppException( code: ErrorCodes.httpError, message: 'HTTP $status 且无契约错误体', statusCode: status, ); } switch (e.type) { case DioExceptionType.connectionTimeout: case DioExceptionType.sendTimeout: case DioExceptionType.receiveTimeout: case DioExceptionType.transformTimeout: return const AppException( code: ErrorCodes.networkTimeout, message: '网络超时', ); case DioExceptionType.cancel: return const AppException( code: ErrorCodes.cancelled, message: '请求已取消', ); case DioExceptionType.connectionError: return const AppException( code: ErrorCodes.networkError, message: '网络连接失败', ); case DioExceptionType.badCertificate: return const AppException( code: ErrorCodes.networkError, message: '证书校验失败', ); case DioExceptionType.badResponse: // 有响应必经上方分支,防御性兜底。 case DioExceptionType.unknown: return AppException( code: ErrorCodes.networkError, message: e.message ?? '未知网络错误', ); } } /// 统一发送:DioException 归一为 AppException 后抛出。 Future _send(Future Function() run) async { try { return await run(); } on DioException catch (e) { throw mapDioException(e); } } // ── auth(契约 tag: auth)── /// operationId `loginWithPassword`(POST /auth/password)。 /// /// 成功后将会话(令牌/过期时间/账号回显)写入 [SessionStore]。 Future login(String account, String password) async { final resp = await _send( () => _dio.post>( '/v1/auth/password', data: {'account': account, 'password': password}, ), ); final result = LoginResponse.fromJson(resp.data!); await _session.save( Session( token: result.sessionToken, expiresAt: result.expiresAt, account: account, ), ); _unauthorizedNotified = false; return result; } /// operationId `refreshSession`(POST /auth/refresh):令牌不变, /// 滑动续期过期时间——同步回写 [SessionStore]。 Future refresh() async { final resp = await _send( () => _dio.post>('/v1/auth/refresh'), ); final result = RefreshResponse.fromJson(resp.data!); final current = await _session.read(); if (current != null) { await _session.save( Session( token: current.token, expiresAt: result.expiresAt, account: current.account, ), ); } return result; } /// operationId `logout`(POST /auth/logout)。尽力而为:服务端撤销 /// 失败不阻塞本地退出,本地会话恒被清除。 Future logout() async { try { await _send(() => _dio.post('/v1/auth/logout')); } on AppException { // 尽力而为(计划 Task 3 语义):错误吞掉,本地照常登出。 } finally { await _session.clear(); _unauthorizedNotified = false; } } /// operationId `changePassword`(POST /auth/password-change → 204, /// P3 Task 3 契约增补)。 /// /// 服务端成功后撤销其他会话、**保留当前会话**——本地令牌继续有效, /// 无需重登录。旧密码错误 → 401 AUTH_INVALID_CREDENTIALS(不驱逐会话, /// 见 [_evictsSessionOn401])。 Future changePassword(String oldPassword, String newPassword) => _send( () => _dio.post( '/v1/auth/password-change', data: {'oldPassword': oldPassword, 'newPassword': newPassword}, ), ); // ── albums / members / categories / admin ── /// operationId `listAlbums`(GET /albums,游标分页)。 Future> listAlbums({String? cursor, int? limit}) => _getPage('/v1/albums', AlbumSummary.fromJson, cursor: cursor, limit: limit); /// 资产上传凭证(契约 AssetInitResponse)。 /// kind:cover | banner | logo;凭据结构与照片 presign 相同。 static const assetKindCover = 'cover'; static const assetKindBanner = 'banner'; Future<({String assetId, UploadCredential credential})> initAlbumAsset(String albumId, { required String kind, required String filename, required int bytes, required String contentType, }) async { final resp = await _send( () => _dio.post>('/v1/albums/$albumId/assets', data: { 'kind': kind, 'filename': filename, 'bytes': bytes, 'contentType': contentType, }), ); final data = resp.data!; return ( assetId: data['assetId'] as String, credential: UploadCredential.fromJson(data['credential'] as Map), ); } /// 完成资产上传并激活(返回短期签名 URL)。 Future completeAlbumAsset(String albumId, String assetId) async { final resp = await _send( () => _dio.post>( '/v1/albums/$albumId/assets/$assetId/complete', ), ); return resp.data!['url'] as String; } /// 一步上传相册封面:选图 → presign → PUT → complete。 /// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。 /// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。 Future uploadAlbumCoverFromFile(String albumId, String filePath) async { final file = File(filePath); final bytes = await file.length(); final filename = filePath.split(Platform.pathSeparator).last; final contentType = _guessContentType(filename); final init = await initAlbumAsset(albumId, kind: assetKindCover, filename: filename, bytes: bytes, contentType: contentType); final putRes = await Dio().put( init.credential.url, data: file.openRead(), options: Options(headers: { ...init.credential.headers, 'Content-Length': bytes, }), ); if (putRes.statusCode != 200 && putRes.statusCode != 204) { throw Exception('cover PUT failed: ${putRes.statusCode}'); } return completeAlbumAsset(albumId, init.assetId); } String _guessContentType(String filename) { final ext = filename.split('.').last.toLowerCase(); return switch (ext) { 'jpg' || 'jpeg' => 'image/jpeg', 'png' => 'image/png', 'heic' => 'image/heic', _ => 'image/jpeg', }; } /// operationId `createAlbum`(POST /albums → 201)。 Future createAlbum(AlbumCreateRequest req) async { final resp = await _send( () => _dio.post>('/v1/albums', data: req.toJson()), ); return Album.fromJson(resp.data!); } /// operationId `getAlbum`(GET /albums/{albumId})。 Future getAlbum(String albumId) async { final resp = await _send( () => _dio.get>('/v1/albums/$albumId'), ); return Album.fromJson(resp.data!); } /// operationId `updateAlbum`(PATCH /albums/{albumId},部分更新)。 Future updateAlbum(String albumId, AlbumUpdateRequest patch) async { final resp = await _send( () => _dio.patch>( '/v1/albums/$albumId', data: patch.toJson(), ), ); return Album.fromJson(resp.data!); } /// operationId `addAlbumMember`(POST /albums/{albumId}/members → 201)。 /// /// role 省略:契约缺省 collaborator(P1a 唯一可添加角色)。 Future addMember(String albumId, String userId) async { final resp = await _send( () => _dio.post>( '/v1/albums/$albumId/members', data: {'userId': userId}, ), ); return Member.fromJson(resp.data!); } /// operationId `listAlbumMembers`(GET /albums/{albumId}/members)。 Future> listMembers( String albumId, { String? cursor, int? limit, }) => _getPage( '/v1/albums/$albumId/members', Member.fromJson, cursor: cursor, limit: limit, ); /// operationId `removeAlbumMember` /// (DELETE /albums/{albumId}/members/{userId} → 204,目标不存在幂等)。 Future removeMember(String albumId, String userId) => _send(() => _dio.delete('/v1/albums/$albumId/members/$userId')); /// operationId `lookupUsersByAccount` /// (GET /admin/users?accountPrefix=,无游标,按账号升序)。 Future> lookupUsers( String accountPrefix, { int? limit, }) async { final resp = await _send( () => _dio.get>( '/v1/admin/users', queryParameters: { 'accountPrefix': accountPrefix, 'limit': ?limit, }, ), ); final items = resp.data!['items'] as List; return items .map( (dynamic e) => AdminUserLookupItem.fromJson( e as Map, ), ) .toList(); } /// operationId `createCategory` /// (POST /albums/{albumId}/categories → 201)。 Future createCategory( String albumId, CategoryCreateRequest req, ) async { final resp = await _send( () => _dio.post>( '/v1/albums/$albumId/categories', data: req.toJson(), ), ); return Category.fromJson(resp.data!); } /// operationId `updateCategory`(PATCH /categories/{categoryId})。 Future updateCategory( String categoryId, CategoryUpdateRequest patch, ) async { final resp = await _send( () => _dio.patch>( '/v1/categories/$categoryId', data: patch.toJson(), ), ); return Category.fromJson(resp.data!); } /// operationId `deleteCategory`(DELETE /categories/{categoryId} → 204)。 Future deleteCategory(String categoryId) => _send(() => _dio.delete('/v1/categories/$categoryId')); // ── uploads(契约 tag: uploads)── /// operationId `initUpload`(POST /uploads → 201,clientTaskId 幂等)。 Future initUpload(UploadInitRequest req) async { final resp = await _send( () => _dio.post>('/v1/uploads', data: req.toJson()), ); return UploadInitResponse.fromJson(resp.data!); } /// operationId `renewUploadCredential` /// (POST /uploads/{uploadId}/renew,结构同初始化响应)。 Future renewUpload(String uploadId) async { final resp = await _send( () => _dio.post>('/v1/uploads/$uploadId/renew'), ); return UploadInitResponse.fromJson(resp.data!); } /// operationId `completeUpload` /// (POST /uploads/{uploadId}/complete,幂等核验)。 Future completeUpload(String uploadId) async { final resp = await _send( () => _dio.post>('/v1/uploads/$uploadId/complete'), ); return UploadCompleteResponse.fromJson(resp.data!); } /// operationId `listUploads`(GET /uploads,跨设备会话历史,新在前)。 Future> listUploads({ String? cursor, int? limit, }) => _getPage( '/v1/uploads', UploadSessionRecord.fromJson, cursor: cursor, limit: limit, ); // ── photos(契约 tag: photos)── /// operationId `listAlbumPhotos` /// (GET /albums/{albumId}/photos,管理列表,全状态可见)。 /// /// [scope](UI-01):缺省/null=服务端默认 all(仅 owner/admin,协作者 /// 403 AUTH_FORBIDDEN);[AlbumPhotoScope.own]=仅自己上传(任何有效成员, /// 服务端过滤)。页面层据此做「all 403 → own」自适应回退。 Future> listAlbumPhotos( String albumId, { PhotoVisibility? visibility, String? categoryId, AlbumPhotoScope? scope, String? cursor, int? limit, }) => _getPage( '/v1/albums/$albumId/photos', AdminPhoto.fromJson, cursor: cursor, limit: limit, extraQuery: { if (visibility != null) 'visibility': visibility.wire, 'categoryId': ?categoryId, if (scope != null) 'scope': scope.wire, }, ); /// operationId `updatePhoto`(PATCH /photos/{photoId}):仅允许 /// hidden ↔ published 切换,非法取值本地即抛 ArgumentError。 Future updatePhotoVisibility( String photoId, PhotoVisibility visibility, ) async { if (visibility != PhotoVisibility.hidden && visibility != PhotoVisibility.published) { throw ArgumentError( 'updatePhotoVisibility 仅允许 hidden/published,收到 ' '${visibility.wire}', ); } final resp = await _send( () => _dio.patch>( '/v1/photos/$photoId', data: {'visibility': visibility.wire}, ), ); return PhotoRecord.fromJson(resp.data!); } /// operationId `deletePhoto`(DELETE /photos/{photoId} → 204 软删除)。 Future deletePhoto(String photoId) => _send(() => _dio.delete('/v1/photos/$photoId')); // ── 通用游标分页 ── /// 通用游标页请求:cursor/limit 仅在提供时下发,[extraQuery] 携带 /// 领域过滤参数。 Future> _getPage( String path, T Function(Map) fromJson, { String? cursor, int? limit, Map? extraQuery, }) async { final resp = await _send( () => _dio.get>( path, queryParameters: { 'cursor': ?cursor, 'limit': ?limit, ...?extraQuery, }, ), ); return CursorPage.parse(resp.data!, fromJson); } } /// 会话存储 Provider(生产实现:flutter_secure_storage)。 final sessionStoreProvider = Provider((ref) => SecureSessionStore()); /// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized] /// 跳转 /login(命名路由,见 lib/app/app.dart)。 final apiClientProvider = Provider((ref) { final client = ApiClient(sessionStore: ref.watch(sessionStoreProvider)); ref.onDispose(client.close); return client; });