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
+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;
}
}