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
@@ -9,7 +9,9 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
@@ -79,6 +81,15 @@ export class AlbumsController {
return this.albumsService.get(userOf(req), albumId);
}
@Delete(':albumId')
@HttpCode(HttpStatus.NO_CONTENT)
deleteAlbum(
@Param('albumId') albumId: string,
@Req() req: AuthenticatedRequest,
): Promise<void> {
return this.albumsService.softDelete(userOf(req), albumId);
}
@Patch(':albumId')
updateAlbum(
@Param('albumId') albumId: string,
+100 -10
View File
@@ -332,7 +332,7 @@ export class AlbumsService {
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
outcome = await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
if (album === null || album.status === 'deleted') {
throw albumNotFound();
}
@@ -627,7 +627,10 @@ export class AlbumsService {
}
}
const where: Prisma.albumsWhereInput = {};
const where: Prisma.albumsWhereInput = {
// 软删除(deleteAlbum):列表不可见(数据保留,运维可恢复)。
status: { not: 'deleted' },
};
if (user.role !== 'admin') {
const memberships = await this.prisma.album_members.findMany({
where: { userId: user.id, status: 'active' },
@@ -664,24 +667,31 @@ export class AlbumsService {
// UI-01 封面摘要:本页相册一次批量查询(kind=cover、state=effective,
// createdAt/id DESC 后每相册首行即最新 effective 行——同 banner 替换语义,
// cover 无 locale 偏好)。presign 为本地计算,循环不产生网络/查询放大。
// 宣传图/封面共用(用户决策 2026-09-23):cover 缺失回退最新 effective
// banner 行(传一张即可两用)——两类合并查询后按 kind 优先 cover。
const covers = await this.prisma.album_assets.findMany({
where: {
albumId: { in: page.map((a) => a.id) },
kind: 'cover',
kind: { in: ['cover', 'banner'] },
state: 'effective',
},
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { albumId: true, objectKey: true },
select: { albumId: true, objectKey: true, kind: true },
});
const coverKeyByAlbum = new Map<string, string>();
const bannerKeyByAlbum = new Map<string, string>();
for (const row of covers) {
if (!coverKeyByAlbum.has(row.albumId)) {
coverKeyByAlbum.set(row.albumId, row.objectKey);
// 各自首见即最新(DESC);合并时 cover 恒优先(两类并存时封面位用 cover)。
if (row.kind === 'cover') {
if (!coverKeyByAlbum.has(row.albumId)) coverKeyByAlbum.set(row.albumId, row.objectKey);
} else if (!bannerKeyByAlbum.has(row.albumId)) {
bannerKeyByAlbum.set(row.albumId, row.objectKey);
}
}
const coverByAlbum = new Map<string, SignedUrlResponse>();
for (const [albumId, objectKey] of coverKeyByAlbum) {
coverByAlbum.set(albumId, await this.presign(objectKey));
for (const albumId of page.map((a) => a.id)) {
const objectKey = coverKeyByAlbum.get(albumId) ?? bannerKeyByAlbum.get(albumId);
if (objectKey !== undefined) coverByAlbum.set(albumId, await this.presign(objectKey));
}
const last = page.at(-1);
@@ -713,11 +723,82 @@ export class AlbumsService {
* (sortOrder 升序;写入入口为 CategoriesService,Task 17 起)。相册域无
* Prisma relation,全部二次查询应用层组装。
*/
/**
* 软删除(契约 deleteAlbum):status=deleted,管理列表/公开端即刻不可见,
* 数据保留(物理删除走独立运维流程),无恢复端点。
* 权限与 update 一致(canView 404 → canManage 403,防枚举)。
* 事务:乐观锁 claim(entityVersion 自增)+ outbox album.updated
* (priority=high——公开可见性类变更,ov 观众侧须尽快下线)+ audit。
* 幂等:已删除重复删除同样 204。
*/
async softDelete(user: AuthUser, albumId: string): Promise<void> {
if (!(await this.permissions.canViewAlbum(user, albumId))) {
throw albumNotFound();
}
if (!(await this.permissions.canManageAlbum(user, albumId))) {
throw albumForbidden('仅创建者或管理员可删除相册');
}
for (let attempt = 0; attempt < 3; attempt++) {
const done = await this.prisma.$transaction(async (tx) => {
const album = await tx.albums.findUnique({ where: { id: albumId } });
if (album === null) {
throw albumNotFound();
}
if (album.status === 'deleted') {
return true as const; // 幂等:重复删除直接成功
}
const claimed = await tx.albums.updateMany({
where: { id: albumId, entityVersion: album.entityVersion },
data: { status: 'deleted', entityVersion: { increment: 1 } },
});
if (claimed.count === 0) {
return false as const; // 并发更新:整事务重跑
}
const updated = await tx.albums.findUniqueOrThrow({
where: { id: albumId },
});
const translations = await tx.album_translations.findMany({
where: { albumId },
orderBy: { locale: 'asc' },
});
// 公开可见性类变更 → high(与公开关闭迁移同优先级)。
await tx.outbox_events.create({
data: {
type: 'album.updated',
albumId,
entityVersion: album.entityVersion + 1,
priority: 'high',
payload: buildAlbumUpdatedPayload(
updated,
translations,
) as unknown as Prisma.InputJsonValue,
},
});
await tx.audit_logs.create({
data: {
actorId: user.id,
action: 'album.delete',
resourceId: albumId,
},
});
return true as const;
});
if (done) return;
// 败方重试(并发窗口极小;3 次后 5xx 兜底与 update 同策略)
}
throw new ApiException(
ErrorCodes.INTERNAL_ERROR,
'Internal server error',
HttpStatus.INTERNAL_SERVER_ERROR,
);
}
private async loadAlbum(albumId: string): Promise<AlbumResponse | null> {
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
});
if (album === null) {
if (album === null || album.status === 'deleted') {
// 已删除与不存在同 null(调用方映射 404;软删除语义)。
return null;
}
const translations = await this.prisma.album_translations.findMany({
@@ -770,11 +851,20 @@ export class AlbumsService {
private async latestCoverOf(
albumId: string,
): Promise<SignedUrlResponse | null> {
const row = await this.prisma.album_assets.findFirst({
// 宣传图/封面共用(用户决策 2026-09-23):cover 缺失回退最新 effective
// banner 行(与列表摘要同语义)。
const cover = await this.prisma.album_assets.findFirst({
where: { albumId, kind: 'cover', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { objectKey: true },
});
const row =
cover ??
(await this.prisma.album_assets.findFirst({
where: { albumId, kind: 'banner', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
select: { objectKey: true },
}));
return row === null ? null : this.presign(row.objectKey);
}
+3 -2
View File
@@ -84,9 +84,10 @@ export class AssetsService {
// 注意不查 uploadEnabled——开关只管照片直传(§4.9),不管资产配置。
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
select: { id: true, status: true },
});
if (album === null) {
if (album === null || album.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
throw albumNotFound();
}
// ④ 大小限额(惰性读 env)与 filename 扩展名白名单(对象键后缀派生)。
@@ -288,10 +288,10 @@ export class CategoriesService {
}
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
select: { id: true, status: true },
});
if (album === null) {
// admin 视角 canView 恒真,空值兜底同码 404。
if (album === null || album.status === 'deleted') {
// admin 视角 canView 恒真,空值兜底同码 404;已删除同码(软删除)。
throw albumNotFound();
}
}
+3 -2
View File
@@ -123,9 +123,10 @@ export class MembersService {
// 空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
const albumExists = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
select: { id: true, status: true },
});
if (albumExists === null) {
if (albumExists === null || albumExists.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
throw albumNotFound();
}
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
+3 -2
View File
@@ -198,9 +198,10 @@ export class WatermarkService {
}
const album = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
select: { id: true, status: true },
});
if (album === null) {
if (album === null || album.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
throw albumNotFound();
}
const latestConfig = await this.prisma.watermark_configs.findFirst({
+3 -2
View File
@@ -312,9 +312,10 @@ export class EmbedSlotsService {
if (nextAlbumId !== null) {
const album = await tx.albums.findUnique({
where: { id: nextAlbumId },
select: { publicToken: true },
select: { publicToken: true, status: true },
});
if (album === null) {
if (album === null || album.status === 'deleted') {
// 已删除相册不可绑定(软删除语义;既有绑定由拉取端容错)。
throw albumNotFound();
}
nextAlbumPublicToken = album.publicToken;
+6 -4
View File
@@ -142,9 +142,10 @@ export class PhotosService {
// 蕴含相册存在),空相册兜底同码 404——契约「相册不存在,或当前用户不可见」。
const albumExists = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
select: { id: true, status: true },
});
if (albumExists === null) {
if (albumExists === null || albumExists.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
throw albumNotFound();
}
@@ -319,9 +320,10 @@ export class PhotosService {
// listAlbumMembers/listAlbumPhotos 先例)。
const albumExists = await this.prisma.albums.findUnique({
where: { id: albumId },
select: { id: true },
select: { id: true, status: true },
});
if (albumExists === null) {
if (albumExists === null || albumExists.status === 'deleted') {
// 已删除与不存在同码 404(软删除语义,deleteAlbum)。
throw albumNotFound();
}
@@ -142,10 +142,10 @@ export class PublicOvService {
const categories = this.categoryEntries(album.categories)
.slice()
.sort((a, b) => a.sortOrder - b.sortOrder || (a.id < b.id ? -1 : 1));
const banner =
album.bannerAssetKey === null
? null
: await this.presign(album.bannerAssetKey);
// 宣传图/封面共用(用户决策 2026-09-23):banner 投影键缺失回退 cover 键
// (需求 §4.2「封面可复用宣传图」基线的反向补充——传一张即可两用)。
const bannerKey = album.bannerAssetKey ?? album.coverAssetKey;
const banner = bannerKey === null ? null : await this.presign(bannerKey);
const stats = await this.prisma.album_stats.findUnique({
where: { albumId: album.albumId },
+9 -1
View File
@@ -112,10 +112,18 @@ export class PublicService {
where: { albumId: album.id, kind: 'banner', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
const bannerRow =
let bannerRow =
bannerAssets.find((row) => row.locale === resolved.locale) ??
bannerAssets.find((row) => row.locale === null) ??
null;
// 宣传图/封面共用(用户决策 2026-09-23):banner 缺失回退最新 effective
// cover 行(传一张即可两用;与 ov 公开侧同语义)。
if (bannerRow === null) {
bannerRow = await this.prisma.album_assets.findFirst({
where: { albumId: album.id, kind: 'cover', state: 'effective' },
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
});
}
const banner =
bannerRow === null ? null : await this.presign(bannerRow.objectKey);
@@ -395,13 +395,20 @@ export class SyncInboxService {
}
if (event.entityType === 'asset') {
const p = event.payload as unknown as AssetUpdatedPayload;
// banner/cover 分列投影(宣传图/封面共用决策 2026-09-23:展示层双向
// 回退,见 public-ov banner 组装);logo 仍无投影位(YAGNI),仅推进
// 版本账本。替换语义同 banner:新事件覆盖旧键。
if (p.kind === 'banner') {
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { bannerAssetKey: p.objectKey },
});
} else if (p.kind === 'cover') {
await tx.albums_ov.update({
where: { albumId: event.albumId },
data: { coverAssetKey: p.objectKey },
});
}
// cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。
return undefined;
}
// slot-state(P4b Task 2):全量快照直投 embed_slots_ov(版本单调已由 ②
+23
View File
@@ -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,
});
});
});
+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');
});
});
+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);
@@ -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);
});
});