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';
|
} from '@nestjs/common';
|
||||||
import { ErrorCodes } from '@photo/contracts';
|
import { ErrorCodes } from '@photo/contracts';
|
||||||
import { ApiException } from '../api-exception';
|
import { ApiException } from '../api-exception';
|
||||||
|
import { parseCursor } from '../albums/albums.controller';
|
||||||
import {
|
import {
|
||||||
DEFAULT_PUBLIC_PHOTO_PAGE_SIZE,
|
DEFAULT_PUBLIC_PHOTO_PAGE_SIZE,
|
||||||
MAX_PUBLIC_PHOTO_PAGE_SIZE,
|
MAX_PUBLIC_PHOTO_PAGE_SIZE,
|
||||||
type PublicAlbumResponse,
|
type PublicAlbumResponse,
|
||||||
|
type PublicPhotoListResponse,
|
||||||
PublicService,
|
PublicService,
|
||||||
} from './public.service';
|
} from './public.service';
|
||||||
|
|
||||||
@@ -58,4 +60,24 @@ export class PublicController {
|
|||||||
): Promise<PublicAlbumResponse> {
|
): Promise<PublicAlbumResponse> {
|
||||||
return this.publicService.getPublicAlbum(token, locale);
|
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 type { StoragePort } from '@photo/storage';
|
||||||
import { ApiException } from '../api-exception';
|
import { ApiException } from '../api-exception';
|
||||||
import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto';
|
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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { STORAGE_PORT } from '../uploads/storage.provider';
|
import { STORAGE_PORT } from '../uploads/storage.provider';
|
||||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||||
@@ -38,6 +44,9 @@ type AlbumRow = Prisma.albumsGetPayload<Prisma.albumsDefaultArgs>;
|
|||||||
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
|
type AlbumTranslationRow = Prisma.album_translationsGetPayload<
|
||||||
Prisma.album_translationsDefaultArgs
|
Prisma.album_translationsDefaultArgs
|
||||||
>;
|
>;
|
||||||
|
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
||||||
|
Prisma.photo_variantsDefaultArgs
|
||||||
|
>;
|
||||||
|
|
||||||
/** 契约 listPublicPhotos:limit 缺省 30,上下界 1..60(§7 建议 30-60)。 */
|
/** 契约 listPublicPhotos:limit 缺省 30,上下界 1..60(§7 建议 30-60)。 */
|
||||||
export const DEFAULT_PUBLIC_PHOTO_PAGE_SIZE = 30;
|
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
|
* 相册定位:publicToken 唯一索引;不存在 / offline / 未公开统一 404
|
||||||
* ALBUM_NOT_PUBLIC。
|
* ALBUM_NOT_PUBLIC。
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// · banner:album_assets(kind=banner) + 真实对象 → 签名 URL 可取回真实字节、
|
// · banner:album_assets(kind=banner) + 真实对象 → 签名 URL 可取回真实字节、
|
||||||
// expiresAt 晚于现在;无资产行 → null;
|
// expiresAt 晚于现在;无资产行 → null;
|
||||||
// · viewCount P1a 占位 null;startAt/endAt ISO + timezone 原样返回。
|
// · viewCount P1a 占位 null;startAt/endAt ISO + timezone 原样返回。
|
||||||
|
// Task 23 覆盖(listPublicPhotos,见下方第二个 describe 块头注)。
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { type INestApplication } from '@nestjs/common';
|
import { type INestApplication } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
@@ -52,8 +53,24 @@ async function makeJpeg(
|
|||||||
.toBuffer();
|
.toBuffer();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cuid 形状 ID(对象键含 photoId,须先有 ID 再一次 insert 成形,同 api 约定)。
|
||||||
|
* alias 零_pad 索引参与 ID 字典序:同批创建(时间戳相同或单调)时 ID 升序 ==
|
||||||
|
* 创建序,为 id DESC 决胜断言提供确定性。
|
||||||
|
*/
|
||||||
|
function cuidLike(alias: string): string {
|
||||||
|
return `c${Date.now().toString(36)}${alias}${randomBytes(6).toString('hex')}`;
|
||||||
|
}
|
||||||
|
|
||||||
const KEY_ROOT = 'photo-live/dev';
|
const KEY_ROOT = 'photo-live/dev';
|
||||||
|
|
||||||
|
/** 与 src/publish.service.ts(worker)variantsObjectKey 同布局的断言字面量。 */
|
||||||
|
const variantKey = (
|
||||||
|
albumId: string,
|
||||||
|
photoId: string,
|
||||||
|
variant: 'thumb' | 'browse',
|
||||||
|
) => `${KEY_ROOT}/albums/${albumId}/variants/${photoId}/1/${variant}.jpg`;
|
||||||
|
|
||||||
interface SeedAlbumOptions {
|
interface SeedAlbumOptions {
|
||||||
alias: string;
|
alias: string;
|
||||||
defaultLocale?: string;
|
defaultLocale?: string;
|
||||||
@@ -135,6 +152,84 @@ describe('public (e2e)', () => {
|
|||||||
.query(query ?? {});
|
.query(query ?? {});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listPublicPhotos(
|
||||||
|
token: string,
|
||||||
|
query?: Record<string, string | number>,
|
||||||
|
) {
|
||||||
|
return request(app.getHttpServer())
|
||||||
|
.get(`/${API_PREFIX}/public/albums/${token}/photos`)
|
||||||
|
.query(query ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 直种「已发布完成态」照片:photos 行(ready/published/尺寸/entityVersion=2,
|
||||||
|
* 对齐 worker 发布事务落库形状)+ 两条 photo_variants + MinIO 真实变体对象
|
||||||
|
* (thumb 480x300 / browse 800x500,键布局同 worker publish.service)。
|
||||||
|
* visibility/publishedAt 可覆写(hidden/deleted/unpublished/同批时间戳用例)。
|
||||||
|
*/
|
||||||
|
async function seedPhoto(
|
||||||
|
alias: string,
|
||||||
|
albumId: string,
|
||||||
|
options: {
|
||||||
|
categoryId?: string | null;
|
||||||
|
visibility?: string;
|
||||||
|
publishedAt?: Date;
|
||||||
|
withVariants?: boolean; // false = 连变体行/对象都不种(防御性跳过用例)
|
||||||
|
} = {},
|
||||||
|
): Promise<string> {
|
||||||
|
const photoId = cuidLike(alias);
|
||||||
|
const [thumb, browse] = await Promise.all([
|
||||||
|
makeJpeg(480, 300),
|
||||||
|
makeJpeg(800, 500),
|
||||||
|
]);
|
||||||
|
if (options.withVariants !== false) {
|
||||||
|
await storage.put(variantKey(albumId, photoId, 'thumb'), thumb, 'image/jpeg');
|
||||||
|
await storage.put(variantKey(albumId, photoId, 'browse'), browse, 'image/jpeg');
|
||||||
|
}
|
||||||
|
await prisma.photos.create({
|
||||||
|
data: {
|
||||||
|
id: photoId,
|
||||||
|
albumId,
|
||||||
|
uploaderId: `u-${alias}`,
|
||||||
|
categoryId: options.categoryId ?? null,
|
||||||
|
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${photoId}.jpg`,
|
||||||
|
bytes: browse.length,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
publishedAt: options.publishedAt ?? new Date(),
|
||||||
|
processingState: 'ready',
|
||||||
|
visibility: options.visibility ?? 'published',
|
||||||
|
entityVersion: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (options.withVariants !== false) {
|
||||||
|
await prisma.photo_variants.createMany({
|
||||||
|
data: [
|
||||||
|
{
|
||||||
|
photoId,
|
||||||
|
variant: 'thumb',
|
||||||
|
objectKey: variantKey(albumId, photoId, 'thumb'),
|
||||||
|
bytes: thumb.length,
|
||||||
|
version: 1,
|
||||||
|
width: 480,
|
||||||
|
height: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
photoId,
|
||||||
|
variant: 'browse',
|
||||||
|
objectKey: variantKey(albumId, photoId, 'browse'),
|
||||||
|
bytes: browse.length,
|
||||||
|
version: 1,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return photoId;
|
||||||
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
await resetDb();
|
await resetDb();
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
@@ -369,4 +464,288 @@ describe('public (e2e)', () => {
|
|||||||
expect(res.body.publicToken).toMatch(/^[0-9a-z]{20,32}$/);
|
expect(res.body.publicToken).toMatch(/^[0-9a-z]{20,32}$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Task 23:GET /public/albums/{token}/photos(listPublicPhotos)。
|
||||||
|
// 35 张已发布照片跨两分类(catA 20 张 / catB 15 张),publishedAt 显式回写
|
||||||
|
// 互不相同的分钟(排序与真实写入时序解耦);另种 hidden/deleted/unpublished
|
||||||
|
// 各一张(带完整变体)验证永不出现。变体对象为 MinIO 真实 JPEG。
|
||||||
|
// 分页确定性:ID 内嵌零_pad 索引(cuidLike),同批创建下 ID 升序==创建序,
|
||||||
|
// id DESC 决胜可精确断言。
|
||||||
|
describe('Task 23:GET /public/albums/{token}/photos(listPublicPhotos)', () => {
|
||||||
|
const PAGINATION_BASE = Date.parse('2026-09-12T08:00:00.000Z');
|
||||||
|
let feedToken: string;
|
||||||
|
let feedAlbumId: string;
|
||||||
|
let catAId: string;
|
||||||
|
let catBId: string;
|
||||||
|
let feedIds: string[] = []; // indices 0..34,publishedAt 升序(索引大=新)
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
const feed = await seedAlbum({
|
||||||
|
alias: 'pub-feed',
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', title: '照片流相册' },
|
||||||
|
{ locale: 'en', title: 'Photo Feed Album' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
feedAlbumId = feed.id;
|
||||||
|
feedToken = feed.publicToken;
|
||||||
|
catAId = await seedCategory(feed.id, 1, [
|
||||||
|
{ locale: 'zh-CN', name: '日场' },
|
||||||
|
{ locale: 'en', name: 'Day' },
|
||||||
|
]);
|
||||||
|
catBId = await seedCategory(feed.id, 2, [
|
||||||
|
{ locale: 'zh-CN', name: '晚场' },
|
||||||
|
{ locale: 'en', name: 'Night' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
// 35 张:i=0..34,前 20 张 catA、后 15 张 catB;publishedAt = base + i 分钟。
|
||||||
|
const ids: string[] = [];
|
||||||
|
for (let i = 0; i < 35; i++) {
|
||||||
|
ids.push(
|
||||||
|
await seedPhoto(`pl${String(i).padStart(2, '0')}`, feedAlbumId, {
|
||||||
|
categoryId: i < 20 ? catAId : catBId,
|
||||||
|
publishedAt: new Date(PAGINATION_BASE + i * 60_000),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
feedIds = ids;
|
||||||
|
|
||||||
|
// 同 publishedAt 批次:索引 10..14 五张回写同一时间戳(第 12 张的),
|
||||||
|
// 该批次在排序中连续且按 id DESC(== 索引倒序)决胜。
|
||||||
|
await prisma.photos.updateMany({
|
||||||
|
where: { id: { in: ids.slice(10, 15) } },
|
||||||
|
data: { publishedAt: new Date(PAGINATION_BASE + 12 * 60_000) },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 永不出现的三种状态(带完整变体,排除「无变体被跳过」的干扰归因)。
|
||||||
|
await seedPhoto('plhidden', feedAlbumId, {
|
||||||
|
categoryId: catAId,
|
||||||
|
visibility: 'hidden',
|
||||||
|
});
|
||||||
|
await seedPhoto('pldeleted', feedAlbumId, {
|
||||||
|
categoryId: catAId,
|
||||||
|
visibility: 'deleted',
|
||||||
|
});
|
||||||
|
await seedPhoto('plunpub', feedAlbumId, {
|
||||||
|
categoryId: catAId,
|
||||||
|
visibility: 'unpublished',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('缺省 limit=30 → 首页 30 条 + nextCursor/hasMore;次页 5 条;两页并集恰 35 张无重复', async () => {
|
||||||
|
const page1 = await listPublicPhotos(feedToken);
|
||||||
|
expect(page1.status).toBe(200);
|
||||||
|
expect(page1.body.items).toHaveLength(30);
|
||||||
|
expect(page1.body.hasMore).toBe(true);
|
||||||
|
expect(typeof page1.body.nextCursor).toBe('string');
|
||||||
|
|
||||||
|
const page2 = await listPublicPhotos(feedToken, {
|
||||||
|
cursor: page1.body.nextCursor,
|
||||||
|
});
|
||||||
|
expect(page2.status).toBe(200);
|
||||||
|
expect(page2.body.items).toHaveLength(5);
|
||||||
|
expect(page2.body.hasMore).toBe(false);
|
||||||
|
expect(page2.body.nextCursor).toBeNull();
|
||||||
|
|
||||||
|
const page1Ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
|
||||||
|
const page2Ids = page2.body.items.map((p: { photoId: string }) => p.photoId);
|
||||||
|
const union = [...page1Ids, ...page2Ids];
|
||||||
|
// 无重复(跨页稳定:键集谓词从不回看已发射行)。
|
||||||
|
expect(new Set(union).size).toBe(35);
|
||||||
|
expect(union.sort()).toEqual([...feedIds].sort());
|
||||||
|
// 首页 = 最新 30 张(索引 34..5),次页 = 最旧 5 张(索引 4..0)。
|
||||||
|
expect(page2Ids.sort()).toEqual(feedIds.slice(0, 5).sort());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('首页序:publishedAt 降序;末位(第 30 条)为索引 5', async () => {
|
||||||
|
const page1 = await listPublicPhotos(feedToken);
|
||||||
|
expect(page1.status).toBe(200);
|
||||||
|
const ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
|
||||||
|
// 索引 34..15 publishedAt 互不相同(每分钟一张):严格降序段。
|
||||||
|
const expectedHead = [];
|
||||||
|
for (let i = 34; i >= 15; i--) {
|
||||||
|
expectedHead.push(feedIds[i]);
|
||||||
|
}
|
||||||
|
expect(ids.slice(0, 20)).toEqual(expectedHead);
|
||||||
|
expect(ids[29]).toBe(feedIds[5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('同 publishedAt 批次 → id DESC 决胜稳定(批次连续且为索引倒序 14..10)', async () => {
|
||||||
|
const page1 = await listPublicPhotos(feedToken);
|
||||||
|
expect(page1.status).toBe(200);
|
||||||
|
const ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
|
||||||
|
// 批次时间戳 = base+12min:排序位置在索引 13 之后、11 之前 → 首页内
|
||||||
|
// 第 22..26 条(索引 34..15 共 20 条之后)。批次内 id DESC == 索引倒序。
|
||||||
|
expect(ids.slice(20, 25)).toEqual([
|
||||||
|
feedIds[14],
|
||||||
|
feedIds[13],
|
||||||
|
feedIds[12],
|
||||||
|
feedIds[11],
|
||||||
|
feedIds[10],
|
||||||
|
]);
|
||||||
|
// publishedAt 相等性自检:批次五张确实同时间戳(否则上面断言是巧合)。
|
||||||
|
const batchRows = await prisma.photos.findMany({
|
||||||
|
where: { id: { in: feedIds.slice(10, 15) } },
|
||||||
|
select: { publishedAt: true },
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
new Set(batchRows.map((r) => (r.publishedAt as Date).getTime())).size,
|
||||||
|
).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hidden / deleted / unpublished 照片永不出现', async () => {
|
||||||
|
const page1 = await listPublicPhotos(feedToken, { limit: 60 });
|
||||||
|
expect(page1.status).toBe(200);
|
||||||
|
expect(page1.body.items).toHaveLength(35);
|
||||||
|
const visibilityRows = await prisma.photos.findMany({
|
||||||
|
where: { albumId: feedAlbumId },
|
||||||
|
select: { id: true, visibility: true },
|
||||||
|
});
|
||||||
|
const nonPublished = visibilityRows
|
||||||
|
.filter((r) => r.visibility !== 'published')
|
||||||
|
.map((r) => r.id);
|
||||||
|
expect(nonPublished).toHaveLength(3);
|
||||||
|
const ids = page1.body.items.map((p: { photoId: string }) => p.photoId);
|
||||||
|
for (const id of nonPublished) {
|
||||||
|
expect(ids).not.toContain(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('条目形状:width/height 数字、thumb 签名 URL 可取回真实字节、expiresAt 未来、likeCount 0', async () => {
|
||||||
|
const page1 = await listPublicPhotos(feedToken, { limit: 1 });
|
||||||
|
expect(page1.status).toBe(200);
|
||||||
|
const item = page1.body.items[0] as Record<string, unknown>;
|
||||||
|
// 契约 PublicPhoto 必返字段全集。
|
||||||
|
expect(Object.keys(item).sort()).toEqual([
|
||||||
|
'browse',
|
||||||
|
'height',
|
||||||
|
'likeCount',
|
||||||
|
'photoId',
|
||||||
|
'publishedAt',
|
||||||
|
'thumb',
|
||||||
|
'width',
|
||||||
|
]);
|
||||||
|
expect(item.photoId).toBe(feedIds[34]); // 最新一张
|
||||||
|
expect(typeof item.width).toBe('number');
|
||||||
|
expect(typeof item.height).toBe('number');
|
||||||
|
expect(item.width).toBe(800); // browse 变体宽高(列表占位语义)
|
||||||
|
expect(item.height).toBe(500);
|
||||||
|
expect(item.likeCount).toBe(0);
|
||||||
|
expect(Number.isNaN(Date.parse(String(item.publishedAt)))).toBe(false);
|
||||||
|
|
||||||
|
const thumb = item.thumb as { url: string; expiresAt: string };
|
||||||
|
const browse = item.browse as { url: string; expiresAt: string };
|
||||||
|
for (const signed of [thumb, browse]) {
|
||||||
|
expect(Date.parse(signed.expiresAt)).toBeGreaterThan(Date.now());
|
||||||
|
const fetched = await fetch(signed.url);
|
||||||
|
expect(fetched.status).toBe(200);
|
||||||
|
expect(fetched.headers.get('content-type')).toBe('image/jpeg');
|
||||||
|
expect(Buffer.from(await fetched.arrayBuffer()).length).toBeGreaterThan(0);
|
||||||
|
}
|
||||||
|
// thumb 与 browse 是不同对象(不同键 → 不同 URL)。
|
||||||
|
expect(thumb.url).not.toBe(browse.url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('category 过滤:仅该分类;跨册/幽灵 category → 400 VALIDATION_FAILED', async () => {
|
||||||
|
const catB = await listPublicPhotos(feedToken, {
|
||||||
|
category: catBId,
|
||||||
|
limit: 60,
|
||||||
|
});
|
||||||
|
expect(catB.status).toBe(200);
|
||||||
|
expect(catB.body.items).toHaveLength(15);
|
||||||
|
// catB = 索引 20..34;降序首条为索引 34。
|
||||||
|
expect(catB.body.items[0].photoId).toBe(feedIds[34]);
|
||||||
|
expect(catB.body.items.map((p: { photoId: string }) => p.photoId).sort())
|
||||||
|
.toEqual(feedIds.slice(20, 35).sort());
|
||||||
|
|
||||||
|
// 幽灵分类:契约 400 语义(category 属查询参数校验)。
|
||||||
|
const ghost = await listPublicPhotos(feedToken, { category: 'no-such-cat' });
|
||||||
|
expect(ghost.status).toBe(400);
|
||||||
|
expect(ghost.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// 跨册分类:不属于本相册同样 400(不区分幽灵/跨册,防探测)。
|
||||||
|
const other = await seedAlbum({
|
||||||
|
alias: 'pub-feed-other',
|
||||||
|
translations: [{ locale: 'zh-CN', title: '另一本相册' }],
|
||||||
|
});
|
||||||
|
const otherCat = await seedCategory(other.id, 1, [
|
||||||
|
{ locale: 'zh-CN', name: '别册分类' },
|
||||||
|
]);
|
||||||
|
const cross = await listPublicPhotos(feedToken, { category: otherCat });
|
||||||
|
expect(cross.status).toBe(400);
|
||||||
|
expect(cross.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('locale 参数:非法值(fr)静默回退语义 → 200(列表无语言敏感字段,不报 400)', async () => {
|
||||||
|
const res = await listPublicPhotos(feedToken, { locale: 'fr', limit: 5 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.items).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('未公开相册:publicEnabled=false / offline → 404 ALBUM_NOT_PUBLIC', async () => {
|
||||||
|
const disabled = await seedAlbum({
|
||||||
|
alias: 'pub-feed-off',
|
||||||
|
publicEnabled: false,
|
||||||
|
translations: [{ locale: 'zh-CN', title: '未公开照片流' }],
|
||||||
|
});
|
||||||
|
const offline = await seedAlbum({
|
||||||
|
alias: 'pub-feed-dis',
|
||||||
|
status: 'offline',
|
||||||
|
translations: [{ locale: 'zh-CN', title: '下线照片流' }],
|
||||||
|
});
|
||||||
|
for (const token of [disabled.publicToken, offline.publicToken]) {
|
||||||
|
const res = await listPublicPhotos(token);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('limit=60 → 200 全量一页;limit=0/61/非整数 → 400;非法 cursor → 400', async () => {
|
||||||
|
const max = await listPublicPhotos(feedToken, { limit: 60 });
|
||||||
|
expect(max.status).toBe(200);
|
||||||
|
expect(max.body.items).toHaveLength(35);
|
||||||
|
expect(max.body.hasMore).toBe(false);
|
||||||
|
|
||||||
|
for (const limit of [0, 61, 'abc']) {
|
||||||
|
const res = await listPublicPhotos(feedToken, { limit });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const badCursor = await listPublicPhotos(feedToken, {
|
||||||
|
cursor: 'not-a-cursor',
|
||||||
|
});
|
||||||
|
expect(badCursor.status).toBe(400);
|
||||||
|
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('无变体行的已发布照片被防御性跳过(post-publish 不变量破坏时不炸公开页)', async () => {
|
||||||
|
// 直种一张 published 但无 photo_variants 的照片(模拟发布事务半途/手工
|
||||||
|
// 破坏);契约 width/height/thumb/browse 必返,缺变体无法成形 → 跳过而非 5xx。
|
||||||
|
const orphanId = cuidLike('plorphan');
|
||||||
|
await prisma.photos.create({
|
||||||
|
data: {
|
||||||
|
id: orphanId,
|
||||||
|
albumId: feedAlbumId,
|
||||||
|
uploaderId: 'u-plorphan',
|
||||||
|
objectKey: `${KEY_ROOT}/albums/${feedAlbumId}/originals/${orphanId}.jpg`,
|
||||||
|
bytes: 1000,
|
||||||
|
width: 800,
|
||||||
|
height: 500,
|
||||||
|
uploadedAt: new Date(),
|
||||||
|
publishedAt: new Date(PAGINATION_BASE + 99 * 60_000), // 最新
|
||||||
|
processingState: 'ready',
|
||||||
|
visibility: 'published',
|
||||||
|
entityVersion: 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const res = await listPublicPhotos(feedToken, { limit: 60 });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
const ids = res.body.items.map((p: { photoId: string }) => p.photoId);
|
||||||
|
expect(ids).not.toContain(orphanId);
|
||||||
|
expect(ids).toHaveLength(35);
|
||||||
|
// 游标不因跳过而错位:nextCursor 依旧 null(后面没有更多行)。
|
||||||
|
expect(res.body.nextCursor).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user