Files
photo-live/apps/mobile/lib/core/api_client.dart
T
浅念 0fa23e015e feat(app): server-side duplicate pre-check at enqueue moment
Task 2 of docs/plans/2026-09-28-上传查重前置到入队前-实施计划.md:
surface server-side duplicate history at the "click upload" moment,
merged with the local check into the EXISTING dialog.

- ApiClient.checkUploadDuplicates: POST /v1/uploads/duplicates/check
  (commit 805f77b endpoint); 500/chunk sequential requests; chunk-local
  index translated to global; empty list short-circuits (no request);
  errors normalized to AppException via _send.
- enqueue.checkDuplicatesUnified: local TaskStore history ∪ server
  history; server hits map back to filename via global index into the
  stat'd picks; merged, de-duplicated (filename+bytes) list; server
  failure (network/permission/any) silently degrades to local-only —
  init 409 remains the backstop.
- capture_page + album_detail_page _pickFromGallery now call the
  unified entry; dialog/skip/force logic untouched.

Tests: api_client chunking (1200 → 500/500/200) + index translation +
merge + 404 normalization + empty no-request; page-level three states
(hit/no-hit/network failure) incl. fresh-install server-only-hit dialog
on both pages; pre-existing Task 8 dialog tests keep passing via the
degrade path.

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

715 lines
24 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 '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';
/// 查重预检请求条目(契约 UploadDuplicateCheckItem;filename 仅元数据——
/// 服务端查重谓词只看 bytes,与 initUpload ③.5 逐字一致)。
typedef UploadDuplicateCheckItem = ({String filename, int bytes});
/// 查重预检命中(契约 UploadDuplicateCheckDuplicate;index 已从分块局部
/// 下标换算为对入参列表的全局下标)。
typedef UploadDuplicateHit = ({int index, int bytes, DateTime uploadedAt});
/// 单批查重条目上限(契约 items maxItems=500;客户端按此分块顺序调用)。
const int kUploadDuplicateCheckChunkSize = 500;
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) {
// P0-1 服务端查重拒绝(Task 8):409 的 wire code 复用
// VALIDATION_FAILED,按 message=PHOTO_DUPLICATE 识别归一——所有
// 调用方(上传引擎分类/l10n 文案)据此走查重分支;契约日后若设
// 独立 code,此处识别与透传均兼容。
if (status == 409 && message.contains(ErrorCodes.photoDuplicate)) {
return AppException(
code: ErrorCodes.photoDuplicate,
message: message,
requestId: requestId is String ? requestId : null,
statusCode: status,
);
}
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,
);
/// 资产上传凭证(契约 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<Map<String, dynamic>>(
'/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<String, dynamic>,
),
);
}
/// 完成资产上传并激活(返回短期签名 URL)。
Future<String> completeAlbumAsset(String albumId, String assetId) async {
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/albums/$albumId/assets/$assetId/complete',
),
);
return resp.data!['url'] as String;
}
/// 一步上传相册封面:选图 → presign → PUT → complete。
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
Future<String> uploadAlbumCoverFromFile(String albumId, String filePath) =>
uploadAlbumAssetFromFile(
albumId,
kind: assetKindCover,
filePath: filePath,
);
/// 资产现状(listAlbumAssetStatus):指定 kind 的全局行(locale null)URL;
/// 未设置返回 null。banner 预览用(语言行不区分——编辑页只管全局)。
Future<String?> fetchAlbumAssetUrl(
String albumId,
String kind, {
String? locale,
}) async {
final resp = await _send(
() => _dio.get<Map<String, dynamic>>('/v1/albums/$albumId/assets'),
);
final items = (resp.data!['items'] as List<dynamic>)
.cast<Map<String, dynamic>>();
for (final item in items) {
if (item['kind'] == kind && item['locale'] == locale) {
return item['url'] as String;
}
}
return null;
}
/// 一步上传相册资产(banner/cover/logo):选图 → presign → PUT → complete。
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
/// 返回资产短期 URL(仅用于即时预览;现状经 [fetchAlbumAssetUrl] 查询)。
Future<String> uploadAlbumAssetFromFile(
String albumId, {
required String kind,
required 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: kind,
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<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 `deleteAlbum`(DELETE /albums/{albumId} → 204 软删除)。
///
/// status=deleted:列表/公开端即刻不可见;数据保留,无恢复端点。
Future<void> deleteAlbum(String albumId) async {
await _send(() => _dio.delete<dynamic>('/v1/albums/$albumId'));
}
/// 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,
);
/// operationId `checkUploadDuplicates`
/// (POST /uploads/duplicates/check,批量只读预检)。
///
/// 按 [kUploadDuplicateCheckChunkSize] 顺序分块调用(契约单批上限 500;
/// 块间串行防瞬时并发放大);各块命中的分块局部下标换算为对 [items] 的
/// 全局下标后合并返回。空列表直接返回空(契约 items minItems=1,不发
/// 请求)。错误经 [_send] 归一为 [AppException](404/403/400/401 见契约)。
Future<List<UploadDuplicateHit>> checkUploadDuplicates(
String albumId,
List<UploadDuplicateCheckItem> items,
) async {
if (items.isEmpty) return const <UploadDuplicateHit>[];
final hits = <UploadDuplicateHit>[];
for (
var start = 0;
start < items.length;
start += kUploadDuplicateCheckChunkSize
) {
final chunk = items
.skip(start)
.take(kUploadDuplicateCheckChunkSize)
.toList();
final resp = await _send(
() => _dio.post<Map<String, dynamic>>(
'/v1/uploads/duplicates/check',
data: {
'albumId': albumId,
'items': [
for (final item in chunk)
{'filename': item.filename, 'bytes': item.bytes},
],
},
),
);
final duplicates = (resp.data!['duplicates'] as List<dynamic>)
.cast<Map<String, dynamic>>();
for (final d in duplicates) {
hits.add((
index: start + (d['index'] as int),
bytes: d['bytes'] as int,
uploadedAt: DateTime.parse(d['uploadedAt'] as String),
));
}
}
return hits;
}
// ── 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;
});