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:
浅念
2026-09-23 10:13:08 +08:00
parent 9cb960c27a
commit 8e674a902e
31 changed files with 995 additions and 258 deletions
+81 -74
View File
@@ -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)。