feat(albums): soft-delete albums (admin+owner, hidden & irreversible, both app entries)
契约 deleteAlbum(DELETE /albums/{albumId} → 204 幂等)+ status enum 'deleted':
- 服务端:softDelete(canView 404→canManage 403 防枚举;乐观锁 claim +
outbox album.updated priority=high + audit album.delete);列表/详情/更新/
成员/资产/分类/水印/照片/展示位绑定全部 deleted→404;公开端 cn
(status!=='active')与 ov(isAlbumVisibleOv)天然屏蔽;ov 副本经
album.updated 载荷 status 直投自动下线
- Admin:列表表格操作「删除」+ 详情页头「删除相册」(确认含不可恢复说明)
- App:操作面板「删除相册」(红字、二次确认、成功退回列表并刷新)
- 验证:API 56/56、App 397/397(analyze 0)、admin typecheck/lint/test/build 绿
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -33,7 +33,8 @@ class ApiClient {
|
||||
String baseUrl = ApiConfig.baseUrl,
|
||||
Dio? dio,
|
||||
}) : _session = sessionStore {
|
||||
_dio = dio ??
|
||||
_dio =
|
||||
dio ??
|
||||
Dio(
|
||||
BaseOptions(
|
||||
baseUrl: baseUrl,
|
||||
@@ -68,8 +69,7 @@ class ApiClient {
|
||||
void close({bool force = false}) => _dio.close(force: force);
|
||||
|
||||
/// 登录端点:不附加令牌(未认证端点)。
|
||||
static bool _isLoginPath(String path) =>
|
||||
path.endsWith('/v1/auth/password');
|
||||
static bool _isLoginPath(String path) => path.endsWith('/v1/auth/password');
|
||||
|
||||
/// 401 驱逐豁免:登录(凭据错误)与修改密码(旧密码错误)的 401 都不是
|
||||
/// 会话过期语义,不得清会话/触发跳登录;其余端点 401 一律驱逐。
|
||||
@@ -175,10 +175,7 @@ class ApiClient {
|
||||
message: '网络超时',
|
||||
);
|
||||
case DioExceptionType.cancel:
|
||||
return const AppException(
|
||||
code: ErrorCodes.cancelled,
|
||||
message: '请求已取消',
|
||||
);
|
||||
return const AppException(code: ErrorCodes.cancelled, message: '请求已取消');
|
||||
case DioExceptionType.connectionError:
|
||||
return const AppException(
|
||||
code: ErrorCodes.networkError,
|
||||
@@ -270,44 +267,53 @@ class ApiClient {
|
||||
/// 服务端成功后撤销其他会话、**保留当前会话**——本地令牌继续有效,
|
||||
/// 无需重登录。旧密码错误 → 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},
|
||||
),
|
||||
);
|
||||
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);
|
||||
_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, {
|
||||
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,
|
||||
}),
|
||||
() => _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>),
|
||||
credential: UploadCredential.fromJson(
|
||||
data['credential'] as Map<String, dynamic>,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -324,23 +330,27 @@ class ApiClient {
|
||||
/// 一步上传相册封面:选图 → presign → PUT → complete。
|
||||
/// [filePath] 是本机源文件路径(由 singleImagePicker 返回)。
|
||||
/// 返回封面短期 URL(仅用于即时预览;列表走 AlbumSummary.cover)。
|
||||
Future<String> uploadAlbumCoverFromFile(String albumId, String filePath) async {
|
||||
Future<String> 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 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,
|
||||
}),
|
||||
options: Options(
|
||||
headers: {...init.credential.headers, 'Content-Length': bytes},
|
||||
),
|
||||
);
|
||||
if (putRes.statusCode != 200 && putRes.statusCode != 204) {
|
||||
throw Exception('cover PUT failed: ${putRes.statusCode}');
|
||||
@@ -385,6 +395,13 @@ class ApiClient {
|
||||
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 唯一可添加角色)。
|
||||
@@ -403,13 +420,12 @@ class ApiClient {
|
||||
String albumId, {
|
||||
String? cursor,
|
||||
int? limit,
|
||||
}) =>
|
||||
_getPage(
|
||||
'/v1/albums/$albumId/members',
|
||||
Member.fromJson,
|
||||
cursor: cursor,
|
||||
limit: limit,
|
||||
);
|
||||
}) => _getPage(
|
||||
'/v1/albums/$albumId/members',
|
||||
Member.fromJson,
|
||||
cursor: cursor,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
/// operationId `removeAlbumMember`
|
||||
/// (DELETE /albums/{albumId}/members/{userId} → 204,目标不存在幂等)。
|
||||
@@ -425,18 +441,14 @@ class ApiClient {
|
||||
final resp = await _send(
|
||||
() => _dio.get<Map<String, dynamic>>(
|
||||
'/v1/admin/users',
|
||||
queryParameters: {
|
||||
'accountPrefix': accountPrefix,
|
||||
'limit': ?limit,
|
||||
},
|
||||
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>,
|
||||
),
|
||||
(dynamic e) =>
|
||||
AdminUserLookupItem.fromJson(e as Map<String, dynamic>),
|
||||
)
|
||||
.toList();
|
||||
}
|
||||
@@ -506,13 +518,12 @@ class ApiClient {
|
||||
Future<CursorPage<UploadSessionRecord>> listUploads({
|
||||
String? cursor,
|
||||
int? limit,
|
||||
}) =>
|
||||
_getPage(
|
||||
'/v1/uploads',
|
||||
UploadSessionRecord.fromJson,
|
||||
cursor: cursor,
|
||||
limit: limit,
|
||||
);
|
||||
}) => _getPage(
|
||||
'/v1/uploads',
|
||||
UploadSessionRecord.fromJson,
|
||||
cursor: cursor,
|
||||
limit: limit,
|
||||
);
|
||||
|
||||
// ── photos(契约 tag: photos)──
|
||||
|
||||
@@ -529,18 +540,17 @@ class ApiClient {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}) => _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。
|
||||
@@ -582,11 +592,7 @@ class ApiClient {
|
||||
final resp = await _send(
|
||||
() => _dio.get<Map<String, dynamic>>(
|
||||
path,
|
||||
queryParameters: {
|
||||
'cursor': ?cursor,
|
||||
'limit': ?limit,
|
||||
...?extraQuery,
|
||||
},
|
||||
queryParameters: {'cursor': ?cursor, 'limit': ?limit, ...?extraQuery},
|
||||
),
|
||||
);
|
||||
return CursorPage.parse(resp.data!, fromJson);
|
||||
@@ -594,8 +600,9 @@ class ApiClient {
|
||||
}
|
||||
|
||||
/// 会话存储 Provider(生产实现:flutter_secure_storage)。
|
||||
final sessionStoreProvider =
|
||||
Provider<SessionStore>((ref) => SecureSessionStore());
|
||||
final sessionStoreProvider = Provider<SessionStore>(
|
||||
(ref) => SecureSessionStore(),
|
||||
);
|
||||
|
||||
/// API 客户端 Provider。App 壳启动时注册 [ApiClient.onUnauthorized]
|
||||
/// 跳转 /login(命名路由,见 lib/app/app.dart)。
|
||||
|
||||
Reference in New Issue
Block a user