feat(api): embed slots with ov replica and public feed
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -70,3 +70,7 @@ CLEANUP_INTERVAL_MINUTES=60
|
||||
EMBED_POLL_INTERVAL_MS=30000
|
||||
EMBED_POLL_MAX_FAILURES=5
|
||||
EMBED_POLL_BACKOFF_MULTIPLIER=5
|
||||
# P4b Task 2:展示位公开端点基础限速(slotToken+来源 IP 固定窗;GET 无幂等面,
|
||||
# 含 304 命中——轮询本身 30s/次,余量给页面重载突发,P5 速率层接真实防护)。
|
||||
EMBED_WINDOW_REQUESTS=120
|
||||
EMBED_WINDOW_SECONDS=60
|
||||
|
||||
@@ -60,7 +60,7 @@ describe('OutboxEvent', () => {
|
||||
});
|
||||
|
||||
describe('OUTBOX_EVENT_TYPES', () => {
|
||||
it('恰好包含全部 8 个事件类型字符串', () => {
|
||||
it('恰好包含全部 9 个事件类型字符串', () => {
|
||||
expect([...OUTBOX_EVENT_TYPES]).toEqual([
|
||||
'photo.published',
|
||||
'photo.hidden',
|
||||
@@ -70,6 +70,7 @@ describe('OUTBOX_EVENT_TYPES', () => {
|
||||
'category.updated',
|
||||
'category.deleted',
|
||||
'asset.updated',
|
||||
'slot.updated',
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -50,3 +50,7 @@ export const EMBED_POLL_INTERVAL_MS = 30000;
|
||||
export const EMBED_POLL_MAX_FAILURES = 5;
|
||||
/** 轮询失败退避倍数(连续失败达上限后拉长间隔,成功即恢复正常间隔)。 */
|
||||
export const EMBED_POLL_BACKOFF_MULTIPLIER = 5;
|
||||
/** 展示位公开端点基础限速:slotToken+IP 固定窗(EMBED_WINDOW_SECONDS)内请求数上限(含 304)。 */
|
||||
export const EMBED_WINDOW_REQUESTS = 120;
|
||||
/** 展示位限速固定窗秒数(轮询 30s/次 + 页面重载突发,窗口内余量充足)。 */
|
||||
export const EMBED_WINDOW_SECONDS = 60;
|
||||
|
||||
@@ -12,7 +12,8 @@ export interface OutboxEvent<T = unknown> {
|
||||
| 'album.updated'
|
||||
| 'category.updated'
|
||||
| 'category.deleted'
|
||||
| 'asset.updated';
|
||||
| 'asset.updated'
|
||||
| 'slot.updated';
|
||||
albumId: string;
|
||||
photoId?: string;
|
||||
entityVersion: number; // 单调递增,P2 inbox 消费依据
|
||||
@@ -122,6 +123,33 @@ export interface AssetUpdatedPayload {
|
||||
entityVersion: number;
|
||||
}
|
||||
|
||||
// —— 展示位(P4b Task 2)——
|
||||
// slot.updated 载荷:ov 副本(embed_slots_ov)重建展示位所需的**全量快照**——
|
||||
// 展示位无墓碑(无删除端点,enabled=false 即下线),每次管理面变更整行替换。
|
||||
// slotId 供发送器推导 entityId(同 categoryId/assetId 约定,outbox 表无此列);
|
||||
// albumId/albumPublicToken 为绑定相册双标识(未绑定时均 null):ov 副本外键
|
||||
// 落 albumPublicToken(公开端点以它关联 albums_ov),照片按副本实时查询、
|
||||
// 不随载荷携带(新照片发布走 photo.published,ETag 四元组含发布时间)。
|
||||
export interface SlotStateUpdatedPayload {
|
||||
slotId: string;
|
||||
publicToken: string;
|
||||
enabled: boolean;
|
||||
albumId: string | null;
|
||||
albumPublicToken: string | null;
|
||||
configVersion: number;
|
||||
settings: SlotSettingsPayload;
|
||||
entityVersion: number;
|
||||
}
|
||||
|
||||
/** 展示位设置载荷(契约 EmbedSlotSettings 的载荷形状;字段恒齐全)。 */
|
||||
export interface SlotSettingsPayload {
|
||||
position: string; // left|right
|
||||
size: string; // sm|md
|
||||
intervalMs: number;
|
||||
lang: string; // auto|zh-CN|en
|
||||
collapsedDefault: boolean;
|
||||
}
|
||||
|
||||
// 运行时校验用:全部合法事件类型字符串。satisfies 保证 数组 ⊆ 接口 union;
|
||||
// __tests__/events.test.ts 的 Equal 断言反向锁定 union ⊆ 数组,两侧永远 1:1。
|
||||
export const OUTBOX_EVENT_TYPES = [
|
||||
@@ -133,13 +161,14 @@ export const OUTBOX_EVENT_TYPES = [
|
||||
'category.updated',
|
||||
'category.deleted',
|
||||
'asset.updated',
|
||||
'slot.updated',
|
||||
] as const satisfies readonly OutboxEvent['type'][];
|
||||
|
||||
// ── outbox 类型 → 内部同步线格式(openapi SyncEvent.entityType)的映射 ──
|
||||
// 发送器(services/sync)与 cn reconcile 端点共用,单一实现防两侧漂移;
|
||||
// exhaustive switch:新增事件类型漏映射会在此编译报错。
|
||||
|
||||
/** 内部同步实体类型(对齐 openapi SyncEntityType;slot-state 为预留)。 */
|
||||
/** 内部同步实体类型(对齐 openapi SyncEntityType;slot-state 自 P4b Task 2 起 landing)。 */
|
||||
export type SyncEntityType =
|
||||
| 'album'
|
||||
| 'category'
|
||||
@@ -158,6 +187,8 @@ export function syncEntityTypeOfOutboxType(
|
||||
return 'category';
|
||||
case 'asset.updated':
|
||||
return 'asset';
|
||||
case 'slot.updated':
|
||||
return 'slot-state';
|
||||
case 'photo.published':
|
||||
case 'photo.hidden':
|
||||
case 'photo.deleted':
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
-- P4b Task 2:展示位域(embed_slots/embed_slot_members)。
|
||||
-- 校对手决:Prisma 生成 DDL 固定 utf8mb4_unicode_ci,逐表改写为库级约定的
|
||||
-- utf8mb4_0900_bin(publicToken 的大小写敏感唯一语义依赖它,同 albums 口径)。
|
||||
-- · embed_slots 双版本位:configVersion(嵌入端可见配置版本,任一管理面
|
||||
-- 变更递增)/entityVersion(slot.updated 同步事件版本);无删除端点,
|
||||
-- enabled=false 即下线。settings 为归一化 EmbedSlotSettings 全集 JSON。
|
||||
-- · embed_slot_members:授权成员表**建而未用**(P4b 权限简化 admin-only,
|
||||
-- 登记偏差;P5 授权面按需启用)。
|
||||
-- · 两表均扁平字符串外键(albumId/userId/slotId 无 FK 约束,同相册域约定)。
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `embed_slots` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`publicToken` VARCHAR(32) NOT NULL,
|
||||
`name` VARCHAR(64) NOT NULL,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT true,
|
||||
`albumId` VARCHAR(64) NULL,
|
||||
`configVersion` INTEGER NOT NULL DEFAULT 1,
|
||||
`settings` JSON NOT NULL,
|
||||
`entityVersion` INTEGER NOT NULL DEFAULT 1,
|
||||
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
`updatedAt` DATETIME(6) NOT NULL,
|
||||
|
||||
UNIQUE INDEX `embed_slots_publicToken_key`(`publicToken`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `embed_slot_members` (
|
||||
`id` VARCHAR(191) NOT NULL,
|
||||
`slotId` VARCHAR(64) NOT NULL,
|
||||
`userId` VARCHAR(64) NOT NULL,
|
||||
`permission` VARCHAR(32) NOT NULL,
|
||||
`createdAt` DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||
|
||||
INDEX `embed_slot_members_userId_idx`(`userId`),
|
||||
UNIQUE INDEX `embed_slot_members_slotId_userId_key`(`slotId`, `userId`),
|
||||
PRIMARY KEY (`id`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
@@ -332,3 +332,33 @@ model user_sync_state {
|
||||
runCount Int @default(0) // 已启动批次数(含失败),驱动周期全量对账
|
||||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||||
}
|
||||
// —— 展示位域(P4b Task 2,悬浮轮播组件挂载点)——
|
||||
// cn 权威实体:admin 建槽/绑定相册,经 outbox slot.updated 事件同步 ov 副本
|
||||
// (embed_slots_ov,载荷全量快照)。扁平字符串外键(albumId 无 relation/FK,
|
||||
// 同相册域约定)。双版本位:configVersion 是「嵌入端可见」的配置版本(任一
|
||||
// 管理面变更递增,公开端 ETag 组成部分);entityVersion 是同步版本(同相册
|
||||
// 模式,事务内自增)。无删除端点:enabled=false 即下线(软停用)。
|
||||
// embed_slot_members:授权成员表**建而未用**(P4b 权限简化为 admin-only——
|
||||
// 需求「创建者只操作被授权展示位」的授权面登记偏差,P5 按需出 API/UI)。
|
||||
model embed_slots {
|
||||
id String @id @default(cuid())
|
||||
publicToken String @unique @db.VarChar(32) // 嵌入代码 data-slot 引用(24 字符 [0-9a-z],同相册令牌口径)
|
||||
name String @db.VarChar(64)
|
||||
enabled Boolean @default(true)
|
||||
albumId String? @db.VarChar(64) // 绑定相册;null=未绑定(公开端 404)
|
||||
configVersion Int @default(1) // 任一管理面变更递增(嵌入端整体重置渲染依据)
|
||||
settings Json // EmbedSlotSettings 归一化全集(字段恒齐全)
|
||||
entityVersion Int @default(1) // slot.updated 事件版本(同 photos 模式)
|
||||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||||
}
|
||||
model embed_slot_members {
|
||||
id String @id @default(cuid())
|
||||
slotId String @db.VarChar(64)
|
||||
userId String @db.VarChar(64)
|
||||
permission String @db.VarChar(32) // manage|view(P4b 未用,P5 授权面按需启用)
|
||||
createdAt DateTime @default(now()) @db.DateTime(6)
|
||||
|
||||
@@unique([slotId, userId])
|
||||
@@index([userId])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
-- P4b Task 2:展示位副本表(cn embed_slots 的展示投影,slot-state 事件应用)。
|
||||
-- 校对手决:Prisma 生成 DDL 固定 utf8mb4_unicode_ci,改写为库级约定的
|
||||
-- utf8mb4_0900_bin(slotToken 主键/albumPublicToken 的大小写敏感寻址语义
|
||||
-- 依赖它,同 albums_ov.publicToken 口径)。无墓碑:下线是 enabled=false 的
|
||||
-- 全量快照替换(cn 无删除端点)。
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE `embed_slots_ov` (
|
||||
`slotToken` VARCHAR(32) NOT NULL,
|
||||
`enabled` BOOLEAN NOT NULL DEFAULT true,
|
||||
`albumPublicToken` VARCHAR(32) NULL,
|
||||
`configVersion` INTEGER NOT NULL DEFAULT 1,
|
||||
`settings` JSON NOT NULL,
|
||||
`entityVersion` INTEGER NOT NULL,
|
||||
`updatedAt` DATETIME(6) NOT NULL,
|
||||
|
||||
PRIMARY KEY (`slotToken`)
|
||||
) DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin;
|
||||
@@ -162,6 +162,23 @@ model visit_requests {
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
// —— 副本域:embed_slots_ov(cn embed_slots 的展示投影,P4b Task 2)——
|
||||
// slotToken 即 cn embed_slots.publicToken(公开端点按它寻址,稳定不变);
|
||||
// albumPublicToken 为绑定相册的公开令牌(null=未绑定 → 公开端 404)——副本
|
||||
// 外键落公开令牌而非 cn id:嵌入侧只认 token(A22 换绑不认相册身份)。
|
||||
// entityVersion 是 cn 权威版本(应用写入,ov 不自增);settings 为载荷原文
|
||||
// (当前公开响应不含设置——loader 经 data-* 配置,副本位为对账/后续使用保留)。
|
||||
// 无墓碑:下线是 enabled=false 的全量快照替换(cn 无删除端点)。
|
||||
model embed_slots_ov {
|
||||
slotToken String @id @db.VarChar(32)
|
||||
enabled Boolean @default(true)
|
||||
albumPublicToken String? @db.VarChar(32)
|
||||
configVersion Int @default(1)
|
||||
settings Json
|
||||
entityVersion Int
|
||||
updatedAt DateTime @updatedAt @db.DateTime(6)
|
||||
}
|
||||
|
||||
// —— 统计回传队列:outbox_stat_events(ov→cn,Task 9 消费)——
|
||||
// 与 cn outbox_events 同构的发送位:sentAt 为空即重发(中断恢复),statVersion
|
||||
// 让 cn 侧「仅更高版本替换」幂等。total 恒为权威快照值(非增量)。
|
||||
|
||||
@@ -18,6 +18,7 @@ import { AlbumsModule } from './albums/albums.module';
|
||||
import { AdminModule } from './admin/admin.module';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { EmbedModule } from './embed/embed.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { PhotosModule } from './photos/photos.module';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
@@ -46,6 +47,8 @@ import { createLoggerOptions } from './shared/logger-options';
|
||||
PhotosModule,
|
||||
// 管理辅助域(P1b Task 4:/admin/users 账号前缀查询)。
|
||||
AdminModule,
|
||||
// 展示位管理域(P4b Task 2:/admin/embed-slots,slot-state 事件源)。
|
||||
EmbedModule,
|
||||
// 内部同步域(P2 Task 3:/internal/sync/bootstrap|reconcile,HMAC 鉴权,
|
||||
// 仅 cn 入口装配——cn 是内容同步的源端)。
|
||||
SyncModule,
|
||||
|
||||
@@ -22,6 +22,7 @@ import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { LoggerModule } from 'nestjs-pino';
|
||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||
import { CleanupModule } from './cleanup/cleanup.module';
|
||||
import { EmbedOvModule } from './embed-ov/embed-ov.module';
|
||||
import { EmergencyModule } from './emergency/emergency.module';
|
||||
import { HealthController } from './health.controller';
|
||||
import { InteractionsModule } from './interactions/interactions.module';
|
||||
@@ -43,6 +44,9 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
||||
EmergencyModule,
|
||||
// ov 公开读(P2 Task 6):/public/albums/* 读 ov 副本(含 sort=hot 快照)。
|
||||
PublicOvModule,
|
||||
// 展示位公开读(P4b Task 2):/public/embed-slots/* 读 slot-state 副本
|
||||
// (嵌入组件数据源:近期照片 + ETag/304 + slotToken+IP 限速)。
|
||||
EmbedOvModule,
|
||||
// ov 公开互动(P2 Task 7):POST /public/albums/*/photos/*/likes。
|
||||
InteractionsModule,
|
||||
// ov 实时广播(P2 Task 7):WS /v1/realtime(适配器见 main-ov)。
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
// services/api/src/embed-ov/embed-ov.controller.ts —— 展示位公开端点
|
||||
// (P4b Task 2,ov 域)。路由与契约 getPublicEmbedSlot 一致(tags [public],
|
||||
// /public/embed-slots/{slotToken},匿名只读——嵌入 iframe 轮询调用)。
|
||||
// ETag/304 判定在控制器(需读 If-None-Match 头;服务产出 body+ETag):
|
||||
// · If-None-Match 匹配(含 * 通配与弱校验器 W/ 前缀、逗号多值)→ 304
|
||||
// 无响应体、仍携带 ETag(passthrough 模式下 return undefined 即空 body);
|
||||
// · 200 携带 ETag 头 + 完整载荷。
|
||||
// IP 取 req.ip 并归一 IPv6 映射前缀(同 visits 口径),仅作限速键。
|
||||
// 响应类型取结构子集(status/set 两方法)而非 express 类型——api 包不直接
|
||||
// 依赖 express 类型声明(同 request-id.middleware 的 node:http 口径)。
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Headers,
|
||||
Ip,
|
||||
Param,
|
||||
Query,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
EmbedOvService,
|
||||
type PublicEmbedSlotResponse,
|
||||
} from './embed-ov.service';
|
||||
|
||||
/** passthrough 响应的结构子集:写状态码与响应头(返回值仍走 Nest 序列化)。 */
|
||||
interface PassthroughResponse {
|
||||
status(code: number): PassthroughResponse;
|
||||
set(field: string, value: string): PassthroughResponse;
|
||||
}
|
||||
|
||||
/**
|
||||
* If-None-Match 匹配判定:逗号分隔多值,逐个剥离 W/ 前缀后与当前 ETag
|
||||
* 全等比较;`*` 匹配任意当前状态(RFC 9110 §13.1.2最小实现)。
|
||||
*/
|
||||
export function etagMatches(ifNoneMatch: string, etag: string): boolean {
|
||||
for (const raw of ifNoneMatch.split(',')) {
|
||||
let candidate = raw.trim();
|
||||
if (candidate.startsWith('W/')) {
|
||||
candidate = candidate.slice(2).trim();
|
||||
}
|
||||
if (candidate === etag || candidate === '*') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
@Controller('public/embed-slots')
|
||||
export class EmbedOvController {
|
||||
constructor(private readonly embedOvService: EmbedOvService) {}
|
||||
|
||||
@Get(':slotToken')
|
||||
async getPublicEmbedSlot(
|
||||
@Param('slotToken') slotToken: string,
|
||||
@Query('lang') lang: string | undefined,
|
||||
@Headers('if-none-match') ifNoneMatch: string | undefined,
|
||||
@Ip() ip: string | undefined,
|
||||
@Res({ passthrough: true }) res: PassthroughResponse,
|
||||
): Promise<PublicEmbedSlotResponse | undefined> {
|
||||
const clientIp = ip === undefined ? undefined : ip.replace(/^::ffff:/, '');
|
||||
const { body, etag } = await this.embedOvService.getPublicEmbedSlot(
|
||||
slotToken,
|
||||
lang,
|
||||
clientIp,
|
||||
);
|
||||
if (ifNoneMatch !== undefined && etagMatches(ifNoneMatch, etag)) {
|
||||
// 304:无响应体,仍携带 ETag(契约 304 语义;passthrough 下不写 body)。
|
||||
res.status(304).set('ETag', etag);
|
||||
return undefined;
|
||||
}
|
||||
res.set('ETag', etag);
|
||||
return body;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
// services/api/src/embed-ov/ —— 展示位公开读模块(P4b Task 2,仅 ov 入口装配:
|
||||
// 嵌入组件数据源部署在观众域名,读 slot-state 副本)。装配 EmbedOvController/
|
||||
// EmbedOvService + OvPrismaModule + OvRedisModule(限速)+ StorageOvModule
|
||||
// (thumb 现签,与 public-ov/interactions 共享同一只读实例)。无认证语义
|
||||
// (公开端点匿名,持有 slotToken 即可访问)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { OvPrismaModule } from '../prisma-ov/ov-prisma.module';
|
||||
import { OvRedisModule } from '../redis-ov/ov-redis.module';
|
||||
import { StorageOvModule } from '../public-ov/storage-ov.module';
|
||||
import { EmbedOvController } from './embed-ov.controller';
|
||||
import { EmbedOvService } from './embed-ov.service';
|
||||
|
||||
@Module({
|
||||
imports: [OvPrismaModule, OvRedisModule, StorageOvModule],
|
||||
controllers: [EmbedOvController],
|
||||
providers: [EmbedOvService],
|
||||
})
|
||||
export class EmbedOvModule {}
|
||||
@@ -0,0 +1,230 @@
|
||||
// services/api/src/embed-ov/embed-ov.service.ts —— 展示位公开数据(P4b Task 2,
|
||||
// ov 域)。契约权威:getPublicEmbedSlot(openapi tags [public],路径前缀不变、
|
||||
// 部署域名区分——嵌入 iframe 轮询的数据源)。流程:
|
||||
//
|
||||
// ① 展示位副本:slotToken → embed_slots_ov;不存在 / enabled=false /
|
||||
// albumPublicToken null 统一 404 NOT_FOUND(同码同文案防探测,契约语义);
|
||||
// ② 相册副本:albumPublicToken → albums_ov + isAlbumVisibleOv(含紧急停用;
|
||||
// 相册下线/关闭公开 → 同 404——嵌入须尊重相册公开状态);
|
||||
// ③ 基础限速(Redis ov 固定窗,slotToken+IP 键,EMBED_WINDOW_REQUESTS/
|
||||
// EMBED_WINDOW_SECONDS;404 校验不消耗预算——同 visits ①→③ 排序口径);
|
||||
// ④ 照片:photos_ov published 近期 20(publishedAt+photoId 稳定倒序),
|
||||
// thumb 现签(小尺寸省流量,与公开列表 browse 大图区分);宽高缺失跳过
|
||||
// (同 public-ov 防御语义);
|
||||
// ⑤ 标题解析:lang=auto(缺省)或不支持时按相册 defaultLocale 回退(不报
|
||||
// 400,与公开相册一致);defaultLocale 翻译缺失属装配异常 → 5xx 兜底;
|
||||
// ⑥ ETag 四元组 `"{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"`
|
||||
// (契约评审修正:含照片条数与最新发布时间——新照片发布必变,避免仅比对
|
||||
// 实体版本时新照片小版本导致 304 陈旧);If-None-Match 匹配 → 304 无body
|
||||
// (判定在控制器,服务只产出 body+ETag)。空照片列:后两元取 0 与空串。
|
||||
import { HttpStatus, Inject, Injectable } from '@nestjs/common';
|
||||
import type { components } from '@photo/contracts';
|
||||
import {
|
||||
EMBED_WINDOW_REQUESTS,
|
||||
EMBED_WINDOW_SECONDS,
|
||||
ErrorCodes,
|
||||
} from '@photo/contracts';
|
||||
import { ApiException } from '../api-exception';
|
||||
import { isAlbumVisibleOv } from '../shared/album-visibility';
|
||||
import { OvPrismaService } from '../prisma-ov/ov-prisma.module';
|
||||
import { OvRedisService } from '../redis-ov/ov-redis.module';
|
||||
import { signedUrlTtlSeconds } from '../uploads/support';
|
||||
import { STORAGE_PORT_OV } from '../public-ov/storage-ov.provider';
|
||||
import type { StoragePort } from '@photo/storage';
|
||||
|
||||
export type PublicEmbedSlotResponse = components['schemas']['PublicEmbedSlot'];
|
||||
type PublicEmbedAlbumResponse = NonNullable<
|
||||
PublicEmbedSlotResponse['album']
|
||||
>;
|
||||
type SignedUrlResponse = components['schemas']['SignedUrl'];
|
||||
|
||||
/** 公开照片条数上限(契约 description:近期已发布 ≤20)。 */
|
||||
export const EMBED_FEED_LIMIT = 20;
|
||||
|
||||
/** slotToken+IP 固定窗内请求数上限(惰性读 env,同 visits 口径)。 */
|
||||
export function embedWindowRequests(): number {
|
||||
const parsed = Number.parseInt(process.env.EMBED_WINDOW_REQUESTS ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : EMBED_WINDOW_REQUESTS;
|
||||
}
|
||||
|
||||
/** 展示位限速固定窗秒数(惰性读 env)。 */
|
||||
export function embedWindowSeconds(): number {
|
||||
const parsed = Number.parseInt(process.env.EMBED_WINDOW_SECONDS ?? '', 10);
|
||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : EMBED_WINDOW_SECONDS;
|
||||
}
|
||||
|
||||
/** 防探测统一 404(NOT_FOUND;不存在/停用/未绑定/相册不可见同码同文案)。 */
|
||||
function embedSlotNotFound(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'展示位不存在或不可用',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
function rateLimited(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.RATE_LIMITED,
|
||||
'请求过于频繁,请稍后再试',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
|
||||
/** albums_ov.translations JSON 元素形状(sync payload 原文)。 */
|
||||
interface TranslationEntry {
|
||||
locale: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
/** 服务产出:响应体 + 强 ETag(304 判定归控制器——需读 If-None-Match 头)。 */
|
||||
export interface EmbedSlotFeed {
|
||||
body: PublicEmbedSlotResponse;
|
||||
etag: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class EmbedOvService {
|
||||
constructor(
|
||||
private readonly prisma: OvPrismaService,
|
||||
private readonly redis: OvRedisService,
|
||||
@Inject(STORAGE_PORT_OV) private readonly storage: StoragePort,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 展示位公开数据(①-⑥ 见文件头)。IP 仅作限速键——不落库、不进日志
|
||||
* (匿名观众端无 IP 留存需求,同 visits/likes 约定)。
|
||||
*/
|
||||
async getPublicEmbedSlot(
|
||||
slotToken: string,
|
||||
langRaw: string | undefined,
|
||||
clientIp: string | undefined,
|
||||
): Promise<EmbedSlotFeed> {
|
||||
// ① 展示位副本(防探测 404)。
|
||||
const slot = await this.prisma.embed_slots_ov.findUnique({
|
||||
where: { slotToken },
|
||||
});
|
||||
if (slot === null || !slot.enabled || slot.albumPublicToken === null) {
|
||||
throw embedSlotNotFound();
|
||||
}
|
||||
// ② 相册副本(下线/关闭公开/紧急停用 → 同 404)。
|
||||
const album = await this.prisma.albums_ov.findUnique({
|
||||
where: { publicToken: slot.albumPublicToken },
|
||||
});
|
||||
if (album === null || !isAlbumVisibleOv(album)) {
|
||||
throw embedSlotNotFound();
|
||||
}
|
||||
// ③ 基础限速(校验通过后才计预算)。
|
||||
await this.assertRateLimit(slotToken, clientIp);
|
||||
|
||||
// ④ 近期已发布照片(稳定倒序,上限 20;宽高缺失跳过)。
|
||||
const rows = (
|
||||
await this.prisma.photos_ov.findMany({
|
||||
where: { albumId: album.albumId, visibility: 'published' },
|
||||
orderBy: [{ publishedAt: 'desc' }, { photoId: 'desc' }],
|
||||
take: EMBED_FEED_LIMIT,
|
||||
})
|
||||
).filter((p) => p.width !== null && p.height !== null);
|
||||
const photos: PublicEmbedSlotResponse['photos'] = [];
|
||||
for (const photo of rows) {
|
||||
photos.push({
|
||||
photoId: photo.photoId,
|
||||
thumb: await this.presign(photo.thumbObjectKey),
|
||||
width: photo.width as number,
|
||||
height: photo.height as number,
|
||||
});
|
||||
}
|
||||
|
||||
// ⑤ 标题回退解析(auto/缺省/不支持 → defaultLocale,不报 400)。
|
||||
const title = this.resolveTitle(
|
||||
this.translationEntries(album.translations),
|
||||
album.defaultLocale,
|
||||
langRaw,
|
||||
);
|
||||
|
||||
// ⑥ ETag 四元组:取「实际返回」的首行(倒序即最新)——计数与发布时间均
|
||||
// 以响应内容为准,被防御跳过的行不影响校验器(响应不变则 304 语义成立)。
|
||||
const newest = rows[0];
|
||||
const etag = `"${slot.configVersion}-${photos.length}-${
|
||||
newest?.publishedAt?.getTime() ?? 0
|
||||
}-${newest?.photoId ?? ''}"`;
|
||||
|
||||
return {
|
||||
body: {
|
||||
slotToken,
|
||||
configVersion: slot.configVersion,
|
||||
album: {
|
||||
publicToken: album.publicToken,
|
||||
title,
|
||||
// DB 存自由字符串;取值来自相册创建/更新入参的枚举校验,收窄为契约 Locale。
|
||||
defaultLocale: album.defaultLocale as PublicEmbedAlbumResponse['defaultLocale'],
|
||||
},
|
||||
photos,
|
||||
},
|
||||
etag,
|
||||
};
|
||||
}
|
||||
|
||||
/** slotToken+IP 固定窗限速(Redis INCR + 首次 EXPIRE;同 visits 模式)。 */
|
||||
private async assertRateLimit(
|
||||
slotToken: string,
|
||||
clientIp: string | undefined,
|
||||
): Promise<void> {
|
||||
const key = `embed:window:${slotToken}|${clientIp ?? ''}`;
|
||||
const counted = await this.redis.incr(key);
|
||||
if (counted === 1) {
|
||||
await this.redis.expire(key, embedWindowSeconds()).catch(() => undefined);
|
||||
}
|
||||
if (counted > embedWindowRequests()) {
|
||||
throw rateLimited();
|
||||
}
|
||||
}
|
||||
|
||||
/** 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',
|
||||
)
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 标题解析:lang 合法(契约 EmbedSlotLang 非 auto)且副本有条目 → 用之;
|
||||
* 否则 defaultLocale(defaultLocale 条目缺失属装配异常 → 5xx,同公开相册
|
||||
* 口径:公开端点不得以半翻译状态 200)。
|
||||
*/
|
||||
private resolveTitle(
|
||||
entries: TranslationEntry[],
|
||||
defaultLocale: string,
|
||||
langRaw: string | undefined,
|
||||
): string {
|
||||
const byLocale = new Map(entries.map((row) => [row.locale, row.title]));
|
||||
const defaultTitle = byLocale.get(defaultLocale);
|
||||
if (defaultTitle === undefined) {
|
||||
throw new ApiException(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal server error',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
if (
|
||||
langRaw !== undefined &&
|
||||
langRaw !== 'auto' &&
|
||||
(langRaw === 'zh-CN' || langRaw === 'en')
|
||||
) {
|
||||
const title = byLocale.get(langRaw);
|
||||
if (title !== undefined) {
|
||||
return title;
|
||||
}
|
||||
}
|
||||
return defaultTitle;
|
||||
}
|
||||
|
||||
/** 现签短期 GET 凭证(TTL 惰性读取;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,92 @@
|
||||
// services/api/src/embed/dto.ts —— 展示位管理端点入参 DTO(P4b Task 2)。
|
||||
// 校验约束对齐契约 EmbedSlotCreateRequest / EmbedSlotUpdateRequest /
|
||||
// EmbedSlotSettings:
|
||||
// · name 1..64(VarChar(64) 列宽);
|
||||
// · settings 整体提交语义:创建可整体缺省(各字段按缺省值落库,服务层
|
||||
// 归一),提供时字段齐全——嵌套 DTO 无 @IsOptional,缺字段 400;
|
||||
// · albumId [string,'null']:非 null 绑定/换绑(≤64,绑定存在性校验在
|
||||
// 服务层事务内),null 解绑。@IsOptional 对 undefined/null 均跳过校验
|
||||
// 且 null 值保留在 DTO 上——服务层以 `!== undefined` 区分「未提交」与
|
||||
// 「显式 null」(@ValidateIf 回调收到的是整个 DTO 对象而非属性值,
|
||||
// 不能用于按值判空);
|
||||
// · 更新请求 minProperties:1(空体 400)在服务层裁决(DTO 层无法表达,
|
||||
// 同相册 update 口径)。
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
/** 契约 EmbedSlotLang 枚举(auto=按相册 defaultLocale)。 */
|
||||
export const EMBED_SLOT_LANGS = ['auto', 'zh-CN', 'en'] as const;
|
||||
export type EmbedSlotLang = (typeof EMBED_SLOT_LANGS)[number];
|
||||
|
||||
/** 契约 EmbedSlotSettings 字段枚举与边界(intervalMs 3000..30000)。 */
|
||||
const EMBED_SLOT_POSITIONS = ['left', 'right'] as const;
|
||||
const EMBED_SLOT_SIZES = ['sm', 'md'] as const;
|
||||
export const EMBED_INTERVAL_MS_MIN = 3000;
|
||||
export const EMBED_INTERVAL_MS_MAX = 30000;
|
||||
|
||||
export class EmbedSlotSettingsDto {
|
||||
@IsIn(EMBED_SLOT_POSITIONS)
|
||||
position!: (typeof EMBED_SLOT_POSITIONS)[number];
|
||||
|
||||
@IsIn(EMBED_SLOT_SIZES)
|
||||
size!: (typeof EMBED_SLOT_SIZES)[number];
|
||||
|
||||
@IsInt()
|
||||
@Min(EMBED_INTERVAL_MS_MIN)
|
||||
@Max(EMBED_INTERVAL_MS_MAX)
|
||||
intervalMs!: number;
|
||||
|
||||
@IsIn(EMBED_SLOT_LANGS)
|
||||
lang!: EmbedSlotLang;
|
||||
|
||||
@IsBoolean()
|
||||
collapsedDefault!: boolean;
|
||||
}
|
||||
|
||||
export class EmbedSlotCreateRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
name!: string;
|
||||
|
||||
/** 缺省整体落库默认值;提供时字段齐全(嵌套必填)。null 视同缺省(服务层)。 */
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmbedSlotSettingsDto)
|
||||
settings?: EmbedSlotSettingsDto | null;
|
||||
}
|
||||
|
||||
export class EmbedSlotUpdateRequestDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(64)
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
enabled?: boolean;
|
||||
|
||||
/** 整体替换;null 视同未提交(服务层以 !== undefined 判定提交与否)。 */
|
||||
@IsOptional()
|
||||
@ValidateNested()
|
||||
@Type(() => EmbedSlotSettingsDto)
|
||||
settings?: EmbedSlotSettingsDto | null;
|
||||
|
||||
/** 非字符串给值时拒绝(null 走 @IsOptional 放行 = 解绑语义)。 */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
albumId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
// services/api/src/embed/embed.controller.ts —— /admin/embed-slots 端点
|
||||
// (P4b Task 2,仅 cn 入口装配)。方法名与契约 operationId 一一对应
|
||||
// (listEmbedSlots/createEmbedSlot/updateEmbedSlot,tag [embed-slots])。
|
||||
// 控制器级 SessionGuard:三端点全需认证(契约 bearerAuth);admin 判定在
|
||||
// 服务层(AuthUser.role,403 AUTH_FORBIDDEN——同 admin-users 口径)。查询参数
|
||||
// 规整复用 shared/pagination(缺省 20、1..100,与契约 limit bounds 一致)。
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { userOf } from '../albums/albums.controller';
|
||||
import { AuthenticatedRequest, SessionGuard } from '../auth/session.guard';
|
||||
import { parseCursor, parseLimit } from '../shared/pagination';
|
||||
import {
|
||||
EmbedSlotCreateRequestDto,
|
||||
EmbedSlotUpdateRequestDto,
|
||||
} from './dto';
|
||||
import {
|
||||
EmbedSlotsService,
|
||||
type EmbedSlotListResponse,
|
||||
type EmbedSlotResponse,
|
||||
} from './embed.service';
|
||||
|
||||
@Controller('admin/embed-slots')
|
||||
@UseGuards(SessionGuard)
|
||||
export class EmbedSlotsController {
|
||||
constructor(private readonly embedSlotsService: EmbedSlotsService) {}
|
||||
|
||||
/** 展示位列表(createdAt+id 稳定倒序键集游标)。 */
|
||||
@Get()
|
||||
listEmbedSlots(
|
||||
@Query('cursor') cursor: string | undefined,
|
||||
@Query('limit') limit: string | undefined,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<EmbedSlotListResponse> {
|
||||
return this.embedSlotsService.list(userOf(req), parseCursor(cursor), parseLimit(limit));
|
||||
}
|
||||
|
||||
/** 创建展示位(publicToken 服务端生成;初始未绑定、configVersion=1)。 */
|
||||
@Post()
|
||||
createEmbedSlot(
|
||||
@Body() dto: EmbedSlotCreateRequestDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
return this.embedSlotsService.create(userOf(req), dto);
|
||||
}
|
||||
|
||||
/** 部分更新(任一管理面变更递增 configVersion 并触发 slot-state 同步)。 */
|
||||
@Patch(':id')
|
||||
updateEmbedSlot(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: EmbedSlotUpdateRequestDto,
|
||||
@Req() req: AuthenticatedRequest,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
return this.embedSlotsService.update(userOf(req), id, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
// services/api/src/embed/ —— 展示位管理模块(P4b Task 2,仅 cn 入口装配:
|
||||
// 展示位是 cn 权威实体,ov 侧只承接 slot-state 副本与公开读端点)。
|
||||
// PrismaModule 为 @Global,直接注入;无 Redis/存储依赖(管理面纯 DB 写)。
|
||||
import { Module } from '@nestjs/common';
|
||||
import { EmbedSlotsController } from './embed.controller';
|
||||
import { EmbedSlotsService } from './embed.service';
|
||||
|
||||
@Module({
|
||||
controllers: [EmbedSlotsController],
|
||||
providers: [EmbedSlotsService],
|
||||
})
|
||||
export class EmbedModule {}
|
||||
@@ -0,0 +1,406 @@
|
||||
// services/api/src/embed/embed.service.ts —— 展示位管理核心逻辑(P4b Task 2)。
|
||||
// 契约权威:packages/contracts/openapi.yaml tags [embed-slots]
|
||||
// (listEmbedSlots/createEmbedSlot/updateEmbedSlot)。要点:
|
||||
// · admin-only(AuthUser.role;P4b 权限简化:embed_slot_members 建而未用,
|
||||
// 被授权面 P5 按需补——计划 Task 1 登记偏差),非 admin 403 AUTH_FORBIDDEN;
|
||||
// · publicToken:24 字符 [0-9a-z](randomBytes 均匀映射,同 albums 口径),
|
||||
// P2002 撞唯一索引时整事务重试一次;
|
||||
// · create:单事务 embed_slots + outbox slot.updated(v1 全量快照——创建即
|
||||
// 未绑定态也同步,ov 副本与 sync_state 从第一版起对齐)+ audit_logs;
|
||||
// · update:条件更新(WHERE entityVersion=读取版本,同 albums 乐观重试
|
||||
// 模式)。任一管理面字段提交即 configVersion+1 **且** entityVersion+1
|
||||
// (双版本位,同一 updateMany 原子推进),事务内写 outbox slot.updated
|
||||
// 载荷=更新后全量。albumId 绑定/换绑校验相册存在(404 ALBUM_NOT_FOUND,
|
||||
// 相册行顺带取 publicToken 供载荷),null 解绑(albumPublicToken null)。
|
||||
// 停用迁移(enabled true→false)高优先级(ov 观众侧尽快 404,同相册
|
||||
// publicEnabled 口径);
|
||||
// · settings 整体替换;存储值恒为归一化全集(normalizeSettings);
|
||||
// · 列表:createdAt+id 稳定倒序键集游标(shared/cursor),albumTitle 按绑定
|
||||
// 相册 defaultLocale 解析(album_translations 二次查询,应用层组装——
|
||||
// 扁平字符串外键无 relation,同相册域约定)。
|
||||
// 无删除端点:enabled=false 即下线(软停用,契约语义)。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { HttpStatus, Injectable } from '@nestjs/common';
|
||||
import { ErrorCodes } from '@photo/contracts';
|
||||
import type { components } from '@photo/contracts';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import { ApiException } from '../api-exception';
|
||||
import type { AuthUser } from '../auth/session.guard';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
decodeKeysetCursor,
|
||||
encodeKeysetCursor,
|
||||
keysetWhereDesc,
|
||||
} from '../shared/cursor';
|
||||
import { buildSlotUpdatedPayload } from '../sync/payloads';
|
||||
import type {
|
||||
EmbedSlotCreateRequestDto,
|
||||
EmbedSlotSettingsDto,
|
||||
EmbedSlotUpdateRequestDto,
|
||||
} from './dto';
|
||||
import {
|
||||
albumNotFound,
|
||||
isUniqueViolationOn,
|
||||
validationFailed,
|
||||
} from '../albums/support';
|
||||
|
||||
export type EmbedSlotResponse = components['schemas']['EmbedSlot'];
|
||||
export type EmbedSlotListResponse = components['schemas']['EmbedSlotList'];
|
||||
export type EmbedSlotSettingsResponse =
|
||||
components['schemas']['EmbedSlotSettings'];
|
||||
|
||||
const PUBLIC_TOKEN_LENGTH = 24;
|
||||
const PUBLIC_TOKEN_ALPHABET = '0123456789abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
/** 契约 EmbedSlotSettings 各字段缺省值(整体缺省时落库形态)。 */
|
||||
export const DEFAULT_EMBED_SLOT_SETTINGS: EmbedSlotSettingsResponse = {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* updateEmbedSlot 审计 metadata.fields 的字段名序列(契约 EmbedSlotUpdateRequest
|
||||
* 属性序,同 albums UPDATE_FIELD_ORDER 约定:固定顺序保证同形请求同形审计)。
|
||||
*/
|
||||
const UPDATE_FIELD_ORDER = [
|
||||
'name',
|
||||
'enabled',
|
||||
'settings',
|
||||
'albumId',
|
||||
] as const satisfies readonly (keyof EmbedSlotUpdateRequestDto)[];
|
||||
|
||||
function generatePublicToken(): string {
|
||||
const bytes = randomBytes(PUBLIC_TOKEN_LENGTH);
|
||||
let token = '';
|
||||
for (let i = 0; i < PUBLIC_TOKEN_LENGTH; i++) {
|
||||
// %36 模偏差对 124bit 令牌熵无实际影响(同 albums 口径)。
|
||||
token += PUBLIC_TOKEN_ALPHABET[bytes[i] % PUBLIC_TOKEN_ALPHABET.length];
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/** 入参 settings → 归一化存储值(null/undefined=整体缺省 → 契约缺省值)。 */
|
||||
function normalizeSettings(
|
||||
settings: EmbedSlotSettingsDto | null | undefined,
|
||||
): EmbedSlotSettingsResponse {
|
||||
if (settings === null || settings === undefined) {
|
||||
return { ...DEFAULT_EMBED_SLOT_SETTINGS };
|
||||
}
|
||||
return {
|
||||
position: settings.position,
|
||||
size: settings.size,
|
||||
intervalMs: settings.intervalMs,
|
||||
lang: settings.lang,
|
||||
collapsedDefault: settings.collapsedDefault,
|
||||
};
|
||||
}
|
||||
|
||||
function forbidden(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.AUTH_FORBIDDEN,
|
||||
'仅管理员可管理展示位',
|
||||
HttpStatus.FORBIDDEN,
|
||||
);
|
||||
}
|
||||
|
||||
function slotNotFound(): ApiException {
|
||||
return new ApiException(
|
||||
ErrorCodes.NOT_FOUND,
|
||||
'展示位不存在',
|
||||
HttpStatus.NOT_FOUND,
|
||||
);
|
||||
}
|
||||
|
||||
type EmbedSlotRow = Prisma.embed_slotsGetPayload<Prisma.embed_slotsDefaultArgs>;
|
||||
|
||||
@Injectable()
|
||||
export class EmbedSlotsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
/** P4b 权限简化:仅公司 admin 可管理展示位(被授权面 P5)。 */
|
||||
private assertAdmin(user: AuthUser): void {
|
||||
if (user.role !== 'admin') {
|
||||
throw forbidden();
|
||||
}
|
||||
}
|
||||
|
||||
/** 展示位列表:createdAt+id 稳定倒序键集游标(同 albums.list 模式)。 */
|
||||
async list(
|
||||
user: AuthUser,
|
||||
cursor: string | undefined,
|
||||
limit: number,
|
||||
): Promise<EmbedSlotListResponse> {
|
||||
this.assertAdmin(user);
|
||||
let decoded: ReturnType<typeof decodeKeysetCursor> = null;
|
||||
if (cursor !== undefined && cursor !== '') {
|
||||
decoded = decodeKeysetCursor(cursor);
|
||||
if (decoded === null) {
|
||||
throw validationFailed('cursor 不合法');
|
||||
}
|
||||
}
|
||||
const rows = await this.prisma.embed_slots.findMany({
|
||||
where: decoded === null ? {} : keysetWhereDesc('createdAt', 'id', decoded),
|
||||
orderBy: [{ createdAt: 'desc' }, { id: 'desc' }],
|
||||
take: limit + 1, // 多取一条探测 hasMore(避免额外 count)
|
||||
});
|
||||
const hasMore = rows.length > limit;
|
||||
const page = hasMore ? rows.slice(0, limit) : rows;
|
||||
const titles = await this.albumTitlesByDefaultLocale(
|
||||
this.prisma,
|
||||
page.map((s) => s.albumId),
|
||||
);
|
||||
const last = page.at(-1);
|
||||
const nextCursor =
|
||||
hasMore && last !== undefined
|
||||
? encodeKeysetCursor({
|
||||
sort: last.createdAt.toISOString(),
|
||||
id: last.id,
|
||||
})
|
||||
: null;
|
||||
return {
|
||||
items: page.map((row) => this.toResponse(row, titles.get(row.albumId ?? '') ?? null)),
|
||||
nextCursor,
|
||||
hasMore: nextCursor !== null,
|
||||
};
|
||||
}
|
||||
|
||||
/** 创建展示位:事务三写(embed_slots/outbox slot.updated v1/audit)。 */
|
||||
async create(
|
||||
user: AuthUser,
|
||||
dto: EmbedSlotCreateRequestDto,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
this.assertAdmin(user);
|
||||
const settings = normalizeSettings(dto.settings);
|
||||
|
||||
let slotId: string;
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
slotId = await this.prisma.$transaction(async (tx) => {
|
||||
const slot = await tx.embed_slots.create({
|
||||
data: {
|
||||
publicToken: generatePublicToken(),
|
||||
name: dto.name,
|
||||
settings: settings as unknown as Prisma.InputJsonValue,
|
||||
// enabled/configVersion/entityVersion 走 schema 默认。
|
||||
},
|
||||
});
|
||||
// 创建即同步未绑定态(albumPublicToken null):ov 副本行与 sync_state
|
||||
// 版本账本从 v1 起对齐,后续换绑/停用事件有单调基准。outbox.albumId
|
||||
// NOT NULL,未绑定以空串占位(ov 侧 slot-state 不做相册依赖暂存)。
|
||||
await tx.outbox_events.create({
|
||||
data: {
|
||||
type: 'slot.updated',
|
||||
albumId: slot.albumId ?? '',
|
||||
entityVersion: slot.entityVersion,
|
||||
payload: buildSlotUpdatedPayload(
|
||||
slot,
|
||||
null,
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await tx.audit_logs.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
action: 'embed_slot.create',
|
||||
resourceId: slot.id,
|
||||
},
|
||||
});
|
||||
return slot.id;
|
||||
});
|
||||
break;
|
||||
} catch (err) {
|
||||
// publicToken 撞唯一索引:换令牌整事务重来一次(同 albums 口径)。
|
||||
if (attempt === 0 && isUniqueViolationOn(err, 'publicToken')) {
|
||||
continue;
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
return this.loadSlot(slotId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 部分更新(契约 updateEmbedSlot):仅提交的字段被修改;任一管理面字段
|
||||
* 提交即 configVersion+1 且 entityVersion+1(同一条件 updateMany 原子推进,
|
||||
* WHERE entityVersion=读取版本,并发败方整事务重跑——同 albums.update)。
|
||||
* albumId 非空校验相册存在(404 ALBUM_NOT_FOUND)并取 publicToken 入载荷;
|
||||
* null 解绑。settings 整体替换。事务内写 outbox slot.updated(载荷=更新后
|
||||
* 全量快照)+ audit(metadata.fields=提交字段名)。
|
||||
*/
|
||||
async update(
|
||||
user: AuthUser,
|
||||
slotId: string,
|
||||
dto: EmbedSlotUpdateRequestDto,
|
||||
): Promise<EmbedSlotResponse> {
|
||||
this.assertAdmin(user);
|
||||
const fields = UPDATE_FIELD_ORDER.filter(
|
||||
(key) => dto[key] !== undefined,
|
||||
) as string[];
|
||||
if (fields.length === 0) {
|
||||
throw validationFailed('请求体至少需要包含一个待更新字段');
|
||||
}
|
||||
|
||||
let outcome: 'done' | 'retry' = 'retry';
|
||||
for (let attempt = 0; attempt < 3 && outcome === 'retry'; attempt++) {
|
||||
outcome = await this.prisma.$transaction(async (tx) => {
|
||||
const slot = await tx.embed_slots.findUnique({ where: { id: slotId } });
|
||||
if (slot === null) {
|
||||
throw slotNotFound();
|
||||
}
|
||||
|
||||
// albumId 裁决:显式 null=解绑;字符串=绑定/换绑(相册须存在,顺带取
|
||||
// publicToken 供事件载荷);未提交=保持(载荷仍需现绑相册的 token)。
|
||||
let nextAlbumId = slot.albumId;
|
||||
let nextAlbumPublicToken: string | null = null;
|
||||
if (dto.albumId !== undefined) {
|
||||
nextAlbumId = dto.albumId;
|
||||
}
|
||||
if (nextAlbumId !== null) {
|
||||
const album = await tx.albums.findUnique({
|
||||
where: { id: nextAlbumId },
|
||||
select: { publicToken: true },
|
||||
});
|
||||
if (album === null) {
|
||||
throw albumNotFound();
|
||||
}
|
||||
nextAlbumPublicToken = album.publicToken;
|
||||
}
|
||||
|
||||
const claimed = await tx.embed_slots.updateMany({
|
||||
where: { id: slotId, entityVersion: slot.entityVersion },
|
||||
data: {
|
||||
name: dto.name,
|
||||
enabled: dto.enabled,
|
||||
settings:
|
||||
dto.settings === undefined || dto.settings === null
|
||||
? undefined
|
||||
: (normalizeSettings(dto.settings) as unknown as Prisma.InputJsonValue),
|
||||
albumId: dto.albumId === undefined ? undefined : dto.albumId,
|
||||
configVersion: { increment: 1 },
|
||||
entityVersion: { increment: 1 },
|
||||
},
|
||||
});
|
||||
if (claimed.count === 0) {
|
||||
return 'retry' as const;
|
||||
}
|
||||
|
||||
const updated = await tx.embed_slots.findUniqueOrThrow({
|
||||
where: { id: slotId },
|
||||
});
|
||||
// 停用迁移(true→false)高优先级:ov 观众侧须尽快 404(同相册
|
||||
// publicEnabled 关闭口径);已在停用态的常规更新保持 normal。
|
||||
await tx.outbox_events.create({
|
||||
data: {
|
||||
type: 'slot.updated',
|
||||
albumId: updated.albumId ?? '',
|
||||
entityVersion: updated.entityVersion,
|
||||
priority:
|
||||
dto.enabled === false && slot.enabled === true ? 'high' : 'normal',
|
||||
payload: buildSlotUpdatedPayload(
|
||||
updated,
|
||||
nextAlbumPublicToken,
|
||||
) as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
await tx.audit_logs.create({
|
||||
data: {
|
||||
actorId: user.id,
|
||||
action: 'embed_slot.update',
|
||||
resourceId: slotId,
|
||||
metadata: { fields },
|
||||
},
|
||||
});
|
||||
return 'done' as const;
|
||||
});
|
||||
}
|
||||
if (outcome !== 'done') {
|
||||
throw new ApiException(
|
||||
ErrorCodes.INTERNAL_ERROR,
|
||||
'Internal server error',
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
);
|
||||
}
|
||||
return this.loadSlot(slotId);
|
||||
}
|
||||
|
||||
/** 组装 EmbedSlot 响应(albumTitle 按绑定相册 defaultLocale 解析)。 */
|
||||
private async loadSlot(slotId: string): Promise<EmbedSlotResponse> {
|
||||
const slot = await this.prisma.embed_slots.findUnique({
|
||||
where: { id: slotId },
|
||||
});
|
||||
if (slot === null) {
|
||||
throw slotNotFound();
|
||||
}
|
||||
const title = slot.albumId === null
|
||||
? null
|
||||
: (await this.albumTitlesByDefaultLocale(this.prisma, [slot.albumId])).get(
|
||||
slot.albumId,
|
||||
) ?? null;
|
||||
return this.toResponse(slot, title);
|
||||
}
|
||||
|
||||
private toResponse(
|
||||
slot: EmbedSlotRow,
|
||||
albumTitle: string | null,
|
||||
): EmbedSlotResponse {
|
||||
return {
|
||||
id: slot.id,
|
||||
publicToken: slot.publicToken,
|
||||
name: slot.name,
|
||||
enabled: slot.enabled,
|
||||
albumId: slot.albumId,
|
||||
albumTitle,
|
||||
configVersion: slot.configVersion,
|
||||
settings: slot.settings as EmbedSlotSettingsResponse,
|
||||
updatedAt: slot.updatedAt.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 相册 id 集 → albumTitle(按各相册 defaultLocale 解析)。绑定相册的
|
||||
* defaultLocale 翻译行由相册创建/更新路径保证必在;缺行(防御)回退该相册
|
||||
* 任意翻译行(locale 升序首个,确定性),再缺则为 null(与未绑定同形——
|
||||
* 管理列表展示字段,不因脏数据 5xx)。
|
||||
*/
|
||||
private async albumTitlesByDefaultLocale(
|
||||
client: PrismaService | Prisma.TransactionClient,
|
||||
albumIds: Array<string | null>,
|
||||
): Promise<Map<string, string>> {
|
||||
const ids = [...new Set(albumIds.filter((id): id is string => id !== null))];
|
||||
const result = new Map<string, string>();
|
||||
if (ids.length === 0) {
|
||||
return result;
|
||||
}
|
||||
const albums = await client.albums.findMany({
|
||||
where: { id: { in: ids } },
|
||||
select: { id: true, defaultLocale: true },
|
||||
});
|
||||
const localesByAlbum = new Map(albums.map((a) => [a.id, a.defaultLocale]));
|
||||
const translations = await client.album_translations.findMany({
|
||||
where: { albumId: { in: ids } },
|
||||
orderBy: { locale: 'asc' },
|
||||
});
|
||||
const byAlbum = new Map<string, typeof translations>();
|
||||
for (const row of translations) {
|
||||
const bucket = byAlbum.get(row.albumId);
|
||||
if (bucket === undefined) {
|
||||
byAlbum.set(row.albumId, [row]);
|
||||
} else {
|
||||
bucket.push(row);
|
||||
}
|
||||
}
|
||||
for (const [albumId, defaultLocale] of localesByAlbum) {
|
||||
const rows = byAlbum.get(albumId) ?? [];
|
||||
const title =
|
||||
rows.find((r) => r.locale === defaultLocale)?.title ??
|
||||
rows[0]?.title;
|
||||
if (title !== undefined) {
|
||||
result.set(albumId, title);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@
|
||||
// 保留 likeTotal/countVersion 权威计数);photo.hidden/
|
||||
// deleted → photos_ov.visibility 置位(行不存在 = 从未复制即不可见,缺席
|
||||
// 即撤下语义);asset → banner 投影 albums_ov.bannerAssetKey(cover/logo
|
||||
// 仅推进版本,ov schema 无位);
|
||||
// 仅推进版本,ov schema 无位);slot-state(P4b Task 2)→ embed_slots_ov
|
||||
// 全量快照直投(albumPublicToken 弱关联相册、无依赖暂存,见 ③ 注);
|
||||
// ⑤ sync_state upsert appliedVersion(watermark=false 实体行;水位行预留)。
|
||||
// ⑥ 实时转发(Task 7):photo 事件**应用事务提交后**发布 OV_PHOTO_APPLIED
|
||||
// 进程内事件 → realtime 网关向相册房间广播契约 photo.* 消息(stale/
|
||||
@@ -40,6 +41,7 @@ import type {
|
||||
CategoryUpdatedPayload,
|
||||
PhotoPublishedPayload,
|
||||
PhotoVisibilityChangedPayload,
|
||||
SlotStateUpdatedPayload,
|
||||
} from '@photo/contracts';
|
||||
import type { Prisma } from '@photo/db-ov';
|
||||
// 值导入(非 import type):构造器注入的 design:paramtypes 依赖类引用,
|
||||
@@ -168,8 +170,11 @@ export class SyncInboxService {
|
||||
});
|
||||
return { outcome: 'stale' };
|
||||
}
|
||||
// ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。
|
||||
if (event.entityType !== 'album') {
|
||||
// ③ 依赖检查:非 album 事件须所属相册副本已存在,否则暂存。slot-state
|
||||
// 豁免:展示位副本以 albumPublicToken 弱关联相册(公开读时按 albums_ov
|
||||
// 现查,缺席=404 与相册下线同形),且解绑事件 albumId 为空串——相册行的
|
||||
// 到达顺序对副本正确性无影响,暂存反而会让解绑事件无限积压。
|
||||
if (event.entityType !== 'album' && event.entityType !== 'slot-state') {
|
||||
const album = await tx.albums_ov.findUnique({
|
||||
where: { albumId: event.albumId },
|
||||
select: { albumId: true },
|
||||
@@ -399,7 +404,31 @@ export class SyncInboxService {
|
||||
// cover/logo:ov schema 无投影位(YAGNI,schema 注释),仅推进版本账本。
|
||||
return undefined;
|
||||
}
|
||||
// slot-state 预留:受理即 applied(无投影),版本账本照常推进。
|
||||
// slot-state(P4b Task 2):全量快照直投 embed_slots_ov(版本单调已由 ②
|
||||
// 保证;同版本重断言同样走这里——整行替换幂等)。albumPublicToken null =
|
||||
// 解绑(公开端随即 404);无墓碑,下线是 enabled=false 的快照替换。
|
||||
if (event.entityType === 'slot-state') {
|
||||
const p = event.payload as unknown as SlotStateUpdatedPayload;
|
||||
await tx.embed_slots_ov.upsert({
|
||||
where: { slotToken: p.publicToken },
|
||||
create: {
|
||||
slotToken: p.publicToken,
|
||||
enabled: p.enabled,
|
||||
albumPublicToken: p.albumPublicToken ?? null,
|
||||
configVersion: p.configVersion,
|
||||
settings: p.settings as unknown as Prisma.InputJsonValue,
|
||||
entityVersion: event.entityVersion,
|
||||
},
|
||||
update: {
|
||||
enabled: p.enabled,
|
||||
albumPublicToken: p.albumPublicToken ?? null,
|
||||
configVersion: p.configVersion,
|
||||
settings: p.settings as unknown as Prisma.InputJsonValue,
|
||||
entityVersion: event.entityVersion,
|
||||
},
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@ import type {
|
||||
CategoryDeletedPayload,
|
||||
CategoryUpdatedPayload,
|
||||
PhotoPublishedPayload,
|
||||
SlotSettingsPayload,
|
||||
SlotStateUpdatedPayload,
|
||||
} from '@photo/contracts';
|
||||
import type { Prisma } from '@photo/db-cn';
|
||||
import { ApiException } from '../api-exception';
|
||||
@@ -51,6 +53,16 @@ type PhotoRow = Prisma.photosGetPayload<Prisma.photosDefaultArgs>;
|
||||
type PhotoVariantRow = Prisma.photo_variantsGetPayload<
|
||||
Prisma.photo_variantsDefaultArgs
|
||||
>;
|
||||
type EmbedSlotRow = Pick<
|
||||
Prisma.embed_slotsGetPayload<Prisma.embed_slotsDefaultArgs>,
|
||||
| 'id'
|
||||
| 'publicToken'
|
||||
| 'enabled'
|
||||
| 'albumId'
|
||||
| 'configVersion'
|
||||
| 'settings'
|
||||
| 'entityVersion'
|
||||
>;
|
||||
|
||||
/**
|
||||
* album.updated 载荷:albums_ov 展示所需标量 + 翻译全集(locale 升序)。
|
||||
@@ -131,6 +143,31 @@ export function buildAssetUpdatedPayload(asset: AssetRow): AssetUpdatedPayload {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* slot.updated 载荷(P4b Task 2):ov 副本重建展示位所需的全量快照。
|
||||
* albumPublicToken 由调用方按绑定相册行解析传入(未绑定传 null)——embed_slots
|
||||
* 行只存 albumId,跨表解析归业务事务(绑定校验时已读取相册行,顺带取
|
||||
* publicToken,避免载荷构建器再查一次)。settings 为归一化存储值直通(写入
|
||||
* 侧已保证字段齐全)。
|
||||
*/
|
||||
export function buildSlotUpdatedPayload(
|
||||
slot: EmbedSlotRow,
|
||||
albumPublicToken: string | null,
|
||||
): SlotStateUpdatedPayload {
|
||||
return {
|
||||
slotId: slot.id,
|
||||
publicToken: slot.publicToken,
|
||||
enabled: slot.enabled,
|
||||
albumId: slot.albumId,
|
||||
albumPublicToken,
|
||||
configVersion: slot.configVersion,
|
||||
// Prisma JsonValue → 载荷接口(写入侧已保证形状;接口类型无索引签名,
|
||||
// 需经 unknown 中转,同 outbox 写入侧约定)。
|
||||
settings: slot.settings as unknown as SlotSettingsPayload,
|
||||
entityVersion: slot.entityVersion,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* photo.published 载荷(契约全集)。能进入 published/hidden 态的照片必经发布
|
||||
* 事务(变体行+尺寸+publishedAt 齐备);缺任一项属 post-publish 不变量破坏
|
||||
|
||||
@@ -0,0 +1,758 @@
|
||||
// services/api/test/embed.e2e-spec.ts —— P4b Task 2 展示位后端 e2e。
|
||||
// ONE spec 串「cn 管理 → slot-state 同步 → ov 公开读」全链路:进程内起两个
|
||||
// 真实入口(cn + ov,同 public-ov.e2e-spec 模式),管理动作全走 cn API,
|
||||
// 发送器为生产 OutboxSender(指向 ov 入口真实 URL)——ov 读到的数据与生产
|
||||
// 链路逐字节同源。
|
||||
//
|
||||
// 覆盖(任务验收):
|
||||
// ① admin CRUD:创建缺省 settings/publicToken 形状/未绑定;非 admin 403;
|
||||
// 未认证 401;settings 部分提交/越界 400;列表倒序 + 键集游标翻页;
|
||||
// 创建事件同步后 ov 副本行就位(未绑定 → 公开端 404);
|
||||
// ② 绑定相册:albumTitle 按相册 defaultLocale 解析;ov 公开数据 photos
|
||||
// publishedAt 稳定倒序、thumb 现签可取回真实字节;lang 回退
|
||||
// (缺省/zh-CN/en/auto/非法值不 400);ETag 四元组 200 → If-None-Match
|
||||
// 重复请求 304(无 body,ETag 仍携带);
|
||||
// ③ 新照片发布 → 同步 → ETag 变化 → 200 含新照片(A11 数据路径);
|
||||
// ④ 换绑相册:configVersion+1,ov 服务新相册照片(A22 换绑不改官网——
|
||||
// loader 只认 token);近期照片 20 上限与倒序;
|
||||
// ⑤ 相册下线(publicEnabled=false)→ ov 公开端 404;恢复 → 200;
|
||||
// ⑥ 解绑 → 404;复绑 → 200;停用(enabled=false)→ 404;
|
||||
// ⑦ slot-state 幂等:同 eventId 重复受理不计应用(duplicate);同版本
|
||||
// 不同 eventId 重断言应用(幂等重投影);更低版本 stale 不回卷;更高
|
||||
// 版本直投生效(乱序收敛);
|
||||
// ⑧ 限速:压小窗口(EMBED_WINDOW_REQUESTS 惰性 env)→ 超额 429
|
||||
// RATE_LIMITED(304 也消耗预算)。
|
||||
// 真实 MySQL×2 + MinIO(真实 JPEG 上传发布)+ HMAC 同步链路字节一致。
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { type NestExpressApplication } from '@nestjs/platform-express';
|
||||
import request from 'supertest';
|
||||
import sharp from 'sharp';
|
||||
import IORedis from 'ioredis';
|
||||
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 { hashSessionToken } from '../src/auth/session.guard';
|
||||
import { ContractWsAdapter } from '../src/realtime/contract-ws.adapter';
|
||||
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';
|
||||
|
||||
/** 测试内生成真实 JPEG(同 public-ov.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(content-batches 内部端点,⑦ 幂等直推)。 */
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
describe('embed slots (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 redisOv: IORedis;
|
||||
let adminToken: string;
|
||||
let staffToken: string;
|
||||
|
||||
let slotId: string;
|
||||
let slotToken: string;
|
||||
/** 相册 A:zh-CN + en 双语、真实上传发布照片。 */
|
||||
let albumAId: string;
|
||||
let albumAToken: string;
|
||||
/** 相册 B:zh 单语 + 直种 photos_ov 副本行(20 上限验证)。 */
|
||||
let albumBId: string;
|
||||
let albumBToken: string;
|
||||
let albumBSeedIds: string[];
|
||||
|
||||
/** 排空发送器:循环 runOnce 直至无未投递事件(sync 侧 flush)。 */
|
||||
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(
|
||||
targetAlbumId: string,
|
||||
clientTaskId: string,
|
||||
jpeg: Buffer,
|
||||
): Promise<string> {
|
||||
const init = await request(cnBase)
|
||||
.post('/v1/uploads')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
albumId: targetAlbumId,
|
||||
clientTaskId,
|
||||
filename: 'embed-e2e.jpg',
|
||||
bytes: jpeg.length,
|
||||
contentType: 'image/jpeg',
|
||||
});
|
||||
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;
|
||||
}
|
||||
|
||||
/** ov 公开端点 GET(可选 If-None-Match)。 */
|
||||
async function getEmbed(
|
||||
token: string,
|
||||
query = '',
|
||||
ifNoneMatch?: string,
|
||||
): Promise<request.Response> {
|
||||
const req = request(ovBase)
|
||||
.get(`/v1/public/embed-slots/${token}${query}`)
|
||||
.set('accept', 'application/json');
|
||||
if (ifNoneMatch !== undefined) {
|
||||
req.set('If-None-Match', ifNoneMatch);
|
||||
}
|
||||
return req;
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
expect(SYNC_KEY).not.toBe('');
|
||||
await resetDb();
|
||||
await resetOvDb();
|
||||
|
||||
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,
|
||||
});
|
||||
ovApp.useWebSocketAdapter(new ContractWsAdapter(ovApp));
|
||||
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,
|
||||
});
|
||||
redisOv = new IORedis(process.env.REDIS_URL_OV ?? 'redis://127.0.0.1:6381', {
|
||||
maxRetriesPerRequest: 2,
|
||||
});
|
||||
redisOv.on('error', () => undefined);
|
||||
|
||||
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;
|
||||
|
||||
// 非 admin 对照账号(staff):直插 users + 会话(同 members-categories 模式)。
|
||||
const staff = await prismaCn.users.create({
|
||||
data: {
|
||||
account: 'embed-staff',
|
||||
role: 'staff',
|
||||
passwordHash: 'x',
|
||||
displayName: 'embed-staff',
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const staffSession = randomBytes(32).toString('hex');
|
||||
await prismaCn.sessions.create({
|
||||
data: {
|
||||
userId: staff.id,
|
||||
tokenHash: hashSessionToken(staffSession),
|
||||
expiresAt: new Date(Date.now() + 3_600_000),
|
||||
},
|
||||
});
|
||||
staffToken = staffSession;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await cnApp.close();
|
||||
await ovApp.close();
|
||||
await prismaCn.$disconnect();
|
||||
await prismaOv.$disconnect();
|
||||
await redisOv.quit().catch(() => undefined);
|
||||
});
|
||||
|
||||
it('① admin 创建/列表 + 权限 + 校验 + 创建事件同步(未绑定 → 公开 404)', async () => {
|
||||
// 缺省 settings 创建。
|
||||
const created = await request(cnBase)
|
||||
.post('/v1/admin/embed-slots')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ name: '官网首页悬浮位' });
|
||||
expect(created.status).toBe(201);
|
||||
slotId = created.body.id as string;
|
||||
slotToken = created.body.publicToken as string;
|
||||
expect(created.body).toMatchObject({
|
||||
id: expect.any(String),
|
||||
publicToken: expect.stringMatching(/^[0-9a-z]{24}$/),
|
||||
name: '官网首页悬浮位',
|
||||
enabled: true,
|
||||
albumId: null,
|
||||
albumTitle: null,
|
||||
configVersion: 1,
|
||||
settings: {
|
||||
position: 'right',
|
||||
size: 'md',
|
||||
intervalMs: 5000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
},
|
||||
updatedAt: expect.any(String),
|
||||
});
|
||||
|
||||
// 完整 settings 创建(第二槽,验证字段落库)。
|
||||
const second = await request(cnBase)
|
||||
.post('/v1/admin/embed-slots')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
name: '活动页左侧位',
|
||||
settings: {
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 8000,
|
||||
lang: 'zh-CN',
|
||||
collapsedDefault: true,
|
||||
},
|
||||
});
|
||||
expect(second.status).toBe(201);
|
||||
expect(second.body.settings).toEqual({
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 8000,
|
||||
lang: 'zh-CN',
|
||||
collapsedDefault: true,
|
||||
});
|
||||
|
||||
// 校验失败:settings 缺字段 / intervalMs 越界 / 空 name。
|
||||
const partial = await request(cnBase)
|
||||
.post('/v1/admin/embed-slots')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ name: 'x', settings: { position: 'left' } as never });
|
||||
expect(partial.status).toBe(400);
|
||||
expect(partial.body.code).toBe(ErrorCodes.VALIDATION_FAILED);
|
||||
const outOfRange = await request(cnBase)
|
||||
.post('/v1/admin/embed-slots')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
name: 'x',
|
||||
settings: {
|
||||
position: 'left',
|
||||
size: 'sm',
|
||||
intervalMs: 2000,
|
||||
lang: 'auto',
|
||||
collapsedDefault: false,
|
||||
},
|
||||
});
|
||||
expect(outOfRange.status).toBe(400);
|
||||
const emptyName = await request(cnBase)
|
||||
.post('/v1/admin/embed-slots')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ name: '' });
|
||||
expect(emptyName.status).toBe(400);
|
||||
|
||||
// 权限:staff 三端点 403(入参合法——校验管道先于服务层 admin 判定,
|
||||
// 带病请求会先 400 而测不到权限路径);匿名 401。
|
||||
const staffAttempts = [
|
||||
request(cnBase)
|
||||
.get('/v1/admin/embed-slots')
|
||||
.set('Authorization', `Bearer ${staffToken}`),
|
||||
request(cnBase)
|
||||
.post('/v1/admin/embed-slots')
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ name: '越权创建' }),
|
||||
request(cnBase)
|
||||
.patch(`/v1/admin/embed-slots/${slotId}`)
|
||||
.set('Authorization', `Bearer ${staffToken}`)
|
||||
.send({ enabled: false }),
|
||||
];
|
||||
for (const attempt of staffAttempts) {
|
||||
const res = await attempt;
|
||||
expect(res.status).toBe(403);
|
||||
expect(res.body.code).toBe(ErrorCodes.AUTH_FORBIDDEN);
|
||||
}
|
||||
const anon = await request(cnBase).get('/v1/admin/embed-slots');
|
||||
expect(anon.status).toBe(401);
|
||||
|
||||
// 列表:createdAt+id 稳定倒序 + limit=1 键集游标翻页。
|
||||
const page1 = await request(cnBase)
|
||||
.get('/v1/admin/embed-slots?limit=1')
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(page1.status).toBe(200);
|
||||
expect(page1.body.items).toHaveLength(1);
|
||||
expect(page1.body.items[0].id).toBe(second.body.id); // 后创建在前
|
||||
expect(page1.body.hasMore).toBe(true);
|
||||
const page2 = await request(cnBase)
|
||||
.get(
|
||||
`/v1/admin/embed-slots?limit=1&cursor=${page1.body.nextCursor as string}`,
|
||||
)
|
||||
.set('Authorization', `Bearer ${adminToken}`);
|
||||
expect(page2.status).toBe(200);
|
||||
expect(page2.body.items).toHaveLength(1);
|
||||
expect(page2.body.items[0].id).toBe(slotId);
|
||||
expect(page2.body.hasMore).toBe(false);
|
||||
expect(page2.body.nextCursor).toBeNull();
|
||||
|
||||
// 创建事件同步:ov 副本行就位(未绑定 albumPublicToken null)+ 版本账本。
|
||||
await drainSender();
|
||||
const replica = await prismaOv.embed_slots_ov.findUnique({
|
||||
where: { slotToken },
|
||||
});
|
||||
expect(replica).toMatchObject({
|
||||
slotToken,
|
||||
enabled: true,
|
||||
albumPublicToken: null,
|
||||
configVersion: 1,
|
||||
entityVersion: 1,
|
||||
});
|
||||
const state = await prismaOv.sync_state.findUnique({
|
||||
where: {
|
||||
entityType_entityId: { entityType: 'slot-state', entityId: slotId },
|
||||
},
|
||||
});
|
||||
expect(state?.appliedVersion).toBe(1);
|
||||
// 公开端:未绑定 → 404 NOT_FOUND(防探测同码)。
|
||||
const unbound = await getEmbed(slotToken);
|
||||
expect(unbound.status).toBe(404);
|
||||
expect(unbound.body.code).toBe(ErrorCodes.NOT_FOUND);
|
||||
});
|
||||
|
||||
it('② 绑定相册 → ov 公开数据:照片倒序/标题回退/ETag 304', async () => {
|
||||
// 相册 A(zh-CN + en,defaultLocale zh-CN)+ 3 张真实发布照片。
|
||||
const album = await request(cnBase)
|
||||
.post('/v1/albums')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
translations: [
|
||||
{ locale: 'zh-CN', title: '周年晚会' },
|
||||
{ locale: 'en', title: 'Anniversary Gala' },
|
||||
],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: '2026-10-01T10:00:00.000Z',
|
||||
});
|
||||
expect(album.status).toBe(201);
|
||||
albumAId = album.body.id as string;
|
||||
albumAToken = album.body.publicToken as string;
|
||||
|
||||
const shapes = [
|
||||
{ w: 900, h: 600, rgb: { r: 180, g: 120, b: 60 } },
|
||||
{ w: 800, h: 500, rgb: { r: 60, g: 130, b: 180 } },
|
||||
{ w: 700, h: 450, rgb: { r: 120, g: 180, b: 90 } },
|
||||
] as const;
|
||||
const photoIds: string[] = [];
|
||||
for (let i = 0; i < shapes.length; i++) {
|
||||
const s = shapes[i]!;
|
||||
photoIds.push(
|
||||
await uploadAndPublish(
|
||||
albumAId,
|
||||
`embed-e2e-a-${i}`,
|
||||
await makeJpeg(s.w, s.h, s.rgb),
|
||||
),
|
||||
);
|
||||
}
|
||||
await drainSender();
|
||||
|
||||
// 绑定:albumTitle 按相册 defaultLocale(zh-CN)解析;configVersion+1。
|
||||
const bind = await request(cnBase)
|
||||
.patch(`/v1/admin/embed-slots/${slotId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ albumId: albumAId });
|
||||
expect(bind.status).toBe(200);
|
||||
expect(bind.body).toMatchObject({
|
||||
albumId: albumAId,
|
||||
albumTitle: '周年晚会',
|
||||
configVersion: 2,
|
||||
});
|
||||
// 绑定不存在的相册 → 404 ALBUM_NOT_FOUND。
|
||||
const ghost = await request(cnBase)
|
||||
.patch(`/v1/admin/embed-slots/${slotId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ albumId: 'no-such-album' });
|
||||
expect(ghost.status).toBe(404);
|
||||
expect(ghost.body.code).toBe(ErrorCodes.ALBUM_NOT_FOUND);
|
||||
|
||||
await drainSender();
|
||||
|
||||
// ov 公开数据:照片 publishedAt 稳定倒序 + ETag 四元组。
|
||||
const first = await getEmbed(slotToken);
|
||||
expect(first.status).toBe(200);
|
||||
expect(first.body.slotToken).toBe(slotToken);
|
||||
expect(first.body.configVersion).toBe(2);
|
||||
expect(first.body.album).toEqual({
|
||||
publicToken: albumAToken,
|
||||
title: '周年晚会',
|
||||
defaultLocale: 'zh-CN',
|
||||
});
|
||||
expect(first.body.photos).toHaveLength(3);
|
||||
expect(first.body.photos.map((p: { photoId: string }) => p.photoId)).toEqual(
|
||||
[photoIds[2], photoIds[1], photoIds[0]],
|
||||
);
|
||||
// thumb 现签:URL 可取回真实字节(走完整上传/处理/发布管线)。
|
||||
const thumbUrl = first.body.photos[0].thumb.url as string;
|
||||
const fetched = await fetch(thumbUrl);
|
||||
expect(fetched.ok).toBe(true);
|
||||
expect((await fetched.arrayBuffer()).byteLength).toBeGreaterThan(0);
|
||||
expect(Date.parse(first.body.photos[0].thumb.expiresAt as string)).toBeGreaterThan(
|
||||
Date.now(),
|
||||
);
|
||||
|
||||
// ETag 四元组:"{configVersion}-{photoCount}-{maxPublishedAtMs}-{maxPhotoId}"。
|
||||
const newest = await prismaOv.photos_ov.findFirst({
|
||||
where: { albumId: albumAId, visibility: 'published' },
|
||||
orderBy: [{ publishedAt: 'desc' }, { photoId: 'desc' }],
|
||||
select: { photoId: true, publishedAt: true },
|
||||
});
|
||||
const expectedEtag = `"2-3-${newwestMs(newest)}-${newest?.photoId ?? ''}"`;
|
||||
const etag = first.headers.etag as string;
|
||||
expect(etag).toBe(expectedEtag);
|
||||
|
||||
// lang 回退:en 显式 / auto(缺省)→ defaultLocale / 非法值不 400。
|
||||
const en = await getEmbed(slotToken, '?lang=en');
|
||||
expect(en.status).toBe(200);
|
||||
expect(en.body.album.title).toBe('Anniversary Gala');
|
||||
const auto = await getEmbed(slotToken, '?lang=auto');
|
||||
expect(auto.body.album.title).toBe('周年晚会');
|
||||
const invalid = await getEmbed(slotToken, '?lang=fr');
|
||||
expect(invalid.status).toBe(200);
|
||||
expect(invalid.body.album.title).toBe('周年晚会');
|
||||
// ETag 不含 lang:语言变化不影响校验器(客户端同槽轮询 304 语义稳定)。
|
||||
expect(en.headers.etag).toBe(etag);
|
||||
|
||||
// If-None-Match 匹配 → 304(无 body,仍携带 ETag);W/ 前缀与 * 同样命中。
|
||||
const notModified = await getEmbed(slotToken, '', etag);
|
||||
expect(notModified.status).toBe(304);
|
||||
expect(notModified.text).toBe('');
|
||||
expect(notModified.headers.etag).toBe(etag);
|
||||
expect((await getEmbed(slotToken, '', `W/${etag}`)).status).toBe(304);
|
||||
expect((await getEmbed(slotToken, '', '*')).status).toBe(304);
|
||||
// 不匹配的旧值 → 200 全量。
|
||||
expect((await getEmbed(slotToken, '', '"stale-1-0-0-"')).status).toBe(200);
|
||||
});
|
||||
|
||||
it('③ 新照片发布 → 同步 → ETag 变化 → 200 含新照片(A11 数据路径)', async () => {
|
||||
const before = await getEmbed(slotToken);
|
||||
const etagBefore = before.headers.etag as string;
|
||||
|
||||
const newPhotoId = await uploadAndPublish(
|
||||
albumAId,
|
||||
'embed-e2e-a-new',
|
||||
await makeJpeg(640, 420, { r: 200, g: 80, b: 80 }),
|
||||
);
|
||||
await drainSender();
|
||||
|
||||
// 旧 If-None-Match 不再匹配 → 200 全量,新照片居首。
|
||||
const refreshed = await getEmbed(slotToken, '', etagBefore);
|
||||
expect(refreshed.status).toBe(200);
|
||||
expect(refreshed.body.photos).toHaveLength(4);
|
||||
expect(refreshed.body.photos[0].photoId).toBe(newPhotoId);
|
||||
const etagAfter = refreshed.headers.etag as string;
|
||||
expect(etagAfter).not.toBe(etagBefore);
|
||||
// 新 ETag 立即命中 304。
|
||||
expect((await getEmbed(slotToken, '', etagAfter)).status).toBe(304);
|
||||
});
|
||||
|
||||
it('④ 换绑相册:configVersion+1 → ov 服务新相册照片(A22)+ 20 上限', async () => {
|
||||
// 相册 B(zh 单语)+ 直种 25 行 photos_ov 副本(不同 publishedAt)。
|
||||
const album = await request(cnBase)
|
||||
.post('/v1/albums')
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({
|
||||
translations: [{ locale: 'zh-CN', title: '品牌发布会' }],
|
||||
defaultLocale: 'zh-CN',
|
||||
startAt: '2026-11-01T10:00:00.000Z',
|
||||
});
|
||||
expect(album.status).toBe(201);
|
||||
albumBId = album.body.id as string;
|
||||
albumBToken = album.body.publicToken as string;
|
||||
await drainSender(); // 相册副本先落位
|
||||
|
||||
albumBSeedIds = [];
|
||||
const base = Date.now() - 26 * 60_000;
|
||||
for (let i = 0; i < 25; i++) {
|
||||
const photoId = `embedseedB${String(i).padStart(2, '0')}`;
|
||||
albumBSeedIds.push(photoId);
|
||||
await prismaOv.photos_ov.create({
|
||||
data: {
|
||||
photoId,
|
||||
albumId: albumBId,
|
||||
width: 320 + i,
|
||||
height: 240 + i,
|
||||
originalObjectKey: `embed-e2e/b/${photoId}/original.jpg`,
|
||||
thumbObjectKey: `embed-e2e/b/${photoId}/thumb.jpg`,
|
||||
browseObjectKey: `embed-e2e/b/${photoId}/browse.jpg`,
|
||||
visibility: 'published',
|
||||
publishedAt: new Date(base + i * 1000),
|
||||
entityVersion: 1,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 换绑:configVersion+1(未提交 settings/enabled/name 不受影响)。
|
||||
const rebind = await request(cnBase)
|
||||
.patch(`/v1/admin/embed-slots/${slotId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ albumId: albumBId });
|
||||
expect(rebind.status).toBe(200);
|
||||
expect(rebind.body).toMatchObject({
|
||||
albumId: albumBId,
|
||||
albumTitle: '品牌发布会',
|
||||
configVersion: expect.any(Number),
|
||||
name: '官网首页悬浮位',
|
||||
enabled: true,
|
||||
});
|
||||
const configVersionAfterRebind = rebind.body.configVersion as number;
|
||||
expect(configVersionAfterRebind).toBeGreaterThanOrEqual(3);
|
||||
|
||||
await drainSender();
|
||||
|
||||
// ov:新相册照片、20 上限、publishedAt 倒序(最新种子居首)。
|
||||
const feed = await getEmbed(slotToken);
|
||||
expect(feed.status).toBe(200);
|
||||
expect(feed.body.album).toEqual({
|
||||
publicToken: albumBToken,
|
||||
title: '品牌发布会',
|
||||
defaultLocale: 'zh-CN',
|
||||
});
|
||||
expect(feed.body.photos).toHaveLength(20);
|
||||
const expectedOrder = [...albumBSeedIds].slice(-20).reverse();
|
||||
expect(
|
||||
feed.body.photos.map((p: { photoId: string }) => p.photoId),
|
||||
).toEqual(expectedOrder);
|
||||
// ETag 首元随相册切换(configVersion 与照片集合都变了)。
|
||||
expect(feed.headers.etag).toBe(
|
||||
`"${configVersionAfterRebind as number}-20-${(base + 24 * 1000).toFixed(0)}-embedseedB24"`,
|
||||
);
|
||||
// 相册 B 仅 zh:lang=en 回退 defaultLocale(不 400)。
|
||||
const en = await getEmbed(slotToken, '?lang=en');
|
||||
expect(en.body.album.title).toBe('品牌发布会');
|
||||
});
|
||||
|
||||
it('⑤ 相册下线(publicEnabled=false)→ ov 公开端 404;恢复 → 200', async () => {
|
||||
const offline = await request(cnBase)
|
||||
.patch(`/v1/albums/${albumBId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ publicEnabled: false });
|
||||
expect(offline.status).toBe(200);
|
||||
await drainSender();
|
||||
expect((await getEmbed(slotToken)).status).toBe(404);
|
||||
|
||||
const back = await request(cnBase)
|
||||
.patch(`/v1/albums/${albumBId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ publicEnabled: true });
|
||||
expect(back.status).toBe(200);
|
||||
await drainSender();
|
||||
expect((await getEmbed(slotToken)).status).toBe(200);
|
||||
});
|
||||
|
||||
it('⑥ 解绑 → 404;复绑 → 200;停用 → 404', async () => {
|
||||
// 解绑:albumId 显式 null。
|
||||
const unbind = await request(cnBase)
|
||||
.patch(`/v1/admin/embed-slots/${slotId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ albumId: null });
|
||||
expect(unbind.status).toBe(200);
|
||||
expect(unbind.body.albumId).toBeNull();
|
||||
expect(unbind.body.albumTitle).toBeNull();
|
||||
await drainSender();
|
||||
expect((await getEmbed(slotToken)).status).toBe(404);
|
||||
|
||||
// 复绑相册 A → 恢复(loader 只认 token:换绑不改官网嵌入代码)。
|
||||
const rebind = await request(cnBase)
|
||||
.patch(`/v1/admin/embed-slots/${slotId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ albumId: albumAId });
|
||||
expect(rebind.status).toBe(200);
|
||||
await drainSender();
|
||||
const feed = await getEmbed(slotToken);
|
||||
expect(feed.status).toBe(200);
|
||||
expect(feed.body.album.publicToken).toBe(albumAToken);
|
||||
expect(feed.body.photos).toHaveLength(4);
|
||||
|
||||
// 停用(enabled=false)→ 404(软下线,无删除端点)。
|
||||
const disable = await request(cnBase)
|
||||
.patch(`/v1/admin/embed-slots/${slotId}`)
|
||||
.set('Authorization', `Bearer ${adminToken}`)
|
||||
.send({ enabled: false, name: '官网首页悬浮位(下线)' });
|
||||
expect(disable.status).toBe(200);
|
||||
expect(disable.body.enabled).toBe(false);
|
||||
expect(disable.body.name).toBe('官网首页悬浮位(下线)');
|
||||
await drainSender();
|
||||
expect((await getEmbed(slotToken)).status).toBe(404);
|
||||
});
|
||||
|
||||
it('⑦ slot-state 乱序/重复幂等(直推 content-batches)', async () => {
|
||||
// 基准:cn 当前权威行(此刻停用态)。
|
||||
const slot = await prismaCn.embed_slots.findUniqueOrThrow({
|
||||
where: { id: slotId },
|
||||
});
|
||||
const currentVersion = slot.entityVersion;
|
||||
const settings = slot.settings as Record<string, unknown>;
|
||||
|
||||
// 更高版本「复活」事件(enabled=true,绑定相册 A):直投生效。
|
||||
const revivePayload = {
|
||||
slotId,
|
||||
publicToken: slotToken,
|
||||
enabled: true,
|
||||
albumId: albumAId,
|
||||
albumPublicToken: albumAToken,
|
||||
configVersion: slot.configVersion + 1,
|
||||
settings,
|
||||
entityVersion: currentVersion + 1,
|
||||
};
|
||||
const postBatch = async (eventId: string, entityVersion: number) =>
|
||||
signedPost(ovBase, '/v1/internal/sync/content-batches', {
|
||||
batch: [
|
||||
{
|
||||
eventId,
|
||||
schemaVersion: 1,
|
||||
entityType: 'slot-state',
|
||||
entityId: slotId,
|
||||
albumId: '',
|
||||
entityVersion,
|
||||
tombstone: false,
|
||||
priority: 'normal',
|
||||
payload: revivePayload,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const revived = await postBatch('e2e-slot-revive-1', currentVersion + 1);
|
||||
expect(revived.status).toBe(200);
|
||||
expect(await revived.json()).toEqual({
|
||||
acceptedEventIds: ['e2e-slot-revive-1'],
|
||||
applied: 1,
|
||||
});
|
||||
expect((await getEmbed(slotToken)).status).toBe(200);
|
||||
|
||||
// 同 eventId 重复投递 → duplicate(受理不计应用),公开数据不变。
|
||||
const dup = await postBatch('e2e-slot-revive-1', currentVersion + 1);
|
||||
expect(await dup.json()).toEqual({
|
||||
acceptedEventIds: ['e2e-slot-revive-1'],
|
||||
applied: 0,
|
||||
});
|
||||
|
||||
// 同版本不同 eventId → 重断言应用(幂等重投影,applied=1)。
|
||||
const reassert = await postBatch('e2e-slot-reassert-1', currentVersion + 1);
|
||||
expect(await reassert.json()).toEqual({
|
||||
acceptedEventIds: ['e2e-slot-reassert-1'],
|
||||
applied: 1,
|
||||
});
|
||||
expect((await getEmbed(slotToken)).status).toBe(200);
|
||||
|
||||
// 更低版本(旧快照:停用态)→ stale 不回卷:公开端仍 200。
|
||||
const stale = await postBatch('e2e-slot-stale-1', currentVersion - 1);
|
||||
expect(await stale.json()).toEqual({
|
||||
acceptedEventIds: ['e2e-slot-stale-1'],
|
||||
applied: 0,
|
||||
});
|
||||
expect((await getEmbed(slotToken)).status).toBe(200);
|
||||
|
||||
// 版本账本收敛在最高版本。
|
||||
const state = await prismaOv.sync_state.findUnique({
|
||||
where: {
|
||||
entityType_entityId: { entityType: 'slot-state', entityId: slotId },
|
||||
},
|
||||
});
|
||||
expect(state?.appliedVersion).toBe(currentVersion + 1);
|
||||
});
|
||||
|
||||
it('⑧ 限速:slotToken+IP 固定窗超额 429(304 同样消耗预算)', async () => {
|
||||
// 清本 spec 造成的限速计数(窗口 60s,跨用例残留会污染压小窗口断言)。
|
||||
const keys = await redisOv.keys('embed:window:*');
|
||||
if (keys.length > 0) {
|
||||
await redisOv.del(keys);
|
||||
}
|
||||
process.env.EMBED_WINDOW_REQUESTS = '2';
|
||||
try {
|
||||
const first = await getEmbed(slotToken);
|
||||
expect(first.status).toBe(200);
|
||||
const etag = first.headers.etag as string;
|
||||
// 第二次(304 命中)也计预算 → 窗口耗尽。
|
||||
expect((await getEmbed(slotToken, '', etag)).status).toBe(304);
|
||||
const third = await getEmbed(slotToken);
|
||||
expect(third.status).toBe(429);
|
||||
expect(third.body.code).toBe(ErrorCodes.RATE_LIMITED);
|
||||
// 404 路径不消耗预算(校验先于限速,同 visits 排序口径)。
|
||||
expect((await getEmbed('no-such-slot-token-0000000000')).status).toBe(404);
|
||||
} finally {
|
||||
delete process.env.EMBED_WINDOW_REQUESTS;
|
||||
const again = await redisOv.keys('embed:window:*');
|
||||
if (again.length > 0) {
|
||||
await redisOv.del(again);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
/** ETag 第二元:副本最新已发布照片的 publishedAt 毫秒(缺行 0)。 */
|
||||
function newwestMs(
|
||||
row: { publishedAt: Date | null } | null,
|
||||
): string {
|
||||
return String(row?.publishedAt?.getTime() ?? 0);
|
||||
}
|
||||
@@ -21,9 +21,10 @@ export const RESET_TABLES_OV: readonly string[] = [
|
||||
// 同步账本(Task 4 inbox 消费)。
|
||||
'sync_inbox',
|
||||
'sync_state',
|
||||
// 副本域(photos 先于 albums)。
|
||||
// 副本域(photos 先于 albums;展示位副本相册无关,随副本域一并清)。
|
||||
'photos_ov',
|
||||
'albums_ov',
|
||||
'embed_slots_ov',
|
||||
];
|
||||
|
||||
/** TRUNCATE photo_ov 中 RESET_TABLES_OV 里**实际存在**的表(空库安全)。 */
|
||||
|
||||
@@ -24,6 +24,9 @@ export const RESET_TABLES: readonly string[] = [
|
||||
// 照片与上传域 + outbox(Task 10):均为扁平字符串外键(无 FK 约束),
|
||||
// 顺序按逻辑依赖排列(事件/作业/变体/会话先于 photos)。
|
||||
'outbox_events',
|
||||
// 展示位域(P4b Task 2):授权成员表先于展示位(建而未用,P5 激活)。
|
||||
'embed_slot_members',
|
||||
'embed_slots',
|
||||
'photo_jobs',
|
||||
'photo_variants',
|
||||
'upload_sessions',
|
||||
|
||||
@@ -188,8 +188,8 @@ export class OutboxSender {
|
||||
|
||||
/**
|
||||
* outbox 行 → SyncEvent 线格式。entityId 按实体类型取自不同位置:
|
||||
* album → albumId 列;photo → photoId 列;category/asset → 载荷内的
|
||||
* categoryId/assetId(outbox 表无此二列)。映射缺料抛错(fail-fast:
|
||||
* album → albumId 列;photo → photoId 列;category/asset/slot-state → 载荷内
|
||||
* 的 categoryId/assetId/slotId(outbox 表无此三列)。映射缺料抛错(fail-fast:
|
||||
* 本轮不推、不标记,错误日志暴露;cn 侧发射器按构造保证不缺料)。
|
||||
*/
|
||||
private toWireEvent(row: OutboxRow): SyncEventWire {
|
||||
@@ -204,6 +204,11 @@ export class OutboxSender {
|
||||
throw new Error(`outbox 事件 ${row.id}(${type})缺 photoId,无法推导 entityId`);
|
||||
}
|
||||
entityId = row.photoId;
|
||||
} else if (entityType === 'slot-state') {
|
||||
if (typeof payload.slotId !== 'string') {
|
||||
throw new Error(`outbox 事件 ${row.id}(${type})载荷缺 slotId,无法推导 entityId`);
|
||||
}
|
||||
entityId = payload.slotId;
|
||||
} else if (typeof payload.categoryId === 'string') {
|
||||
entityId = payload.categoryId;
|
||||
} else if (typeof payload.assetId === 'string') {
|
||||
|
||||
Reference in New Issue
Block a user