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:
@@ -264,6 +264,14 @@ describe('embed slots (e2e)', () => {
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
// UI-04 新字段(旧形状创建 → 归一化默认:carousel/16/true)。
|
||||
imageFit: 'contain',
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
},
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
@@ -294,6 +302,9 @@ describe('embed slots (e2e)', () => {
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
});
|
||||
|
||||
// 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。
|
||||
@@ -794,6 +805,9 @@ describe('embed slots (e2e)', () => {
|
||||
offsetX: 40,
|
||||
offsetBottom: 80,
|
||||
zIndex: 5000,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
});
|
||||
|
||||
// 新字段校验:非法枚举/越界/非整数 → 400(老字段约束不回归)。
|
||||
@@ -845,6 +859,9 @@ describe('embed slots (e2e)', () => {
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
};
|
||||
expect(patched.body.settings).toEqual(normalizedFull);
|
||||
|
||||
@@ -868,6 +885,9 @@ describe('embed slots (e2e)', () => {
|
||||
offsetX: 8,
|
||||
offsetBottom: 120,
|
||||
zIndex: 999,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
};
|
||||
expect(explicit.body.settings).toEqual(explicitFull);
|
||||
|
||||
@@ -911,6 +931,9 @@ describe('embed slots (e2e)', () => {
|
||||
offsetX: 16,
|
||||
offsetBottom: 24,
|
||||
zIndex: 1000,
|
||||
displayMode: 'carousel',
|
||||
scrollSpeed: 16,
|
||||
reactionsEnabled: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -624,4 +624,45 @@ describe('UI-01 契约扩展:cover / preview / scope=own (e2e)', () => {
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('cover 回退 banner(宣传图/封面共用:仅 banner 相册 → cover=banner 对象)', async () => {
|
||||
// 直种仅 banner 相册(复用本文件资产直种模式)。
|
||||
const album = await prisma.albums.create({
|
||||
data: {
|
||||
publicToken: makePublicToken(),
|
||||
ownerId: 'u-ui01-coverfb',
|
||||
startAt: new Date('2026-10-03T02:00:00.000Z'),
|
||||
endAt: new Date('2026-10-03T12:00:00.000Z'),
|
||||
defaultLocale: 'zh-CN',
|
||||
},
|
||||
});
|
||||
await prisma.album_translations.create({
|
||||
data: { albumId: album.id, locale: 'zh-CN', title: '仅宣传图相册' },
|
||||
});
|
||||
const key = `photo-live/dev/albums/${album.id}/assets/u01assetbannerfb00000000.jpg`;
|
||||
await putObject(key, Buffer.from([0x51, 0x52, 0x53]));
|
||||
await prisma.album_assets.create({
|
||||
data: {
|
||||
id: 'u01assetbannerfb00000000',
|
||||
albumId: album.id,
|
||||
kind: 'banner',
|
||||
locale: null,
|
||||
objectKey: key,
|
||||
state: 'effective',
|
||||
expectedBytes: 3,
|
||||
contentType: 'image/jpeg',
|
||||
},
|
||||
});
|
||||
|
||||
const res = await listAlbums(adminToken);
|
||||
expect(res.status).toBe(200);
|
||||
const item = (res.body.items as Array<{ id: string; cover: { url: string } | null }>).find(
|
||||
(row) => row.id === album.id,
|
||||
);
|
||||
expect(item).toBeDefined();
|
||||
expect(item?.cover).not.toBeNull();
|
||||
const fetched = await fetch(item!.cover!.url);
|
||||
expect(fetched.status).toBe(200);
|
||||
expect(Buffer.from(await fetched.arrayBuffer()).length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user