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:
浅念
2026-09-18 17:05:56 +08:00
parent eac8da6d6c
commit 0fcf18cc5c
26 changed files with 1355 additions and 40 deletions
@@ -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(),
};