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:
@@ -51,7 +51,8 @@ tags:
|
|||||||
description: 观众匿名互动(点赞批次与浏览上报;ov 域部署,路径前缀不变、域名区分)
|
description: 观众匿名互动(点赞批次与浏览上报;ov 域部署,路径前缀不变、域名区分)
|
||||||
- name: internal
|
- name: internal
|
||||||
description: |
|
description: |
|
||||||
服务间同步接口,HMAC 鉴权,不对匿名开放。共用签名规则:
|
服务间内部接口(cn↔ov 同步 + ov 紧急停用通道),HMAC 鉴权,不对匿名
|
||||||
|
开放。共用签名规则:
|
||||||
`X-Sync-Signature` = hex(HMAC-SHA256(原始请求体, SYNC_INTERNAL_KEY)),
|
`X-Sync-Signature` = hex(HMAC-SHA256(原始请求体, SYNC_INTERNAL_KEY)),
|
||||||
`X-Sync-Timestamp` = Unix 秒(与服务端时差 ±300s 内有效,防重放);
|
`X-Sync-Timestamp` = Unix 秒(与服务端时差 ±300s 内有效,防重放);
|
||||||
签名缺失/不符或时间戳超窗一律拒绝。OpenAPI 无 HMAC 标准 scheme,
|
签名缺失/不符或时间戳超窗一律拒绝。OpenAPI 无 HMAC 标准 scheme,
|
||||||
@@ -1403,6 +1404,86 @@ paths:
|
|||||||
'500':
|
'500':
|
||||||
$ref: '#/components/responses/InternalError'
|
$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:
|
components:
|
||||||
securitySchemes:
|
securitySchemes:
|
||||||
bearerAuth:
|
bearerAuth:
|
||||||
@@ -2842,3 +2923,43 @@ components:
|
|||||||
description: 相册摘要清单;空库为空数组。
|
description: 相册摘要清单;空库为空数组。
|
||||||
items:
|
items:
|
||||||
$ref: '#/components/schemas/AlbumDigest'
|
$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。
|
||||||
|
|||||||
+157
@@ -688,6 +688,54 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: 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<string, never>;
|
export type webhooks = Record<string, never>;
|
||||||
export interface components {
|
export interface components {
|
||||||
@@ -1454,6 +1502,29 @@ export interface components {
|
|||||||
/** @description 相册摘要清单;空库为空数组。 */
|
/** @description 相册摘要清单;空库为空数组。 */
|
||||||
albums: components["schemas"]["AlbumDigest"][];
|
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: {
|
responses: {
|
||||||
/** @description 未认证:令牌缺失、无效或已过期(AUTH_TOKEN_EXPIRED)。 */
|
/** @description 未认证:令牌缺失、无效或已过期(AUTH_TOKEN_EXPIRED)。 */
|
||||||
@@ -3008,4 +3079,90 @@ export interface operations {
|
|||||||
500: components["responses"]["InternalError"];
|
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"];
|
||||||
|
};
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
// 骨架 + Task 3 起装配收件箱)。与 app-cn.module.ts 同一管线骨架(日志 +
|
// 骨架 + Task 3 起装配收件箱)。与 app-cn.module.ts 同一管线骨架(日志 +
|
||||||
// request-id + 全局异常过滤器 + 全局校验管道 + /v1/health),但**显式挑选**
|
// request-id + 全局异常过滤器 + 全局校验管道 + /v1/health),但**显式挑选**
|
||||||
// ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO
|
// ov 角色模块:cn 专属域(auth/albums/uploads/photos/admin——写路径与 MinIO
|
||||||
// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);公开读、
|
// 上传)不装配;P2 Task 3 加入 sync-inbox(内容事件接收 + 副本投影);Task 5
|
||||||
// likes/visits、WS 网关自 Task 6+ 起逐个加入。cn 模块保持原样,两入口互不影响。
|
// 加入 emergency(海外紧急停用通道,独立于同步);公开读、likes/visits、
|
||||||
|
// WS 网关自 Task 6+ 起逐个加入。cn 模块保持原样,两入口互不影响。
|
||||||
import {
|
import {
|
||||||
type MiddlewareConsumer,
|
type MiddlewareConsumer,
|
||||||
Module,
|
Module,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
import { APP_FILTER, APP_PIPE } from '@nestjs/core';
|
||||||
import { LoggerModule } from 'nestjs-pino';
|
import { LoggerModule } from 'nestjs-pino';
|
||||||
import { AllExceptionsFilter } from './all-exceptions.filter';
|
import { AllExceptionsFilter } from './all-exceptions.filter';
|
||||||
|
import { EmergencyModule } from './emergency/emergency.module';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { RequestIdMiddleware } from './request-id.middleware';
|
import { RequestIdMiddleware } from './request-id.middleware';
|
||||||
import { createLoggerOptions } from './shared/logger-options';
|
import { createLoggerOptions } from './shared/logger-options';
|
||||||
@@ -23,6 +25,8 @@ import { SyncInboxModule } from './sync-inbox/sync-inbox.module';
|
|||||||
LoggerModule.forRoot(createLoggerOptions()),
|
LoggerModule.forRoot(createLoggerOptions()),
|
||||||
// ov 内容事件收件箱(P2 Task 3):POST /v1/internal/sync/content-batches。
|
// ov 内容事件收件箱(P2 Task 3):POST /v1/internal/sync/content-batches。
|
||||||
SyncInboxModule,
|
SyncInboxModule,
|
||||||
|
// 海外紧急停用通道(P2 Task 5):POST /v1/internal/emergency/disable|enable。
|
||||||
|
EmergencyModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
controllers: [HealthController],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user