feat(api): public photo feed with stable cursor pagination

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-17 23:39:50 +08:00
parent 6863c736c3
commit 1f1c8ce191
3 changed files with 526 additions and 0 deletions
@@ -16,10 +16,12 @@ import {
} from '@nestjs/common';
import { ErrorCodes } from '@photo/contracts';
import { ApiException } from '../api-exception';
import { parseCursor } from '../albums/albums.controller';
import {
DEFAULT_PUBLIC_PHOTO_PAGE_SIZE,
MAX_PUBLIC_PHOTO_PAGE_SIZE,
type PublicAlbumResponse,
type PublicPhotoListResponse,
PublicService,
} from './public.service';
@@ -58,4 +60,24 @@ export class PublicController {
): Promise<PublicAlbumResponse> {
return this.publicService.getPublicAlbum(token, locale);
}
/**
* 公开照片列表:仅 published;(publishedAt,id) 稳定倒序键集游标;category
* 限本册分类;locale 不校验取值(非法值静默回退语义,Task 5 决策)。
*/
@Get(':token/photos')
listPublicPhotos(
@Param('token') token: string,
@Query('category') category: string | undefined,
@Query('locale') locale: string | undefined,
@Query('cursor') cursor: string | undefined,
@Query('limit') limit: string | undefined,
): Promise<PublicPhotoListResponse> {
return this.publicService.listPublicPhotos(token, {
category,
// locale 仅保证「不因非法值报 400」,列表无语言敏感字段,不向下传。
cursor: parseCursor(cursor),
limit: parsePublicPhotoLimit(limit),
});
}
}
+125
View File
@@ -22,6 +22,12 @@ import type { Prisma } from '@photo/db-cn';
import type { StoragePort } from '@photo/storage';
import { ApiException } from '../api-exception';
import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto';
import { validationFailed } from '../albums/support';
import {
decodeKeysetCursor,
encodeKeysetCursor,
keysetWhereDesc,
} from '../shared/cursor';
import { PrismaService } from '../prisma/prisma.service';
import { STORAGE_PORT } from '../uploads/storage.provider';
import { signedUrlTtlSeconds } from '../uploads/support';
@@ -38,6 +44,9 @@ type AlbumRow = Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>;
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
Prisma.album_translationsDefaultArgs
>;
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
Prisma.photo_variantsDefaultArgs
>;
/** 契约 listPublicPhotos:limit 缺省 30,上下界 1..60(§7 建议 30-60)。 */
export const DEFAULT_PUBLIC_PHOTO_PAGE_SIZE = 30;
@@ -130,6 +139,122 @@ export class PublicService {
};
}
/**
* 公开照片列表(Task 23):仅 published,(publishedAt,id) 双列稳定倒序键集
* 游标(shared/cursor,同批 publishedAt 以 id DESC 决胜)。签名 URL 逐条现签
* (2×30 次 presign 为 P1a 可接受量级——SigV4 本地计算无网络往返;P5 若成
* 瓶颈再考虑批量/缩短 TTL 策略,见任务注记)。locale 参数接受但列表响应无
* 语言敏感字段,语义上仅保证「不因非法值报 400」(Task 5 决策)。
*/
async listPublicPhotos(
token: string,
query: {
category?: string;
cursor?: string;
limit: number;
},
): Promise<PublicPhotoListResponse> {
const album = await this.findPublicAlbum(token);
// category 过滤:须为本相册分类;幽灵/跨册同码 400(契约 category 属查询
// 参数校验;不区分两种失败,防探测)。
if (query.category !== undefined && query.category !== '') {
const category = await this.prisma.categories.findUnique({
where: { id: query.category },
select: { albumId: true },
});
if (category === null || category.albumId !== album.id) {
throw validationFailed('category 不属于该相册');
}
}
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
if (query.cursor !== undefined && query.cursor !== '') {
decoded = decodeKeysetCursor(query.cursor);
if (decoded === null) {
throw validationFailed('cursor 不合法');
}
}
const where: Prisma.photosWhereInput = {
albumId: album.id,
visibility: 'published',
...(query.category !== undefined && query.category !== ''
? { categoryId: query.category }
: {}),
};
if (decoded !== null) {
Object.assign(where, keysetWhereDesc('publishedAt', 'id', decoded));
}
const rows = await this.prisma.photos.findMany({
where,
orderBy: [{ publishedAt: 'desc' }, { id: 'desc' }],
take: query.limit + 1, // 多取一条探测 hasMore(同相册列表)
});
const hasMore = rows.length > query.limit;
const page = hasMore ? rows.slice(0, query.limit) : rows;
// 变体行二次查询(扁平外键,应用层组装;发布事务保证 published 照片必有两
// 变体行)。缺行/缺尺寸属 post-publish 不变量破坏:防御性跳过而非 5xx——
// 公开页不因单张脏数据整页失败;宽高缺失同理(契约 width/height 必返)。
const variants =
page.length === 0
? []
: await this.prisma.photo_variants.findMany({
where: { photoId: { in: page.map((p) => p.id) } },
});
const variantsByPhoto = new Map<string, Map<string, PhotoVariantRow>>();
for (const row of variants) {
let bucket = variantsByPhoto.get(row.photoId);
if (bucket === undefined) {
bucket = new Map();
variantsByPhoto.set(row.photoId, bucket);
}
bucket.set(row.variant, row);
}
const items: PublicPhotoListResponse['items'] = [];
for (const photo of page) {
const bucket = variantsByPhoto.get(photo.id);
const thumbRow = bucket?.get('thumb');
const browseRow = bucket?.get('browse');
if (thumbRow === undefined || browseRow === undefined) {
continue;
}
// 宽高取 browse 变体尺寸(契约「浏览图宽高,用于列表占位」);变体行缺
// 尺寸时回退 photos 主行(原始尺寸),两侧均缺 → 同样跳过(必返字段)。
const width = browseRow.width ?? photo.width;
const height = browseRow.height ?? photo.height;
if (width === null || height === null) {
continue;
}
items.push({
photoId: photo.id,
width,
height,
thumb: await this.presign(thumbRow.objectKey),
browse: await this.presign(browseRow.objectKey),
likeCount: photo.likeCount,
// published 行的 publishedAt 必非空(worker 发布事务写入);?? 兜底仅
// 收窄类型,不构成可观察行为。
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
});
}
// 游标取自「DB 页末行」而非「末个发射条目」:被跳过的行不应在下一页重扫
// (末行被跳过时若以末个发射条目为游标,跳过的行会被反复重取再跳过;取
// DB 行位置则一次越过)。
const last = page.at(-1);
const nextCursor =
hasMore && last !== undefined
? encodeKeysetCursor({
sort: (last.publishedAt ?? new Date(0)).toISOString(),
id: last.id,
})
: null;
return { items, nextCursor, hasMore: nextCursor !== null };
}
/**
* 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404
* ALBUM_NOT_PUBLIC。