398 lines
17 KiB
TypeScript
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 {}
|