Files
lislgosms/api/src/sending-monitor/sending-monitor.module.ts
T
2026-09-06 19:22:49 +08:00

398 lines
17 KiB
TypeScript

import {
BadRequestException,
Body,
ConflictException,
Controller,
ForbiddenException,
Get,
Injectable,
Module,
NotFoundException,
Param,
Post,
Put,
Query,
Req,
} from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service';
import { PrismaModule } from '../prisma/prisma.module';
import type { SessionRequest } from '../auth/session-validation.middleware';
import {
horizons,
matchRules,
validateRule,
type MonitorScope,
type MonitorType,
type Rule,
type RuleConfig,
} from './monitor-metrics';
type QueryParams = Record<string, string | undefined>;
const pageNumber = (value: string | undefined, fallback: number, max = 100000) => {
const n = value === undefined ? fallback : Number(value);
if (!Number.isSafeInteger(n) || n < 1 || n > max) throw new BadRequestException('分页参数无效');
return n;
};
const healthy = `EXISTS(SELECT 1 FROM "SendingMonitorCheckpoint" WHERE id='health' AND "updatedAt">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '30 seconds' AND data->>'complete'='true')`;
const monitorType = (value = 'industry'): MonitorType => {
if (!Object.hasOwn(horizons, value)) throw new BadRequestException('监控类型无效');
return value as MonitorType;
};
@Injectable()
export class SendingMonitorService {
constructor(private readonly prisma: PrismaService) {}
async authorize(req: SessionRequest, permission: 'view' | 'rules' | 'targets' = 'view') {
const user =
req.authSession?.portal === 'admin' &&
req.sessionUserId &&
(await this.prisma.user.findFirst({
where: {
id: req.sessionUserId,
status: 'active',
deletedAt: null,
roles: { some: { role: { code: 'platform_admin' } } },
},
select: { id: true },
}));
if (!user) throw new ForbiddenException(`无发送监控${permission === 'view' ? '查看' : '配置'}权限`);
return user.id;
}
async rows(query: QueryParams) {
const type = monitorType(query.type),
page = pageNumber(query.page, 1),
pageSize = pageNumber(query.pageSize, 20, 100);
const filter = `s.type=$1 AND s."evaluationAt"=(SELECT max("evaluationAt") FROM "SendingMonitorSnapshot" WHERE type=$1)
AND ($2='' OR (CASE WHEN ${healthy} THEN s.status ELSE 'stale' END)=$2) AND ($3='' OR s.dimensions::text ILIKE '%'||$3||'%')
AND ($4='' OR s.dimensions->>'tenantId'=$4) AND ($5='' OR s.dimensions->>'applicationId'=$5) AND ($6='' OR s.dimensions->>'signatureId'=$6)`;
const args = [
type,
query.status ?? '',
query.keyword?.trim() ?? '',
query.tenantId ?? '',
query.applicationId ?? '',
query.signatureId ?? '',
];
const [items, count, checkpoint] = await Promise.all([
this.prisma.$queryRawUnsafe(
`SELECT s.*,CASE WHEN ${healthy} THEN s.status ELSE 'stale' END status FROM "SendingMonitorSnapshot" s WHERE ${filter} ORDER BY (s.status='abnormal') DESC,s."dimensionKey" LIMIT $7 OFFSET $8`,
...args,
pageSize,
(page - 1) * pageSize,
),
this.prisma.$queryRawUnsafe<Array<{ total: number }>>(
`SELECT count(*)::int total FROM "SendingMonitorSnapshot" s WHERE ${filter}`,
...args,
),
this.prisma.$queryRawUnsafe<Array<{ data: unknown; updatedAt: Date }>>(
`SELECT data,"updatedAt" FROM "SendingMonitorCheckpoint" WHERE id='health'`,
),
]);
return { items, total: count[0].total, page, pageSize, health: checkpoint[0] ?? null };
}
async overview(query: QueryParams) {
const type = monitorType(query.type);
const rows = await this.prisma.$queryRawUnsafe(
`SELECT CASE WHEN ${healthy} THEN status ELSE 'stale' END status,count(*)::int dimensions,sum((metrics->>'total')::bigint)::text total,max("evaluationAt") "evaluationAt",max("computedAt") "computedAt"
FROM "SendingMonitorSnapshot" WHERE type=$1 AND "evaluationAt"=(SELECT max("evaluationAt") FROM "SendingMonitorSnapshot" WHERE type=$1) GROUP BY 1`,
type,
);
return { type, rows, permissions: { view: true, rules: true, targets: true } };
}
async history(query: QueryParams) {
const type = monitorType(query.type);
if (!query.dimensionId || query.dimensionId.length > 500) throw new BadRequestException('维度无效');
const hours = query.range === '24h' ? 24 : 2;
return this.prisma.$queryRawUnsafe(
`SELECT * FROM "SendingMonitorSnapshot" WHERE type=$1 AND "dimensionKey"=$2 AND "evaluationAt">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-($3::int*interval '1 hour') ORDER BY "evaluationAt" LIMIT 300`,
type,
query.dimensionId,
hours,
);
}
async targets() {
return this.prisma.$queryRawUnsafe(
`SELECT c.id,c.name,c.carrier,c.carriers,c.status,COALESCE(t.enabled,false) enabled,COALESCE(t.version,0) version,t."effectiveFrom" FROM "SmsChannel" c LEFT JOIN "SendingMonitorTarget" t ON t."channelId"=c.id WHERE c.status<>'deleted' ORDER BY c.name,c.id`,
);
}
async target(id: string, body: { enabled?: boolean; version?: number }, actor: string) {
if (!body || typeof body.enabled !== 'boolean' || !Number.isSafeInteger(body.version) || body.version! < 0)
throw new BadRequestException('监控配置参数无效');
return this.prisma.$transaction(async (tx) => {
const channel = await tx.smsChannel.findFirst({
where: { id, status: { not: 'deleted' } },
select: { id: true },
});
if (!channel) throw new NotFoundException('通道不存在');
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-target:${id}`},0))`;
const old = await tx.$queryRawUnsafe<Array<{ version: number; enabled: boolean }>>(
`SELECT * FROM "SendingMonitorTarget" WHERE "channelId"=$1`,
id,
);
if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('监控范围已被修改,请刷新');
const result = await tx.$queryRawUnsafe(
`INSERT INTO "SendingMonitorTarget" ("channelId",enabled,version,"effectiveFrom","updatedBy") VALUES($1,$2,1,to_timestamp((floor(extract(epoch FROM (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))/300)+1)*300) AT TIME ZONE 'UTC',$3)
ON CONFLICT("channelId") DO UPDATE SET enabled=EXCLUDED.enabled,version="SendingMonitorTarget".version+1,"effectiveFrom"=EXCLUDED."effectiveFrom","updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),"updatedBy"=$3 RETURNING *`,
id,
body.enabled,
actor,
);
await tx.$executeRawUnsafe(
`INSERT INTO "SendingMonitorTargetVersion" ("channelId",version,enabled,"effectiveFrom","updatedBy") SELECT "channelId",version,enabled,"effectiveFrom","updatedBy" FROM "SendingMonitorTarget" WHERE "channelId"=$1`,
id,
);
await tx.operationLog.create({
data: {
userId: actor,
action: 'sending_monitor.target',
resource: 'sending_monitor',
resourceId: id,
detail: { before: old[0] ?? null, after: { enabled: body.enabled, version: body.version! + 1 } },
},
});
if (!body.enabled)
await tx.$executeRawUnsafe(
`UPDATE "SendingMonitorAlert" SET state='closed',"closedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),"closeReason"='enrollment_removed' WHERE type='industry' AND dimensions->>'channelId'=$1 AND state='active'`,
id,
);
return result;
});
}
async rules() {
return this.prisma.$queryRawUnsafe(`SELECT * FROM "SendingMonitorRule" ORDER BY type,"scopeKey"`);
}
async options(q: QueryParams) {
const page = pageNumber(q.page, 1),
take = 20,
skip = (page - 1) * take,
keyword = q.keyword?.trim() ?? '';
if (q.kind === 'tenant')
return this.prisma.tenant.findMany({
where: { name: { contains: keyword } },
select: { id: true, name: true },
take,
skip,
orderBy: { name: 'asc' },
});
if (!q.tenantId) throw new BadRequestException('请先选择企业');
if (q.kind === 'application')
return this.prisma.smsApplication.findMany({
where: { tenantId: q.tenantId, name: { contains: keyword } },
select: { id: true, name: true },
take,
skip,
orderBy: { name: 'asc' },
});
if (q.kind === 'signature')
return this.prisma.smsSignature.findMany({
where: { tenantId: q.tenantId, applicationId: q.applicationId || undefined, name: { contains: keyword } },
select: { id: true, name: true },
take,
skip,
orderBy: { name: 'asc' },
});
throw new BadRequestException('选项类型无效');
}
async effective(query: QueryParams) {
const type = monitorType(query.type);
const rows = await this.prisma.$queryRawUnsafe<Rule[]>(
`SELECT DISTINCT ON ("ruleId") * FROM "SendingMonitorRuleVersion" WHERE type=$1 AND "effectiveAt"<=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') ORDER BY "ruleId",version DESC`,
type,
);
return matchRules(rows, type, {
tenantId: query.tenantId,
applicationId: query.applicationId,
signatureId: query.signatureId,
});
}
async saveRule(body: { type: string; scope: MonitorScope; config: RuleConfig; version: number }, actor: string) {
if (!body) throw new BadRequestException('规则参数无效');
const error = validateRule(body.type, body.scope, body.config);
if (error) throw new BadRequestException(error);
if (!Number.isInteger(body.version) || body.version < 0) throw new BadRequestException('版本无效');
const scope = Object.fromEntries(
Object.entries(body.scope)
.filter(([, v]) => Boolean(v))
.sort(),
) as MonitorScope;
if (
scope.applicationId &&
!(await this.prisma.smsApplication.findFirst({
where: { id: scope.applicationId, tenantId: scope.tenantId },
select: { id: true },
}))
)
throw new BadRequestException('应用不属于该企业');
if (
scope.signatureId &&
!(await this.prisma.smsSignature.findFirst({
where: {
id: scope.signatureId,
tenantId: scope.tenantId,
...(scope.applicationId ? { applicationId: scope.applicationId } : {}),
},
select: { id: true },
}))
)
throw new BadRequestException('签名不属于该企业或应用');
const scopeKey = JSON.stringify(scope),
type = monitorType(body.type);
return this.prisma.$transaction(async (tx) => {
await tx.$queryRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`monitor-rule:${type}:${scopeKey}`},0))`;
const old = await tx.$queryRawUnsafe<Array<{ id: string; version: number }>>(
`SELECT * FROM "SendingMonitorRule" WHERE type=$1 AND "scopeKey"=$2`,
type,
scopeKey,
);
if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('规则已被修改,请刷新后重试');
const id = old[0]?.id ?? randomUUID(),
period = type === 'overall' ? 600 : 300;
const config = {
enabled: body.config.enabled,
minSamples: body.config.minSamples,
thresholds: body.config.thresholds,
consecutiveBad: body.config.consecutiveBad,
consecutiveGood: body.config.consecutiveGood,
deleted: Boolean(body.config.deleted),
};
const result = await tx.$queryRawUnsafe(
`INSERT INTO "SendingMonitorRule" (id,type,"scopeKey",scope,config,version,"effectiveAt","updatedBy")
VALUES($1,$2,$3,$4::jsonb,$5::jsonb,$6,to_timestamp((floor(extract(epoch FROM (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))/$7::int)+1)*$7::int) AT TIME ZONE 'UTC',$8)
ON CONFLICT(type,"scopeKey") DO UPDATE SET config=EXCLUDED.config,version=EXCLUDED.version,"effectiveAt"=EXCLUDED."effectiveAt","updatedAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),"updatedBy"=$8 RETURNING *`,
id,
type,
scopeKey,
JSON.stringify(scope),
JSON.stringify(config),
body.version + 1,
period,
actor,
);
await tx.$executeRawUnsafe(
`INSERT INTO "SendingMonitorRuleVersion" ("ruleId",version,type,scope,config,"effectiveAt","createdBy") SELECT id,version,type,scope,config,"effectiveAt","updatedBy" FROM "SendingMonitorRule" WHERE id=$1`,
id,
);
await tx.operationLog.create({
data: {
userId: actor,
action: 'sending_monitor.rule',
resource: 'sending_monitor',
resourceId: id,
detail: { before: old[0] ?? null, after: { scope, config, version: body.version + 1 } },
},
});
return result;
});
}
async alerts(query: QueryParams, user: string) {
const page = pageNumber(query.page, 1),
size = pageNumber(query.pageSize, 20, 100);
const state = query.state ?? '';
if (state && !['active', 'recovered', 'closed'].includes(state)) throw new BadRequestException('告警状态无效');
const [items, total] = await Promise.all([
this.prisma.$queryRawUnsafe(
`SELECT a.*,r."readAt" IS NULL unread FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE ($2='' OR a.state=$2) ORDER BY a."openedAt" DESC,a.id LIMIT $3 OFFSET $4`,
user,
state,
size,
(page - 1) * size,
),
this.prisma.$queryRawUnsafe<Array<{ total: number }>>(
`SELECT count(*)::int total FROM "SendingMonitorAlert" WHERE ($1='' OR state=$1)`,
state,
),
]);
return { items, total: total[0].total, page, pageSize: size };
}
async summary(user: string) {
const rows = await this.prisma.$queryRawUnsafe(
`SELECT count(*) FILTER(WHERE r."readAt" IS NULL)::int count,count(*)::int "activeCount",NOT (${healthy}) unavailable FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$1 WHERE a.state='active'`,
user,
);
return (rows as object[])[0];
}
async alert(id: string, user: string) {
const rows = await this.prisma.$queryRawUnsafe<object[]>(
`SELECT a.*,r."readAt" IS NULL unread FROM "SendingMonitorAlert" a LEFT JOIN "SendingMonitorAlertRead" r ON r."alertId"=a.id AND r."userId"=$2 WHERE a.id=$1`,
id,
user,
);
if (!rows.length) throw new NotFoundException('告警不存在');
return rows[0];
}
async read(id: string, user: string) {
const rows = await this.prisma.$queryRawUnsafe<Array<{ id: string }>>(
`SELECT id FROM "SendingMonitorAlert" WHERE id=$1`,
id,
);
if (!rows.length) throw new NotFoundException('告警不存在');
await this.prisma.$executeRawUnsafe(
`INSERT INTO "SendingMonitorAlertRead" ("alertId","userId") VALUES($1,$2) ON CONFLICT DO NOTHING`,
id,
user,
);
return { success: true };
}
}
@Controller('admin/sending-monitor')
class SendingMonitorController {
constructor(private readonly service: SendingMonitorService) {}
@Get('rows') async rows(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.service.rows(q);
}
@Get('overview') async overview(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.service.overview(q);
}
@Get('history') async history(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.service.history(q);
}
@Get('targets') async targets(@Req() req: SessionRequest) {
await this.service.authorize(req);
return this.service.targets();
}
@Put('targets/:id') async target(
@Req() req: SessionRequest,
@Param('id') id: string,
@Body() body: { enabled?: boolean; version?: number },
) {
return this.service.target(id, body, await this.service.authorize(req, 'targets'));
}
@Get('rules') async rules(@Req() req: SessionRequest) {
await this.service.authorize(req);
return this.service.rules();
}
@Get('options') async options(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.service.options(q);
}
@Post('rules') async save(
@Req() req: SessionRequest,
@Body() body: { type: string; scope: MonitorScope; config: RuleConfig; version: number },
) {
return this.service.saveRule(body, await this.service.authorize(req, 'rules'));
}
@Get('effective-rule') async effective(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.service.effective(q);
}
@Get('alerts') async alerts(@Req() req: SessionRequest, @Query() q: QueryParams) {
return this.service.alerts(q, await this.service.authorize(req));
}
@Get('notification-summary') async summary(@Req() req: SessionRequest) {
return this.service.summary(await this.service.authorize(req));
}
@Get('alerts/:id') async alert(@Req() req: SessionRequest, @Param('id') id: string) {
return this.service.alert(id, await this.service.authorize(req));
}
@Post('alerts/:id/read') async read(@Req() req: SessionRequest, @Param('id') id: string) {
return this.service.read(id, await this.service.authorize(req));
}
}
@Module({ imports: [PrismaModule], controllers: [SendingMonitorController], providers: [SendingMonitorService] })
export class SendingMonitorModule {}