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
+29
View File
@@ -255,6 +255,7 @@ describe('public (e2e)', () => {
let disabledToken: string;
let bannerToken: string;
let noBannerToken: string;
let coverOnlyToken: string;
let catAId: string;
let catBId: string;
let catZhOnlyId: string;
@@ -342,6 +343,24 @@ describe('public (e2e)', () => {
translations: [{ locale: 'zh-CN', title: '无封面相册' }],
});
noBannerToken = noBanner.publicToken;
// 宣传图/封面共用(2026-09-23 决策):仅 cover 相册 → banner 回退 cover。
const coverOnly = await seedAlbum({
alias: 'pub-coveronly',
translations: [{ locale: 'zh-CN', title: '仅封面相册' }],
});
coverOnlyToken = coverOnly.publicToken;
const coverOnlyKey = `${KEY_ROOT}/albums/${coverOnly.id}/assets/cover.jpg`;
await storage.put(coverOnlyKey, await makeJpeg(900, 300), 'image/jpeg');
await prisma.album_assets.create({
data: {
albumId: coverOnly.id,
kind: 'cover',
locale: null,
objectKey: coverOnlyKey,
state: 'effective',
},
});
});
it('locale=en → 200 translation=en 内容且 resolvedLocale=en;categories 同语言解析且按 sortOrder 升序', async () => {
@@ -446,6 +465,16 @@ describe('public (e2e)', () => {
expect(without.body.banner).toBeNull();
});
it('banner 回退 cover(宣传图/封面共用:仅 cover 相册 → banner=cover 对象)', async () => {
const res = await getPublic(coverOnlyToken);
expect(res.status).toBe(200);
const banner = res.body.banner as { url: string; expiresAt: string };
expect(banner).not.toBeNull();
const fetched = await fetch(banner.url);
expect(fetched.status).toBe(200);
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
});
it('viewCount P1a 占位 null;startAt/endAt ISO + timezone 返回', async () => {
const res = await getPublic(mainToken);
expect(res.status).toBe(200);