feat: 优化整体兜底个性化规则管理交互
CSS quality / css-quality (push) Has been cancelled

This commit is contained in:
hectorzhao
2026-09-06 22:16:09 +08:00
parent 0e3424c4a7
commit e4f93f7193
16 changed files with 1580 additions and 4 deletions
@@ -0,0 +1,114 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { matchRules, type MonitorScope, type Rule } from './monitor-metrics';
type Query = Record<string, string | undefined>;
const names = `jsonb_build_object('tenantName',t.name,'applicationName',a.name,'signatureName',s.name,
'tenantStatus',t.status,'applicationStatus',a.status,'signatureStatus',s."auditStatus")`;
const joins = `LEFT JOIN "Tenant" t ON t.id=r.scope->>'tenantId'
LEFT JOIN "SmsApplication" a ON a.id=r.scope->>'applicationId'
LEFT JOIN "SmsSignature" s ON s.id=r.scope->>'signatureId'`;
const pageOf = (value = '1') => {
const page = Number(value);
if (!Number.isSafeInteger(page) || page < 1 || page > 100000) throw new BadRequestException('分页参数无效');
return page;
};
export function managementScope(q: Query): MonitorScope {
const scope = Object.fromEntries(
['tenantId', 'applicationId', 'signatureId'].filter((key) => q[key]).map((key) => [key, q[key]]),
);
if (Object.values(scope).some((v) => typeof v !== 'string' || !v.trim() || v.length > 200))
throw new BadRequestException('规则范围无效');
if (Object.keys(scope).length && (!scope.tenantId || (!scope.applicationId && !scope.signatureId)))
throw new BadRequestException('请选择企业应用或签名');
return scope;
}
@Injectable()
export class MonitorRuleManagementService {
constructor(private readonly prisma: PrismaService) {}
async list(q: Query) {
const page = pageOf(q.page),
keyword = q.keyword?.trim() ?? '',
kind = q.kind ?? '';
if (!['', 'application', 'signature', 'combined'].includes(kind)) throw new BadRequestException('覆盖类型无效');
const where = `r.type='overall' AND r.scope<>'{}'::jsonb
AND (COALESCE((r.config->>'deleted')::boolean,false)=false OR r."effectiveAt">(CURRENT_TIMESTAMP AT TIME ZONE 'UTC'))
AND ($1='' OR concat_ws(' ',t.name,a.name,s.name) ILIKE '%'||$1||'%')
AND ($2='' OR CASE WHEN r.scope ? 'applicationId' AND r.scope ? 'signatureId' THEN 'combined'
WHEN r.scope ? 'signatureId' THEN 'signature' ELSE 'application' END=$2)`;
return this.prisma.$transaction(
async (tx) => {
const items = await tx.$queryRawUnsafe(
`SELECT r.*,${names} AS names,
(SELECT to_jsonb(v) FROM "SendingMonitorRuleVersion" v WHERE v."ruleId"=r.id
AND v."effectiveAt"<=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') ORDER BY version DESC LIMIT 1) AS active
FROM "SendingMonitorRule" r ${joins} WHERE ${where} ORDER BY r."updatedAt" DESC,r.id LIMIT 20 OFFSET $3`,
keyword,
kind,
(page - 1) * 20,
);
const count = await tx.$queryRawUnsafe<Array<{ total: number }>>(
`SELECT count(*)::int total FROM "SendingMonitorRule" r ${joins} WHERE ${where}`,
keyword,
kind,
);
return { items, total: count[0].total, page, pageSize: 20, serverTime: new Date().toISOString() };
},
{ isolationLevel: 'RepeatableRead' },
);
}
async options(q: Query) {
const page = pageOf(q.page),
keyword = q.keyword?.trim() ?? '';
let from: string, fields: string, restriction: string;
if (q.kind === 'tenant') {
from = '"Tenant" t';
fields = `t.id,t.name,t.id AS "tenantId"`;
restriction = `t.status<>'deleted' AND ($1='' OR t.name ILIKE '%'||$1||'%') AND $2::text IS NOT NULL AND $3::text IS NOT NULL`;
} else if (q.kind === 'application') {
from = '"SmsApplication" a JOIN "Tenant" t ON t.id=a."tenantId"';
fields = `a.id,concat_ws(' · ',t.name,a.name) AS name,t.id AS "tenantId"`;
restriction = `a.status<>'deleted' AND t.status<>'deleted' AND ($1='' OR concat_ws(' ',t.name,a.name) ILIKE '%'||$1||'%') AND ($2='' OR t.id=$2) AND $3::text IS NOT NULL`;
} else if (q.kind === 'signature' && q.tenantId) {
from =
'"SmsSignature" s JOIN "Tenant" t ON t.id=s."tenantId" LEFT JOIN "SmsApplication" a ON a.id=s."applicationId"';
fields = `s.id,concat_ws(' · ',s.name,a.name) AS name,t.id AS "tenantId"`;
restriction = `s."auditStatus"<>'deleted' AND t.status<>'deleted' AND (a.id IS NULL OR a.status<>'deleted') AND ($1='' OR s.name ILIKE '%'||$1||'%') AND t.id=$2 AND ($3='' OR s."applicationId"=$3)`;
} else throw new BadRequestException('选项类型无效或尚未选择企业');
const items = await this.prisma.$queryRawUnsafe<Array<{ id: string; name: string; tenantId: string }>>(
`SELECT ${fields} FROM ${from} WHERE ${restriction} ORDER BY name,id LIMIT 21 OFFSET $4`,
keyword,
q.tenantId ?? '',
q.applicationId ?? '',
(page - 1) * 20,
);
return { items: items.slice(0, 20), page, hasMore: items.length > 20 };
}
async editor(q: Query) {
const scope = managementScope(q),
scopeKey = JSON.stringify(Object.fromEntries(Object.entries(scope).sort()));
return this.prisma.$transaction(
async (tx) => {
const current = await tx.$queryRawUnsafe<Array<Rule & { id: string }>>(
`SELECT r.*,${names} AS names FROM "SendingMonitorRule" r ${joins} WHERE r.type='overall' AND r."scopeKey"=$1`,
scopeKey,
);
const versions = await tx.$queryRawUnsafe<Rule[]>(`SELECT r.*,${names} AS names FROM
(SELECT DISTINCT ON ("ruleId") * FROM "SendingMonitorRuleVersion" WHERE type='overall'
AND "effectiveAt"<=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') ORDER BY "ruleId",version DESC) r ${joins}`);
const matched = matchRules(versions, 'overall', scope);
return {
current: current[0] ?? null,
matched,
inherited: matched.filter((r) => r.ruleId !== current[0]?.id),
serverTime: new Date().toISOString(),
};
},
{ isolationLevel: 'RepeatableRead' },
);
}
}
@@ -18,6 +18,7 @@ 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 { MonitorRuleManagementService } from './monitor-rule-management.service';
import {
horizons,
matchRules,
@@ -217,19 +218,31 @@ export class SendingMonitorService {
.sort(),
) as MonitorScope;
if (
!body.config.deleted &&
scope.tenantId &&
!(await this.prisma.tenant.findFirst({
where: { id: scope.tenantId, status: { not: 'deleted' } },
select: { id: true },
}))
)
throw new BadRequestException('企业不存在或已删除');
if (
!body.config.deleted &&
scope.applicationId &&
!(await this.prisma.smsApplication.findFirst({
where: { id: scope.applicationId, tenantId: scope.tenantId },
where: { id: scope.applicationId, tenantId: scope.tenantId, status: { not: 'deleted' } },
select: { id: true },
}))
)
throw new BadRequestException('应用不属于该企业');
if (
!body.config.deleted &&
scope.signatureId &&
!(await this.prisma.smsSignature.findFirst({
where: {
id: scope.signatureId,
tenantId: scope.tenantId,
auditStatus: { not: 'deleted' },
...(scope.applicationId ? { applicationId: scope.applicationId } : {}),
},
select: { id: true },
@@ -245,6 +258,7 @@ export class SendingMonitorService {
type,
scopeKey,
);
if (body.config.deleted && !old[0]) throw new NotFoundException('要恢复继承的规则不存在');
if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('规则已被修改,请刷新后重试');
const id = old[0]?.id ?? randomUUID(),
period = type === 'overall' ? 600 : 300;
@@ -285,6 +299,14 @@ export class SendingMonitorService {
return result;
});
}
async restoreRule(id: string, version: number, actor: string) {
const rows = await this.prisma.$queryRawUnsafe<Array<{ type: string; scope: MonitorScope; config: RuleConfig }>>(
`SELECT type,scope,config FROM "SendingMonitorRule" WHERE id=$1 AND type='overall' AND scope<>'{}'::jsonb`,
id,
);
if (!rows[0]) throw new NotFoundException('个性化规则不存在');
return this.saveRule({ ...rows[0], config: { ...rows[0].config, deleted: true }, version }, actor);
}
async alerts(query: QueryParams, user: string) {
const page = pageNumber(query.page, 1),
size = pageNumber(query.pageSize, 20, 100);
@@ -338,7 +360,29 @@ export class SendingMonitorService {
@Controller('admin/sending-monitor')
class SendingMonitorController {
constructor(private readonly service: SendingMonitorService) {}
constructor(
private readonly service: SendingMonitorService,
private readonly management: MonitorRuleManagementService,
) {}
@Get('rule-management') async managementList(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.management.list(q);
}
@Get('rule-options') async ruleOptions(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.management.options(q);
}
@Get('rule-editor') async ruleEditor(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.management.editor(q);
}
@Post('rules/:id/restore') async restore(
@Req() req: SessionRequest,
@Param('id') id: string,
@Body() body: { version?: number },
) {
return this.service.restoreRule(id, body?.version as number, await this.service.authorize(req, 'rules'));
}
@Get('rows') async rows(@Req() req: SessionRequest, @Query() q: QueryParams) {
await this.service.authorize(req);
return this.service.rows(q);
@@ -393,5 +437,9 @@ class SendingMonitorController {
return this.service.read(id, await this.service.authorize(req));
}
}
@Module({ imports: [PrismaModule], controllers: [SendingMonitorController], providers: [SendingMonitorService] })
@Module({
imports: [PrismaModule],
controllers: [SendingMonitorController],
providers: [SendingMonitorService, MonitorRuleManagementService],
})
export class SendingMonitorModule {}