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:
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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(版本单调已由 ②
|
||||
|
||||
@@ -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