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:
+11
-1
@@ -9,7 +9,11 @@ SIGNED_URL_TTL_SECONDS=900
|
|||||||
AUTH_SESSION_TTL_SECONDS=2592000
|
AUTH_SESSION_TTL_SECONDS=2592000
|
||||||
UPLOAD_MAX_BYTES=52428800
|
UPLOAD_MAX_BYTES=52428800
|
||||||
PORT=8080
|
PORT=8080
|
||||||
CORS_ORIGINS=http://localhost:5175,http://localhost:5176
|
# viewer(P2 Task 6 起直连 ov 8081,跨源)需两形 origin:Playwright/本机访问
|
||||||
|
# 常走 127.0.0.1,浏览器 Origin 头按实际 host 发出——localhost 与 127.0.0.1
|
||||||
|
# 是两个不同 origin,缺一则直连 fetch 被 CORS 拦截。admin(5176)走自身
|
||||||
|
# /v1 代理同源,不依赖此清单。
|
||||||
|
CORS_ORIGINS=http://localhost:5175,http://127.0.0.1:5175,http://localhost:5176
|
||||||
LOG_LEVEL=info
|
LOG_LEVEL=info
|
||||||
ADMIN_ACCOUNT=admin
|
ADMIN_ACCOUNT=admin
|
||||||
ADMIN_PASSWORD=admin-secret-change-me
|
ADMIN_PASSWORD=admin-secret-change-me
|
||||||
@@ -35,6 +39,12 @@ CN_API_URL=http://127.0.0.1:8080
|
|||||||
SYNC_BATCH_SIZE=100
|
SYNC_BATCH_SIZE=100
|
||||||
SYNC_PUSH_INTERVAL_MS=2000
|
SYNC_PUSH_INTERVAL_MS=2000
|
||||||
SYNC_RECONCILE_INTERVAL_MS=300000
|
SYNC_RECONCILE_INTERVAL_MS=300000
|
||||||
|
# P2 Task 6:ov 公开列表 sort=hot 的排序快照 TTL(毫秒,缺省 30000)——
|
||||||
|
# 顺序冻结窗口(点赞数仍逐请求现读),由 services/api public-ov 惰性读取;
|
||||||
|
# e2e 以短 TTL 加速翻转断言。另:viewer 的 API 基址是构建期变量
|
||||||
|
# VITE_API_BASE(缺省 http://127.0.0.1:8081 ov 入口;空串=同源网关)——
|
||||||
|
# Vite 只读 apps/viewer/.env*,根 .env 对前端无效(见 apps/viewer/src/api/client.ts)。
|
||||||
|
HOT_SORT_TTL_MS=30000
|
||||||
STAT_RETURN_INTERVAL_MS=60000
|
STAT_RETURN_INTERVAL_MS=60000
|
||||||
VISIT_WINDOW_MINUTES=30
|
VISIT_WINDOW_MINUTES=30
|
||||||
LIKE_BATCH_WINDOW_HOURS=24
|
LIKE_BATCH_WINDOW_HOURS=24
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { expect, test } from '@playwright/test';
|
import { expect, test } from '@playwright/test';
|
||||||
import {
|
import {
|
||||||
cleanupSeededAlbums,
|
cleanupSeededAlbums,
|
||||||
|
flushSync,
|
||||||
makePublicToken,
|
makePublicToken,
|
||||||
seedAlbum,
|
seedAlbum,
|
||||||
seedBannerAsset,
|
seedBannerAsset,
|
||||||
seedCategory,
|
seedCategory,
|
||||||
} from './helpers';
|
} from './helpers';
|
||||||
|
|
||||||
// Task 6 活动页 Playwright(真实后端:API 8080 由 playwright.config webServer 拉起
|
// Task 6 活动页 Playwright(真实后端:cn API 8080 + ov API 8081 由
|
||||||
// 或复用已在跑的实例;种子走 Prisma/MinIO 直种,见 helpers.ts)。
|
// playwright.config webServer 拉起或复用已在跑的实例;种子走 Prisma/MinIO 直种
|
||||||
|
// cn 侧,再 flushSync 经真实管线投影到 ov——viewer 已切 ov 读,见 helpers.ts)。
|
||||||
// 语言前置:无头 Chromium 默认 en-US——用 addInitScript 预置 localStorage,避免
|
// 语言前置:无头 Chromium 默认 en-US——用 addInitScript 预置 localStorage,避免
|
||||||
// 依赖浏览器语言;zh 断言由此确定性地落在 zh-CN UI。
|
// 依赖浏览器语言;zh 断言由此确定性地落在 zh-CN UI。
|
||||||
|
|
||||||
@@ -35,6 +37,7 @@ test('renders album info in zh with timezone-adjusted time, chips and empty stat
|
|||||||
{ locale: 'en', name: 'Check-in Wall' },
|
{ locale: 'en', name: 'Check-in Wall' },
|
||||||
]);
|
]);
|
||||||
await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
|
await seedCategory(album.id, 1, [{ locale: 'zh-CN', name: '开场' }]);
|
||||||
|
await flushSync(album.id);
|
||||||
|
|
||||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||||
await page.goto(`/a/${album.publicToken}`);
|
await page.goto(`/a/${album.publicToken}`);
|
||||||
@@ -66,6 +69,7 @@ test('switching locale re-fetches and shows the English translation', async ({ p
|
|||||||
{ locale: 'en', title: 'Bilingual Test Album', description: 'English intro', venue: 'Main Hall' },
|
{ locale: 'en', title: 'Bilingual Test Album', description: 'English intro', venue: 'Main Hall' },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
await flushSync(album.id);
|
||||||
|
|
||||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||||
await page.goto(`/a/${album.publicToken}`);
|
await page.goto(`/a/${album.publicToken}`);
|
||||||
@@ -84,6 +88,7 @@ test('en UI with a zh-only album falls back to zh content while chrome stays en'
|
|||||||
alias: 'fallback',
|
alias: 'fallback',
|
||||||
translations: [{ locale: 'zh-CN', title: '只有中文的相册', venue: '大宴会厅' }],
|
translations: [{ locale: 'zh-CN', title: '只有中文的相册', venue: '大宴会厅' }],
|
||||||
});
|
});
|
||||||
|
await flushSync(album.id);
|
||||||
|
|
||||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'en'));
|
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'en'));
|
||||||
await page.goto(`/a/${album.publicToken}`);
|
await page.goto(`/a/${album.publicToken}`);
|
||||||
@@ -101,6 +106,7 @@ test('banner signed URL loads a real image', async ({ page }) => {
|
|||||||
translations: [{ locale: 'zh-CN', title: '有封面的相册' }],
|
translations: [{ locale: 'zh-CN', title: '有封面的相册' }],
|
||||||
});
|
});
|
||||||
await seedBannerAsset(album.id);
|
await seedBannerAsset(album.id);
|
||||||
|
await flushSync(album.id);
|
||||||
|
|
||||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||||
await page.goto(`/a/${album.publicToken}`);
|
await page.goto(`/a/${album.publicToken}`);
|
||||||
@@ -119,6 +125,8 @@ test('offline and ghost tokens show the localized unavailable error', async ({ p
|
|||||||
status: 'offline',
|
status: 'offline',
|
||||||
translations: [{ locale: 'zh-CN', title: '已下线相册' }],
|
translations: [{ locale: 'zh-CN', title: '已下线相册' }],
|
||||||
});
|
});
|
||||||
|
// 下线态也经真实管线同步:ov 侧 status=offline → isAlbumVisibleOv=false → 404。
|
||||||
|
await flushSync(offline.id);
|
||||||
|
|
||||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||||
await page.goto(`/a/${offline.publicToken}`);
|
await page.goto(`/a/${offline.publicToken}`);
|
||||||
|
|||||||
@@ -1,9 +1,16 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import { cleanupSeededAlbums, seedAlbum, seedCategory, seedPhoto } from './helpers';
|
import {
|
||||||
|
cleanupSeededAlbums,
|
||||||
|
flushSync,
|
||||||
|
seedAlbum,
|
||||||
|
seedCategory,
|
||||||
|
seedPhoto,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
// Task 7 瀑布流 Playwright(真实后端):35 张已发布照片(公开列表默认页 30)→
|
// Task 7 瀑布流 Playwright(真实后端):35 张已发布照片(公开列表默认页 30)→
|
||||||
// 初始 30 + 滚动取第 2 页 5 张;分类 chips 过滤;新照片轮询提示(__viewerPollMs
|
// 初始 30 + 滚动取第 2 页 5 张;分类 chips 过滤;新照片轮询提示(__viewerPollMs
|
||||||
// 注入缩短间隔,见 PhotoFeed pollIntervalMs 的 dev/test 覆盖钩子)。
|
// 注入缩短间隔,见 PhotoFeed pollIntervalMs 的 dev/test 覆盖钩子)。
|
||||||
|
// 种子直种 cn 侧,seedFeedAlbum/新增照片后 flushSync 投影到 ov(viewer 读 ov)。
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
await cleanupSeededAlbums();
|
await cleanupSeededAlbums();
|
||||||
@@ -39,6 +46,7 @@ async function seedFeedAlbum(alias: string): Promise<{ albumId: string; token: s
|
|||||||
publishedAt: new Date(base - (i + 1) * 60_000),
|
publishedAt: new Date(base - (i + 1) * 60_000),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await flushSync(album.id);
|
||||||
return { albumId: album.id, token: album.publicToken };
|
return { albumId: album.id, token: album.publicToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -109,7 +117,7 @@ test('new-photo poll hint appears; click refreshes to include new photos', async
|
|||||||
await expect(page.locator('.cell')).toHaveCount(35);
|
await expect(page.locator('.cell')).toHaveCount(35);
|
||||||
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
|
await expect(page.locator('.new-photo-bar')).toHaveCount(0);
|
||||||
|
|
||||||
// 轮询期间直种 2 张更新的照片(publishedAt 晚于全部既有)。
|
// 轮询期间直种 2 张更新的照片(publishedAt 晚于全部既有)并同步到 ov。
|
||||||
const base = Date.now();
|
const base = Date.now();
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
await seedPhoto(albumId, `poll-new-${i}`, {
|
await seedPhoto(albumId, `poll-new-${i}`, {
|
||||||
@@ -119,6 +127,7 @@ test('new-photo poll hint appears; click refreshes to include new photos', async
|
|||||||
publishedAt: new Date(base + (i + 1) * 60_000),
|
publishedAt: new Date(base + (i + 1) * 60_000),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
await flushSync(albumId);
|
||||||
|
|
||||||
// 提示条出现(不自动插入——仍 35 张)。
|
// 提示条出现(不自动插入——仍 35 张)。
|
||||||
await expect(page.locator('.new-photo-bar')).toBeVisible({ timeout: 10_000 });
|
await expect(page.locator('.new-photo-bar')).toBeVisible({ timeout: 10_000 });
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。
|
// services/api/test/public.e2e-spec.ts 的种子形状)。公开端点匿名可达,无需登录态。
|
||||||
// Task 7 起补 seedPhoto:直种「已发布完成态」照片(photos + thumb/browse 变体行
|
// Task 7 起补 seedPhoto:直种「已发布完成态」照片(photos + thumb/browse 变体行
|
||||||
// + MinIO 真实 JPEG 对象,行形状同 worker 发布事务落库形态)。
|
// + MinIO 真实 JPEG 对象,行形状同 worker 发布事务落库形态)。
|
||||||
|
// P2 Task 6 起 viewer 读 ov 副本(VITE_API_BASE 直连 8081)——直种只落 cn 库,
|
||||||
|
// 故 seed* 后必须 flushSync(albumId):经 cn bootstrap(当前态快照写回 outbox)
|
||||||
|
// + deliverPendingOnce(真实发送管线排空)把种子投影到 ov 副本,无旁路。
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -10,6 +13,12 @@ import { config } from 'dotenv';
|
|||||||
import sharp from 'sharp';
|
import sharp from 'sharp';
|
||||||
import { PrismaClient } from '@photo/db-cn';
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
|
import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
|
||||||
|
import {
|
||||||
|
SYNC_SIGNATURE_HEADER,
|
||||||
|
SYNC_TIMESTAMP_HEADER,
|
||||||
|
signSyncBody,
|
||||||
|
} from '@photo/contracts';
|
||||||
|
import { deliverPendingOnce } from '../../../services/sync/src/sender';
|
||||||
|
|
||||||
// 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在
|
// 仓库根 .env 唯一来源(同 services/api test/load-env.ts 约定)。必须在
|
||||||
// PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。
|
// PrismaClient / createStorageFromEnv 构造前执行——两者均在首次查询/调用时读 env。
|
||||||
@@ -198,11 +207,45 @@ export async function seedPhoto(
|
|||||||
return photoId;
|
return photoId;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 直删照片域行(Task 8 e2e:模拟已分享深链的照片下线——变体行在前、主行在后;
|
/** cn→ov 种子同步(P2 Task 6):先打 cn bootstrap(当前态快照事件写回 outbox,
|
||||||
* MinIO 对象随 dev 前缀清理约定,不在此删)。 */
|
* 优先级 normal),再 deliverPendingOnce 经真实发送管线(HMAC 签名
|
||||||
|
* content-batches)排空至 ov 副本。幂等可重入:同版本新 eventId 在 ov 侧按
|
||||||
|
* 当前态重断言应用;与常驻 dev:sync 发送器并发亦安全(eventId 幂等)。 */
|
||||||
|
export async function flushSync(albumId: string): Promise<void> {
|
||||||
|
const internalKey = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
||||||
|
const rawBody = JSON.stringify({ albumId });
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000).toString();
|
||||||
|
const cnBase = (process.env.CN_API_URL ?? 'http://127.0.0.1:8080').replace(/\/+$/, '');
|
||||||
|
const bootstrap = await fetch(`${cnBase}/v1/internal/sync/bootstrap`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
[SYNC_TIMESTAMP_HEADER]: timestamp,
|
||||||
|
[SYNC_SIGNATURE_HEADER]: signSyncBody(internalKey, rawBody),
|
||||||
|
},
|
||||||
|
body: rawBody,
|
||||||
|
});
|
||||||
|
if (!bootstrap.ok) {
|
||||||
|
throw new Error(
|
||||||
|
`flushSync bootstrap 失败(${bootstrap.status}):${(await bootstrap.text()).slice(0, 300)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await deliverPendingOnce({
|
||||||
|
prisma,
|
||||||
|
ovApiUrl: process.env.OV_API_URL ?? 'http://127.0.0.1:8081',
|
||||||
|
internalKey,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 模拟已分享深链的照片下线(Task 8 e2e):软删(visibility=deleted +
|
||||||
|
* entityVersion 自增)——硬删会让 cn 行缺席、bootstrap 快照无从发墓碑,ov 副本
|
||||||
|
* 将永远留着旧发布行;软删 + flushSync 让 photo.deleted 事件走真实管线撤下。
|
||||||
|
* 变体行/MinIO 对象保留(公开读只看 photos 行;行清理归 cleanupSeededAlbums)。 */
|
||||||
export async function deleteSeededPhoto(photoId: string): Promise<void> {
|
export async function deleteSeededPhoto(photoId: string): Promise<void> {
|
||||||
await prisma.photo_variants.deleteMany({ where: { photoId } });
|
await prisma.photos.update({
|
||||||
await prisma.photos.deleteMany({ where: { id: photoId } });
|
where: { id: photoId },
|
||||||
|
data: { visibility: 'deleted', entityVersion: { increment: 1 } },
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
|
/** 清理本文件种下的相册域行(扁平字符串外键、无 DB 级联——按子表在前手工删;
|
||||||
@@ -223,6 +266,11 @@ export async function cleanupSeededAlbums(): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
await prisma.photos.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
|
await prisma.photos.deleteMany({ where: { albumId: { in: seededAlbumIds } } });
|
||||||
|
// flushSync 经 outbox 投递的快照事件行(已投递/未投递一并清——未投递
|
||||||
|
// 残留会污染后续 deliverPendingOnce 的排空判定轮次)。
|
||||||
|
await prisma.outbox_events.deleteMany({
|
||||||
|
where: { albumId: { in: seededAlbumIds } },
|
||||||
|
});
|
||||||
const categories = await prisma.categories.findMany({
|
const categories = await prisma.categories.findMany({
|
||||||
where: { albumId: { in: seededAlbumIds } },
|
where: { albumId: { in: seededAlbumIds } },
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
|
|||||||
@@ -12,6 +12,9 @@
|
|||||||
// ④ admin UI 再登录:照片管理 3 行(就绪/已发布)→ 隐藏 1 张 → viewer 重载
|
// ④ admin UI 再登录:照片管理 3 行(就绪/已发布)→ 隐藏 1 张 → viewer 重载
|
||||||
// 剩 2 张 → 被隐深链 404 本地化提示;
|
// 剩 2 张 → 被隐深链 404 本地化提示;
|
||||||
// ⑤ 空相册 B(admin API 建,无照片):viewer 空态提示「照片即将上线」。
|
// ⑤ 空相册 B(admin API 建,无照片):viewer 空态提示「照片即将上线」。
|
||||||
|
// P2 Task 6 起 viewer 观众腿读 ov 副本(VITE_API_BASE 直连 8081)——本剧本的
|
||||||
|
// cn 侧变更(发布/隐藏/建 B 相册)在各 viewer 腿前 flushSync(bootstrap 快照 +
|
||||||
|
// 真实发送管线排空)投影到 ov;admin/staffB 腿仍走 cn 8080(代理/直连,不变)。
|
||||||
// 数据清理:afterAll 按 viewer helpers 同序直删(含 album_members/upload_sessions/
|
// 数据清理:afterAll 按 viewer helpers 同序直删(含 album_members/upload_sessions/
|
||||||
// photo_jobs/outbox_events 等 API 链路落库行)+ staffB 账号域行。
|
// photo_jobs/outbox_events 等 API 链路落库行)+ staffB 账号域行。
|
||||||
import { randomBytes } from 'node:crypto';
|
import { randomBytes } from 'node:crypto';
|
||||||
@@ -25,6 +28,7 @@ import { createStorageFromEnv, type MinioStorageAdapter } from '@photo/storage';
|
|||||||
import { hashPassword } from '../../../services/api/src/auth/password';
|
import { hashPassword } from '../../../services/api/src/auth/password';
|
||||||
import { seedAdmin } from '../../../services/api/src/scripts/seed-admin';
|
import { seedAdmin } from '../../../services/api/src/scripts/seed-admin';
|
||||||
import { PublishService } from '../../../services/worker/src/publish.service';
|
import { PublishService } from '../../../services/worker/src/publish.service';
|
||||||
|
import { flushSync } from './helpers';
|
||||||
|
|
||||||
// 仓库根 .env 唯一来源(同 helpers.ts 约定):PrismaClient/createStorageFromEnv
|
// 仓库根 .env 唯一来源(同 helpers.ts 约定):PrismaClient/createStorageFromEnv
|
||||||
// 构造前先加载。API 腿直连 8080(服务端到服务端,无 CORS 面)。
|
// 构造前先加载。API 腿直连 8080(服务端到服务端,无 CORS 面)。
|
||||||
@@ -92,6 +96,7 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
|||||||
let adminToken: string;
|
let adminToken: string;
|
||||||
let albumId: string;
|
let albumId: string;
|
||||||
let publicToken: string;
|
let publicToken: string;
|
||||||
|
let emptyAlbumId: string;
|
||||||
let emptyToken: string;
|
let emptyToken: string;
|
||||||
let categoryId: string;
|
let categoryId: string;
|
||||||
const photoIds: string[] = [];
|
const photoIds: string[] = [];
|
||||||
@@ -284,7 +289,8 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(emptyAlbum.status).toBe(201);
|
expect(emptyAlbum.status).toBe(201);
|
||||||
journeyAlbumIds.push(emptyAlbum.json.id as string);
|
emptyAlbumId = emptyAlbum.json.id as string;
|
||||||
|
journeyAlbumIds.push(emptyAlbumId);
|
||||||
emptyToken = emptyAlbum.json.publicToken as string;
|
emptyToken = emptyAlbum.json.publicToken as string;
|
||||||
|
|
||||||
// ── ② staffB API:登录 → init×3 → presign PUT 实写 → complete×3 ──
|
// ── ② staffB API:登录 → init×3 → presign PUT 实写 → complete×3 ──
|
||||||
@@ -363,6 +369,10 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
|||||||
expect(items).toHaveLength(3);
|
expect(items).toHaveLength(3);
|
||||||
expect(new Set(items.map((p) => p.photoId))).toEqual(new Set(photoIds));
|
expect(new Set(items.map((p) => p.photoId))).toEqual(new Set(photoIds));
|
||||||
|
|
||||||
|
// cn→ov:发布事实经真实管线投影(album/banner/分类/3 张照片快照入 outbox
|
||||||
|
// 并排空投递)——viewer 腿读 ov 副本,此后才可见。
|
||||||
|
await flushSync(albumId);
|
||||||
|
|
||||||
// ── ③ viewer UI:zh 打开 → banner 渲染 → 切 en 内容随切 → 回 zh ──
|
// ── ③ viewer UI:zh 打开 → banner 渲染 → 切 en 内容随切 → 回 zh ──
|
||||||
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
await page.addInitScript(() => localStorage.setItem('viewer.locale', 'zh-CN'));
|
||||||
await page.goto(`/a/${publicToken}`);
|
await page.goto(`/a/${publicToken}`);
|
||||||
@@ -446,6 +456,9 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
|||||||
await page.getByRole('button', { name: '确定' }).click();
|
await page.getByRole('button', { name: '确定' }).click();
|
||||||
await expect(targetRow.getByTestId('photo-visibility-tag')).toHaveText('已隐藏');
|
await expect(targetRow.getByTestId('photo-visibility-tag')).toHaveText('已隐藏');
|
||||||
|
|
||||||
|
// 隐藏事实同步到 ov(viewer 下一腿的重载/深链 404 断言读 ov 副本)。
|
||||||
|
await flushSync(albumId);
|
||||||
|
|
||||||
// ── ④ viewer:重载列表变 2 → 被隐深链 404 本地化提示 ──
|
// ── ④ viewer:重载列表变 2 → 被隐深链 404 本地化提示 ──
|
||||||
await page.goto(`/a/${publicToken}`);
|
await page.goto(`/a/${publicToken}`);
|
||||||
await expect(page.locator('.cell')).toHaveCount(2);
|
await expect(page.locator('.cell')).toHaveCount(2);
|
||||||
@@ -455,7 +468,8 @@ test.describe.serial('cross-frontend journey (admin → api/worker → viewer)',
|
|||||||
await expect(notice).toContainText('照片不存在或已下线');
|
await expect(notice).toContainText('照片不存在或已下线');
|
||||||
await expect(page.locator('.lightbox')).toHaveCount(0);
|
await expect(page.locator('.lightbox')).toHaveCount(0);
|
||||||
|
|
||||||
// ── ⑤ 空相册 B:viewer 空态提示 ──
|
// ── ⑤ 空相册 B:viewer 空态提示(B 经 API 建于 cn,先同步到 ov)──
|
||||||
|
await flushSync(emptyAlbumId);
|
||||||
await page.goto(`/a/${emptyToken}`);
|
await page.goto(`/a/${emptyToken}`);
|
||||||
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
await expect(page.locator('.empty-notice')).toHaveText('照片即将上线');
|
||||||
await expect(page.locator('.cell')).toHaveCount(0);
|
await expect(page.locator('.cell')).toHaveCount(0);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import { expect, test, type Page } from '@playwright/test';
|
import { expect, test, type Page } from '@playwright/test';
|
||||||
import { cleanupSeededAlbums, deleteSeededPhoto, seedAlbum, seedPhoto } from './helpers';
|
import {
|
||||||
|
cleanupSeededAlbums,
|
||||||
|
deleteSeededPhoto,
|
||||||
|
flushSync,
|
||||||
|
seedAlbum,
|
||||||
|
seedPhoto,
|
||||||
|
} from './helpers';
|
||||||
|
|
||||||
// Task 8 大图查看器 Playwright(真实后端):点击开大图(browse 经签名 URL 取回
|
// Task 8 大图查看器 Playwright(真实后端):点击开大图(browse 经签名 URL 取回
|
||||||
// 真实字节)→ 左右切换保持列表序列 → 滚轮缩放(受控 transform)→ 关闭回原滚动
|
// 真实字节)→ 左右切换保持列表序列 → 滚轮缩放(受控 transform)→ 关闭回原滚动
|
||||||
// 位置 → 深链直达(单图先行 + 背后列表构成序列)→ 404 深链本地化提示 → 越过
|
// 位置 → 深链直达(单图先行 + 背后列表构成序列)→ 404 深链本地化提示 → 越过
|
||||||
// 末页边界的导航扩展序列。
|
// 末页边界的导航扩展序列。种子直种 cn 侧后 flushSync 投影到 ov(viewer 读 ov)。
|
||||||
|
|
||||||
test.afterAll(async () => {
|
test.afterAll(async () => {
|
||||||
await cleanupSeededAlbums();
|
await cleanupSeededAlbums();
|
||||||
@@ -19,8 +25,11 @@ const RATIOS: Array<{ width: number; height: number }> = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/** 种「查看器相册」:count 张已发布照片,publishedAt 逐张递减 60s(列表顺序确定)。
|
/** 种「查看器相册」:count 张已发布照片,publishedAt 逐张递减 60s(列表顺序确定)。
|
||||||
* 返回 token 与按列表顺序排列的 photoId。 */
|
* 返回 token 与按列表顺序排列的 photoId。种子后 flushSync 投影到 ov。 */
|
||||||
async function seedViewerAlbum(alias: string, count: number): Promise<{ token: string; photoIds: string[] }> {
|
async function seedViewerAlbum(
|
||||||
|
alias: string,
|
||||||
|
count: number,
|
||||||
|
): Promise<{ albumId: string; token: string; photoIds: string[] }> {
|
||||||
const album = await seedAlbum({
|
const album = await seedAlbum({
|
||||||
alias,
|
alias,
|
||||||
translations: [{ locale: 'zh-CN', title: '大图查看测试相册' }],
|
translations: [{ locale: 'zh-CN', title: '大图查看测试相册' }],
|
||||||
@@ -37,7 +46,8 @@ async function seedViewerAlbum(alias: string, count: number): Promise<{ token: s
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return { token: album.publicToken, photoIds };
|
await flushSync(album.id);
|
||||||
|
return { albumId: album.id, token: album.publicToken, photoIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function openLocale(page: Page, token: string, path = ''): Promise<void> {
|
async function openLocale(page: Page, token: string, path = ''): Promise<void> {
|
||||||
@@ -135,9 +145,11 @@ test('deep link opens on the target photo with the list loaded behind', async ({
|
|||||||
|
|
||||||
test('deep link to a removed photo shows the localized notice and a working back link', async ({ page }) => {
|
test('deep link to a removed photo shows the localized notice and a working back link', async ({ page }) => {
|
||||||
test.setTimeout(90_000);
|
test.setTimeout(90_000);
|
||||||
const { token, photoIds } = await seedViewerAlbum('viewer-404', 2);
|
const { token, albumId, photoIds } = await seedViewerAlbum('viewer-404', 2);
|
||||||
const gone = photoIds[1]!;
|
const gone = photoIds[1]!;
|
||||||
await deleteSeededPhoto(gone); // 模拟照片已下线(旧分享链接)
|
// 模拟照片已下线(旧分享链接):软删 + flushSync 让墓碑走真实管线撤下 ov 副本。
|
||||||
|
await deleteSeededPhoto(gone);
|
||||||
|
await flushSync(albumId);
|
||||||
|
|
||||||
await openLocale(page, token, `/p/${gone}`);
|
await openLocale(page, token, `/p/${gone}`);
|
||||||
const notice = page.locator('.deep-link-notice');
|
const notice = page.locator('.deep-link-notice');
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { defineConfig, devices } from '@playwright/test';
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
// Task 6 起 e2e 打真实后端:webServer 先等 API(8080,仓库根 `pnpm dev:api`
|
// Task 6 起 e2e 打真实后端:webServer 先等 cn API(8080,仓库根 `pnpm dev:api`
|
||||||
// 构建+启动;已在跑的实例直接复用),再等 viewer dev(5175,/v1 代理 → 8080)。
|
// 构建+启动;已在跑的实例直接复用;journey 管理/上传腿与 flushSync 的
|
||||||
// compose 基础设施(MySQL/MinIO/Redis)需预先 `docker compose up -d`(计划约定)。
|
// bootstrap 触发仍走 cn),再等 ov API(8081,`pnpm dev:ov`——P2 Task 6 起
|
||||||
// Task 11 起跨端联调:第三个 webServer 一并等 admin dev(5176,/v1 代理 → 8080,
|
// viewer 观众腿全部切 ov 副本端点),再等 viewer dev(5175,直连
|
||||||
// 同样复用已在跑的实例)——journey.spec.ts 以绝对 baseURL 驱动 admin 腿。
|
// VITE_API_BASE=ov 8081,无代理)。compose 基础设施(MySQL/MinIO/Redis×2)
|
||||||
|
// 需预先 `docker compose up -d`(计划约定)。Task 11 起跨端联调:第四个
|
||||||
|
// webServer 一并等 admin dev(5176,/v1 代理 → 8080,同样复用已在跑的实例)
|
||||||
|
// ——journey.spec.ts 以绝对 baseURL 驱动 admin 腿。
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
testDir: './e2e',
|
testDir: './e2e',
|
||||||
timeout: 30_000,
|
timeout: 30_000,
|
||||||
@@ -23,6 +26,13 @@ export default defineConfig({
|
|||||||
reuseExistingServer: true,
|
reuseExistingServer: true,
|
||||||
timeout: 180_000, // 首次含 tsc 全量构建
|
timeout: 180_000, // 首次含 tsc 全量构建
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
command: 'pnpm dev:ov',
|
||||||
|
cwd: '../..',
|
||||||
|
url: 'http://127.0.0.1:8081/v1/health',
|
||||||
|
reuseExistingServer: true,
|
||||||
|
timeout: 180_000, // 复用同一构建产物,首次仍可能全量构建
|
||||||
|
},
|
||||||
{
|
{
|
||||||
command: 'pnpm run dev',
|
command: 'pnpm run dev',
|
||||||
url: 'http://127.0.0.1:5175',
|
url: 'http://127.0.0.1:5175',
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
import { ApiError, NETWORK_ERROR_CODE, getPublicAlbum, getPublicPhotos } from '../api/client';
|
import {
|
||||||
|
ApiError,
|
||||||
|
DEFAULT_API_BASE,
|
||||||
|
NETWORK_ERROR_CODE,
|
||||||
|
apiBase,
|
||||||
|
getPublicAlbum,
|
||||||
|
getPublicPhotos,
|
||||||
|
} from '../api/client';
|
||||||
import { errorMessageKey } from '../api/errors';
|
import { errorMessageKey } from '../api/errors';
|
||||||
|
|
||||||
function jsonResponse(status: number, body: unknown): Response {
|
function jsonResponse(status: number, body: unknown): Response {
|
||||||
@@ -8,6 +15,37 @@ function jsonResponse(status: number, body: unknown): Response {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
|
vi.unstubAllEnvs();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api base url', () => {
|
||||||
|
it('defaults to the ov entry (8081) when VITE_API_BASE is unset', () => {
|
||||||
|
expect(apiBase()).toBe(DEFAULT_API_BASE);
|
||||||
|
expect(DEFAULT_API_BASE).toBe('http://127.0.0.1:8081');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses VITE_API_BASE when the build provides one', () => {
|
||||||
|
vi.stubEnv('VITE_API_BASE', 'https://api.example.com');
|
||||||
|
expect(apiBase()).toBe('https://api.example.com');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('treats empty string as same-origin (production gateway deploy)', () => {
|
||||||
|
// 空串非 nullish:?? 不回退缺省,fetch 得到相对 /v1 路径(同源网关)。
|
||||||
|
vi.stubEnv('VITE_API_BASE', '');
|
||||||
|
expect(apiBase()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('requests go to the configured base + /v1 path', async () => {
|
||||||
|
const fetchMock = vi.fn().mockResolvedValue(jsonResponse(200, { items: [], nextCursor: null }));
|
||||||
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
vi.stubEnv('VITE_API_BASE', 'https://api.example.com');
|
||||||
|
|
||||||
|
await getPublicPhotos('tok-1');
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
'https://api.example.com/v1/public/albums/tok-1/photos',
|
||||||
|
expect.objectContaining({}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('api client', () => {
|
describe('api client', () => {
|
||||||
@@ -20,7 +58,7 @@ describe('api client', () => {
|
|||||||
const album = await getPublicAlbum('tok-1', 'zh-CN');
|
const album = await getPublicAlbum('tok-1', 'zh-CN');
|
||||||
expect(album.publicToken).toBe('tok-1');
|
expect(album.publicToken).toBe('tok-1');
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
'/v1/public/albums/tok-1?locale=zh-CN',
|
`${DEFAULT_API_BASE}/v1/public/albums/tok-1?locale=zh-CN`,
|
||||||
expect.objectContaining({ headers: expect.objectContaining({ accept: 'application/json' }) }),
|
expect.objectContaining({ headers: expect.objectContaining({ accept: 'application/json' }) }),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,11 +42,23 @@ function parseErrorEnvelope(body: unknown): { code: string | null; message: stri
|
|||||||
return { code: null, message: null, requestId: null };
|
return { code: null, message: null, requestId: null };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P2 Task 6:API 基址可配(VITE_API_BASE)。dev 缺省直连 ov 入口(127.0.0.1:8081,
|
||||||
|
// 观众域切换海外副本——契约路径不变,仅部署域名区分);生产同源网关部署时构建期
|
||||||
|
// 置 VITE_API_BASE=""(空串 → 同源相对路径)。注意 Vite 只读 apps/viewer/.env*,
|
||||||
|
// 仓库根 .env 对前端无效(缺省值即 dev 指向 ov)。直连跨源依赖 API 侧
|
||||||
|
// CORS_ORIGINS 包含 viewer origin(根 .env 已含 5175 的 localhost/127.0.0.1 两形)。
|
||||||
|
export const DEFAULT_API_BASE = 'http://127.0.0.1:8081';
|
||||||
|
|
||||||
|
/** 逐请求读取(惰性):测试可 stub import.meta.env 覆盖。 */
|
||||||
|
export function apiBase(): string {
|
||||||
|
return import.meta.env.VITE_API_BASE ?? DEFAULT_API_BASE;
|
||||||
|
}
|
||||||
|
|
||||||
/** 类型化 fetch 封装:统一前缀 /v1、JSON 解析、错误信封 → ApiError。 */
|
/** 类型化 fetch 封装:统一前缀 /v1、JSON 解析、错误信封 → ApiError。 */
|
||||||
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
export async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
||||||
let response: Response;
|
let response: Response;
|
||||||
try {
|
try {
|
||||||
response = await fetch(`/v1${path}`, {
|
response = await fetch(`${apiBase()}/v1${path}`, {
|
||||||
...init,
|
...init,
|
||||||
headers: { accept: 'application/json', ...init?.headers },
|
headers: { accept: 'application/json', ...init?.headers },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
import vue from '@vitejs/plugin-vue';
|
import vue from '@vitejs/plugin-vue';
|
||||||
import { defineConfig } from 'vitest/config';
|
import { defineConfig } from 'vitest/config';
|
||||||
|
|
||||||
// dev server 固定 5175(CORS_ORIGINS 已包含);/v1 代理仅作用于 `vite dev`,
|
// dev server 固定 5175。P2 Task 6 起不再代理 /v1:前端直连 VITE_API_BASE
|
||||||
// build/preview 不经此处——生产由网关同源挂载 /v1。
|
// (缺省 ov 入口 127.0.0.1:8081,见 src/api/client.ts)——跨源访问由 API 侧
|
||||||
// host 显式 127.0.0.1:Windows 下 localhost 默认可能只绑 [::1],代理/e2e 探活走 IPv4。
|
// CORS_ORIGINS 放行(根 .env 含 5175 的 localhost/127.0.0.1 两形);admin 入口
|
||||||
|
// (5176)仍各自代理 /v1 → cn 8080(journey 管理腿不走此处)。
|
||||||
|
// host 显式 127.0.0.1:Windows 下 localhost 默认可能只绑 [::1],e2e 探活走 IPv4。
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [vue()],
|
plugins: [vue()],
|
||||||
server: {
|
server: {
|
||||||
host: '127.0.0.1',
|
host: '127.0.0.1',
|
||||||
port: 5175,
|
port: 5175,
|
||||||
strictPort: true,
|
strictPort: true,
|
||||||
proxy: {
|
|
||||||
'/v1': { target: 'http://127.0.0.1:8080', changeOrigin: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
preview: { host: '127.0.0.1', port: 5175, strictPort: true },
|
preview: { host: '127.0.0.1', port: 5175, strictPort: true },
|
||||||
test: {
|
test: {
|
||||||
|
|||||||
@@ -1055,6 +1055,17 @@ paths:
|
|||||||
description: 分类 ID 过滤(来自 PublicAlbum.categories 的 id)。
|
description: 分类 ID 过滤(来自 PublicAlbum.categories 的 id)。
|
||||||
schema:
|
schema:
|
||||||
type: string
|
type: string
|
||||||
|
- name: sort
|
||||||
|
in: query
|
||||||
|
description: |
|
||||||
|
排序模式:latest=发布时间倒序(缺省);hot=点赞数倒序(photoId 倒序
|
||||||
|
决胜,短期快照语义——服务端约 30s 缓冲,窗口内顺序不随点赞变化,
|
||||||
|
技术实施方案 §7)。hot 仅 ov 部署实现;cn 部署不感知该参数(固定
|
||||||
|
latest 语义)。ov 部署对非法取值返回 400 VALIDATION_FAILED。
|
||||||
|
schema:
|
||||||
|
type: string
|
||||||
|
enum: [latest, hot]
|
||||||
|
default: latest
|
||||||
- $ref: '#/components/parameters/LocaleQuery'
|
- $ref: '#/components/parameters/LocaleQuery'
|
||||||
- $ref: '#/components/parameters/CursorQuery'
|
- $ref: '#/components/parameters/CursorQuery'
|
||||||
- name: limit
|
- name: limit
|
||||||
@@ -2509,9 +2520,14 @@ components:
|
|||||||
- $ref: '#/components/schemas/SignedUrl'
|
- $ref: '#/components/schemas/SignedUrl'
|
||||||
- type: 'null'
|
- type: 'null'
|
||||||
viewCount:
|
viewCount:
|
||||||
type: 'null'
|
type: [integer, 'null']
|
||||||
|
format: int64
|
||||||
|
minimum: 0
|
||||||
readOnly: true
|
readOnly: true
|
||||||
description: P1a 占位 null;浏览计数由后续 /visits 端点提供(技术实施方案 §7.1)。
|
description: |
|
||||||
|
累计浏览数。cn 部署返回 null(P1a 占位,Task 9 起由统计副本回填);
|
||||||
|
ov 部署返回 album_stats 权威累计值(无行默认 0;计数由 /visits
|
||||||
|
端点维护,技术实施方案 §7.1)。
|
||||||
|
|
||||||
PublicPhoto:
|
PublicPhoto:
|
||||||
type: object
|
type: object
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ const publishedEvent: OutboxEvent<PhotoPublishedPayload> = {
|
|||||||
payload: {
|
payload: {
|
||||||
photoId: 'photo-1',
|
photoId: 'photo-1',
|
||||||
albumId: 'album-1',
|
albumId: 'album-1',
|
||||||
|
categoryId: null, // P2 Task 6:分类随载荷同步(ov 公开列表分类过滤所需)
|
||||||
objectKey: 'albums/album-1/photo-1.webp',
|
objectKey: 'albums/album-1/photo-1.webp',
|
||||||
thumbKey: 'thumbs/album-1/photo-1.webp',
|
thumbKey: 'thumbs/album-1/photo-1.webp',
|
||||||
browseKey: 'browse/album-1/photo-1.webp',
|
browseKey: 'browse/album-1/photo-1.webp',
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ export interface OutboxEvent<T = unknown> {
|
|||||||
export interface PhotoPublishedPayload {
|
export interface PhotoPublishedPayload {
|
||||||
photoId: string;
|
photoId: string;
|
||||||
albumId: string;
|
albumId: string;
|
||||||
|
categoryId: string | null; // 分类过滤(ov 公开列表 ?category=)与单图同形所需
|
||||||
objectKey: string; // 原图对象键
|
objectKey: string; // 原图对象键
|
||||||
thumbKey: string; // 缩略图对象键
|
thumbKey: string; // 缩略图对象键
|
||||||
browseKey: string; // 浏览图对象键
|
browseKey: string; // 浏览图对象键
|
||||||
|
|||||||
+14
-2
@@ -1253,8 +1253,13 @@ export interface components {
|
|||||||
categories: components["schemas"]["PublicCategory"][];
|
categories: components["schemas"]["PublicCategory"][];
|
||||||
/** @description 封面图签名 URL;未设置为 null。 */
|
/** @description 封面图签名 URL;未设置为 null。 */
|
||||||
banner: components["schemas"]["SignedUrl"] | null;
|
banner: components["schemas"]["SignedUrl"] | null;
|
||||||
/** @description P1a 占位 null;浏览计数由后续 /visits 端点提供(技术实施方案 §7.1)。 */
|
/**
|
||||||
readonly viewCount: null;
|
* Format: int64
|
||||||
|
* @description 累计浏览数。cn 部署返回 null(P1a 占位,Task 9 起由统计副本回填);
|
||||||
|
* ov 部署返回 album_stats 权威累计值(无行默认 0;计数由 /visits
|
||||||
|
* 端点维护,技术实施方案 §7.1)。
|
||||||
|
*/
|
||||||
|
readonly viewCount: number | null;
|
||||||
};
|
};
|
||||||
/** @description 公开照片条目(仅 published 照片,处理完成即有宽高;URL 为短期签名,需在过期前刷新)。 */
|
/** @description 公开照片条目(仅 published 照片,处理完成即有宽高;URL 为短期签名,需在过期前刷新)。 */
|
||||||
PublicPhoto: {
|
PublicPhoto: {
|
||||||
@@ -2707,6 +2712,13 @@ export interface operations {
|
|||||||
query?: {
|
query?: {
|
||||||
/** @description 分类 ID 过滤(来自 PublicAlbum.categories 的 id)。 */
|
/** @description 分类 ID 过滤(来自 PublicAlbum.categories 的 id)。 */
|
||||||
category?: string;
|
category?: string;
|
||||||
|
/**
|
||||||
|
* @description 排序模式:latest=发布时间倒序(缺省);hot=点赞数倒序(photoId 倒序
|
||||||
|
* 决胜,短期快照语义——服务端约 30s 缓冲,窗口内顺序不随点赞变化,
|
||||||
|
* 技术实施方案 §7)。hot 仅 ov 部署实现;cn 部署不感知该参数(固定
|
||||||
|
* latest 语义)。ov 部署对非法取值返回 400 VALIDATION_FAILED。
|
||||||
|
*/
|
||||||
|
sort?: "latest" | "hot";
|
||||||
/** @description 请求语言;缺省或取值不支持时按相册 defaultLocale 回退(不报 400,响应 resolvedLocale 指示实际返回语言)。 */
|
/** @description 请求语言;缺省或取值不支持时按相册 defaultLocale 回退(不报 400,响应 resolvedLocale 指示实际返回语言)。 */
|
||||||
locale?: components["parameters"]["LocaleQuery"];
|
locale?: components["parameters"]["LocaleQuery"];
|
||||||
/** @description 分页游标(上一页响应的 nextCursor)。 */
|
/** @description 分页游标(上一页响应的 nextCursor)。 */
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
// request-id + 全局异常过滤器 + 全局校验管道 + /v1/health),但**显式挑选**
|
// request-id + 全局异常过滤器 + 全局校验管道 + /v1/health),但**显式挑选**
|
||||||
// ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO
|
// ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO
|
||||||
// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);Task 5
|
// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);Task 5
|
||||||
// 加入 emergency(海外紧急停用通道,独立于同步);公开读、likes/visits、
|
// 加入 emergency(海外紧急停用通道,独立于同步);公开读自 Task 6 起装配
|
||||||
// WS 网关自 Task 6+ 起逐个加入。cn 模块保持原样,两入口互不影响。
|
// (public-ov:读 photos_ov 副本的同形契约端点 + hot 排序);likes/visits、
|
||||||
|
// WS 网关自 Task 7+ 起逐个加入。cn 模块保持原样,两入口互不影响。
|
||||||
import {
|
import {
|
||||||
type MiddlewareConsumer,
|
type MiddlewareConsumer,
|
||||||
Module,
|
Module,
|
||||||
@@ -16,6 +17,7 @@ import { LoggerModule } from 'nestjs-pino';
|
|||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||||
import { EmergencyModule } from './emergency/emergency.module';
|
import { EmergencyModule } from './emergency/emergency.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
|
import { PublicOvModule } from './public-ov/public-ov.module';
|
||||||
import { RequestIdMiddleware } from './request-id.middleware';
|
import { RequestIdMiddleware } from './request-id.middleware';
|
||||||
import { createLoggerOptions } from './shared/logger-options';
|
import { createLoggerOptions } from './shared/logger-options';
|
||||||
import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
||||||
@@ -27,6 +29,8 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
|||||||
SyncInboxModule,
|
SyncInboxModule,
|
||||||
// 海外紧急停用通道(P2 Task 5):POST /v1/internal/emergency/disable|enable。
|
// 海外紧急停用通道(P2 Task 5):POST /v1/internal/emergency/disable|enable。
|
||||||
EmergencyModule,
|
EmergencyModule,
|
||||||
|
// ov 公开读(P2 Task 6):/public/albums/* 读 ov 副本(含 sort=hot 快照)。
|
||||||
|
PublicOvModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
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 快照事件)
|
// Task 5;categories 仅当载荷携带全量清单(bootstrap/reconcile 快照事件)
|
||||||
// 时整体替换——错过的 category 墓碑由此收敛,增量事件不携带则不动);
|
// 时整体替换——错过的 category 墓碑由此收敛,增量事件不携带则不动);
|
||||||
// category → albums_ov.categories JSON 元素 upsert/移除;photo.published →
|
// 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 置位(行不存在 = 从未复制即不可见,缺席
|
// deleted → photos_ov.visibility 置位(行不存在 = 从未复制即不可见,缺席
|
||||||
// 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo
|
// 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo
|
||||||
// 仅推进版本,ov schema 无位);
|
// 仅推进版本,ov schema 无位);
|
||||||
@@ -299,6 +300,7 @@ export class SyncInboxService {
|
|||||||
data: {
|
data: {
|
||||||
photoId: event.entityId,
|
photoId: event.entityId,
|
||||||
albumId: event.albumId,
|
albumId: event.albumId,
|
||||||
|
categoryId: p.categoryId ?? null,
|
||||||
originalObjectKey: p.objectKey,
|
originalObjectKey: p.objectKey,
|
||||||
thumbObjectKey: p.thumbKey,
|
thumbObjectKey: p.thumbKey,
|
||||||
browseObjectKey: p.browseKey,
|
browseObjectKey: p.browseKey,
|
||||||
@@ -318,6 +320,7 @@ export class SyncInboxService {
|
|||||||
};
|
};
|
||||||
if (visibility === 'published') {
|
if (visibility === 'published') {
|
||||||
const p = payload as PhotoPublishedPayload;
|
const p = payload as PhotoPublishedPayload;
|
||||||
|
data.categoryId = p.categoryId ?? null;
|
||||||
data.originalObjectKey = p.objectKey;
|
data.originalObjectKey = p.objectKey;
|
||||||
data.thumbObjectKey = p.thumbKey;
|
data.thumbObjectKey = p.thumbKey;
|
||||||
data.browseObjectKey = p.browseKey;
|
data.browseObjectKey = p.browseKey;
|
||||||
|
|||||||
@@ -177,6 +177,7 @@ export function buildPhotoPublishedPayload(
|
|||||||
return {
|
return {
|
||||||
photoId: photo.id,
|
photoId: photo.id,
|
||||||
albumId: photo.albumId,
|
albumId: photo.albumId,
|
||||||
|
categoryId: photo.categoryId,
|
||||||
objectKey: photo.objectKey,
|
objectKey: photo.objectKey,
|
||||||
thumbKey: thumb.objectKey,
|
thumbKey: thumb.objectKey,
|
||||||
browseKey: browse.objectKey,
|
browseKey: browse.objectKey,
|
||||||
|
|||||||
@@ -542,6 +542,7 @@ describe('photos visibility (e2e)', () => {
|
|||||||
expect(events[1].payload).toEqual({
|
expect(events[1].payload).toEqual({
|
||||||
photoId: lifecycleId,
|
photoId: lifecycleId,
|
||||||
albumId,
|
albumId,
|
||||||
|
categoryId: null, // 本 spec 种子未分类(P2 Task 6:categoryId 随载荷同步 ov)
|
||||||
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`,
|
objectKey: `${KEY_ROOT}/albums/${albumId}/originals/${lifecycleId}.jpg`,
|
||||||
thumbKey: variantKey(albumId, lifecycleId, 'thumb'),
|
thumbKey: variantKey(albumId, lifecycleId, 'thumb'),
|
||||||
browseKey: variantKey(albumId, lifecycleId, 'browse'),
|
browseKey: variantKey(albumId, lifecycleId, 'browse'),
|
||||||
|
|||||||
@@ -0,0 +1,508 @@
|
|||||||
|
// services/api/test/public-ov.e2e-spec.ts —— P2 Task 6 ov 公开读端点 e2e。
|
||||||
|
// ONE spec 串「cn 种 → 同步 → ov 读」全链路:进程内起两个真实入口(cn + ov,
|
||||||
|
// 同 sync.e2e-spec 模式),业务动作全走 cn API(建相册/分类/banner 资产/上传
|
||||||
|
// complete + 直驱 worker 发布事务),发送器为生产 OutboxSender(指向 ov 入口
|
||||||
|
// 真实 URL)——ov 读到的数据与生产链路逐字节同源。
|
||||||
|
//
|
||||||
|
// 覆盖(任务验收):
|
||||||
|
// ① 契约同形(本 spec 的核心证明):ov getPublicAlbum/listPublicPhotos/
|
||||||
|
// getPublicPhoto 与 cn 同端点响应 deep-equal(剥离现签 URL 后逐字段相等;
|
||||||
|
// album 另差 viewCount——cn 占位 null / ov 真实值);游标字符串相等
|
||||||
|
// (同一 shared/cursor 编码);分类过滤/单图 categoryId 同形;
|
||||||
|
// banner 签名 URL 可取回真实字节;locale 回退(en/zh)同形;
|
||||||
|
// ② viewCount 真实读:无 album_stats 行 → 0;有行 → 行值(计数端点 Task 8);
|
||||||
|
// ③ 参数校验同形:sort 非法 → 400(ov 新增参数);cursor 非法 → 400;
|
||||||
|
// category 幽灵 → 400(与 cn 同码同因);幽灵 token 三端点 → 404
|
||||||
|
// ALBUM_NOT_PUBLIC;
|
||||||
|
// ④ hot 排序快照:likeTotal 全 0 → photoId 倒序决胜;TTL 窗口内顺序冻结
|
||||||
|
// (DB 改 likeTotal 后响应顺序不变,计数现读);翻页对同一快照切片;
|
||||||
|
// TTL 过期后重算 → 顺序翻转;
|
||||||
|
// ⑤ 隐藏照片:cn 隐藏 → 高优先级同步 → ov 列表剔除 + 单图 404
|
||||||
|
// PHOTO_NOT_PUBLIC;
|
||||||
|
// ⑥ 相册下线:cn publicEnabled=false → ov 404(与 cn 同码);
|
||||||
|
// ⑦ 紧急停用(Task 5 通道 × 公开读):ov disable → ov 404 而 cn 仍 200
|
||||||
|
// (ov 本地独立位);enable → 恢复。
|
||||||
|
// 真实 MySQL×2 + MinIO(banner/变体真实 JPEG)+ HMAC 同步链路字节一致。
|
||||||
|
import { NestFactory } from '@nestjs/core';
|
||||||
|
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||||
|
import request from 'supertest';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
import { PrismaClient } from '@photo/db-cn';
|
||||||
|
import { PrismaClient as OvPrismaClient } from '@photo/db-ov';
|
||||||
|
import { ErrorCodes, signSyncBody } from '@photo/contracts';
|
||||||
|
import {
|
||||||
|
createStorageFromEnv,
|
||||||
|
type MinioStorageAdapter,
|
||||||
|
} from '@photo/storage';
|
||||||
|
import { AppCnModule } from '../src/app-cn.module';
|
||||||
|
import { AppOvModule } from '../src/app-ov.module';
|
||||||
|
import { seedAdmin } from '../src/scripts/seed-admin';
|
||||||
|
import { configureApp } from '../src/shared/configure-app';
|
||||||
|
import { PublishService } from '../../worker/src/publish.service';
|
||||||
|
import { resetDb } from './reset';
|
||||||
|
import { resetOvDb } from './reset-ov';
|
||||||
|
import { OutboxSender } from '../../sync/src/sender';
|
||||||
|
|
||||||
|
jest.setTimeout(180_000);
|
||||||
|
|
||||||
|
const ADMIN_ACCOUNT = process.env.ADMIN_ACCOUNT ?? 'admin';
|
||||||
|
const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD ?? 'admin-secret-change-me';
|
||||||
|
const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me';
|
||||||
|
|
||||||
|
/** hot 快照 TTL(默认 30s 对 e2e 太长:缩短到 2s,「窗口内冻结」断言留足余量)。 */
|
||||||
|
const HOT_TTL_TEST_MS = 2000;
|
||||||
|
|
||||||
|
/** 测试内生成真实 JPEG(同 sync.e2e-spec;无二进制入库)。 */
|
||||||
|
async function makeJpeg(
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
rgb: { r: number; g: number; b: number },
|
||||||
|
): Promise<Buffer> {
|
||||||
|
return sharp({ create: { width, height, channels: 3, background: rgb } })
|
||||||
|
.jpeg({ quality: 85 })
|
||||||
|
.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 签名 POST(emergency 内部端点)。 */
|
||||||
|
async function signedPost(
|
||||||
|
base: string,
|
||||||
|
path: string,
|
||||||
|
body: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
const raw = JSON.stringify(body);
|
||||||
|
return fetch(`${base}${path}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-sync-timestamp': String(Math.floor(Date.now() / 1000)),
|
||||||
|
'x-sync-signature': signSyncBody(SYNC_KEY, raw),
|
||||||
|
},
|
||||||
|
body: raw,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** SignedUrl 字段剥离(现签 URL 必然不同:两端各自签名;形状仍断言)。 */
|
||||||
|
interface ItemNoUrl {
|
||||||
|
photoId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
likeCount: number;
|
||||||
|
publishedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripItem(item: {
|
||||||
|
photoId: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
thumb: unknown;
|
||||||
|
browse: unknown;
|
||||||
|
likeCount: number;
|
||||||
|
publishedAt: string;
|
||||||
|
}): ItemNoUrl {
|
||||||
|
const { thumb: _thumb, browse: _browse, ...rest } = item;
|
||||||
|
void _thumb;
|
||||||
|
void _browse;
|
||||||
|
return rest;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('public-ov (e2e)', () => {
|
||||||
|
let cnApp: NestExpressApplication;
|
||||||
|
let ovApp: NestExpressApplication;
|
||||||
|
let cnBase: string;
|
||||||
|
let ovBase: string;
|
||||||
|
let prismaCn: PrismaClient;
|
||||||
|
let prismaOv: OvPrismaClient;
|
||||||
|
let storage: MinioStorageAdapter;
|
||||||
|
let sender: OutboxSender;
|
||||||
|
let adminToken: string;
|
||||||
|
|
||||||
|
let albumId: string;
|
||||||
|
let publicToken: string;
|
||||||
|
let categoryId: string;
|
||||||
|
/** 按上传顺序(publishedAt 升序)的三张照片。 */
|
||||||
|
let photoIds: string[];
|
||||||
|
|
||||||
|
/** 排空发送器:循环 runOnce 直至无未投递事件。 */
|
||||||
|
async function drainSender(): Promise<void> {
|
||||||
|
for (let round = 0; round < 20; round++) {
|
||||||
|
const stats = await sender.runOnce();
|
||||||
|
if (stats.picked === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('drainSender 超时:20 轮后 outbox 仍有未投递事件');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上传全链路:init → presign PUT 实写 MinIO → complete → 直驱发布事务。 */
|
||||||
|
async function uploadAndPublish(
|
||||||
|
clientTaskId: string,
|
||||||
|
jpeg: Buffer,
|
||||||
|
withCategory: boolean,
|
||||||
|
): Promise<string> {
|
||||||
|
const init = await request(cnBase)
|
||||||
|
.post('/v1/uploads')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
albumId,
|
||||||
|
clientTaskId,
|
||||||
|
filename: 'pub-ov.jpg',
|
||||||
|
bytes: jpeg.length,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
...(withCategory ? { categoryId } : {}),
|
||||||
|
});
|
||||||
|
expect(init.status).toBe(201);
|
||||||
|
const { uploadId, photoId, credential } = init.body as {
|
||||||
|
uploadId: string;
|
||||||
|
photoId: string;
|
||||||
|
credential: { url: string; method: string; headers: Record<string, string> };
|
||||||
|
};
|
||||||
|
const put = await fetch(credential.url, {
|
||||||
|
method: credential.method,
|
||||||
|
headers: credential.headers,
|
||||||
|
body: new Uint8Array(jpeg),
|
||||||
|
});
|
||||||
|
expect(put.ok).toBe(true);
|
||||||
|
const done = await request(cnBase)
|
||||||
|
.post(`/v1/uploads/${uploadId}/complete`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
expect(done.status).toBe(200);
|
||||||
|
expect(await new PublishService(prismaCn, storage).processPhoto(photoId)).toBe(
|
||||||
|
'published',
|
||||||
|
);
|
||||||
|
return photoId;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** GET(匿名公开端点;supertest Response.body 已是宽松 JSON 类型)。 */
|
||||||
|
async function getJson(base: string, path: string): Promise<request.Response> {
|
||||||
|
return request(base).get(path).set('accept', 'application/json');
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
expect(SYNC_KEY).not.toBe('');
|
||||||
|
await resetDb();
|
||||||
|
await resetOvDb();
|
||||||
|
// hot 快照 TTL 缩短(服务端逐请求惰性读 env,见 public-ov.service)。
|
||||||
|
process.env.HOT_SORT_TTL_MS = String(HOT_TTL_TEST_MS);
|
||||||
|
|
||||||
|
cnApp = await NestFactory.create<NestExpressApplication>(AppCnModule, {
|
||||||
|
rawBody: true,
|
||||||
|
});
|
||||||
|
configureApp(cnApp);
|
||||||
|
await cnApp.listen(0);
|
||||||
|
cnBase = await cnApp.getUrl();
|
||||||
|
|
||||||
|
ovApp = await NestFactory.create<NestExpressApplication>(AppOvModule, {
|
||||||
|
rawBody: true,
|
||||||
|
});
|
||||||
|
configureApp(ovApp);
|
||||||
|
await ovApp.listen(0);
|
||||||
|
ovBase = await ovApp.getUrl();
|
||||||
|
|
||||||
|
prismaCn = new PrismaClient();
|
||||||
|
prismaOv = new OvPrismaClient();
|
||||||
|
storage = createStorageFromEnv();
|
||||||
|
sender = new OutboxSender({
|
||||||
|
prisma: prismaCn,
|
||||||
|
ovApiUrl: ovBase,
|
||||||
|
internalKey: SYNC_KEY,
|
||||||
|
});
|
||||||
|
|
||||||
|
await seedAdmin(prismaCn, ADMIN_ACCOUNT, ADMIN_PASSWORD);
|
||||||
|
const login = await request(cnBase).post('/v1/auth/password').send({
|
||||||
|
account: ADMIN_ACCOUNT,
|
||||||
|
password: ADMIN_PASSWORD,
|
||||||
|
});
|
||||||
|
expect(login.status).toBe(200);
|
||||||
|
adminToken = login.body.sessionToken as string;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
delete process.env.HOT_SORT_TTL_MS;
|
||||||
|
await cnApp.close();
|
||||||
|
await ovApp.close();
|
||||||
|
await prismaCn.$disconnect();
|
||||||
|
await prismaOv.$disconnect();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('① ov 三端点与 cn 契约同形(deep-equal 剥离现签 URL;游标逐字节相等)', async () => {
|
||||||
|
// —— cn 侧种全套:相册(zh+en)+ 分类 + banner(全局)+ 3 张发布照片 ——
|
||||||
|
const album = await request(cnBase)
|
||||||
|
.post('/v1/albums')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', title: '海外晚会', description: 'ov 公开读验收', venue: '主会场' },
|
||||||
|
{ locale: 'en', title: 'Overseas Gala' },
|
||||||
|
],
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
startAt: '2026-10-01T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(album.status).toBe(201);
|
||||||
|
albumId = album.body.id as string;
|
||||||
|
publicToken = album.body.publicToken as string;
|
||||||
|
|
||||||
|
const category = await request(cnBase)
|
||||||
|
.post(`/v1/albums/${albumId}/categories`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
sortOrder: 0,
|
||||||
|
translations: [
|
||||||
|
{ locale: 'zh-CN', name: '红毯' },
|
||||||
|
{ locale: 'en', name: 'Red Carpet' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(category.status).toBe(201);
|
||||||
|
categoryId = category.body.id as string;
|
||||||
|
|
||||||
|
// banner:资产 API 全链路(init → PUT → complete → asset.updated 事件)。
|
||||||
|
const bannerJpeg = await makeJpeg(1200, 300, { r: 150, g: 100, b: 60 });
|
||||||
|
const bannerInit = await request(cnBase)
|
||||||
|
.post(`/v1/albums/${albumId}/assets`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
kind: 'banner',
|
||||||
|
filename: 'banner.jpg',
|
||||||
|
bytes: bannerJpeg.length,
|
||||||
|
contentType: 'image/jpeg',
|
||||||
|
});
|
||||||
|
expect(bannerInit.status).toBe(201);
|
||||||
|
const bannerCredential = bannerInit.body.credential as {
|
||||||
|
url: string;
|
||||||
|
method: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
};
|
||||||
|
expect(
|
||||||
|
await fetch(bannerCredential.url, {
|
||||||
|
method: bannerCredential.method,
|
||||||
|
headers: bannerCredential.headers,
|
||||||
|
body: new Uint8Array(bannerJpeg),
|
||||||
|
}).then((r) => r.ok),
|
||||||
|
).toBe(true);
|
||||||
|
const bannerDone = await request(cnBase)
|
||||||
|
.post(`/v1/albums/${albumId}/assets/${bannerInit.body.assetId as string}/complete`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`);
|
||||||
|
expect(bannerDone.status).toBe(200);
|
||||||
|
|
||||||
|
photoIds = [];
|
||||||
|
const shapes = [
|
||||||
|
{ w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 }, withCategory: true },
|
||||||
|
{ w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 }, withCategory: false },
|
||||||
|
{ w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 }, withCategory: false },
|
||||||
|
] as const;
|
||||||
|
for (let i = 0; i < shapes.length; i++) {
|
||||||
|
const s = shapes[i]!;
|
||||||
|
photoIds.push(
|
||||||
|
await uploadAndPublish(
|
||||||
|
`pub-ov-${i}`,
|
||||||
|
await makeJpeg(s.w, s.h, s.rgb),
|
||||||
|
s.withCategory,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await drainSender();
|
||||||
|
|
||||||
|
// —— getPublicAlbum 同形(zh 缺省 + en 显式两个 locale)——
|
||||||
|
for (const localeQuery of ['', '?locale=en']) {
|
||||||
|
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}${localeQuery}`);
|
||||||
|
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}${localeQuery}`);
|
||||||
|
expect(ov.status).toBe(200);
|
||||||
|
// 剥离 banner(现签 URL 必不同)与 viewCount(cn 占位 null / ov 真实 0)
|
||||||
|
// 后逐字段相等——契约同形的直接证明。
|
||||||
|
const { banner: _cb, viewCount: _cv, ...cnRest } = cn.body;
|
||||||
|
const { banner: ovBanner, viewCount: ovViewCount, ...ovRest } = ov.body;
|
||||||
|
void _cb;
|
||||||
|
void _cv;
|
||||||
|
expect(ovRest).toEqual(cnRest);
|
||||||
|
expect(ovViewCount).toBe(0); // 无 album_stats 行 → 默认 0(真实值语义)
|
||||||
|
// banner:两端都有、形状合法、ov URL 取回真实字节(签名对副本键有效)。
|
||||||
|
expect(ovBanner).not.toBeNull();
|
||||||
|
expect(typeof ovBanner.url).toBe('string');
|
||||||
|
expect(Date.parse(ovBanner.expiresAt)).toBeGreaterThan(Date.now());
|
||||||
|
const fetched = await fetch(ovBanner.url as string);
|
||||||
|
expect(fetched.ok).toBe(true);
|
||||||
|
expect((await fetched.arrayBuffer()).byteLength).toBe(bannerJpeg.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
// —— listPublicPhotos 同形:默认页 / limit=2(hasMore+游标值)/ 分类过滤 ——
|
||||||
|
const listCases = [
|
||||||
|
'',
|
||||||
|
'?limit=2',
|
||||||
|
`?limit=2&category=${categoryId}`,
|
||||||
|
'?sort=latest',
|
||||||
|
];
|
||||||
|
for (const query of listCases) {
|
||||||
|
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos${query}`);
|
||||||
|
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos${query}`);
|
||||||
|
expect(ov.status).toBe(200);
|
||||||
|
expect(ov.body.items.map(stripItem)).toEqual(cn.body.items.map(stripItem));
|
||||||
|
expect(ov.body.nextCursor).toBe(cn.body.nextCursor); // 同一 shared/cursor 编码 → 逐字节相等
|
||||||
|
expect(ov.body.hasMore).toBe(cn.body.hasMore);
|
||||||
|
// 现签 URL 形状仍校验(每条可取回真实字节)。
|
||||||
|
for (const item of ov.body.items) {
|
||||||
|
expect(Date.parse(item.thumb.expiresAt)).toBeGreaterThan(Date.now());
|
||||||
|
expect(await fetch(item.thumb.url).then((r) => r.ok)).toBe(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// limit=2 分页确实产生了 hasMore(上面游标相等性才有意义)。
|
||||||
|
const firstPage = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?limit=2`);
|
||||||
|
expect(firstPage.body.hasMore).toBe(true);
|
||||||
|
expect(firstPage.body.items).toHaveLength(2);
|
||||||
|
|
||||||
|
// —— getPublicPhoto 同形(含 categoryId;刷新入口语义=每次现签)——
|
||||||
|
for (const photoId of photoIds) {
|
||||||
|
const cn = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos/${photoId}`);
|
||||||
|
const ov = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos/${photoId}`);
|
||||||
|
expect(ov.status).toBe(200);
|
||||||
|
expect(stripItem(ov.body)).toEqual(stripItem(cn.body));
|
||||||
|
expect(ov.body.categoryId).toBe(cn.body.categoryId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('② viewCount 真实读:album_stats 行值(计数端点 Task 8 接线)', async () => {
|
||||||
|
const before = await getJson(ovBase, `/v1/public/albums/${publicToken}`);
|
||||||
|
expect(before.body.viewCount).toBe(0);
|
||||||
|
await prismaOv.album_stats.create({
|
||||||
|
data: { albumId, viewCount: 5, statVersion: 3 },
|
||||||
|
});
|
||||||
|
const after = await getJson(ovBase, `/v1/public/albums/${publicToken}`);
|
||||||
|
expect(after.body.viewCount).toBe(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('③ 参数校验同形:sort/cursor/category 400;幽灵 token 三端点 404', async () => {
|
||||||
|
const badSort = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=bogus`);
|
||||||
|
expect(badSort.status).toBe(400);
|
||||||
|
expect(badSort.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
const badCursor = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?cursor=zzz`);
|
||||||
|
expect(badCursor.status).toBe(400);
|
||||||
|
expect(badCursor.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
|
||||||
|
// 幽灵分类:与 cn 同码同因(防探测,不区分不存在/跨册)。
|
||||||
|
for (const base of [cnBase, ovBase]) {
|
||||||
|
const ghostCategory = await getJson(base, `/v1/public/albums/${publicToken}/photos?category=cat-ghost`);
|
||||||
|
expect(ghostCategory.status).toBe(400);
|
||||||
|
expect(ghostCategory.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ghostToken = 'g' + '0'.repeat(23);
|
||||||
|
for (const path of [
|
||||||
|
`/v1/public/albums/${ghostToken}`,
|
||||||
|
`/v1/public/albums/${ghostToken}/photos`,
|
||||||
|
`/v1/public/albums/${ghostToken}/photos/p-x`,
|
||||||
|
]) {
|
||||||
|
const res = await getJson(ovBase, path);
|
||||||
|
expect(res.status).toBe(404);
|
||||||
|
expect(res.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('④ hot 排序:photoId 决胜 → TTL 窗口冻结(计数现读)→ 过期翻转', async () => {
|
||||||
|
// likeTotal 全 0:hot = likeTotal DESC, photoId DESC(id 决胜)。
|
||||||
|
const idDesc = [...photoIds].sort((a, b) => (a < b ? 1 : -1));
|
||||||
|
const first = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot&limit=2`);
|
||||||
|
expect(first.status).toBe(200);
|
||||||
|
expect(first.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(idDesc.slice(0, 2));
|
||||||
|
expect(first.body.hasMore).toBe(true);
|
||||||
|
|
||||||
|
// 快照语义:DB 改 likeTotal 后,TTL 窗口内顺序冻结(仍 id 序)——但条目
|
||||||
|
// 计数现读(尾张已带新值)。
|
||||||
|
const tail = idDesc[2]!;
|
||||||
|
await prismaOv.photos_ov.update({
|
||||||
|
where: { photoId: tail },
|
||||||
|
data: { likeTotal: 10 },
|
||||||
|
});
|
||||||
|
const stale = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot`);
|
||||||
|
expect(stale.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(idDesc);
|
||||||
|
expect(stale.body.items.find((i: ItemNoUrl) => i.photoId === tail).likeCount).toBe(10);
|
||||||
|
|
||||||
|
// 翻页对同一快照切片:第 2 页从冻结清单 offset 2 取——尾张仍在末位。
|
||||||
|
const page2 = await getJson(
|
||||||
|
ovBase,
|
||||||
|
`/v1/public/albums/${publicToken}/photos?sort=hot&limit=2&cursor=${first.body.nextCursor as string}`,
|
||||||
|
);
|
||||||
|
expect(page2.status).toBe(200);
|
||||||
|
expect(page2.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual([tail]);
|
||||||
|
expect(page2.body.hasMore).toBe(false);
|
||||||
|
|
||||||
|
// TTL 过期 → 快照重算 → 顺序翻转(尾张 +10 居首)。
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, HOT_TTL_TEST_MS + 200));
|
||||||
|
const flipped = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos?sort=hot`);
|
||||||
|
expect(flipped.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual([
|
||||||
|
tail,
|
||||||
|
...idDesc.slice(0, 2),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⑤ 隐藏照片:cn 隐藏 → 同步 → ov 列表剔除 + 单图 404 PHOTO_NOT_PUBLIC', async () => {
|
||||||
|
const hidden = photoIds[1]!;
|
||||||
|
const hide = await request(cnBase)
|
||||||
|
.patch(`/v1/photos/${hidden}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ visibility: 'hidden' });
|
||||||
|
expect(hide.status).toBe(200);
|
||||||
|
await drainSender();
|
||||||
|
|
||||||
|
const cnList = await getJson(cnBase, `/v1/public/albums/${publicToken}/photos`);
|
||||||
|
const ovList = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos`);
|
||||||
|
expect(ovList.body.items.map((i: ItemNoUrl) => i.photoId)).toEqual(
|
||||||
|
cnList.body.items.map((i: ItemNoUrl) => i.photoId),
|
||||||
|
);
|
||||||
|
expect(ovList.body.items.map((i: ItemNoUrl) => i.photoId)).not.toContain(hidden);
|
||||||
|
|
||||||
|
const single = await getJson(ovBase, `/v1/public/albums/${publicToken}/photos/${hidden}`);
|
||||||
|
expect(single.status).toBe(404);
|
||||||
|
expect(single.body.code).toBe(ErrorCodes.PHOTO_NOT_PUBLIC);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⑥ 相册下线:cn publicEnabled=false → ov 与 cn 同码 404', async () => {
|
||||||
|
const offline = await request(cnBase)
|
||||||
|
.patch(`/v1/albums/${albumId}`)
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({ publicEnabled: false });
|
||||||
|
expect(offline.status).toBe(200);
|
||||||
|
await drainSender();
|
||||||
|
|
||||||
|
for (const base of [cnBase, ovBase]) {
|
||||||
|
const album = await getJson(base, `/v1/public/albums/${publicToken}`);
|
||||||
|
expect(album.status).toBe(404);
|
||||||
|
expect(album.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||||||
|
const list = await getJson(base, `/v1/public/albums/${publicToken}/photos`);
|
||||||
|
expect(list.status).toBe(404);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('⑦ 紧急停用:ov disable → ov 404 而 cn 无感;enable 恢复', async () => {
|
||||||
|
// 独立相册(不影响前序场景):cn 建 + 同步 → ov 可读。
|
||||||
|
const album = await request(cnBase)
|
||||||
|
.post('/v1/albums')
|
||||||
|
.set('Authorization', `Bearer ${adminToken}`)
|
||||||
|
.send({
|
||||||
|
translations: [{ locale: 'zh-CN', title: '紧急停用验证' }],
|
||||||
|
defaultLocale: 'zh-CN',
|
||||||
|
startAt: '2026-10-01T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
expect(album.status).toBe(201);
|
||||||
|
const token = album.body.publicToken as string;
|
||||||
|
await drainSender();
|
||||||
|
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
||||||
|
|
||||||
|
// ov 本地停用(独立于同步版本链)。
|
||||||
|
const disable = await signedPost(ovBase, '/v1/internal/emergency/disable', {
|
||||||
|
albumToken: token,
|
||||||
|
reason: 'e2e: public-ov visibility',
|
||||||
|
});
|
||||||
|
expect(disable.status).toBe(200);
|
||||||
|
const ovRes = await getJson(ovBase, `/v1/public/albums/${token}`);
|
||||||
|
expect(ovRes.status).toBe(404);
|
||||||
|
expect(ovRes.body.code).toBe(ErrorCodes.ALBUM_NOT_PUBLIC);
|
||||||
|
// cn 无感:仍 200(紧急停用是 ov 观众域动作,cn 权威数据不变)。
|
||||||
|
expect((await getJson(cnBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
||||||
|
|
||||||
|
// 显式 enable → 恢复。
|
||||||
|
const enable = await signedPost(ovBase, '/v1/internal/emergency/enable', {
|
||||||
|
albumToken: token,
|
||||||
|
});
|
||||||
|
expect(enable.status).toBe(200);
|
||||||
|
expect((await getJson(ovBase, `/v1/public/albums/${token}`)).status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -74,6 +74,30 @@ const DEFAULT_TIMEOUT_MS = 30_000;
|
|||||||
/** ov 侧 content-batches 端点路径(全局前缀 v1 + InternalSyncGuard 域)。 */
|
/** ov 侧 content-batches 端点路径(全局前缀 v1 + InternalSyncGuard 域)。 */
|
||||||
const CONTENT_BATCHES_PATH = '/v1/internal/sync/content-batches';
|
const CONTENT_BATCHES_PATH = '/v1/internal/sync/content-batches';
|
||||||
|
|
||||||
|
/** deliverPendingOnce 的排空轮数上限(每轮一批 ≤ batchSize;防御性熔断)。 */
|
||||||
|
const DELIVER_MAX_ROUNDS = 100;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性排空 outbox(P2 Task 6):循环 runOnce 直至无未投递事件。
|
||||||
|
* 消费方:viewer e2e 夹具(flushSync,种子后经真实管线同步到 ov)与运维手工
|
||||||
|
* 触发——与常驻 main 循环同一 OutboxSender,无旁路语义;与常驻发送器并发
|
||||||
|
* 亦安全(eventId 幂等 + deliveredAt 条件回写,两侧竞争不双发不丢发)。
|
||||||
|
*/
|
||||||
|
export async function deliverPendingOnce(
|
||||||
|
options: OutboxSenderOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const sender = new OutboxSender(options);
|
||||||
|
for (let round = 0; round < DELIVER_MAX_ROUNDS; round++) {
|
||||||
|
const stats = await sender.runOnce();
|
||||||
|
if (stats.picked === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`deliverPendingOnce 超时:${DELIVER_MAX_ROUNDS} 轮后 outbox 仍有未投递事件`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export class OutboxSender {
|
export class OutboxSender {
|
||||||
private readonly batchSize: number;
|
private readonly batchSize: number;
|
||||||
private readonly timeoutMs: number;
|
private readonly timeoutMs: number;
|
||||||
|
|||||||
@@ -55,7 +55,12 @@ export class PublishService {
|
|||||||
async processPhoto(photoId: string): Promise<ProcessOutcome> {
|
async processPhoto(photoId: string): Promise<ProcessOutcome> {
|
||||||
const photo = await this.prisma.photos.findUnique({
|
const photo = await this.prisma.photos.findUnique({
|
||||||
where: { id: photoId },
|
where: { id: photoId },
|
||||||
select: { albumId: true, objectKey: true, processingState: true },
|
select: {
|
||||||
|
albumId: true,
|
||||||
|
categoryId: true,
|
||||||
|
objectKey: true,
|
||||||
|
processingState: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
if (photo === null) {
|
if (photo === null) {
|
||||||
this.logger?.warn({ photoId }, '照片不存在,跳过处理');
|
this.logger?.warn({ photoId }, '照片不存在,跳过处理');
|
||||||
@@ -144,6 +149,7 @@ export class PublishService {
|
|||||||
const payload: PhotoPublishedPayload = {
|
const payload: PhotoPublishedPayload = {
|
||||||
photoId,
|
photoId,
|
||||||
albumId: photo.albumId,
|
albumId: photo.albumId,
|
||||||
|
categoryId: photo.categoryId,
|
||||||
objectKey: photo.objectKey,
|
objectKey: photo.objectKey,
|
||||||
thumbKey,
|
thumbKey,
|
||||||
browseKey,
|
browseKey,
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ describe('publish (e2e)', () => {
|
|||||||
const expectedPayload: PhotoPublishedPayload = {
|
const expectedPayload: PhotoPublishedPayload = {
|
||||||
photoId,
|
photoId,
|
||||||
albumId,
|
albumId,
|
||||||
|
categoryId: null, // 本 spec 种子未分类(categoryId 随载荷同步到 ov 副本)
|
||||||
objectKey,
|
objectKey,
|
||||||
thumbKey: variantKey(albumId, photoId, 'thumb'),
|
thumbKey: variantKey(albumId, photoId, 'thumb'),
|
||||||
browseKey: variantKey(albumId, photoId, 'browse'),
|
browseKey: variantKey(albumId, photoId, 'browse'),
|
||||||
|
|||||||
Reference in New Issue
Block a user