feat: 持久化通知 outbox 记录

- notifications: 通知发送前写入 outbox,并在成功、失败或未配置时回写状态

- deploy-runs: 审计通知结果时带上 outboxMessageId

- outbox: 新增超级管理员查询最近通知投递摘要接口
This commit is contained in:
湛兮
2026-06-12 03:22:38 +08:00
parent 36c22eb00d
commit 4597dcaa38
9 changed files with 406 additions and 10 deletions
@@ -361,6 +361,7 @@ export class DeployExecutionService {
event, event,
channel: notification.channel, channel: notification.channel,
reason: notification.reason, reason: notification.reason,
outboxMessageId: notification.outboxMessageId,
}, },
}); });
@@ -547,6 +548,7 @@ export class DeployExecutionService {
event, event,
channel: notification.channel, channel: notification.channel,
reason: notification.reason, reason: notification.reason,
outboxMessageId: notification.outboxMessageId,
}, },
}); });
} }
+4
View File
@@ -1,3 +1,6 @@
/**
* 发布记录应用服务负责创建、取消、同步发布单,并归档审计与通知结果。
*/
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { AuthService } from '../auth/auth.service'; import { AuthService } from '../auth/auth.service';
import { AuthenticatedUser } from '../auth/auth.types'; import { AuthenticatedUser } from '../auth/auth.types';
@@ -370,6 +373,7 @@ export class DeployRunsService {
event, event,
channel: notification.channel, channel: notification.channel,
reason: notification.reason, reason: notification.reason,
outboxMessageId: notification.outboxMessageId,
}, },
}); });
} }
@@ -0,0 +1,27 @@
/**
* 通知 outbox 查询入口只允许超级管理员查看脱敏投递摘要。
*/
import { Controller, Get, UseGuards } from '@nestjs/common';
import { ApiOkResponse, ApiTags } from '@nestjs/swagger';
import { AdminOnlyMessage } from '../../auth/admin-only-message.decorator';
import { AuthGuard } from '../../auth/auth.guard';
import { PasswordChangeGuard } from '../../auth/password-change.guard';
import { SuperAdminGuard } from '../../auth/super-admin.guard';
import { NotificationOutboxService } from './notification-outbox.service';
import { NotificationOutboxMessage } from './notification-outbox.types';
@ApiTags('通知 outbox')
@Controller('notification-outbox')
@UseGuards(AuthGuard, PasswordChangeGuard, SuperAdminGuard)
@AdminOnlyMessage('无权限访问通知 outbox')
export class NotificationOutboxController {
constructor(
private readonly notificationOutboxService: NotificationOutboxService,
) {}
@Get()
@ApiOkResponse({ description: '查询最近的通知 outbox 投递摘要。' })
async listRecentMessages(): Promise<NotificationOutboxMessage[]> {
return this.notificationOutboxService.listRecentMessages();
}
}
@@ -0,0 +1,13 @@
/**
* 通知 outbox 模块提供投递记录写入服务和超级管理员查询入口。
*/
import { Module } from '@nestjs/common';
import { NotificationOutboxController } from './notification-outbox.controller';
import { NotificationOutboxService } from './notification-outbox.service';
@Module({
controllers: [NotificationOutboxController],
providers: [NotificationOutboxService],
exports: [NotificationOutboxService],
})
export class NotificationOutboxModule {}
@@ -0,0 +1,187 @@
/**
* 通知 outbox 负责在真正投递前持久化通知事件,并记录后续投递结果。
*/
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { OutboxMessage, OutboxStatus, Prisma } from '@prisma/client';
import { redactSensitive } from '../../common/security/redact-sensitive';
import { EnvConfig } from '../../config/env.schema';
import { PrismaService } from '../../prisma/prisma.service';
import {
CompleteNotificationOutboxInput,
CreateNotificationOutboxInput,
NotificationOutboxMessage,
NotificationOutboxStatus,
} from './notification-outbox.types';
const statusToPrisma: Record<
CompleteNotificationOutboxInput['status'],
OutboxStatus
> = {
sent: OutboxStatus.SENT,
failed: OutboxStatus.FAILED,
};
const statusFromPrisma: Record<OutboxStatus, NotificationOutboxStatus> = {
PENDING: 'pending',
SENT: 'sent',
FAILED: 'failed',
DEAD: 'dead',
};
@Injectable()
export class NotificationOutboxService {
private readonly memoryMessages: NotificationOutboxMessage[] = [];
constructor(
private readonly config: ConfigService<EnvConfig, true>,
private readonly prisma: PrismaService,
) {}
async createMessage(
input: CreateNotificationOutboxInput,
): Promise<NotificationOutboxMessage> {
const payload = this.toJsonValue(redactSensitive(input.payload));
const prisma = this.databaseClient();
if (!prisma) {
const existing = this.memoryMessages.find(
(message) => message.idempotencyKey === input.idempotencyKey,
);
if (existing) {
return existing;
}
const now = new Date().toISOString();
const message: NotificationOutboxMessage = {
id: `outbox_${Date.now()}_${this.memoryMessages.length + 1}`,
deployRunId: input.deployRunId,
channel: input.channel,
template: input.template,
payloadSummary: this.jsonToRecord(payload),
status: 'pending',
attemptCount: 0,
idempotencyKey: input.idempotencyKey,
createdAt: now,
updatedAt: now,
};
this.memoryMessages.unshift(message);
return message;
}
const message = await prisma.outboxMessage.upsert({
where: {
idempotencyKey: input.idempotencyKey,
},
update: {
channel: input.channel,
template: input.template,
payload,
},
create: {
deployRunId: input.deployRunId,
channel: input.channel,
template: input.template,
payload,
idempotencyKey: input.idempotencyKey,
},
});
return this.toSummary(message);
}
async completeMessage(
id: string,
input: CompleteNotificationOutboxInput,
): Promise<NotificationOutboxMessage | undefined> {
const prisma = this.databaseClient();
if (!prisma) {
const message = this.memoryMessages.find((item) => item.id === id);
if (!message) {
return undefined;
}
message.status = input.status;
message.attemptCount += 1;
message.lastError = input.lastError;
message.nextAttemptAt = input.nextAttemptAt?.toISOString();
message.updatedAt = new Date().toISOString();
return message;
}
const message = await prisma.outboxMessage.update({
where: { id },
data: {
status: statusToPrisma[input.status],
attemptCount: {
increment: 1,
},
lastError: input.lastError,
nextAttemptAt: input.nextAttemptAt,
},
});
return this.toSummary(message);
}
async listRecentMessages(): Promise<NotificationOutboxMessage[]> {
const prisma = this.databaseClient();
if (!prisma) {
return [...this.memoryMessages].slice(0, 50);
}
const messages = await prisma.outboxMessage.findMany({
orderBy: {
createdAt: 'desc',
},
take: 50,
});
return messages.map((message) => this.toSummary(message));
}
private toSummary(message: OutboxMessage): NotificationOutboxMessage {
return {
id: message.id,
deployRunId: message.deployRunId ?? undefined,
channel: message.channel,
template: message.template,
payloadSummary: this.jsonToRecord(message.payload),
status: statusFromPrisma[message.status],
attemptCount: message.attemptCount,
nextAttemptAt: message.nextAttemptAt?.toISOString(),
lastError: message.lastError ?? undefined,
idempotencyKey: message.idempotencyKey,
createdAt: message.createdAt.toISOString(),
updatedAt: message.updatedAt.toISOString(),
};
}
private toJsonValue(value: unknown): Prisma.InputJsonValue {
return JSON.parse(JSON.stringify(value)) as Prisma.InputJsonValue;
}
private jsonToRecord(value: unknown): Record<string, unknown> | undefined {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return undefined;
}
return value as Record<string, unknown>;
}
private databaseClient(): PrismaService | undefined {
if (
!this.config.get('USE_DATABASE_READS', { infer: true }) ||
!this.config.get('DATABASE_URL', { infer: true })
) {
return undefined;
}
return this.prisma;
}
}
@@ -0,0 +1,33 @@
/**
* 通知 outbox 只暴露脱敏后的投递摘要,供运维页面确认通知链路状态。
*/
export type NotificationOutboxStatus = 'pending' | 'sent' | 'failed' | 'dead';
export type NotificationOutboxMessage = {
id: string;
deployRunId?: string;
channel: string;
template: string;
payloadSummary?: Record<string, unknown>;
status: NotificationOutboxStatus;
attemptCount: number;
nextAttemptAt?: string;
lastError?: string;
idempotencyKey: string;
createdAt: string;
updatedAt: string;
};
export type CreateNotificationOutboxInput = {
deployRunId?: string;
channel: string;
template: string;
payload: Record<string, unknown>;
idempotencyKey: string;
};
export type CompleteNotificationOutboxInput = {
status: 'sent' | 'failed';
lastError?: string;
nextAttemptAt?: Date;
};
@@ -1,12 +1,13 @@
/**
* 覆盖通知 provider 与 outbox 组合边界:缺配置时只记录摘要,配置后才投递。
*/
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { EnvConfig } from '../../config/env.schema'; import { EnvConfig } from '../../config/env.schema';
import { WeComClient } from './wecom.client'; import { WeComClient } from './wecom.client';
import { DeployNotificationService } from './deploy-notification.service'; import { DeployNotificationService } from './deploy-notification.service';
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types'; import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
import { NotificationOutboxService } from '../outbox/notification-outbox.service';
/**
* 覆盖通知 provider 的安全边界:缺少配置时跳过,配置后才发送。
*/
describe('DeployNotificationService', () => { describe('DeployNotificationService', () => {
const run: DeployRunSummary = { const run: DeployRunSummary = {
id: 'run_001', id: 'run_001',
@@ -35,6 +36,7 @@ describe('DeployNotificationService', () => {
const service = new DeployNotificationService( const service = new DeployNotificationService(
createConfig({}), createConfig({}),
weCom as unknown as WeComClient, weCom as unknown as WeComClient,
createOutbox() as unknown as NotificationOutboxService,
); );
const result = await service.sendDeployRunEvent(run, 'created'); const result = await service.sendDeployRunEvent(run, 'created');
@@ -52,6 +54,7 @@ describe('DeployNotificationService', () => {
const service = new DeployNotificationService( const service = new DeployNotificationService(
createConfig({ NOTIFICATION_PROVIDER: 'wecom' }), createConfig({ NOTIFICATION_PROVIDER: 'wecom' }),
weCom as unknown as WeComClient, weCom as unknown as WeComClient,
createOutbox() as unknown as NotificationOutboxService,
); );
const result = await service.sendDeployRunEvent(run, 'created'); const result = await service.sendDeployRunEvent(run, 'created');
@@ -80,6 +83,7 @@ describe('DeployNotificationService', () => {
FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/test', FEISHU_WEBHOOK_URL: 'https://open.feishu.cn/open-apis/bot/v2/hook/test',
}), }),
weCom as unknown as WeComClient, weCom as unknown as WeComClient,
createOutbox() as unknown as NotificationOutboxService,
); );
const result = await service.sendDeployRunEvent(run, 'failed'); const result = await service.sendDeployRunEvent(run, 'failed');
@@ -109,6 +113,7 @@ describe('DeployNotificationService', () => {
DEVOPS_PUBLIC_URL: 'https://devops.mrzhan.top', DEVOPS_PUBLIC_URL: 'https://devops.mrzhan.top',
}), }),
weCom as unknown as WeComClient, weCom as unknown as WeComClient,
createOutbox() as unknown as NotificationOutboxService,
); );
const result = await service.sendDeployRunEvent(run, 'succeeded'); const result = await service.sendDeployRunEvent(run, 'succeeded');
@@ -131,3 +136,26 @@ function createConfig(
get: jest.fn((key: keyof EnvConfig) => values[key]), get: jest.fn((key: keyof EnvConfig) => values[key]),
} as unknown as ConfigService<EnvConfig, true>; } as unknown as ConfigService<EnvConfig, true>;
} }
function createOutbox(): Pick<
NotificationOutboxService,
'createMessage' | 'completeMessage'
> {
return {
createMessage: jest.fn((input) =>
Promise.resolve({
id: 'outbox_001',
deployRunId: input.deployRunId,
channel: input.channel,
template: input.template,
payloadSummary: input.payload,
status: 'pending' as const,
attemptCount: 0,
idempotencyKey: input.idempotencyKey,
createdAt: '2026-06-11T00:00:00.000Z',
updatedAt: '2026-06-11T00:00:00.000Z',
}),
),
completeMessage: jest.fn(),
};
}
@@ -1,8 +1,13 @@
/**
* 发布通知适配服务封装 provider 选择、outbox 记录和后端 webhook 投递。
*/
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { redactSensitive } from '../../common/security/redact-sensitive'; import { redactSensitive } from '../../common/security/redact-sensitive';
import { EnvConfig } from '../../config/env.schema'; import { EnvConfig } from '../../config/env.schema';
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types'; import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
import { NotificationOutboxService } from '../outbox/notification-outbox.service';
import { NotificationOutboxMessage } from '../outbox/notification-outbox.types';
import { WeComClient } from './wecom.client'; import { WeComClient } from './wecom.client';
export type DeployNotificationEvent = export type DeployNotificationEvent =
@@ -17,6 +22,7 @@ export type DeployNotificationResult = {
status: 'sent' | 'skipped' | 'failed'; status: 'sent' | 'skipped' | 'failed';
channel: DeployNotificationProvider | 'none'; channel: DeployNotificationProvider | 'none';
reason?: string; reason?: string;
outboxMessageId?: string;
}; };
export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic'; export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic';
@@ -35,6 +41,7 @@ export class DeployNotificationService {
constructor( constructor(
private readonly config: ConfigService<EnvConfig, true>, private readonly config: ConfigService<EnvConfig, true>,
private readonly weComClient: WeComClient, private readonly weComClient: WeComClient,
private readonly notificationOutboxService: NotificationOutboxService,
) {} ) {}
healthSummary(): DeployNotificationHealth { healthSummary(): DeployNotificationHealth {
@@ -72,39 +79,89 @@ export class DeployNotificationService {
event: DeployNotificationEvent, event: DeployNotificationEvent,
): Promise<DeployNotificationResult> { ): Promise<DeployNotificationResult> {
const health = this.healthSummary(); const health = this.healthSummary();
const channel = health.provider ?? 'none';
const outboxMessage = await this.createOutboxMessage(run, event, channel);
if (health.status === 'not_configured') { if (health.status === 'not_configured') {
await this.completeOutboxFailure(
outboxMessage,
health.message ?? '通知平台尚未配置',
);
return { return {
status: 'skipped', status: 'skipped',
channel: health.provider ?? 'none', channel,
reason: health.message ?? '通知平台尚未配置', reason: health.message ?? '通知平台尚未配置',
outboxMessageId: outboxMessage.id,
}; };
} }
if (health.status === 'unavailable' || !health.provider) { if (health.status === 'unavailable' || !health.provider) {
await this.completeOutboxFailure(
outboxMessage,
health.message ?? '通知平台不可用',
);
return { return {
status: 'failed', status: 'failed',
channel: health.provider ?? 'none', channel,
reason: health.message ?? '通知平台不可用', reason: health.message ?? '通知平台不可用',
outboxMessageId: outboxMessage.id,
}; };
} }
try { try {
await this.sendByProvider(health.provider, run, event); await this.sendByProvider(health.provider, run, event);
await this.notificationOutboxService.completeMessage(outboxMessage.id, {
status: 'sent',
});
return { status: 'sent', channel: health.provider }; return {
status: 'sent',
channel: health.provider,
outboxMessageId: outboxMessage.id,
};
} catch (error) { } catch (error) {
const reason =
error instanceof Error
? redactSensitive({ message: error.message }).message
: '未知通知错误';
await this.completeOutboxFailure(outboxMessage, reason);
return { return {
status: 'failed', status: 'failed',
channel: health.provider, channel: health.provider,
reason: reason,
error instanceof Error outboxMessageId: outboxMessage.id,
? redactSensitive({ message: error.message }).message
: '未知通知错误',
}; };
} }
} }
private async createOutboxMessage(
run: DeployRunSummary,
event: DeployNotificationEvent,
channel: DeployNotificationProvider | 'none',
): Promise<NotificationOutboxMessage> {
return this.notificationOutboxService.createMessage({
deployRunId: run.id,
channel,
template: `deploy.${event}`,
payload: this.outboxPayloadFor(run, event),
idempotencyKey: this.outboxIdempotencyKey(run, event),
});
}
private async completeOutboxFailure(
message: NotificationOutboxMessage,
reason: string,
): Promise<void> {
await this.notificationOutboxService.completeMessage(message.id, {
status: 'failed',
lastError: reason,
});
}
private resolveProvider(): DeployNotificationProvider | undefined { private resolveProvider(): DeployNotificationProvider | undefined {
const provider = this.config const provider = this.config
.get('NOTIFICATION_PROVIDER', { infer: true }) .get('NOTIFICATION_PROVIDER', { infer: true })
@@ -224,6 +281,46 @@ export class DeployNotificationService {
return 'DevOps 发布已创建'; return 'DevOps 发布已创建';
} }
private outboxPayloadFor(
run: DeployRunSummary,
event: DeployNotificationEvent,
): Record<string, unknown> {
return {
event: `deploy.${event}`,
title: this.titleFor(event),
deployRunId: run.id,
projectKey: run.projectKey,
environment: run.environment,
ref: this.safeRefForOutbox(run.ref),
status: run.status,
operator: run.operator,
jenkinsQueueId: run.jenkinsQueueId,
jenkinsBuildNumber: run.jenkinsBuildNumber,
failureSummary: run.failureSummary,
publicUrl: this.config.get('DEVOPS_PUBLIC_URL', { infer: true }),
};
}
private outboxIdempotencyKey(
run: DeployRunSummary,
event: DeployNotificationEvent,
): string {
return [
run.id,
`deploy.${event}`,
run.status,
run.jenkinsBuildNumber ?? run.jenkinsQueueId ?? 'no-jenkins-ref',
].join(':');
}
private safeRefForOutbox(ref: string): string {
if (/^[a-f0-9]{40}$/i.test(ref)) {
return ref.slice(0, 12);
}
return ref.length > 48 ? `${ref.slice(0, 45)}...` : ref;
}
private markdownFor( private markdownFor(
run: DeployRunSummary, run: DeployRunSummary,
event: DeployNotificationEvent, event: DeployNotificationEvent,
+5
View File
@@ -1,8 +1,13 @@
/**
* 通知模块集中注册 provider 客户端、发布通知服务和 outbox 能力。
*/
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { NotificationOutboxModule } from '../outbox/notification-outbox.module';
import { DeployNotificationService } from './deploy-notification.service'; import { DeployNotificationService } from './deploy-notification.service';
import { WeComClient } from './wecom.client'; import { WeComClient } from './wecom.client';
@Module({ @Module({
imports: [NotificationOutboxModule],
providers: [WeComClient, DeployNotificationService], providers: [WeComClient, DeployNotificationService],
exports: [WeComClient, DeployNotificationService], exports: [WeComClient, DeployNotificationService],
}) })