f6929ae18f
Co-Authored-By: Claude Code <noreply@anthropic.com>
531 lines
18 KiB
Dart
531 lines
18 KiB
Dart
/// 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 '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<void> _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<void> _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<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;
|
||
}
|
||
}
|
||
|
||
/// operationId `changePassword`(POST /auth/password-change → 204,
|
||
/// P3 Task 3 契约增补)。
|
||
///
|
||
/// 服务端成功后撤销其他会话、**保留当前会话**——本地令牌继续有效,
|
||
/// 无需重登录。旧密码错误 → 401 AUTH_INVALID_CREDENTIALS(不驱逐会话,
|
||
/// 见 [_evictsSessionOn401])。
|
||
Future<void> changePassword(String oldPassword, String newPassword) =>
|
||
_send(
|
||
() => _dio.post<void>(
|
||
'/v1/auth/password-change',
|
||
data: {'oldPassword': oldPassword, 'newPassword': newPassword},
|
||
),
|
||
);
|
||
|
||
// ── 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,管理列表,全状态可见)。
|
||
///
|
||
/// [scope](UI-01):缺省/null=服务端默认 all(仅 owner/admin,协作者
|
||
/// 403 AUTH_FORBIDDEN);[AlbumPhotoScope.own]=仅自己上传(任何有效成员,
|
||
/// 服务端过滤)。页面层据此做「all 403 → own」自适应回退。
|
||
Future<CursorPage<AdminPhoto>> 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<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;
|
||
});
|