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:
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user