feat(ov): emergency disable channel independent of sync

P2 Task 5(海外紧急停用入口):
- ov 新增 POST /v1/internal/emergency/disable|enable(InternalSyncGuard
  HMAC,albumToken/albumId 恰好其一、都缺省 400;副本不存在 404
  ALBUM_NOT_FOUND):直写 albums_ov.emergencyDisabled(唯一 ov 本地可写
  位),独立于同步版本链——更高版本 album.updated 重放不解除(e2e 断言
  entityVersion 推进而标志保持),恢复仅经显式 enable;幂等语义
  (alreadyDisabled/wasDisabled 入审计行)
- cn 侧不感知:无对应 cn 状态位、无反向同步(契约描述文档化)
- 审计:pino child(audit=emergency 字段常驻每行)结构化日志——偏差
  登记:计划原文「写 ov 侧日志文件」落地为字段标记而非独立文件通道
  (免新增文件 transport 依赖,P5 运维按字段路由聚合);注入令牌
  EMERGENCY_AUDIT_LOGGER,测试覆写为内存目的地断言字段
- isAlbumVisibleOv 共享判定(status=active && publicEnabled &&
  !emergencyDisabled——镜像 cn 公开语义叠加本地停用位):Task 6 公开
  API / Task 7 WS subscribe 的 404 ALBUM_NOT_PUBLIC 语义单一实现,
  判定矩阵单测锁定
- 契约 [internal] 增 emergency disable/enable 操作与 schema(gen 已跑、
  redocly 通过);e2e:token/id 定位、幂等、enable 恢复、未签名 401、
  均缺省 400、未知相册 404、同步事件不解除

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-18 16:19:02 +08:00
parent 36082c5a74
commit eac8da6d6c
11 changed files with 813 additions and 3 deletions
+6 -2
View File
@@ -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: [
+32
View File
@@ -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 {}
@@ -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);
}
}
@@ -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 {}
@@ -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',
});
}
@@ -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<EmergencyStatus> {
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<EmergencyStatus> {
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;
}
}
@@ -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);
});
});
@@ -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
);
}