feat(api): photo visibility lifecycle with audit and outbox events
Serializes turbo tasks (concurrency=1): api and worker e2e suites share one MySQL/MinIO and resetDb TRUNCATEs raced when run in parallel. Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import {
|
||||
DEFAULT_PUBLIC_PHOTO_PAGE_SIZE,
|
||||
MAX_PUBLIC_PHOTO_PAGE_SIZE,
|
||||
type PublicAlbumResponse,
|
||||
type PublicPhotoDetailResponse,
|
||||
type PublicPhotoListResponse,
|
||||
PublicService,
|
||||
} from './public.service';
|
||||
@@ -80,4 +81,16 @@ export class PublicController {
|
||||
limit: parsePublicPhotoLimit(limit),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开单图:深链定位;隐藏/删除/换册后的旧链接 404 PHOTO_NOT_PUBLIC。
|
||||
* 每次调用现签 thumb/browse(契约规定的过期刷新入口)。
|
||||
*/
|
||||
@Get(':token/photos/:photoId')
|
||||
getPublicPhoto(
|
||||
@Param('token') token: string,
|
||||
@Param('photoId') photoId: string,
|
||||
): Promise<PublicPhotoDetailResponse> {
|
||||
return this.publicService.getPublicPhoto(token, photoId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ import {
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { STORAGE_PORT } from '../uploads/storage.provider';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import { albumNotPublic } from './support';
|
||||
import { albumNotPublic, photoNotPublic } from './support';
|
||||
|
||||
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
|
||||
export type PublicPhotoListResponse =
|
||||
@@ -255,6 +255,55 @@ export class PublicService {
|
||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开单图(Task 24):深链定位 + 签名 URL 过期后的刷新入口(每次现签)。
|
||||
* 服务端校验相册与照片归属:照片不存在 / 不属于该相册 / 非 published
|
||||
* (隐藏、软删除后的旧链接)统一 404 PHOTO_NOT_PUBLIC(不泄露状态,A16)。
|
||||
* 变体行缺失的防御语义同列表(跳过)——此处无「跳过」可选,落 404。
|
||||
*/
|
||||
async getPublicPhoto(
|
||||
token: string,
|
||||
photoId: string,
|
||||
): Promise<PublicPhotoDetailResponse> {
|
||||
const album = await this.findPublicAlbum(token);
|
||||
const photo = await this.prisma.photos.findUnique({
|
||||
where: { id: photoId },
|
||||
});
|
||||
if (
|
||||
photo === null ||
|
||||
photo.albumId !== album.id ||
|
||||
photo.visibility !== 'published'
|
||||
) {
|
||||
throw photoNotPublic();
|
||||
}
|
||||
const variants = await this.prisma.photo_variants.findMany({
|
||||
where: { photoId: photo.id },
|
||||
});
|
||||
const thumbRow = variants.find((v) => v.variant === 'thumb');
|
||||
const browseRow = variants.find((v) => v.variant === 'browse');
|
||||
const width = browseRow?.width ?? photo.width;
|
||||
const height = browseRow?.height ?? photo.height;
|
||||
if (
|
||||
thumbRow === undefined ||
|
||||
browseRow === undefined ||
|
||||
width === null ||
|
||||
height === null
|
||||
) {
|
||||
// post-publish 不变量破坏:公开面按「不可展示」处理(同列表跳过语义)。
|
||||
throw photoNotPublic();
|
||||
}
|
||||
return {
|
||||
photoId: photo.id,
|
||||
width,
|
||||
height,
|
||||
thumb: await this.presign(thumbRow.objectKey),
|
||||
browse: await this.presign(browseRow.objectKey),
|
||||
likeCount: photo.likeCount,
|
||||
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
||||
categoryId: photo.categoryId,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404
|
||||
* ALBUM_NOT_PUBLIC。
|
||||
|
||||
Reference in New Issue
Block a user