feat: 持久化通知 outbox 记录
- notifications: 通知发送前写入 outbox,并在成功、失败或未配置时回写状态 - deploy-runs: 审计通知结果时带上 outboxMessageId - outbox: 新增超级管理员查询最近通知投递摘要接口
This commit is contained in:
@@ -361,6 +361,7 @@ export class DeployExecutionService {
|
||||
event,
|
||||
channel: notification.channel,
|
||||
reason: notification.reason,
|
||||
outboxMessageId: notification.outboxMessageId,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -547,6 +548,7 @@ export class DeployExecutionService {
|
||||
event,
|
||||
channel: notification.channel,
|
||||
reason: notification.reason,
|
||||
outboxMessageId: notification.outboxMessageId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
/**
|
||||
* 发布记录应用服务负责创建、取消、同步发布单,并归档审计与通知结果。
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { AuthService } from '../auth/auth.service';
|
||||
import { AuthenticatedUser } from '../auth/auth.types';
|
||||
@@ -370,6 +373,7 @@ export class DeployRunsService {
|
||||
event,
|
||||
channel: notification.channel,
|
||||
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 { EnvConfig } from '../../config/env.schema';
|
||||
import { WeComClient } from './wecom.client';
|
||||
import { DeployNotificationService } from './deploy-notification.service';
|
||||
import { DeployRunSummary } from '../../deploy-runs/deploy-run.types';
|
||||
import { NotificationOutboxService } from '../outbox/notification-outbox.service';
|
||||
|
||||
/**
|
||||
* 覆盖通知 provider 的安全边界:缺少配置时跳过,配置后才发送。
|
||||
*/
|
||||
describe('DeployNotificationService', () => {
|
||||
const run: DeployRunSummary = {
|
||||
id: 'run_001',
|
||||
@@ -35,6 +36,7 @@ describe('DeployNotificationService', () => {
|
||||
const service = new DeployNotificationService(
|
||||
createConfig({}),
|
||||
weCom as unknown as WeComClient,
|
||||
createOutbox() as unknown as NotificationOutboxService,
|
||||
);
|
||||
|
||||
const result = await service.sendDeployRunEvent(run, 'created');
|
||||
@@ -52,6 +54,7 @@ describe('DeployNotificationService', () => {
|
||||
const service = new DeployNotificationService(
|
||||
createConfig({ NOTIFICATION_PROVIDER: 'wecom' }),
|
||||
weCom as unknown as WeComClient,
|
||||
createOutbox() as unknown as NotificationOutboxService,
|
||||
);
|
||||
|
||||
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',
|
||||
}),
|
||||
weCom as unknown as WeComClient,
|
||||
createOutbox() as unknown as NotificationOutboxService,
|
||||
);
|
||||
|
||||
const result = await service.sendDeployRunEvent(run, 'failed');
|
||||
@@ -109,6 +113,7 @@ describe('DeployNotificationService', () => {
|
||||
DEVOPS_PUBLIC_URL: 'https://devops.mrzhan.top',
|
||||
}),
|
||||
weCom as unknown as WeComClient,
|
||||
createOutbox() as unknown as NotificationOutboxService,
|
||||
);
|
||||
|
||||
const result = await service.sendDeployRunEvent(run, 'succeeded');
|
||||
@@ -131,3 +136,26 @@ function createConfig(
|
||||
get: jest.fn((key: keyof EnvConfig) => values[key]),
|
||||
} 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 { ConfigService } from '@nestjs/config';
|
||||
import { redactSensitive } from '../../common/security/redact-sensitive';
|
||||
import { EnvConfig } from '../../config/env.schema';
|
||||
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';
|
||||
|
||||
export type DeployNotificationEvent =
|
||||
@@ -17,6 +22,7 @@ export type DeployNotificationResult = {
|
||||
status: 'sent' | 'skipped' | 'failed';
|
||||
channel: DeployNotificationProvider | 'none';
|
||||
reason?: string;
|
||||
outboxMessageId?: string;
|
||||
};
|
||||
|
||||
export type DeployNotificationProvider = 'wecom' | 'feishu' | 'generic';
|
||||
@@ -35,6 +41,7 @@ export class DeployNotificationService {
|
||||
constructor(
|
||||
private readonly config: ConfigService<EnvConfig, true>,
|
||||
private readonly weComClient: WeComClient,
|
||||
private readonly notificationOutboxService: NotificationOutboxService,
|
||||
) {}
|
||||
|
||||
healthSummary(): DeployNotificationHealth {
|
||||
@@ -72,39 +79,89 @@ export class DeployNotificationService {
|
||||
event: DeployNotificationEvent,
|
||||
): Promise<DeployNotificationResult> {
|
||||
const health = this.healthSummary();
|
||||
const channel = health.provider ?? 'none';
|
||||
const outboxMessage = await this.createOutboxMessage(run, event, channel);
|
||||
|
||||
if (health.status === 'not_configured') {
|
||||
await this.completeOutboxFailure(
|
||||
outboxMessage,
|
||||
health.message ?? '通知平台尚未配置',
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'skipped',
|
||||
channel: health.provider ?? 'none',
|
||||
channel,
|
||||
reason: health.message ?? '通知平台尚未配置',
|
||||
outboxMessageId: outboxMessage.id,
|
||||
};
|
||||
}
|
||||
|
||||
if (health.status === 'unavailable' || !health.provider) {
|
||||
await this.completeOutboxFailure(
|
||||
outboxMessage,
|
||||
health.message ?? '通知平台不可用',
|
||||
);
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
channel: health.provider ?? 'none',
|
||||
channel,
|
||||
reason: health.message ?? '通知平台不可用',
|
||||
outboxMessageId: outboxMessage.id,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
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) {
|
||||
const reason =
|
||||
error instanceof Error
|
||||
? redactSensitive({ message: error.message }).message
|
||||
: '未知通知错误';
|
||||
|
||||
await this.completeOutboxFailure(outboxMessage, reason);
|
||||
|
||||
return {
|
||||
status: 'failed',
|
||||
channel: health.provider,
|
||||
reason:
|
||||
error instanceof Error
|
||||
? redactSensitive({ message: error.message }).message
|
||||
: '未知通知错误',
|
||||
reason,
|
||||
outboxMessageId: outboxMessage.id,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
const provider = this.config
|
||||
.get('NOTIFICATION_PROVIDER', { infer: true })
|
||||
@@ -224,6 +281,46 @@ export class DeployNotificationService {
|
||||
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(
|
||||
run: DeployRunSummary,
|
||||
event: DeployNotificationEvent,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
/**
|
||||
* 通知模块集中注册 provider 客户端、发布通知服务和 outbox 能力。
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationOutboxModule } from '../outbox/notification-outbox.module';
|
||||
import { DeployNotificationService } from './deploy-notification.service';
|
||||
import { WeComClient } from './wecom.client';
|
||||
|
||||
@Module({
|
||||
imports: [NotificationOutboxModule],
|
||||
providers: [WeComClient, DeployNotificationService],
|
||||
exports: [WeComClient, DeployNotificationService],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user