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
+52
View File
@@ -509,4 +509,56 @@ describe('public-ov (e2e)', () => {
expect(enable.status).toBe(200);
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
});
it('⑥ banner 回退 cover(asset.updated kind=cover 投影 coverAssetKey + ov 读侧回退)', async () => {
// cn 真实链路:建相册 → cover 资产 init/PUT/complete(真实 asset.updated 事件)。
const album = await request(cnBase)
.post('/v1/albums')
.set('Authorization', `Bearer ${adminToken}`)
.send({
translations: [{ locale: 'zh-CN', title: '仅封面 ov 相册' }],
defaultLocale: 'zh-CN',
startAt: '2026-10-02T10:00:00.000Z',
});
expect(album.status).toBe(201);
const token = album.body.publicToken as string;
const coverJpeg = await makeJpeg(960, 320, { r: 90, g: 130, b: 180 });
const init = await request(cnBase)
.post(`/v1/albums/${album.body.id as string}/assets`)
.set('Authorization', `Bearer ${adminToken}`)
.send({
kind: 'cover',
filename: 'cover.jpg',
bytes: coverJpeg.length,
contentType: 'image/jpeg',
});
expect(init.status).toBe(201);
const credential = init.body.credential as { url: string; method: string; headers: Record<string, string> };
await fetch(credential.url, {
method: credential.method,
headers: credential.headers,
body: new Uint8Array(coverJpeg),
});
const done = await request(cnBase)
.post(`/v1/albums/${album.body.id as string}/assets/${init.body.assetId as string}/complete`)
.set('Authorization', `Bearer ${adminToken}`);
expect(done.status).toBe(200);
// 真实发送管线:asset.updated(cover) → ov coverAssetKey 投影。
await drainSender();
// ov 读侧回退:bannerAssetKey 空 → coverAssetKey 签发(字节=cover 对象)。
const before = await prismaOv.albums_ov.findUnique({ where: { publicToken: token } });
expect(before?.bannerAssetKey).toBeNull();
expect(before?.coverAssetKey).toBeTruthy();
const ov = await getJson(ovBase, `/v1/public/albums/${token}`);
expect(ov.status).toBe(200);
const banner = ov.body.banner as { url: 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');
});
});