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
);
}
+258
View File
@@ -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<Response> {
const raw = JSON.stringify(body);
const headers: Record<string, string> = { '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<NestExpressApplication>({
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<string, unknown>;
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<string, unknown>)
.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,
);
});
});