feat(ov): public read api on replicas with hot sorting
P2 Task 6(ov 公开读 API + viewer 切换):
- ov 装配 PublicOvModule(仅 app-ov.module):/public/albums/{token}、
/photos、/photos/{id} 三端点同契约读 ov 副本——translations/categories
自 JSON 列解析(防御性过滤),banner 经 ov 侧 STORAGE_PORT_OV 预签名
(同本地 MinIO;只读属约定,真实 OSS 只读凭证 P5),viewCount 取
album_stats 权威累计(无行默认 0;计数端点 Task 8 接线)
- 排序:latest=发布时间 keyset(shared/cursor.ts 同 cn);hot=likeTotal
desc + photoId desc 决胜,顺序按 `${albumId}|${category}` 短期快照
(内存 TTL 默认 30s,HOT_SORT_TTL_MS 惰性读取)——计数逐请求现读、
顺序冻结窗口内一致,偏移游标(base64url 整数)在快照内严格稳定;
非法 sort 400 VALIDATION_FAILED(cn 不感知该参数)
- 契约:viewCount 放宽为 integer|null(cn null、ov 权威值);
listPublicPhotos 增 sort 枚举(latest|hot,缺省 latest);
PhotoPublishedPayload 增 categoryId——补上 Tasks 3/4 管线缺口
(payloads/publish.service/sync-inbox 投影 + 关联测试随改)
- e2e(services/api/test/public-ov.e2e-spec.ts,7 用例):双入口同进程
起 cn+ov,cn API 种子→真实发送管线投递;断言相册/列表/单图与 cn
深等(仅签名 URL 剔除)、banner 真字节、参数矩阵 400/404、hot 排序
TTL 内快照(计数新/顺序旧)+TTL 过期翻转+偏移翻页、隐藏/下线/紧急
停用逐态撤下
- viewer 切 ov:API 基址 VITE_API_BASE(缺省 http://127.0.0.1:8081,
空串=同源网关;Vite 只读 apps/viewer/.env*,根 .env 对前端无效),
移除 /v1 dev 代理(CORS_ORIGINS 增 127.0.0.1:5175 直连两形);
playwright webServer 增 dev:ov;e2e 夹具 flushSync(albumId)=cn
bootstrap 快照 + deliverPendingOnce(sender 新导出)经真实管线投影
种子,deleteSeededPhoto 改软删让墓碑可同步;14 个 Playwright 全绿
(journey 管理/staffB 腿仍走 cn 8080)
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -3,8 +3,9 @@
|
||||
// request-id + 全局异常过滤器 + 全局校验管道 + /v1/health),但**显式挑选**
|
||||
// ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO
|
||||
// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);Task 5
|
||||
// 加入 emergency(海外紧急停用通道,独立于同步);公开读、likes/visits、
|
||||
// WS 网关自 Task 6+ 起逐个加入。cn 模块保持原样,两入口互不影响。
|
||||
// 加入 emergency(海外紧急停用通道,独立于同步);公开读自 Task 6 起装配
|
||||
// (public-ov:读 photos_ov 副本的同形契约端点 + hot 排序);likes/visits、
|
||||
// WS 网关自 Task 7+ 起逐个加入。cn 模块保持原样,两入口互不影响。
|
||||
import {
|
||||
type MiddlewareConsumer,
|
||||
Module,
|
||||
@@ -16,6 +17,7 @@ import { LoggerModule } from 'nestjs-pino';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
import { EmergencyModule } from './emergency/emergency.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { PublicOvModule } from './public-ov/public-ov.module';
|
||||
import { RequestIdMiddleware } from './request-id.middleware';
|
||||
import { createLoggerOptions } from './shared/logger-options';
|
||||
import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
||||
@@ -27,6 +29,8 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
||||
SyncInboxModule,
|
||||
// 海外紧急停用通道(P2 Task 5):POST /v1/internal/emergency/disable|enable。
|
||||
EmergencyModule,
|
||||
// ov 公开读(P2 Task 6):/public/albums/* 读 ov 副本(含 sort=hot 快照)。
|
||||
PublicOvModule,
|
||||
],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// services/api/src/public-ov/public-ov.controller.ts —— ov 公开端点
|
||||
// (P2 Task 6)。路由/方法名与 cn public.controller 一一对应(契约同形:
|
||||
// 路径前缀不变、部署域名区分),差异仅两处:
|
||||
// · 新增 sort 查询参数(latest|hot;hot 仅 ov 实现——cn 不感知,见契约
|
||||
// listPublicPhotos sort 描述)。非法取值 400 VALIDATION_FAILED(与
|
||||
// limit/cursor 同口径;locale 仍保持「非法值静默回退」的 Task 5 决策);
|
||||
// · 服务层读 ov 副本(PublicOvService)。
|
||||
// limit/cursor 规整直接复用 cn public.controller 的解析函数(缺省 30、1..60、
|
||||
// cursor 长度上限)——两入口共享同一套校验源,杜绝漂移。匿名访问(无
|
||||
// SessionGuard;认证由持有 publicToken 取代)。
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
// limit 规整复用 cn public.controller 的导出(同一校验源,杜绝两入口漂移)。
|
||||
import { parsePublicPhotoLimit } from '../public/public.controller';
|
||||
import { parseCursor } from '../shared/pagination';
|
||||
import { validationFailed } from '../albums/support';
|
||||
import {
|
||||
type PublicAlbumResponse,
|
||||
type PublicPhotoDetailResponse,
|
||||
type PublicPhotoListResponse,
|
||||
PublicOvService,
|
||||
} from './public-ov.service';
|
||||
|
||||
/** sort 查询参数解析:缺省 latest;非法 → 400(见文件头口径说明)。 */
|
||||
export function parsePublicPhotoSort(
|
||||
raw: string | undefined,
|
||||
): 'latest' | 'hot' {
|
||||
if (raw === undefined || raw === '') {
|
||||
return 'latest';
|
||||
}
|
||||
if (raw === 'latest' || raw === 'hot') {
|
||||
return raw;
|
||||
}
|
||||
throw validationFailed('sort 必须是 latest 或 hot');
|
||||
}
|
||||
|
||||
@Controller('public/albums')
|
||||
export class PublicOvController {
|
||||
constructor(private readonly publicService: PublicOvService) {}
|
||||
|
||||
/** 公开相册信息:locale 缺省/非法/无条目均按 defaultLocale 回退(200)。 */
|
||||
@Get(':token')
|
||||
getPublicAlbum(
|
||||
@Param('token') token: string,
|
||||
@Query('locale') locale: string | undefined,
|
||||
): Promise<PublicAlbumResponse> {
|
||||
return this.publicService.getPublicAlbum(token, locale);
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开照片列表:latest=(publishedAt,photoId) 键集游标(缺省);hot=点赞
|
||||
* 快照 offset 游标(30s 缓冲,服务层语义)。category 限本册分类。
|
||||
*/
|
||||
@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,
|
||||
@Query('sort') sort: string | undefined,
|
||||
): Promise<PublicPhotoListResponse> {
|
||||
return this.publicService.listPublicPhotos(token, {
|
||||
category,
|
||||
// locale 仅保证「不因非法值报 400」,列表无语言敏感字段,不向下传。
|
||||
cursor: parseCursor(cursor),
|
||||
limit: parsePublicPhotoLimit(limit),
|
||||
sort: parsePublicPhotoSort(sort),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开单图:深链定位;隐藏/删除/换册后的旧链接 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
// services/api/src/public-ov/public-ov.module.ts —— ov 公开(观众端)读模块
|
||||
// (P2 Task 6)。仅 ov 入口装配(app-ov.module 显式挑选;cn 的 PublicModule
|
||||
// 互不影响)。装配:PublicOvController/PublicOvService + OvPrismaModule
|
||||
// (非 @Global,显式 import)+ STORAGE_PORT_OV(ov 只读存储约定,见
|
||||
// storage-ov.provider 头注)。无认证语义(公开端点匿名)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
|
||||
import { PublicOvController } from './public-ov.controller';
|
||||
import { PublicOvService } from './public-ov.service';
|
||||
import { storageOvProvider } from './storage-ov.provider';
|
||||
|
||||
@Module({
|
||||
imports: [OvPrismaModule],
|
||||
controllers: [PublicOvController],
|
||||
providers: [PublicOvService, storageOvProvider],
|
||||
})
|
||||
export class PublicOvModule {}
|
||||
@@ -0,0 +1,459 @@
|
||||
// services/api/src/public-ov/public-ov.service.ts —— ov(海外观众)公开读逻辑
|
||||
// (P2 Task 6)。契约权威与 cn public.service 同一来源(openapi tags [public],
|
||||
// 路径不变、部署域名区分)——**契约同形**是本域的验收口径:响应形状一律取
|
||||
// @photo/contracts 生成类型,与 cn 逐字段对齐(e2e deep-eval 见
|
||||
// test/public-ov.e2e-spec.ts)。与 cn 实现的本质差异:
|
||||
// · 数据源是副本表(albums_ov/photos_ov,translations/categories 反规范化
|
||||
// JSON,三对象键内联 photos_ov——无变体表),无关联行二次查询;
|
||||
// · 相册可见性叠加 ov 本地紧急停用位(isAlbumVisibleOv,Task 5 单一实现,
|
||||
// 与 cn findPublicAlbum 同形:任一不满足统一 404 ALBUM_NOT_PUBLIC);
|
||||
// · viewCount 是真实值:album_stats 权威累计(无行默认 0;计数端点 Task 8
|
||||
// 接线,此处只读列)——cn 侧仍是占位 null(Task 9 副本回填);
|
||||
// · banner 是单键(albums_ov.bannerAssetKey,asset.updated 事件投影的「最新
|
||||
// 生效 banner」):cn 侧语言偏好解析(locale 行 → 全局行)在 ov 副本上
|
||||
// 退化为最新键——多语言 banner 的 ov 侧精确解析登记为 P5 展示 refinement
|
||||
// (本地仿真 e2e 用单全局 banner 验证同形);
|
||||
// · 列表新增 sort=hot(plan Task 6 / 技术方案 §7):likeTotal 倒序 + photoId
|
||||
// 倒序决胜,**短期快照语义**——每相册内存缓存排好序的 photoId 清单
|
||||
// (TTL 缺省 30s,env HOT_SORT_TTL_MS 可调,惰性读取),窗口内翻页对同一
|
||||
// 快照切片(offset 游标在冻结清单上严格稳定),不做失效广播:30s 窗口
|
||||
// 外自然过期重算(点赞计数仍逐条现读,仅顺序是快照)。
|
||||
// 防御语义与 cn 对齐:发布副本缺宽高 → 列表跳过 / 单图 404(公开页不因单张
|
||||
// 脏数据整页失败);defaultLocale 翻译条目缺失属装配异常 → 5xx 兜底。
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import type { components } from '@photo/contracts';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import { ApiException } from '../api-exception';
|
||||
import { ALBUM_LOCALES, type AlbumLocale } from '../albums/dto';
|
||||
import { validationFailed } from '../albums/support';
|
||||
import { isAlbumVisibleOv } from '../shared/album-visibility';
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
keysetWhereDesc,
|
||||
} from '../shared/cursor';
|
||||
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import { albumNotPublic, photoNotPublic } from '../public/support';
|
||||
import { STORAGE_PORT_OV } from './storage-ov.provider';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
|
||||
export type PublicAlbumResponse = components['schemas']['PublicAlbum'];
|
||||
export type PublicPhotoListResponse =
|
||||
components['schemas']['PublicPhotoList'];
|
||||
export type PublicPhotoDetailResponse =
|
||||
components['schemas']['PublicPhotoDetail'];
|
||||
type SignedUrlResponse = components['schemas']['SignedUrl'];
|
||||
|
||||
/** 与 cn public.service 同值(契约 listPublicPhotos limit bounds)。 */
|
||||
export const DEFAULT_PUBLIC_PHOTO_PAGE_SIZE = 30;
|
||||
export const MAX_PUBLIC_PHOTO_PAGE_SIZE = 60;
|
||||
|
||||
/** HOT_SORT_TTL_MS 缺省 30s(技术方案 §7 快照缓冲;与 .env.example 同步)。 */
|
||||
export const DEFAULT_HOT_SORT_TTL_MS = 30_000;
|
||||
|
||||
/** 逐请求惰性读取快照 TTL(同 signedUrlTtlSeconds 模式:测试可进程内调 env)。 */
|
||||
export function hotSortTtlMs(): number {
|
||||
const parsed = Number.parseInt(process.env.HOT_SORT_TTL_MS ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : DEFAULT_HOT_SORT_TTL_MS;
|
||||
}
|
||||
|
||||
/** albums_ov.translations JSON 元素形状(sync payload 原文,cn 行可空字段保留)。 */
|
||||
interface TranslationEntry {
|
||||
locale: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
venue: string | null;
|
||||
address: string | null;
|
||||
}
|
||||
|
||||
/** albums_ov.categories JSON 元素形状(sync-inbox 投影结构)。 */
|
||||
interface CategoryEntry {
|
||||
id: string;
|
||||
sortOrder: number;
|
||||
translations: Array<{ locale: string; name: string }>;
|
||||
}
|
||||
|
||||
/** 热门排序快照:冻结的 photoId 有序清单 + 过期时刻(见文件头快照说明)。 */
|
||||
interface HotSnapshot {
|
||||
ids: readonly string[];
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门快照 offset 游标:base64url JSON 数字(快照内下一页起始下标)。快照在
|
||||
* TTL 内冻结,下标分页严格稳定(无并发漂移);解码失败 → null(调用方 400,
|
||||
* 与 latest 游标同语义)。两种模式的游标互不通用(opaque,各解码各的)。
|
||||
*/
|
||||
export function encodeHotCursor(offset: number): string {
|
||||
return Buffer.from(JSON.stringify(offset), 'utf8').toString('base64url');
|
||||
}
|
||||
|
||||
export function decodeHotCursor(raw: string): number | null {
|
||||
if (raw === '') {
|
||||
return null;
|
||||
}
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8'));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return typeof parsed === 'number' && Number.isInteger(parsed) && parsed >= 0
|
||||
? parsed
|
||||
: null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PublicOvService {
|
||||
/** 每相册热门快照(进程内单例状态;TTL 过期懒重算,无失效广播)。 */
|
||||
private readonly hotSnapshots = new Map<string, HotSnapshot>();
|
||||
|
||||
constructor(
|
||||
private readonly prisma: OvPrismaService,
|
||||
@Inject(STORAGE_PORT_OV) private readonly storage: StoragePort,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 公开相册信息:publicToken → albums_ov 副本 → isAlbumVisibleOv → 翻译回退
|
||||
* 解析(translations JSON)→ 分类(categories JSON,sortOrder 升序 + id 决胜
|
||||
* —— cn 侧由 ORDER BY 保证,副本在内存排序)→ banner 现签 → viewCount
|
||||
* (album_stats 真实值,无行默认 0)。
|
||||
*/
|
||||
async getPublicAlbum(
|
||||
token: string,
|
||||
localeRaw: string | undefined,
|
||||
): Promise<PublicAlbumResponse> {
|
||||
const album = await this.findPublicAlbumOv(token);
|
||||
const translations = this.translationEntries(album.translations);
|
||||
const resolved = this.resolveTranslation(
|
||||
album.defaultLocale,
|
||||
translations,
|
||||
localeRaw,
|
||||
);
|
||||
|
||||
const categories = this.categoryEntries(album.categories)
|
||||
.slice()
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder || (a.id < b.id ? -1 : 1));
|
||||
const banner =
|
||||
album.bannerAssetKey === null
|
||||
? null
|
||||
: await this.presign(album.bannerAssetKey);
|
||||
|
||||
const stats = await this.prisma.album_stats.findUnique({
|
||||
where: { albumId: album.albumId },
|
||||
select: { viewCount: true },
|
||||
});
|
||||
|
||||
return {
|
||||
publicToken: album.publicToken,
|
||||
resolvedLocale: resolved.locale,
|
||||
defaultLocale: album.defaultLocale as AlbumLocale,
|
||||
timezone: album.timezone,
|
||||
startAt: (album.startAt ?? new Date(0)).toISOString(),
|
||||
endAt:
|
||||
album.endAt === null ? null : album.endAt.toISOString(),
|
||||
translation: {
|
||||
locale: resolved.locale,
|
||||
title: resolved.row.title,
|
||||
description: resolved.row.description,
|
||||
venue: resolved.row.venue,
|
||||
address: resolved.row.address,
|
||||
},
|
||||
categories: categories.map((c) => ({
|
||||
id: c.id,
|
||||
name: this.resolveCategoryName(
|
||||
c.translations,
|
||||
resolved.locale,
|
||||
album.defaultLocale,
|
||||
),
|
||||
sortOrder: c.sortOrder,
|
||||
})),
|
||||
banner,
|
||||
// 真实值(Task 8 的 /visits 端点维护计数;此处只读列,无行 = 0)。
|
||||
viewCount: stats?.viewCount ?? 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开照片列表。latest(缺省):(publishedAt, photoId) 双列稳定倒序键集游标
|
||||
* (shared/cursor,与 cn 同一实现——游标编码逐字节同形)。hot:快照清单
|
||||
* offset 切片(见文件头)。category 过滤校验同 cn:须出现在本相册 categories
|
||||
* JSON(幽灵/跨册同码 400,防探测)。签名 URL 逐条现签(ov 只读凭证)。
|
||||
*/
|
||||
async listPublicPhotos(
|
||||
token: string,
|
||||
query: {
|
||||
category?: string;
|
||||
cursor?: string;
|
||||
limit: number;
|
||||
sort: 'latest' | 'hot';
|
||||
},
|
||||
): Promise<PublicPhotoListResponse> {
|
||||
const album = await this.findPublicAlbumOv(token);
|
||||
const categoryIds = new Set(
|
||||
this.categoryEntries(album.categories).map((c) => c.id),
|
||||
);
|
||||
if (query.category !== undefined && query.category !== '') {
|
||||
if (!categoryIds.has(query.category)) {
|
||||
throw validationFailed('category 不属于该相册');
|
||||
}
|
||||
}
|
||||
const categoryFilter =
|
||||
query.category !== undefined && query.category !== ''
|
||||
? { categoryId: query.category }
|
||||
: {};
|
||||
|
||||
if (query.sort === 'hot') {
|
||||
return this.listHot(album.albumId, categoryFilter, query);
|
||||
}
|
||||
|
||||
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||
if (query.cursor !== undefined && query.cursor !== '') {
|
||||
decoded = decodeKeysetCursor(query.cursor);
|
||||
if (decoded === null) {
|
||||
throw validationFailed('cursor 不合法');
|
||||
}
|
||||
}
|
||||
const where = {
|
||||
albumId: album.albumId,
|
||||
visibility: 'published',
|
||||
...categoryFilter,
|
||||
...(decoded !== null
|
||||
? keysetWhereDesc('publishedAt', 'photoId', decoded)
|
||||
: {}),
|
||||
};
|
||||
const rows = await this.prisma.photos_ov.findMany({
|
||||
where,
|
||||
orderBy: [{ publishedAt: 'desc' }, { photoId: 'desc' }],
|
||||
take: query.limit + 1, // 多取一条探测 hasMore(同 cn 相册列表)
|
||||
});
|
||||
const hasMore = rows.length > query.limit;
|
||||
const page = hasMore ? rows.slice(0, query.limit) : rows;
|
||||
const items = await this.toItems(page);
|
||||
// 游标取自「DB 页末行」(同 cn:被跳过的行不进下一页重扫)。
|
||||
const last = page.at(-1);
|
||||
const nextCursor =
|
||||
hasMore && last !== undefined
|
||||
? encodeKeysetCursor({
|
||||
sort: (last.publishedAt ?? new Date(0)).toISOString(),
|
||||
id: last.photoId,
|
||||
})
|
||||
: null;
|
||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 公开单图(深链定位 + 签名刷新入口,每次现签)。校验同 cn:照片不存在 /
|
||||
* 不属于该相册 / 非 published 统一 404 PHOTO_NOT_PUBLIC;副本缺宽高同 cn
|
||||
* 防御语义(不可展示 → 404)。
|
||||
*/
|
||||
async getPublicPhoto(
|
||||
token: string,
|
||||
photoId: string,
|
||||
): Promise<PublicPhotoDetailResponse> {
|
||||
const album = await this.findPublicAlbumOv(token);
|
||||
const photo = await this.prisma.photos_ov.findUnique({
|
||||
where: { photoId },
|
||||
});
|
||||
if (
|
||||
photo === null ||
|
||||
photo.albumId !== album.albumId ||
|
||||
photo.visibility !== 'published' ||
|
||||
photo.width === null ||
|
||||
photo.height === null
|
||||
) {
|
||||
throw photoNotPublic();
|
||||
}
|
||||
return {
|
||||
photoId: photo.photoId,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
thumb: await this.presign(photo.thumbObjectKey),
|
||||
browse: await this.presign(photo.browseObjectKey),
|
||||
likeCount: photo.likeTotal,
|
||||
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
||||
categoryId: photo.categoryId,
|
||||
};
|
||||
}
|
||||
|
||||
/** hot 排序页:快照清单(TTL 缓存)offset 切片 → 现取行(跳过中途被撤下的)。 */
|
||||
private async listHot(
|
||||
albumId: string,
|
||||
categoryFilter: { categoryId?: string },
|
||||
query: { cursor?: string; limit: number },
|
||||
): Promise<PublicPhotoListResponse> {
|
||||
let offset = 0;
|
||||
if (query.cursor !== undefined && query.cursor !== '') {
|
||||
offset = decodeHotCursor(query.cursor) ?? -1;
|
||||
if (offset < 0) {
|
||||
throw validationFailed('cursor 不合法');
|
||||
}
|
||||
}
|
||||
const snapshot = await this.hotSnapshot(albumId, categoryFilter);
|
||||
const pageIds = snapshot.slice(offset, offset + query.limit);
|
||||
const hasMore = offset + query.limit < snapshot.length;
|
||||
if (pageIds.length === 0) {
|
||||
return { items: [], nextCursor: null, hasMore: false };
|
||||
}
|
||||
// 行现取:快照只冻结顺序;可见性/计数逐条现读(中途撤下的照片跳过——
|
||||
// 页短于 limit 不影响游标推进,同 cn「游标取自 DB 页末行」的越过语义)。
|
||||
const rows = await this.prisma.photos_ov.findMany({
|
||||
where: { photoId: { in: pageIds }, visibility: 'published' },
|
||||
});
|
||||
const byId = new Map(rows.map((row) => [row.photoId, row]));
|
||||
const ordered = pageIds
|
||||
.map((id) => byId.get(id))
|
||||
.filter((row) => row !== undefined);
|
||||
const items = await this.toItems(ordered);
|
||||
const nextCursor = hasMore ? encodeHotCursor(offset + query.limit) : null;
|
||||
return { items, nextCursor, hasMore: nextCursor !== null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 热门快照:likeTotal DESC, photoId DESC 的 published photoId 清单,TTL 内
|
||||
* 复用(category 过滤参与缓存键——不同过滤各自快照,语义独立且互不污染)。
|
||||
*/
|
||||
private async hotSnapshot(
|
||||
albumId: string,
|
||||
categoryFilter: { categoryId?: string },
|
||||
): Promise<readonly string[]> {
|
||||
const key = `${albumId}|${categoryFilter.categoryId ?? ''}`;
|
||||
const cached = this.hotSnapshots.get(key);
|
||||
if (cached !== undefined && Date.now() < cached.expiresAt) {
|
||||
return cached.ids;
|
||||
}
|
||||
const rows = await this.prisma.photos_ov.findMany({
|
||||
where: { albumId, visibility: 'published', ...categoryFilter },
|
||||
orderBy: [{ likeTotal: 'desc' }, { photoId: 'desc' }],
|
||||
select: { photoId: true },
|
||||
});
|
||||
const ids = rows.map((row) => row.photoId);
|
||||
this.hotSnapshots.set(key, {
|
||||
ids,
|
||||
expiresAt: Date.now() + hotSortTtlMs(),
|
||||
});
|
||||
return ids;
|
||||
}
|
||||
|
||||
/** 副本行 → 契约条目(宽高缺失跳过,同 cn 防御语义;键为发布载荷内联键)。 */
|
||||
private async toItems(
|
||||
page: Array<{
|
||||
photoId: string;
|
||||
width: number | null;
|
||||
height: number | null;
|
||||
thumbObjectKey: string;
|
||||
browseObjectKey: string;
|
||||
likeTotal: number;
|
||||
publishedAt: Date | null;
|
||||
}>,
|
||||
): Promise<PublicPhotoListResponse['items']> {
|
||||
const items: PublicPhotoListResponse['items'] = [];
|
||||
for (const photo of page) {
|
||||
if (photo.width === null || photo.height === null) {
|
||||
continue;
|
||||
}
|
||||
items.push({
|
||||
photoId: photo.photoId,
|
||||
width: photo.width,
|
||||
height: photo.height,
|
||||
thumb: await this.presign(photo.thumbObjectKey),
|
||||
browse: await this.presign(photo.browseObjectKey),
|
||||
likeCount: photo.likeTotal,
|
||||
publishedAt: (photo.publishedAt ?? new Date(0)).toISOString(),
|
||||
});
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
/** publicToken → albums_ov 副本;不存在 / 不可见(含紧急停用)统一 404。 */
|
||||
private async findPublicAlbumOv(token: string) {
|
||||
const album = await this.prisma.albums_ov.findUnique({
|
||||
where: { publicToken: token },
|
||||
});
|
||||
if (album === null || !isAlbumVisibleOv(album)) {
|
||||
throw albumNotPublic();
|
||||
}
|
||||
return album;
|
||||
}
|
||||
|
||||
/** translations JSON → 类型化条目(非数组属投影异常,按空集走 5xx 兜底)。 */
|
||||
private translationEntries(value: unknown): TranslationEntry[] {
|
||||
return Array.isArray(value)
|
||||
? (value as TranslationEntry[]).filter(
|
||||
(t) =>
|
||||
typeof t?.locale === 'string' && typeof t?.title === 'string',
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
/** categories JSON → 类型化条目(防御同上)。 */
|
||||
private categoryEntries(value: unknown): CategoryEntry[] {
|
||||
return Array.isArray(value)
|
||||
? (value as CategoryEntry[]).filter(
|
||||
(c) => typeof c?.id === 'string' && Array.isArray(c.translations),
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 语言回退解析(需求 4.7,与 cn 同形):请求 locale 合法(契约枚举)且副本
|
||||
* 有条目 → 用之;否则 defaultLocale。defaultLocale 条目由 cn 创建/更新路径
|
||||
* 保证必在(同步载荷原样携带),缺行属装配异常 → 5xx(公开端点不得以半
|
||||
* 翻译状态 200)。
|
||||
*/
|
||||
private resolveTranslation(
|
||||
defaultLocale: string,
|
||||
entries: TranslationEntry[],
|
||||
requested: string | undefined,
|
||||
): { locale: AlbumLocale; row: TranslationEntry } {
|
||||
const byLocale = new Map(entries.map((row) => [row.locale, row]));
|
||||
const defaultRow = byLocale.get(defaultLocale);
|
||||
if (defaultRow === undefined) {
|
||||
throw new ApiException(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal server error',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
if (
|
||||
requested !== undefined &&
|
||||
(ALBUM_LOCALES as readonly string[]).includes(requested)
|
||||
) {
|
||||
const row = byLocale.get(requested);
|
||||
if (row !== undefined) {
|
||||
return { locale: requested as AlbumLocale, row };
|
||||
}
|
||||
}
|
||||
return { locale: defaultLocale as AlbumLocale, row: defaultRow };
|
||||
}
|
||||
|
||||
/**
|
||||
* 分类名称解析:resolved locale → defaultLocale → 任一既有条目(locale 升序
|
||||
* 首,确定性)——与 cn resolveCategoryName 同形;真缺行回退空串保持 200
|
||||
* (名称为契约必返 string,公开页不因单分类脏数据整页 5xx)。
|
||||
*/
|
||||
private resolveCategoryName(
|
||||
translations: Array<{ locale: string; name: string }>,
|
||||
resolvedLocale: AlbumLocale,
|
||||
defaultLocale: string,
|
||||
): string {
|
||||
if (translations.length === 0) {
|
||||
return '';
|
||||
}
|
||||
const byLocale = new Map(translations.map((t) => [t.locale, t.name]));
|
||||
return (
|
||||
byLocale.get(resolvedLocale) ??
|
||||
byLocale.get(defaultLocale) ??
|
||||
([...translations]
|
||||
.sort((a, b) => (a.locale < b.locale ? -1 : 1))[0]?.name ?? '')
|
||||
);
|
||||
}
|
||||
|
||||
/** 现签短期 GET 凭证(TTL 惰性读取;同 cn:SigV4 本地计算无网络副作用)。 */
|
||||
private async presign(objectKey: string): Promise<SignedUrlResponse> {
|
||||
const ttl = signedUrlTtlSeconds();
|
||||
const url = await this.storage.presignGet(objectKey, ttl);
|
||||
return { url, expiresAt: new Date(Date.now() + ttl * 1000).toISOString() };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// services/api/src/public-ov/storage-ov.provider.ts —— ov 侧 StoragePort 的 DI
|
||||
// 装配(P2 Task 6)。与 cn 侧 uploads/storage.provider 同一构造
|
||||
// (createStorageFromEnv → MinioStorageAdapter),但**只读约定**:
|
||||
// · ov 公开读域只调用 presignGet(thumb/browse/banner 现签短期 GET 凭证);
|
||||
// cn 副本对象键与 ov 共用同一本地 MinIO(生产为同桶只读凭证/CDN,P5)。
|
||||
// · 「只读」是约定而非凭证约束:本地仿真沿用同一对 access key,真实 OSS
|
||||
// 侧应发放只读凭证(presignGet/head)——登记为 P5 部署事项(计划 Task 6
|
||||
// 执行注意:真实 OSS 只读凭证随 G1/P5)。
|
||||
// 独立 DI token(STORAGE_PORT_OV)而非复用 STORAGE_PORT:uploads 模块属 cn
|
||||
// 角色装配(Redis 队列等),ov 入口不 import UploadsModule,token 不共享实例。
|
||||
import { createStorageFromEnv, type StoragePort } from '@photo/storage';
|
||||
|
||||
/** ov 侧只读存储端口 DI token(public-ov Task 6;likes/visits 后续按需复用)。 */
|
||||
export const STORAGE_PORT_OV = Symbol('STORAGE_PORT_OV');
|
||||
|
||||
export const storageOvProvider = {
|
||||
provide: STORAGE_PORT_OV,
|
||||
useFactory: (): StoragePort => createStorageFromEnv(),
|
||||
};
|
||||
@@ -17,7 +17,8 @@
|
||||
// Task 5;categories 仅当载荷携带全量清单(bootstrap/reconcile 快照事件)
|
||||
// 时整体替换——错过的 category 墓碑由此收敛,增量事件不携带则不动);
|
||||
// category → albums_ov.categories JSON 元素 upsert/移除;photo.published →
|
||||
// photos_ov upsert(保留 likeTotal/countVersion 权威计数);photo.hidden/
|
||||
// photos_ov upsert(含 categoryId——Task 6 公开列表分类过滤/单图同形所需;
|
||||
// 保留 likeTotal/countVersion 权威计数);photo.hidden/
|
||||
// deleted → photos_ov.visibility 置位(行不存在 = 从未复制即不可见,缺席
|
||||
// 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo
|
||||
// 仅推进版本,ov schema 无位);
|
||||
@@ -299,6 +300,7 @@ export class SyncInboxService {
|
||||
data: {
|
||||
photoId: event.entityId,
|
||||
albumId: event.albumId,
|
||||
categoryId: p.categoryId ?? null,
|
||||
originalObjectKey: p.objectKey,
|
||||
thumbObjectKey: p.thumbKey,
|
||||
browseObjectKey: p.browseKey,
|
||||
@@ -318,6 +320,7 @@ export class SyncInboxService {
|
||||
};
|
||||
if (visibility === 'published') {
|
||||
const p = payload as PhotoPublishedPayload;
|
||||
data.categoryId = p.categoryId ?? null;
|
||||
data.originalObjectKey = p.objectKey;
|
||||
data.thumbObjectKey = p.thumbKey;
|
||||
data.browseObjectKey = p.browseKey;
|
||||
|
||||
@@ -177,6 +177,7 @@ export function buildPhotoPublishedPayload(
|
||||
return {
|
||||
photoId: photo.id,
|
||||
albumId: photo.albumId,
|
||||
categoryId: photo.categoryId,
|
||||
objectKey: photo.objectKey,
|
||||
thumbKey: thumb.objectKey,
|
||||
browseKey: browse.objectKey,
|
||||
|
||||
Reference in New Issue
Block a user