diff --git a/packages/contracts/openapi.yaml b/packages/contracts/openapi.yaml index 9543c17..7b225a5 100644 --- a/packages/contracts/openapi.yaml +++ b/packages/contracts/openapi.yaml @@ -51,7 +51,8 @@ tags: description: 观众匿名互动(点赞批次与浏览上报;ov 域部署,路径前缀不变、域名区分) - name: internal description: | - 服务间同步接口,HMAC 鉴权,不对匿名开放。共用签名规则: + 服务间内部接口(cn↔ov 同步 + ov 紧急停用通道),HMAC 鉴权,不对匿名 + 开放。共用签名规则: `X-Sync-Signature` = hex(HMAC-SHA256(原始请求体, SYNC_INTERNAL_KEY)), `X-Sync-Timestamp` = Unix 秒(与服务端时差 ±300s 内有效,防重放); 签名缺失/不符或时间戳超窗一律拒绝。OpenAPI 无 HMAC 标准 scheme, @@ -1403,6 +1404,86 @@ paths: '500': $ref: '#/components/responses/InternalError' + /internal/emergency/disable: + post: + operationId: emergencyDisable + tags: [internal] + summary: 海外紧急停用相册(ov 域) + description: | + ov 侧独立于同步链路的紧急停用入口:直接置 albums_ov.emergency_disabled + =true(ov 本地写,不经同步版本链——后续任何同步事件不得解除,恢复仅经 + /internal/emergency/enable 显式操作)。停用相册在 ov 公开 API/WS 侧按 + 404 ALBUM_NOT_PUBLIC 语义处理;cn 侧不感知(同步照常)。albumToken 与 + albumId 二选一(都缺省 400)。审计:ov 侧结构化日志以 audit=emergency + 字段标记(P2 决策:不做独立文件通道,P5 运维按字段路由聚合)。 + 鉴权见 [internal] tag 描述。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EmergencyDisableRequest' + responses: + '200': + description: 已停用(幂等:重复停用返回同一结果)。 + content: + application/json: + schema: + $ref: '#/components/schemas/EmergencyStatus' + '400': + description: 请求体校验失败(VALIDATION_FAILED;含 albumToken/albumId 均缺省)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 相册副本不存在(ALBUM_NOT_FOUND——ov 尚未同步该相册)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + + /internal/emergency/enable: + post: + operationId: emergencyEnable + tags: [internal] + summary: 解除海外紧急停用(ov 域,显式唯一恢复通道) + description: | + 唯一能清除 albums_ov.emergency_disabled 的入口(显式操作,任何同步 + 事件都不会代为解除)。albumToken 与 albumId 二选一(都缺省 400)。 + 审计同 disable(audit=emergency 字段)。鉴权见 [internal] tag 描述。 + security: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EmergencyEnableRequest' + responses: + '200': + description: 已恢复(幂等:重复启用返回同一结果)。 + content: + application/json: + schema: + $ref: '#/components/schemas/EmergencyStatus' + '400': + description: 请求体校验失败(VALIDATION_FAILED;含 albumToken/albumId 均缺省)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '404': + description: 相册副本不存在(ALBUM_NOT_FOUND)。 + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + $ref: '#/components/responses/InternalError' + components: securitySchemes: bearerAuth: @@ -2842,3 +2923,43 @@ components: description: 相册摘要清单;空库为空数组。 items: $ref: '#/components/schemas/AlbumDigest' + + EmergencyDisableRequest: + type: object + description: 紧急停用请求(albumToken 与 albumId 恰好提供其一)。 + required: [reason] + properties: + albumToken: + type: string + description: 公开令牌定位相册(与 albumId 二选一)。 + albumId: + type: string + description: 相册 ID 定位(与 albumToken 二选一)。 + reason: + type: string + minLength: 1 + maxLength: 500 + description: 停用原因(审计留痕)。 + + EmergencyEnableRequest: + type: object + description: 解除停用请求(albumToken 与 albumId 恰好提供其一)。 + properties: + albumToken: + type: string + description: 公开令牌定位相册(与 albumId 二选一)。 + albumId: + type: string + description: 相册 ID 定位(与 albumToken 二选一)。 + + EmergencyStatus: + type: object + description: 紧急停用通道操作结果。 + required: [disabled, albumId] + properties: + disabled: + type: boolean + description: 操作后的停用状态(disable 恒 true / enable 恒 false)。 + albumId: + type: string + description: 相册 ID。 diff --git a/packages/contracts/src/generated/openapi.d.ts b/packages/contracts/src/generated/openapi.d.ts index b2bbd12..932e7c6 100644 --- a/packages/contracts/src/generated/openapi.d.ts +++ b/packages/contracts/src/generated/openapi.d.ts @@ -688,6 +688,54 @@ export interface paths { patch?: never; trace?: never; }; + "/internal/emergency/disable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 海外紧急停用相册(ov 域) + * @description ov 侧独立于同步链路的紧急停用入口:直接置 albums_ov.emergency_disabled + * =true(ov 本地写,不经同步版本链——后续任何同步事件不得解除,恢复仅经 + * /internal/emergency/enable 显式操作)。停用相册在 ov 公开 API/WS 侧按 + * 404 ALBUM_NOT_PUBLIC 语义处理;cn 侧不感知(同步照常)。albumToken 与 + * albumId 二选一(都缺省 400)。审计:ov 侧结构化日志以 audit=emergency + * 字段标记(P2 决策:不做独立文件通道,P5 运维按字段路由聚合)。 + * 鉴权见 [internal] tag 描述。 + */ + post: operations["emergencyDisable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/internal/emergency/enable": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * 解除海外紧急停用(ov 域,显式唯一恢复通道) + * @description 唯一能清除 albums_ov.emergency_disabled 的入口(显式操作,任何同步 + * 事件都不会代为解除)。albumToken 与 albumId 二选一(都缺省 400)。 + * 审计同 disable(audit=emergency 字段)。鉴权见 [internal] tag 描述。 + */ + post: operations["emergencyEnable"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1454,6 +1502,29 @@ export interface components { /** @description 相册摘要清单;空库为空数组。 */ albums: components["schemas"]["AlbumDigest"][]; }; + /** @description 紧急停用请求(albumToken 与 albumId 恰好提供其一)。 */ + EmergencyDisableRequest: { + /** @description 公开令牌定位相册(与 albumId 二选一)。 */ + albumToken?: string; + /** @description 相册 ID 定位(与 albumToken 二选一)。 */ + albumId?: string; + /** @description 停用原因(审计留痕)。 */ + reason: string; + }; + /** @description 解除停用请求(albumToken 与 albumId 恰好提供其一)。 */ + EmergencyEnableRequest: { + /** @description 公开令牌定位相册(与 albumId 二选一)。 */ + albumToken?: string; + /** @description 相册 ID 定位(与 albumToken 二选一)。 */ + albumId?: string; + }; + /** @description 紧急停用通道操作结果。 */ + EmergencyStatus: { + /** @description 操作后的停用状态(disable 恒 true / enable 恒 false)。 */ + disabled: boolean; + /** @description 相册 ID。 */ + albumId: string; + }; }; responses: { /** @description 未认证:令牌缺失、无效或已过期(AUTH_TOKEN_EXPIRED)。 */ @@ -3008,4 +3079,90 @@ export interface operations { 500: components["responses"]["InternalError"]; }; }; + emergencyDisable: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EmergencyDisableRequest"]; + }; + }; + responses: { + /** @description 已停用(幂等:重复停用返回同一结果)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmergencyStatus"]; + }; + }; + /** @description 请求体校验失败(VALIDATION_FAILED;含 albumToken/albumId 均缺省)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册副本不存在(ALBUM_NOT_FOUND——ov 尚未同步该相册)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; + emergencyEnable: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["EmergencyEnableRequest"]; + }; + }; + responses: { + /** @description 已恢复(幂等:重复启用返回同一结果)。 */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EmergencyStatus"]; + }; + }; + /** @description 请求体校验失败(VALIDATION_FAILED;含 albumToken/albumId 均缺省)。 */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + /** @description 相册副本不存在(ALBUM_NOT_FOUND)。 */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; + 500: components["responses"]["InternalError"]; + }; + }; } diff --git a/services/api/src/app-ov.module.ts b/services/api/src/app-ov.module.ts index 349662e..727c224 100644 --- a/services/api/src/app-ov.module.ts +++ b/services/api/src/app-ov.module.ts @@ -2,8 +2,9 @@ // 骨架 + Task 3 起装配收件箱)。与 app-cn.module.ts 同一管线骨架(日志 + // request-id + 全局异常过滤器 + 全局校验管道 + /v1/health),但**显式挑选** // ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO -// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);公开读、 -// likes/visits、WS 网关自 Task 6+ 起逐个加入。cn 模块保持原样,两入口互不影响。 +// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);Task 5 +// 加入 emergency(海外紧急停用通道,独立于同步);公开读、likes/visits、 +// WS 网关自 Task 6+ 起逐个加入。cn 模块保持原样,两入口互不影响。 import { type MiddlewareConsumer, Module, @@ -13,6 +14,7 @@ import { import { APP_FILTER, APP_PIPE } from '@nestjs/core'; import { LoggerModule } from 'nestjs-pino'; import { AllExceptionsFilter } from './all-exceptions.filter'; +import { EmergencyModule } from './emergency/emergency.module'; import { HealthController } from './health.controller'; import { RequestIdMiddleware } from './request-id.middleware'; import { createLoggerOptions } from './shared/logger-options'; @@ -23,6 +25,8 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module'; LoggerModule.forRoot(createLoggerOptions()), // ov 内容事件收件箱(P2 Task 3):POST /v1/internal/sync/content-batches。 SyncInboxModule, + // 海外紧急停用通道(P2 Task 5):POST /v1/internal/emergency/disable|enable。 + EmergencyModule, ], controllers: [HealthController], providers: [ diff --git a/services/api/src/emergency/dto.ts b/services/api/src/emergency/dto.ts new file mode 100644 index 0000000..584583b --- /dev/null +++ b/services/api/src/emergency/dto.ts @@ -0,0 +1,32 @@ +// services/api/src/emergency/dto.ts —— 紧急停用通道请求 DTO(P2 Task 5)。 +// 契约权威:openapi.yaml [internal] emergencyDisable/emergencyEnable +// (EmergencyDisableRequest/EmergencyEnableRequest)。「albumToken 与 albumId +// 恰好其一」的跨字段约束在服务端校验(EmergencyService.findAlbum)。 +import { + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +/** 相册定位字段(两 DTO 共用;与契约 schema 同步)。 */ +abstract class EmergencyTargetDto { + @IsOptional() + @IsString() + @MaxLength(32) + albumToken?: string; + + @IsOptional() + @IsString() + @MaxLength(64) + albumId?: string; +} + +export class EmergencyDisableDto extends EmergencyTargetDto { + @IsString() + @MinLength(1) + @MaxLength(500) + reason!: string; +} + +export class EmergencyEnableDto extends EmergencyTargetDto {} diff --git a/services/api/src/emergency/emergency.controller.ts b/services/api/src/emergency/emergency.controller.ts new file mode 100644 index 0000000..8f4023a --- /dev/null +++ b/services/api/src/emergency/emergency.controller.ts @@ -0,0 +1,27 @@ +// services/api/src/emergency/emergency.controller.ts —— ov 海外紧急停用入口 +// (P2 Task 5)。契约权威:openapi.yaml [internal](POST +// /internal/emergency/disable、POST /internal/emergency/enable)。鉴权与同步 +// 端点同一 InternalSyncGuard(HMAC 原始请求体签名 + 时间戳 ±300s)。仅 ov +// 入口装配(app-ov.module)。 +import { Body, Controller, HttpCode, Post, UseGuards } from '@nestjs/common'; +import { InternalSyncGuard } from '../shared/internal-sync.guard'; +import { EmergencyDisableDto, EmergencyEnableDto } from './dto'; +import { EmergencyService } from './emergency.service'; + +@Controller('internal/emergency') +@UseGuards(InternalSyncGuard) +export class EmergencyController { + constructor(private readonly emergency: EmergencyService) {} + + @Post('disable') + @HttpCode(200) // 契约 responses '200'(状态操作结果,非资源创建) + disable(@Body() dto: EmergencyDisableDto) { + return this.emergency.disable(dto); + } + + @Post('enable') + @HttpCode(200) + enable(@Body() dto: EmergencyEnableDto) { + return this.emergency.enable(dto); + } +} diff --git a/services/api/src/emergency/emergency.module.ts b/services/api/src/emergency/emergency.module.ts new file mode 100644 index 0000000..d2ef931 --- /dev/null +++ b/services/api/src/emergency/emergency.module.ts @@ -0,0 +1,24 @@ +// services/api/src/emergency/emergency.module.ts —— ov 海外紧急停用通道模块 +// (P2 Task 5)。OvPrismaService 显式 import(非 @Global,按模块注入); +// 审计日志令牌见 emergency.provider.ts。仅 ov 入口装配(app-ov.module)。 +import { Module } from '@nestjs/common'; +import { OvPrismaModule } from '../prisma-ov/ov-prisma.module'; +import { EmergencyController } from './emergency.controller'; +import { + EMERGENCY_AUDIT_LOGGER, + createEmergencyAuditLogger, +} from './emergency.provider'; +import { EmergencyService } from './emergency.service'; + +@Module({ + imports: [OvPrismaModule], + controllers: [EmergencyController], + providers: [ + EmergencyService, + { + provide: EMERGENCY_AUDIT_LOGGER, + useFactory: createEmergencyAuditLogger, + }, + ], +}) +export class EmergencyModule {} diff --git a/services/api/src/emergency/emergency.provider.ts b/services/api/src/emergency/emergency.provider.ts new file mode 100644 index 0000000..4129b24 --- /dev/null +++ b/services/api/src/emergency/emergency.provider.ts @@ -0,0 +1,20 @@ +// services/api/src/emergency/emergency.provider.ts —— 紧急停用审计日志的 +// 注入令牌与默认实现(P2 Task 5)。专用 pino child(字段 audit=emergency +// 常驻每行)输出到进程标准输出:与应用请求日志(nestjs-pino)同一汇聚面, +// P5 运维按字段路由到审计存储(见 emergency.service.ts 头注的偏差登记)。 +// 测试经 overrideProvider(EMERGENCY_AUDIT_LOGGER) 注入内存目的地实例断言。 +import type { Logger } from 'pino'; +import { pino } from 'pino'; +import { resolveLogLevel } from '../shared/log-level'; + +/** 审计日志注入令牌(pino Logger 类型;child 携带 audit=emergency 字段)。 */ +export const EMERGENCY_AUDIT_LOGGER: unique symbol = Symbol( + 'EMERGENCY_AUDIT_LOGGER', +); + +/** 默认实现:stdout + audit=emergency child(level 随 LOG_LEVEL env)。 */ +export function createEmergencyAuditLogger(): Logger { + return pino({ level: resolveLogLevel(process.env.LOG_LEVEL) }).child({ + audit: 'emergency', + }); +} diff --git a/services/api/src/emergency/emergency.service.ts b/services/api/src/emergency/emergency.service.ts new file mode 100644 index 0000000..500b0ac --- /dev/null +++ b/services/api/src/emergency/emergency.service.ts @@ -0,0 +1,113 @@ +// services/api/src/emergency/emergency.service.ts —— ov 海外紧急停用通道 +// (P2 Task 5)。设计要点(openapi [internal] /internal/emergency/* 描述): +// +// · **独立于同步版本链**:emergencyDisabled 是 albums_ov 上唯一 ov 本地可写 +// 位(schema 注释约定),直接 ov 库写入——不经 content-batches、无 +// entityVersion 语义。后续任何同步事件(album.updated 快照重放也不触碰 +// 该列,见 sync-inbox project)都不得解除它;恢复仅经显式 enable 端点。 +// · cn 侧不感知:无对应 cn 状态位、无反向同步(文档化决策——紧急停用是 +// ov 观众域的运维动作,不影响 cn 权威数据)。 +// · 幂等:重复 disable/enable 返回同一结果(already* 字段进审计日志供 +// 区分首次与重复操作)。 +// · 审计:结构化日志行携带 audit=emergency 字段(P2 决策偏差登记:计划 +// 原文「写 ov 侧日志文件」,落地为 pino 字段标记而非独立文件通道—— +// 免新增文件 transport 依赖,P5 运维按字段路由聚合到审计存储)。 +import { HttpStatus, Inject, Injectable } from '@nestjs/common'; +import { ErrorCodes } from '@photo/contracts'; +import type { Logger } from 'pino'; +import type { components } from '@photo/contracts'; +import { ApiException } from '../api-exception'; +import { validationFailed } from '../albums/support'; +// 值导入(非 import type):构造器注入的 design:paramtypes 依赖类引用, +// type-only 导入会让 Nest 侧元数据退化为 Function 而 DI 失败(repo 约定, +// 见 sync-inbox.service 同注)。 +import { OvPrismaService } from '../prisma-ov/ov-prisma.module'; +import { EMERGENCY_AUDIT_LOGGER } from './emergency.provider'; + +export type EmergencyStatus = components['schemas']['EmergencyStatus']; + +/** 相册定位入参(albumToken 与 albumId 恰好其一,服务端校验二选一)。 */ +export interface EmergencyTarget { + albumToken?: string; + albumId?: string; +} + +@Injectable() +export class EmergencyService { + constructor( + private readonly prisma: OvPrismaService, + @Inject(EMERGENCY_AUDIT_LOGGER) private readonly audit: Logger, + ) {} + + /** 紧急停用:albums_ov.emergencyDisabled=true(幂等)。 */ + async disable( + target: EmergencyTarget & { reason: string }, + ): Promise { + const album = await this.findAlbum(target); + if (!album.emergencyDisabled) { + await this.prisma.albums_ov.update({ + where: { albumId: album.albumId }, + data: { emergencyDisabled: true }, + }); + } + this.audit.info( + { + action: 'disable', + albumId: album.albumId, + publicToken: album.publicToken, + reason: target.reason, + alreadyDisabled: album.emergencyDisabled, + }, + 'emergency disable', + ); + return { disabled: true, albumId: album.albumId }; + } + + /** 解除停用:唯一恢复通道(显式操作;幂等)。 */ + async enable(target: EmergencyTarget): Promise { + const album = await this.findAlbum(target); + if (album.emergencyDisabled) { + await this.prisma.albums_ov.update({ + where: { albumId: album.albumId }, + data: { emergencyDisabled: false }, + }); + } + this.audit.info( + { + action: 'enable', + albumId: album.albumId, + publicToken: album.publicToken, + wasDisabled: album.emergencyDisabled, + }, + 'emergency enable', + ); + return { disabled: false, albumId: album.albumId }; + } + + /** + * 定位相册副本:albumToken 与 albumId 恰好提供其一(都缺省/都提供 → 400 + * VALIDATION_FAILED);副本不存在(ov 尚未同步该相册)→ 404 ALBUM_NOT_FOUND。 + */ + private async findAlbum(target: EmergencyTarget) { + const hasToken = target.albumToken !== undefined; + const hasId = target.albumId !== undefined; + if (hasToken === hasId) { + throw validationFailed('albumToken 与 albumId 必须恰好提供其一'); + } + const album = hasToken + ? await this.prisma.albums_ov.findUnique({ + where: { publicToken: target.albumToken }, + }) + : await this.prisma.albums_ov.findUnique({ + where: { albumId: target.albumId }, + }); + if (album === null) { + throw new ApiException( + ErrorCodes.ALBUM_NOT_FOUND, + '相册副本不存在(ov 尚未同步该相册)', + HttpStatus.NOT_FOUND, + ); + } + return album; + } +} diff --git a/services/api/src/shared/album-visibility.spec.ts b/services/api/src/shared/album-visibility.spec.ts new file mode 100644 index 0000000..13994db --- /dev/null +++ b/services/api/src/shared/album-visibility.spec.ts @@ -0,0 +1,33 @@ +// services/api/src/shared/album-visibility.spec.ts —— isAlbumVisibleOv 判定 +// 矩阵(P2 Task 5;Task 6 公开 API / Task 7 WS 消费前的单元锁定)。 +import { isAlbumVisibleOv } from './album-visibility'; + +describe('isAlbumVisibleOv', () => { + const base = { status: 'active', publicEnabled: true, emergencyDisabled: false }; + + it('active + public + 未停用 → 可见', () => { + expect(isAlbumVisibleOv(base)).toBe(true); + }); + + it('status=offline(cn 下线副本值)→ 不可见', () => { + expect(isAlbumVisibleOv({ ...base, status: 'offline' })).toBe(false); + }); + + it('publicEnabled=false(公开关闭)→ 不可见', () => { + expect(isAlbumVisibleOv({ ...base, publicEnabled: false })).toBe(false); + }); + + it('emergencyDisabled=true(ov 本地紧急停用)→ 不可见', () => { + expect(isAlbumVisibleOv({ ...base, emergencyDisabled: true })).toBe(false); + }); + + it('下线 + 紧急停用叠加 → 不可见(多条件同为假不改变语义)', () => { + expect( + isAlbumVisibleOv({ + status: 'offline', + publicEnabled: false, + emergencyDisabled: true, + }), + ).toBe(false); + }); +}); diff --git a/services/api/src/shared/album-visibility.ts b/services/api/src/shared/album-visibility.ts new file mode 100644 index 0000000..1fc7215 --- /dev/null +++ b/services/api/src/shared/album-visibility.ts @@ -0,0 +1,21 @@ +// services/api/src/shared/album-visibility.ts —— ov 侧相册「对外可见」判定 +// 单一实现(P2 Task 5 起;Task 6 公开读 API / Task 7 WS subscribe 共用)。 +// +// 语义与 cn 公开端点(public.service findPublicAlbum)同形:status=active 且 +// publicEnabled 才公开(三者任一不满足统一 404 ALBUM_NOT_PUBLIC 语义,同码 +// 防枚举);ov 侧额外叠加**本地**紧急停用位 emergencyDisabled——它独立于同步 +// 版本链(任何同步事件不得解除,见 emergency.service),停用即等价「不公开」。 +// startAt/endAt 不参与判定(与 cn 同口径:时间窗不是公开条件)。 +// 入参为 albums_ov 行的结构子集(Prisma 类型免耦合:Task 6 起消费方直接传行)。 +export interface AlbumVisibilityOvInput { + status: string; + publicEnabled: boolean; + emergencyDisabled: boolean; +} + +/** ov 相册当前是否对观众可见(false → 404 ALBUM_NOT_PUBLIC 语义)。 */ +export function isAlbumVisibleOv(album: AlbumVisibilityOvInput): boolean { + return ( + album.status === 'active' && album.publicEnabled && !album.emergencyDisabled + ); +} diff --git a/services/api/test/emergency.e2e-spec.ts b/services/api/test/emergency.e2e-spec.ts new file mode 100644 index 0000000..23c6613 --- /dev/null +++ b/services/api/test/emergency.e2e-spec.ts @@ -0,0 +1,258 @@ +// services/api/test/emergency.e2e-spec.ts —— P2 Task 5 ov 紧急停用通道 E2E。 +// 进程内起 ov 入口(AppOvModule + rawBody),审计 logger 覆写为内存目的地 +// 的 pino 实例(断言 audit=emergency 字段结构);相册副本直接落 ov 库 +// (不依赖 cn 链路——本通道纯 ov 本地)。覆盖: +// · disable by albumToken → 200 + albums_ov.emergencyDisabled=true + 审计行; +// · disable by albumId 幂等(重复返回同结果,审计 alreadyDisabled=true); +// · enable by albumId 恢复(显式唯一通道); +// · HMAC 缺头/错签 → 401 SYNC_AUTH_FAILED(与同步端点同一守卫); +// · albumToken/albumId 均缺省 → 400 VALIDATION_FAILED;未知相册 → 404 +// ALBUM_NOT_FOUND; +// · 同步事件不得解除停用:album.updated 直推 content-batches(更高版本) +// → 副本其余字段更新、emergencyDisabled 保持 true。 +import { Test } from '@nestjs/testing'; +import { type NestExpressApplication } from '@nestjs/platform-express'; +import { pino } from 'pino'; +import { PrismaClient as OvPrismaClient } from '@photo/db-ov'; +import { ErrorCodes, signSyncBody } from '@photo/contracts'; +import { AppOvModule } from '../src/app-ov.module'; +import { configureApp } from '../src/shared/configure-app'; +import { EMERGENCY_AUDIT_LOGGER } from '../src/emergency/emergency.provider'; +import { resetOvDb } from './reset-ov'; + +jest.setTimeout(60_000); + +const SYNC_KEY = process.env.SYNC_INTERNAL_KEY ?? 'dev-sync-secret-change-me'; +const ALBUM_ID = 'e2e-emergency-album'; +const PUBLIC_TOKEN = 'emergency-token-01'; + +interface SignOverrides { + signature?: string; + omitSignature?: boolean; + omitTimestamp?: boolean; +} + +async function signedPost( + base: string, + path: string, + body: unknown, + overrides: SignOverrides = {}, +): Promise { + const raw = JSON.stringify(body); + const headers: Record = { 'content-type': 'application/json' }; + if (!overrides.omitTimestamp) { + headers['x-sync-timestamp'] = String(Math.floor(Date.now() / 1000)); + } + if (!overrides.omitSignature) { + headers['x-sync-signature'] = + overrides.signature ?? signSyncBody(SYNC_KEY, raw); + } + return fetch(`${base}${path}`, { method: 'POST', headers, body: raw }); +} + +describe('ov emergency disable channel (e2e)', () => { + let ovApp: NestExpressApplication; + let ovBase: string; + let prismaOv: OvPrismaClient; + let auditLines: string[]; + + beforeAll(async () => { + expect(SYNC_KEY).not.toBe(''); + await resetOvDb(); + + auditLines = []; + // 与默认 provider 同构(child 携带 audit=emergency 常驻字段),仅输出 + // 目的地换成内存收集。 + const auditLogger = pino( + { level: 'info' }, + { write: (line: string) => void auditLines.push(line) }, + ).child({ audit: 'emergency' }); + // overrideProvider 在 TestingModule 上(NestFactory 产物没有)——审计 + // logger 以内存目的地实例覆写,断言 audit=emergency 字段。 + const moduleRef = await Test.createTestingModule({ + imports: [AppOvModule], + }) + .overrideProvider(EMERGENCY_AUDIT_LOGGER) + .useValue(auditLogger) + .compile(); + ovApp = + moduleRef.createNestApplication({ + rawBody: true, + logger: false, + }); + configureApp(ovApp); + await ovApp.listen(0); + ovBase = await ovApp.getUrl(); + + prismaOv = new OvPrismaClient(); + await prismaOv.albums_ov.create({ + data: { + albumId: ALBUM_ID, + publicToken: PUBLIC_TOKEN, + status: 'active', + publicEnabled: true, + downloadEnabled: true, + originalDownloadEnabled: false, + emergencyDisabled: false, + translations: [], + categories: [], + defaultLocale: 'zh-CN', + timezone: 'Asia/Shanghai', + startAt: new Date('2026-10-01T10:00:00.000Z'), + endAt: null, + entityVersion: 1, + }, + }); + }); + + afterAll(async () => { + await ovApp.close(); + await prismaOv.$disconnect(); + }); + + it('未签名请求 → 401 SYNC_AUTH_FAILED(与同步端点同一 HMAC 守卫)', async () => { + const res = await signedPost( + ovBase, + '/v1/internal/emergency/disable', + { albumToken: PUBLIC_TOKEN, reason: 'x' }, + { omitSignature: true, omitTimestamp: true }, + ); + expect(res.status).toBe(401); + expect(((await res.json()) as { code: string }).code).toBe( + ErrorCodes.SYNC_AUTH_FAILED, + ); + expect( + ( + await prismaOv.albums_ov.findUniqueOrThrow({ + where: { albumId: ALBUM_ID }, + }) + ).emergencyDisabled, + ).toBe(false); + }); + + it('disable by albumToken → 200 + 置位 + 审计行(audit=emergency)', async () => { + const res = await signedPost(ovBase, '/v1/internal/emergency/disable', { + albumToken: PUBLIC_TOKEN, + reason: 'content review', + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ disabled: true, albumId: ALBUM_ID }); + expect( + ( + await prismaOv.albums_ov.findUniqueOrThrow({ + where: { albumId: ALBUM_ID }, + }) + ).emergencyDisabled, + ).toBe(true); + + expect(auditLines.length).toBe(1); + const audit = JSON.parse(auditLines[0]) as Record; + expect(audit).toMatchObject({ + audit: 'emergency', + action: 'disable', + albumId: ALBUM_ID, + reason: 'content review', + alreadyDisabled: false, + }); + }); + + it('disable by albumId 幂等:重复停用返回同结果,审计 alreadyDisabled=true', async () => { + const res = await signedPost(ovBase, '/v1/internal/emergency/disable', { + albumId: ALBUM_ID, + reason: 'repeat', + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ disabled: true, albumId: ALBUM_ID }); + + expect(auditLines.length).toBe(2); + expect(JSON.parse(auditLines[1])).toMatchObject({ + audit: 'emergency', + action: 'disable', + alreadyDisabled: true, + }); + }); + + it('同步事件不得解除停用:更高版本 album.updated 直推 → 标志保持 true', async () => { + const res = await signedPost(ovBase, '/v1/internal/sync/content-batches', { + batch: [ + { + eventId: 'e2e-emergency-sync-1', + schemaVersion: 1, + entityType: 'album', + entityId: ALBUM_ID, + albumId: ALBUM_ID, + entityVersion: 2, + tombstone: false, + priority: 'normal', + payload: { + albumId: ALBUM_ID, + publicToken: PUBLIC_TOKEN, + status: 'active', + uploadEnabled: true, + publicEnabled: true, + downloadEnabled: true, + originalDownloadEnabled: false, + defaultLocale: 'zh-CN', + timezone: 'Asia/Shanghai', + startAt: '2026-10-01T10:00:00.000Z', + endAt: null, + translations: [], + entityVersion: 2, + }, + }, + ], + }); + expect(res.status).toBe(200); + // 副本推进到 v2(其余字段照常同步),紧急停用位不受任何同步事件影响。 + expect( + await prismaOv.albums_ov.findUniqueOrThrow({ + where: { albumId: ALBUM_ID }, + }), + ).toMatchObject({ entityVersion: 2, emergencyDisabled: true }); + }); + + it('enable by albumId → 显式恢复(唯一解除通道)', async () => { + const res = await signedPost(ovBase, '/v1/internal/emergency/enable', { + albumId: ALBUM_ID, + }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ disabled: false, albumId: ALBUM_ID }); + expect( + ( + await prismaOv.albums_ov.findUniqueOrThrow({ + where: { albumId: ALBUM_ID }, + }) + ).emergencyDisabled, + ).toBe(false); + + const enableAudit = auditLines + .map((line) => JSON.parse(line) as Record) + .find((line) => line.action === 'enable'); + expect(enableAudit).toMatchObject({ + audit: 'emergency', + albumId: ALBUM_ID, + wasDisabled: true, + }); + }); + + it('albumToken 与 albumId 均缺省 → 400 VALIDATION_FAILED', async () => { + const res = await signedPost(ovBase, '/v1/internal/emergency/disable', { + reason: 'no target', + }); + expect(res.status).toBe(400); + expect(((await res.json()) as { code: string }).code).toBe( + ErrorCodes.VALIDATION_FAILED, + ); + }); + + it('未知相册 → 404 ALBUM_NOT_FOUND', async () => { + const res = await signedPost(ovBase, '/v1/internal/emergency/disable', { + albumToken: 'never-synced-token', + reason: 'x', + }); + expect(res.status).toBe(404); + expect(((await res.json()) as { code: string }).code).toBe( + ErrorCodes.ALBUM_NOT_FOUND, + ); + }); +});