feat(api): public photo feed with stable cursor pagination
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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。
|
||||
|
||||
Reference in New Issue
Block a user