Files
photo-live/apps/mobile/lib/core/api_client.dart
T
浅念 8be21b856b feat(mobile): login, password change and logout screens
P3 Task 3 移动端(对接 Task 2 api client + 本任务新增的后端契约):

- 登录页实装:账号/密码表单(空值客户端校验)→ api.login(会话自动
  持久化)→ pushReplacement /home;错误码本地化(AUTH_INVALID_
  CREDENTIALS / AUTH_RATE_LIMITED(含稍后再试文案)/ 网络 / 校验);
  提交加载态防重复提交。
- 修改密码页(设置页入口):当前/新/确认三字段,客户端校验(≥8 位、
  两次一致)→ api.changePassword → toast + pop;成功后本地会话不变
  (服务端保留当前会话);旧密码错误(401 AUTH_INVALID_CREDENTIALS)
  显示「当前密码错误」且不触发 401 驱逐。
- 退出登录:设置页按钮 → api.logout 尽力而为 → 清会话 → 清栈回
  /login;App 壳 initState 注册 onUnauthorized(业务 401 → 清栈 /login),
  根 navigatorKey 接线;password-change 的 401 豁免会话驱逐(拦截器
  豁免集合与令牌附加分离)。
- l10n:新增 16 组 zh/en 文案;错误码 → 文案解析扩展
  (core/error_l10n.dart,errorCode→AppLocalizations)。
- 测试:ApiClient.changePassword 单测 ×2(Bearer+请求体;旧密码 401
  不驱逐);widget 测试 ×12(登录 5:空值/成功跳转/凭据错误/限速文案/
  加载禁用;改密 4:过短/不一致/旧密码错误文案+会话保留/成功 toast+pop
  且令牌不变;设置 2:改密入口跳转/登出清会话回登录;App 壳 1:401
  清栈回 /login)。flutter analyze 0 issue;flutter test 81/81。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-19 01:19:57 +08:00

525 lines
17 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(凭据错误)与修改密码端点的 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,管理列表,全状态可见)。
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;
});