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:
浅念
2026-09-17 23:51:29 +08:00
parent 1f1c8ce191
commit 6d61d117d5
10 changed files with 1199 additions and 1 deletions
@@ -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);
}
}
+50 -1
View File
@@ -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。