0fa23e015e
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>
715 lines
24 KiB
Dart
715 lines
24 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 '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;
|
||
});
|