This commit is contained in:
@@ -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 { PrismaService } from '../prisma/prisma.service';
|
||||||
import { PrismaModule } from '../prisma/prisma.module';
|
import { PrismaModule } from '../prisma/prisma.module';
|
||||||
import type { SessionRequest } from '../auth/session-validation.middleware';
|
import type { SessionRequest } from '../auth/session-validation.middleware';
|
||||||
|
import { MonitorRuleManagementService } from './monitor-rule-management.service';
|
||||||
import {
|
import {
|
||||||
horizons,
|
horizons,
|
||||||
matchRules,
|
matchRules,
|
||||||
@@ -217,19 +218,31 @@ export class SendingMonitorService {
|
|||||||
.sort(),
|
.sort(),
|
||||||
) as MonitorScope;
|
) as MonitorScope;
|
||||||
if (
|
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 &&
|
scope.applicationId &&
|
||||||
!(await this.prisma.smsApplication.findFirst({
|
!(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 },
|
select: { id: true },
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
throw new BadRequestException('应用不属于该企业');
|
throw new BadRequestException('应用不属于该企业');
|
||||||
if (
|
if (
|
||||||
|
!body.config.deleted &&
|
||||||
scope.signatureId &&
|
scope.signatureId &&
|
||||||
!(await this.prisma.smsSignature.findFirst({
|
!(await this.prisma.smsSignature.findFirst({
|
||||||
where: {
|
where: {
|
||||||
id: scope.signatureId,
|
id: scope.signatureId,
|
||||||
tenantId: scope.tenantId,
|
tenantId: scope.tenantId,
|
||||||
|
auditStatus: { not: 'deleted' },
|
||||||
...(scope.applicationId ? { applicationId: scope.applicationId } : {}),
|
...(scope.applicationId ? { applicationId: scope.applicationId } : {}),
|
||||||
},
|
},
|
||||||
select: { id: true },
|
select: { id: true },
|
||||||
@@ -245,6 +258,7 @@ export class SendingMonitorService {
|
|||||||
type,
|
type,
|
||||||
scopeKey,
|
scopeKey,
|
||||||
);
|
);
|
||||||
|
if (body.config.deleted && !old[0]) throw new NotFoundException('要恢复继承的规则不存在');
|
||||||
if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('规则已被修改,请刷新后重试');
|
if ((old[0]?.version ?? 0) !== body.version) throw new ConflictException('规则已被修改,请刷新后重试');
|
||||||
const id = old[0]?.id ?? randomUUID(),
|
const id = old[0]?.id ?? randomUUID(),
|
||||||
period = type === 'overall' ? 600 : 300;
|
period = type === 'overall' ? 600 : 300;
|
||||||
@@ -285,6 +299,14 @@ export class SendingMonitorService {
|
|||||||
return result;
|
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) {
|
async alerts(query: QueryParams, user: string) {
|
||||||
const page = pageNumber(query.page, 1),
|
const page = pageNumber(query.page, 1),
|
||||||
size = pageNumber(query.pageSize, 20, 100);
|
size = pageNumber(query.pageSize, 20, 100);
|
||||||
@@ -338,7 +360,29 @@ export class SendingMonitorService {
|
|||||||
|
|
||||||
@Controller('admin/sending-monitor')
|
@Controller('admin/sending-monitor')
|
||||||
class SendingMonitorController {
|
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) {
|
@Get('rows') async rows(@Req() req: SessionRequest, @Query() q: QueryParams) {
|
||||||
await this.service.authorize(req);
|
await this.service.authorize(req);
|
||||||
return this.service.rows(q);
|
return this.service.rows(q);
|
||||||
@@ -393,5 +437,9 @@ class SendingMonitorController {
|
|||||||
return this.service.read(id, await this.service.authorize(req));
|
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 {}
|
export class SendingMonitorModule {}
|
||||||
|
|||||||
@@ -311,3 +311,15 @@ R_h = S_h / N_h × 100% # N_h=0时为null
|
|||||||
事实保留期本次采用72小时(第8节2小时是原建议),以保证迟到修改能替换原贡献、不会在删除事实后把旧桶重建成局部样本;分钟7天、快照30天、关闭告警90天。新增表不存短信正文/手机号。需要以真实峰值容量预算再优化事实压缩/冷热分层;当前不得据此宣称500TPS下预算达标。真实短信压测未获授权,只使用隔离数据库样本验证统计和查询成本。
|
事实保留期本次采用72小时(第8节2小时是原建议),以保证迟到修改能替换原贡献、不会在删除事实后把旧桶重建成局部样本;分钟7天、快照30天、关闭告警90天。新增表不存短信正文/手机号。需要以真实峰值容量预算再优化事实压缩/冷热分层;当前不得据此宣称500TPS下预算达标。真实短信压测未获授权,只使用隔离数据库样本验证统计和查询成本。
|
||||||
|
|
||||||
纳管范围使用不可变版本,按窗口评估时刻解析;以后移除/重新加入不改写旧统计。当前API规则按type+scope通过POST完整保存(version乐观锁),不另外提供重复的PUT更新入口。新Worker健康异常通过发送监控数据延迟和预警中心不可用说明展示;监控性能指标接Prometheus和真实峰值对照尚未验收。
|
纳管范围使用不可变版本,按窗口评估时刻解析;以后移除/重新加入不改写旧统计。当前API规则按type+scope通过POST完整保存(version乐观锁),不另外提供重复的PUT更新入口。新Worker健康异常通过发送监控数据延迟和预警中心不可用说明展示;监控性能指标接Prometheus和真实峰值对照尚未验收。
|
||||||
|
|
||||||
|
## 13. 整体兜底规则管理交互改造(2026-09-06)
|
||||||
|
|
||||||
|
状态:用户已授权实施、提交、推送并分别发布测试和预生产。替代第5.3节配置操作方式,统计口径、整套覆盖顺序和下一周期生效规则保持。
|
||||||
|
|
||||||
|
- 阈值设置分“通用规则”和“个性化规则”页签。后者为可搜索、分页的规则列表,展示企业/应用/签名名称、覆盖类型、阈值摘要、状态、生效时间,提供新增覆盖、编辑和恢复继承;不以内部ID作为主要展示。
|
||||||
|
- 新增覆盖默认应用级;支持应用、签名、应用×签名。应用选择器直接搜索“企业·应用”;签名级先选企业,组合级签名限定该应用。搜索和分页放入对应下拉框,选中持续显示名称;上级变化清空下级,未保存变更先确认。
|
||||||
|
- 编辑器与列表在同一弹窗切换,已有规则范围锁定;新增重复范围提示编辑已有规则。新规则按实际继承来源预填整套配置,没有来源则保持空值,不从原型填阈值。异步加载失败阻止提交、提供重试;迟到请求不能覆盖已修改草稿。
|
||||||
|
- 表单分适用范围、告警阈值、触发与恢复三组;保存按钮防重、字段就近校验、失败保留草稿、409允许显式重新加载。保存成功返回列表并保留筛选,显示真实版本及下一周期生效时间。切换页签、返回列表、关闭和刷新保护未保存内容。
|
||||||
|
- 停用仍保留该层覆盖;恢复继承移除该层覆盖,使用数据库已保存配置和version,不受编辑草稿必填校验干扰。确认说明剩余规则匹配顺序,具体应用×签名可查看准确匹配链;没有下层规则明确不告警,更具体范围仍按自身规则匹配。恢复仍通过版本记录/审计事务生效,不物理删除历史。
|
||||||
|
- 新增受既有管理员授权保护的规则管理分页、对象远程搜索、编辑上下文和恢复继承API;保留原rules/options/effective-rule接口兼容其他使用者。返回名称及对象状态、当前生效版本/待生效版本/服务器时间;历史已删除对象保留名称或明确占位,不进入新增选项。使用参数绑定、分页上限和确定性排序;新增查询不进入发送热路径,不改变表结构、Redis或Worker计算。
|
||||||
|
- 样式归sending-monitor组件目录,由所有者import,不修改历史CSS模块或公共Select。验收覆盖真实API/PG持久化、三种范围及跨企业校验、继承/停用差异、重复/并发409、异步及失败、三尺寸和历史对象;发布仅重建前后端并重启API,其他服务按实际兼容检查保留。預生产缺少有效登录入口时如实保留登录后验收缺口,不擅自新增账号。
|
||||||
|
|||||||
@@ -5178,3 +5178,12 @@ npm run verify:phase8
|
|||||||
- SMR-027:运行概况三尺寸首次进入、刷新和跨tab切换,三个最近列表均不存在;真实GET /admin/operations/monitor仅含byStatus,状态卡片/通道表/基础设施入口可用,独立记录页面保留。
|
- SMR-027:运行概况三尺寸首次进入、刷新和跨tab切换,三个最近列表均不存在;真实GET /admin/operations/monitor仅含byStatus,状态卡片/通道表/基础设施入口可用,独立记录页面保留。
|
||||||
- SMR-028:真实Prisma加入、移除、重新加入及规则保存,版本和审计持久化;同版本并发仅一次成功,另一次409;不存在通道404,非法enabled/version400;审计失败回滚当前行和版本行。真实PG隔离schema验证,不用mock掩盖void返回类型。
|
- SMR-028:真实Prisma加入、移除、重新加入及规则保存,版本和审计持久化;同版本并发仅一次成功,另一次409;不存在通道404,非法enabled/version400;审计失败回滚当前行和版本行。真实PG隔离schema验证,不用mock掩盖void返回类型。
|
||||||
- SMR-029:测试环境人工生成并标注“测试演示”的三类监控已恢复/已关闭告警,列表、筛选、详情和历史曲线来自真实API/PG;演示阈值仅存在历史快照,不写业务规则或发送短信,不能用该数据证明自动告警链路或真实投递成功。
|
- SMR-029:测试环境人工生成并标注“测试演示”的三类监控已恢复/已关闭告警,列表、筛选、详情和历史曲线来自真实API/PG;演示阈值仅存在历史快照,不写业务规则或发送短信,不能用该数据证明自动告警链路或真实投递成功。
|
||||||
|
|
||||||
|
### SMR-030~035 整体兜底规则管理(2026-09-06)
|
||||||
|
|
||||||
|
- SMR-030:通用/个性化页签、名称列表、搜索/类型筛选/分页,已配置及空态、失败重试;实际接口有界分页,不能用ID代替名称或失败显示空列表。
|
||||||
|
- SMR-031:新增应用、签名、应用×签名三种覆盖;企业应用可按企业/应用名远程搜索,签名限定企业/应用;选中名称持续显示,上级变化清除下级,不可跨企业保存。已删除对象不进新增选项,历史覆盖仍显示名称与状态。
|
||||||
|
- SMR-032:已有范围提示编辑已有规则,编辑锁定范围;新增预填整套继承值、无来源为空;异步加载期间不可保存,迟到响应不覆盖草稿;字段校验、保存失败、409、未保存离开保护正常。
|
||||||
|
- SMR-033:列表与编辑器区分当前生效版本和下一周期待生效版本,展示真实生效时间;应用×签名>签名>应用>通用,停用保留覆盖,不等于恢复继承。
|
||||||
|
- SMR-034:恢复继承使用数据库保存配置与version,说明无下层规则/更具体规则的影响;新版本、历史版本与审计原子提交,并发同版本仅一次成功;审计失败完整回滚,历史对象删除后仍可移除覆盖。
|
||||||
|
- SMR-035:1600×1000、1366×768、390×844检查列表、新增、编辑、搜索下拉及恢复确认;保存后回列表保留筛选,单层弹窗Footer可见,表格内部滚动;真实API/PG验收与组件测试分别记录,不发送短信。
|
||||||
|
|||||||
@@ -4657,3 +4657,13 @@ git diff --check
|
|||||||
- 告警真实API总数6,恢复/关闭筛选各3;三尺寸列表和详情、6点历史曲线通过,console error/pageerror均0,无整页横向溢出。截图复核监控通道窄屏及告警详情清晰可操作。人工造数只证明真实API/数据库展示,不冒充自然产生的监控告警或真实投递验收。动态业务/规则/余额/通道表摘要造数前后一致,短信仍119509;未发送、补发、重投、重新入队短信。
|
- 告警真实API总数6,恢复/关闭筛选各3;三尺寸列表和详情、6点历史曲线通过,console error/pageerror均0,无整页横向溢出。截图复核监控通道窄屏及告警详情清晰可操作。人工造数只证明真实API/数据库展示,不冒充自然产生的监控告警或真实投递验收。动态业务/规则/余额/通道表摘要造数前后一致,短信仍119509;未发送、补发、重投、重新入队短信。
|
||||||
- 最终前端100项、API639项、真实Prisma配置12项、类型/构建/格式/ESLint/结构/安全/部署门禁通过;未修改CSS或Gateway,不重跑无关Go链路测试。线上主JS index-UxUuzIeW.js摘要3da1b28cfa2997d2d93e374fdd5a575ed629814d6ce33a32bd7b1d0f2b68329f,主CSS仍index-Oaoh-Ppy.css,HTTP下载与运行产物一致。队列三个Stream pending/lag均0,last-delivered和entries-read与上一轮基线相同;监控水位complete=true、pendingReceipts0。
|
- 最终前端100项、API639项、真实Prisma配置12项、类型/构建/格式/ESLint/结构/安全/部署门禁通过;未修改CSS或Gateway,不重跑无关Go链路测试。线上主JS index-UxUuzIeW.js摘要3da1b28cfa2997d2d93e374fdd5a575ed629814d6ce33a32bd7b1d0f2b68329f,主CSS仍index-Oaoh-Ppy.css,HTTP下载与运行产物一致。队列三个Stream pending/lag均0,last-delivered和entries-read与上一轮基线相同;监控水位complete=true、pendingReceipts0。
|
||||||
- 证据目录:本机%TEMP%/cmpp-monitor-fix-20260906的before.json、after.json、runtime/joined/alerts/alert-detail三尺寸截图、前后端测试/构建/质量日志及受限测试脚本;脚本内无认证秘密,持久管理员正常退出后保留。原规则空态正常不等于配置保存正常,本次以真实服务写入/并发/回滚验证补足覆盖。未执行短信发送、真实高峰压测或预生产验收。
|
- 证据目录:本机%TEMP%/cmpp-monitor-fix-20260906的before.json、after.json、runtime/joined/alerts/alert-detail三尺寸截图、前后端测试/构建/质量日志及受限测试脚本;脚本内无认证秘密,持久管理员正常退出后保留。原规则空态正常不等于配置保存正常,本次以真实服务写入/并发/回滚验证补足覆盖。未执行短信发送、真实高峰压测或预生产验收。
|
||||||
|
|
||||||
|
## 2026-09-06 整体兜底规则管理改造(发布前)
|
||||||
|
|
||||||
|
- 用户授权按已确认方案实施、提交、推送及测试/预生产部署。起始main/HEAD/实时origin/main及两环境标记均0e3424c,分叉0/0;8份已有修改文档及3份未跟踪草稿保护。本轮文档只提交精确追加段落,不夹带原修改或上轮未提交发布记录。
|
||||||
|
- 只读证据:同版本测试页面选择企业后搜索结果仍为“请选择”,已选仅显示内部ID;通用、新增、编辑和继承混在同一表单。风控规则提供独立列表/新增应用覆盖及“企业·应用”选择,真实GET均200,测试规则0。原型和说明见sending-monitor-redesign-plan-20260906.md第13节;截图/请求在本机%TEMP%/cmpp-monitor-rule-review-20260906。
|
||||||
|
- 实现:整体兜底独立规则管理,通用/个性化页签、名称及阈值列表、搜索分页、新增三种覆盖、对象联动、重复检测与锁定编辑、继承预填、当前/待生效区分、字段校验、草稿保护和保存回列表。后端新增名称/对象分页/编辑上下文接口与恢复继承入口,沿用管理员权限、乐观锁、整套覆盖及周期版本。历史已删除企业/应用/签名不可新增配置,可恢复继承;签名软删除字段为auditStatus。无数据库迁移、Gateway/Worker计算或队列契约变更。
|
||||||
|
- 样式仅在MonitorRuleManager.css,由同目录所有者直接import,登记严格roots;未改公共Select、历史CSS模块或Stylelint例外。门禁首次提示新文件未登记所有者,补充css-ownership.json后通过,不重算历史摘要。
|
||||||
|
- 本地前端21套105项、API60套639项、前后端TypeScript/生产构建、增量格式/ESLint、Stylelint、CSS治理及其用例、安全/部署/包体门禁通过。首次辅助函数拆分漏导入及测试定位类型错误经类型/全量检查发现并修复,最终全量重跑通过。
|
||||||
|
- 测试机真实Prisma/service隔离schema15项通过:跨企业名称搜索、20+3分页、签名联动、非法查询、三种范围、重复范围版本、当前/待生效、继承优先级、停用保留覆盖、恢复并发409、软删除历史、审计失败回滚。使用全新隔离Tenant/Application/Signature样本,public业务写入0,结束精确删除自己的schema;未发送短信。脚本tools/testing/verify-monitor-rule-management-postgres.mjs可重复执行。
|
||||||
|
- 本机证据目录%TEMP%/cmpp-rule-manager-20260906保存原修改备份、前后端测试/构建/门禁日志。真实登录后页面及两环境发布尚待执行,不能用上述单测和隔离集成结论代替;预生产仍需现有有效管理员安全入口,不擅自重建或重置账号。
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
import { Button, Input, Modal, Select, Tag } from '@/components/ui';
|
||||||
|
import { MonitorRuleManager } from './MonitorRuleManager';
|
||||||
import {
|
import {
|
||||||
monitorApi,
|
monitorApi,
|
||||||
names,
|
names,
|
||||||
@@ -26,7 +27,7 @@ export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
monitorApi
|
monitorApi
|
||||||
.options(kind, scope, keyword, page)
|
.options(kind, { tenantId: scope.tenantId, applicationId: scope.applicationId }, keyword, page)
|
||||||
.then((items) => {
|
.then((items) => {
|
||||||
if (live) {
|
if (live) {
|
||||||
setOptions(items);
|
setOptions(items);
|
||||||
@@ -109,6 +110,14 @@ export function MonitorScopePicker({ scope, onChange }: { scope: Scope; onChange
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) {
|
export function MonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) {
|
||||||
|
return type === 'overall' ? (
|
||||||
|
<MonitorRuleManager onClose={onClose} />
|
||||||
|
) : (
|
||||||
|
<CommonMonitorRulesModal type={type} onClose={onClose} />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CommonMonitorRulesModal({ type, onClose }: { type: MonitorType; onClose: () => void }) {
|
||||||
const dirtyRef = useRef(false);
|
const dirtyRef = useRef(false);
|
||||||
const [rules, setRules] = useState<Rule[]>([]),
|
const [rules, setRules] = useState<Rule[]>([]),
|
||||||
[error, setError] = useState(''),
|
[error, setError] = useState(''),
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { useEffect, useId, useRef, useState } from 'react';
|
||||||
|
import { Button, Input } from '@/components/ui';
|
||||||
|
import { ruleManagementApi, type RuleOption } from './monitorApi';
|
||||||
|
|
||||||
|
export function MonitorObjectSelect({
|
||||||
|
label,
|
||||||
|
kind,
|
||||||
|
value,
|
||||||
|
tenantId,
|
||||||
|
applicationId,
|
||||||
|
onChange,
|
||||||
|
disabled = false,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
kind: 'tenant' | 'application' | 'signature';
|
||||||
|
value?: RuleOption;
|
||||||
|
tenantId?: string;
|
||||||
|
applicationId?: string;
|
||||||
|
disabled?: boolean;
|
||||||
|
onChange: (value: RuleOption) => void;
|
||||||
|
}) {
|
||||||
|
const id = useId(),
|
||||||
|
root = useRef<HTMLDivElement>(null);
|
||||||
|
const [open, setOpen] = useState(false),
|
||||||
|
[keyword, setKeyword] = useState(''),
|
||||||
|
[page, setPage] = useState(1);
|
||||||
|
const [items, setItems] = useState<RuleOption[]>([]),
|
||||||
|
[more, setMore] = useState(false),
|
||||||
|
[loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState(''),
|
||||||
|
[retry, setRetry] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setItems([]);
|
||||||
|
setError('');
|
||||||
|
void ruleManagementApi
|
||||||
|
.options({ kind, keyword, page, tenantId, applicationId }, controller.signal)
|
||||||
|
.then((result) => {
|
||||||
|
if (!controller.signal.aborted) {
|
||||||
|
setItems(result.items);
|
||||||
|
setMore(result.hasMore);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!controller.signal.aborted) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
controller.abort();
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [open, kind, keyword, page, tenantId, applicationId, retry]);
|
||||||
|
useEffect(() => {
|
||||||
|
const outside = (e: PointerEvent) => {
|
||||||
|
if (!root.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', outside);
|
||||||
|
return () => document.removeEventListener('pointerdown', outside);
|
||||||
|
}, []);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="monitor-rules__picker"
|
||||||
|
ref={root}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Escape' && open) {
|
||||||
|
e.stopPropagation();
|
||||||
|
setOpen(false);
|
||||||
|
root.current?.querySelector<HTMLButtonElement>('[aria-haspopup]')?.focus();
|
||||||
|
}
|
||||||
|
if (['ArrowDown', 'ArrowUp'].includes(e.key) && open) {
|
||||||
|
e.preventDefault();
|
||||||
|
const options = [...(root.current?.querySelectorAll<HTMLButtonElement>('[role="option"]') ?? [])];
|
||||||
|
const index = options.indexOf(document.activeElement as HTMLButtonElement);
|
||||||
|
options[(index + (e.key === 'ArrowDown' ? 1 : -1) + options.length) % options.length]?.focus();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span id={id}>{label}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="monitor-rules__select"
|
||||||
|
aria-labelledby={id}
|
||||||
|
aria-haspopup="listbox"
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={disabled}
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
setOpen(!open);
|
||||||
|
setKeyword('');
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{value?.name ?? `请选择${label}`}
|
||||||
|
<span aria-hidden>⌄</span>
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="monitor-rules__dropdown">
|
||||||
|
<Input
|
||||||
|
autoFocus
|
||||||
|
aria-label={`搜索${label}`}
|
||||||
|
placeholder={`搜索${label}名称`}
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setLoading(true);
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{loading ? (
|
||||||
|
<p role="status">加载中…</p>
|
||||||
|
) : error ? (
|
||||||
|
<div role="alert">
|
||||||
|
{error}
|
||||||
|
<Button type="button" size="sm" onClick={() => setRetry(retry + 1)}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div role="listbox" aria-label={`${label}搜索结果`} className="monitor-rules__options">
|
||||||
|
{items.map((item) => (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={item.id === value?.id}
|
||||||
|
onClick={() => {
|
||||||
|
onChange(item);
|
||||||
|
setOpen(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!items.length && <p>没有匹配的{label}</p>}
|
||||||
|
<div className="monitor-rules__actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={page === 1}
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
setPage(page - 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
上一页
|
||||||
|
</Button>
|
||||||
|
<span>第 {page} 页</span>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
disabled={!more}
|
||||||
|
onClick={() => {
|
||||||
|
setLoading(true);
|
||||||
|
setPage(page + 1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
下一页
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,313 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { Button, Modal, Select, Tag } from '@/components/ui';
|
||||||
|
import { MonitorObjectSelect } from './MonitorObjectSelect';
|
||||||
|
import { MonitorRuleForm } from './MonitorRuleForm';
|
||||||
|
import { draftErrors, ruleDraft } from './monitorRuleDraft';
|
||||||
|
import {
|
||||||
|
monitorApi,
|
||||||
|
ruleManagementApi,
|
||||||
|
ruleSource,
|
||||||
|
scopeTitle,
|
||||||
|
time,
|
||||||
|
type ManagedRule,
|
||||||
|
type RuleContext,
|
||||||
|
type RuleOption,
|
||||||
|
type Scope,
|
||||||
|
} from './monitorApi';
|
||||||
|
|
||||||
|
export function MonitorRuleEditor({
|
||||||
|
initial,
|
||||||
|
common = false,
|
||||||
|
onClose,
|
||||||
|
onBack,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
initial?: ManagedRule;
|
||||||
|
common?: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onBack: () => void;
|
||||||
|
onSaved: (rule: ManagedRule) => void;
|
||||||
|
}) {
|
||||||
|
const [scope, setScope] = useState<Scope>(initial?.scope ?? {});
|
||||||
|
const [kind, setKind] = useState(
|
||||||
|
initial?.scope.signatureId ? (initial.scope.applicationId ? 'combined' : 'signature') : 'application',
|
||||||
|
);
|
||||||
|
const [tenant, setTenant] = useState<RuleOption>(),
|
||||||
|
[application, setApplication] = useState<RuleOption>(),
|
||||||
|
[signature, setSignature] = useState<RuleOption>();
|
||||||
|
const [editing, setEditing] = useState(Boolean(initial));
|
||||||
|
const [context, setContext] = useState<RuleContext>(),
|
||||||
|
[draft, setDraft] = useState(ruleDraft());
|
||||||
|
const [error, setError] = useState(''),
|
||||||
|
[errors, setErrors] = useState<Record<string, string>>({});
|
||||||
|
const [busy, setBusy] = useState(false),
|
||||||
|
[loading, setLoading] = useState(false),
|
||||||
|
[dirty, setDirty] = useState(false),
|
||||||
|
[retry, setRetry] = useState(0);
|
||||||
|
const saving = useRef(false);
|
||||||
|
const complete =
|
||||||
|
common ||
|
||||||
|
Boolean(
|
||||||
|
scope.tenantId &&
|
||||||
|
(kind === 'application'
|
||||||
|
? scope.applicationId
|
||||||
|
: kind === 'signature'
|
||||||
|
? scope.signatureId
|
||||||
|
: scope.applicationId && scope.signatureId),
|
||||||
|
);
|
||||||
|
const scopeKey = JSON.stringify(scope);
|
||||||
|
useEffect(() => {
|
||||||
|
setContext(undefined);
|
||||||
|
setError('');
|
||||||
|
setErrors({});
|
||||||
|
if (!complete) {
|
||||||
|
setLoading(false);
|
||||||
|
setDraft(ruleDraft());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const controller = new AbortController();
|
||||||
|
setLoading(true);
|
||||||
|
void ruleManagementApi
|
||||||
|
.editor(JSON.parse(scopeKey) as Scope, controller.signal)
|
||||||
|
.then((result) => {
|
||||||
|
if (controller.signal.aborted) return;
|
||||||
|
setContext(result);
|
||||||
|
setDraft(
|
||||||
|
ruleDraft(
|
||||||
|
result.current && !result.current.config.deleted ? result.current.config : result.inherited[0]?.config,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
setDirty(false);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!controller.signal.aborted) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!controller.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [scopeKey, complete, retry]);
|
||||||
|
useEffect(() => {
|
||||||
|
const before = (event: BeforeUnloadEvent) => {
|
||||||
|
if (dirty) event.preventDefault();
|
||||||
|
};
|
||||||
|
window.addEventListener('beforeunload', before);
|
||||||
|
return () => window.removeEventListener('beforeunload', before);
|
||||||
|
}, [dirty]);
|
||||||
|
const leave = (action: () => void) => {
|
||||||
|
if (!saving.current && (!dirty || window.confirm('有未保存的规则,确认放弃修改?'))) action();
|
||||||
|
};
|
||||||
|
const changeScope = (next: Scope, action: () => void) =>
|
||||||
|
leave(() => {
|
||||||
|
setScope(next);
|
||||||
|
setDirty(false);
|
||||||
|
action();
|
||||||
|
});
|
||||||
|
const duplicate = !common && !editing && context?.current && !context.current.config.deleted;
|
||||||
|
const selected = initial ?? context?.current;
|
||||||
|
const invalidObject = Boolean(
|
||||||
|
selected &&
|
||||||
|
Object.entries(selected.scope).some(([key]) => {
|
||||||
|
const prefix = key.replace('Id', '');
|
||||||
|
return !selected.names?.[`${prefix}Name`] || selected.names?.[`${prefix}Status`] === 'deleted';
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
async function save() {
|
||||||
|
if (!context || !complete || duplicate || invalidObject || saving.current) return;
|
||||||
|
const nextErrors = draftErrors(draft);
|
||||||
|
setErrors(nextErrors);
|
||||||
|
if (Object.keys(nextErrors).length) return;
|
||||||
|
saving.current = true;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = (await monitorApi.saveRule({
|
||||||
|
type: 'overall',
|
||||||
|
scope,
|
||||||
|
version: context.current?.version ?? 0,
|
||||||
|
config: {
|
||||||
|
enabled: draft.enabled,
|
||||||
|
minSamples: Number(draft.min),
|
||||||
|
thresholds: draft.thresholds.map((n) => (n.trim() ? Number(n) : null)),
|
||||||
|
consecutiveBad: Number(draft.bad),
|
||||||
|
consecutiveGood: Number(draft.good),
|
||||||
|
deleted: false,
|
||||||
|
},
|
||||||
|
})) as ManagedRule[];
|
||||||
|
setDirty(false);
|
||||||
|
onSaved(result[0]);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '保存失败,请重试');
|
||||||
|
} finally {
|
||||||
|
saving.current = false;
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title={common ? '整体兜底 · 阈值设置' : editing ? '编辑个性化规则' : '新增个性化规则'}
|
||||||
|
onClose={() => leave(onClose)}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" disabled={busy} onClick={() => leave(common ? onClose : onBack)}>
|
||||||
|
{common ? '关闭' : '返回列表'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy || loading || !context || !complete || Boolean(duplicate) || invalidObject}
|
||||||
|
onClick={() => void save()}
|
||||||
|
>
|
||||||
|
{busy ? '保存中…' : '保存规则'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="monitor-rules">
|
||||||
|
{common && (
|
||||||
|
<div role="tablist" aria-label="规则类别" className="monitor-rules__tabs">
|
||||||
|
<button role="tab" aria-selected type="button">
|
||||||
|
通用规则
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected={false} type="button" onClick={() => leave(onBack)}>
|
||||||
|
个性化规则
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<section className="monitor-rules__section">
|
||||||
|
<h3>适用范围</h3>
|
||||||
|
{common ? (
|
||||||
|
<p>全局默认;没有更具体覆盖时采用此规则。</p>
|
||||||
|
) : editing ? (
|
||||||
|
<p>
|
||||||
|
{selected ? scopeTitle(selected) : '读取范围中…'} <Tag tone="info">{ruleSource(selected ?? null)}</Tag>
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Select
|
||||||
|
label="覆盖类型"
|
||||||
|
value={kind}
|
||||||
|
options={[
|
||||||
|
{ value: 'application', label: '应用覆盖' },
|
||||||
|
{ value: 'signature', label: '签名覆盖' },
|
||||||
|
{ value: 'combined', label: '应用+签名覆盖' },
|
||||||
|
]}
|
||||||
|
onChange={(e) =>
|
||||||
|
changeScope({}, () => {
|
||||||
|
setKind(e.target.value);
|
||||||
|
setTenant(undefined);
|
||||||
|
setApplication(undefined);
|
||||||
|
setSignature(undefined);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="monitor-rules__grid">
|
||||||
|
{kind === 'signature' ? (
|
||||||
|
<MonitorObjectSelect
|
||||||
|
label="企业"
|
||||||
|
kind="tenant"
|
||||||
|
value={tenant}
|
||||||
|
onChange={(option) =>
|
||||||
|
changeScope({ tenantId: option.id }, () => {
|
||||||
|
setTenant(option);
|
||||||
|
setSignature(undefined);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MonitorObjectSelect
|
||||||
|
label="企业应用"
|
||||||
|
kind="application"
|
||||||
|
value={application}
|
||||||
|
onChange={(option) =>
|
||||||
|
changeScope({ tenantId: option.tenantId, applicationId: option.id }, () => {
|
||||||
|
setApplication(option);
|
||||||
|
setSignature(undefined);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{kind !== 'application' && (
|
||||||
|
<MonitorObjectSelect
|
||||||
|
label="签名"
|
||||||
|
kind="signature"
|
||||||
|
value={signature}
|
||||||
|
tenantId={scope.tenantId}
|
||||||
|
applicationId={scope.applicationId}
|
||||||
|
disabled={!scope.tenantId}
|
||||||
|
onChange={(option) => changeScope({ ...scope, signatureId: option.id }, () => setSignature(option))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!common && (
|
||||||
|
<p className="monitor-rules__hint">
|
||||||
|
应用+签名 > 签名 > 应用 > 通用,整套覆盖。应用级设置仍可能被更具体的签名规则覆盖。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
{duplicate && (
|
||||||
|
<div className="monitor-rules__notice" role="status">
|
||||||
|
此范围已配置规则。
|
||||||
|
<Button variant="ghost" onClick={() => setEditing(true)}>
|
||||||
|
编辑已有规则
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{invalidObject && (
|
||||||
|
<p role="alert">此规则关联的对象已删除,无法修改阈值;可以返回列表恢复继承,历史版本继续保留。</p>
|
||||||
|
)}
|
||||||
|
{loading && <p role="status">正在加载规则与继承关系…</p>}
|
||||||
|
{error && (
|
||||||
|
<div className="monitor-rules__error" role="alert">
|
||||||
|
{error}
|
||||||
|
<Button variant="ghost" onClick={() => leave(() => setRetry(retry + 1))}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{context && (
|
||||||
|
<div className="monitor-rules__notice">
|
||||||
|
<p>
|
||||||
|
当前生效:
|
||||||
|
{context.matched[0]
|
||||||
|
? `${ruleSource(context.matched[0])} · ${scopeTitle(context.matched[0])} · v${context.matched[0].version} · ${context.matched[0].config.enabled ? '告警启用' : '告警停用'}`
|
||||||
|
: '尚未配置,不告警'}
|
||||||
|
</p>
|
||||||
|
{context.current && new Date(context.current.effectiveAt) > new Date(context.serverTime) && (
|
||||||
|
<p>
|
||||||
|
待生效:v{context.current.version} ·{' '}
|
||||||
|
{context.current.config.deleted ? '恢复继承' : context.current.config.enabled ? '告警启用' : '告警停用'}{' '}
|
||||||
|
· {time(context.current.effectiveAt)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{(!context.current || context.current.config.deleted) && (
|
||||||
|
<p>
|
||||||
|
预填来源:
|
||||||
|
{context.inherited[0]
|
||||||
|
? `${ruleSource(context.inherited[0])} · ${scopeTitle(context.inherited[0])}`
|
||||||
|
: '无可继承配置,阈值留空'}
|
||||||
|
;保存后成为独立完整规则。
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{context.matched.length > 1 && (
|
||||||
|
<p>匹配顺序:{context.matched.map((r) => `${ruleSource(r)} v${r.version}`).join(' → ')}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<MonitorRuleForm
|
||||||
|
draft={draft}
|
||||||
|
errors={errors}
|
||||||
|
disabled={!context || loading || busy || Boolean(duplicate) || invalidObject}
|
||||||
|
onChange={(next) => {
|
||||||
|
setDraft(next);
|
||||||
|
setDirty(true);
|
||||||
|
setErrors({});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<p className="monitor-rules__hint">保存后从下一个10分钟评估周期生效,不修改历史窗口使用的规则。</p>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
import { Input } from '@/components/ui';
|
||||||
|
import type { RuleDraft } from './monitorRuleDraft';
|
||||||
|
export function MonitorRuleForm({
|
||||||
|
draft,
|
||||||
|
onChange,
|
||||||
|
errors,
|
||||||
|
disabled,
|
||||||
|
}: {
|
||||||
|
draft: RuleDraft;
|
||||||
|
onChange: (draft: RuleDraft) => void;
|
||||||
|
errors: Record<string, string>;
|
||||||
|
disabled: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<fieldset className="monitor-rules__fields" disabled={disabled}>
|
||||||
|
<legend>告警阈值</legend>
|
||||||
|
<div className="monitor-rules__grid">
|
||||||
|
<Input
|
||||||
|
label="最低成熟样本量"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={100000000}
|
||||||
|
value={draft.min}
|
||||||
|
error={errors.min}
|
||||||
|
onChange={(e) => onChange({ ...draft, min: e.target.value })}
|
||||||
|
/>
|
||||||
|
{['1分钟', '5分钟', '20分钟'].map((name, i) => (
|
||||||
|
<Input
|
||||||
|
key={name}
|
||||||
|
label={`${name}到达率下限(%)`}
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
max={100}
|
||||||
|
step="0.01"
|
||||||
|
value={draft.thresholds[i]}
|
||||||
|
error={errors[`threshold${i}`]}
|
||||||
|
onChange={(e) =>
|
||||||
|
onChange({ ...draft, thresholds: draft.thresholds.map((n, j) => (j === i ? e.target.value : n)) })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="monitor-rules__hint">空白代表不启用该指标;0%不会因低到达率触发告警。未配置规则时不告警。</p>
|
||||||
|
<h3>触发与恢复</h3>
|
||||||
|
<label className="monitor-rules__check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={draft.enabled}
|
||||||
|
onChange={(e) => onChange({ ...draft, enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
启用告警
|
||||||
|
</label>
|
||||||
|
<div className="monitor-rules__grid">
|
||||||
|
<Input
|
||||||
|
label="连续异常次数"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={draft.bad}
|
||||||
|
error={errors.bad}
|
||||||
|
onChange={(e) => onChange({ ...draft, bad: e.target.value })}
|
||||||
|
/>
|
||||||
|
<Input
|
||||||
|
label="连续恢复次数"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={5}
|
||||||
|
value={draft.good}
|
||||||
|
error={errors.good}
|
||||||
|
onChange={(e) => onChange({ ...draft, good: e.target.value })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<p className="monitor-rules__hint">停用会保留本层覆盖,在其实际匹配范围内不告警;恢复继承则移除本层覆盖。</p>
|
||||||
|
</fieldset>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
.monitor-rules {
|
||||||
|
display: grid;
|
||||||
|
gap: 18px;
|
||||||
|
color: var(--color-text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__tabs,
|
||||||
|
.monitor-rules .monitor-rules__actions,
|
||||||
|
.monitor-rules .monitor-rules__toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__tabs {
|
||||||
|
border-bottom: 1px solid #e5e7eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__tabs > button {
|
||||||
|
padding: 10px 16px;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
border-bottom: 2px solid transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__tabs > button[aria-selected='true'] {
|
||||||
|
color: #2563eb;
|
||||||
|
border-bottom-color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__toolbar > .ui-field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__section,
|
||||||
|
.monitor-rules .monitor-rules__fields {
|
||||||
|
display: grid;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
min-width: 0;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__fields > legend,
|
||||||
|
.monitor-rules .monitor-rules__section > h3,
|
||||||
|
.monitor-rules .monitor-rules__fields > h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__notice {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #dbeafe;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #eff6ff;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__hint {
|
||||||
|
color: #6b7280;
|
||||||
|
font-size: 13px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__error {
|
||||||
|
color: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__check {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__picker {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__select {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 10px 12px;
|
||||||
|
min-height: 40px;
|
||||||
|
border: 1px solid #e5e7eb;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__select:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__dropdown {
|
||||||
|
position: absolute;
|
||||||
|
top: 100%;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 5;
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 12px;
|
||||||
|
border: 1px solid #d1d5db;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__options {
|
||||||
|
max-height: 220px;
|
||||||
|
overflow: auto;
|
||||||
|
display: grid;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__options > button {
|
||||||
|
border: 0;
|
||||||
|
padding: 10px;
|
||||||
|
background: #fff;
|
||||||
|
text-align: left;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__options > button:hover,
|
||||||
|
.monitor-rules .monitor-rules__options > button:focus-visible,
|
||||||
|
.monitor-rules .monitor-rules__options > button[aria-selected='true'] {
|
||||||
|
background: #eff6ff;
|
||||||
|
color: #2563eb;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (width <= 640px) {
|
||||||
|
.monitor-rules .monitor-rules__grid {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.monitor-rules .monitor-rules__toolbar {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { render, screen, waitFor } from '@testing-library/react';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import { beforeEach, expect, it, vi } from 'vitest';
|
||||||
|
import { MonitorRuleManager } from './MonitorRuleManager';
|
||||||
|
import { MonitorRuleEditor } from './MonitorRuleEditor';
|
||||||
|
const api = vi.hoisted(() => ({
|
||||||
|
list: vi.fn(),
|
||||||
|
options: vi.fn(),
|
||||||
|
editor: vi.fn(),
|
||||||
|
restore: vi.fn(),
|
||||||
|
saveRule: vi.fn(),
|
||||||
|
}));
|
||||||
|
vi.mock('./monitorApi', async (original) => ({
|
||||||
|
...(await original<object>()),
|
||||||
|
ruleManagementApi: api,
|
||||||
|
monitorApi: { saveRule: api.saveRule },
|
||||||
|
}));
|
||||||
|
const config = { enabled: true, minSamples: 100, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 };
|
||||||
|
const rule = {
|
||||||
|
id: 'r1',
|
||||||
|
type: 'overall' as const,
|
||||||
|
scope: { tenantId: 't1', applicationId: 'a1' },
|
||||||
|
config,
|
||||||
|
version: 2,
|
||||||
|
effectiveAt: '2026-09-06T00:10:00Z',
|
||||||
|
names: { tenantName: '企业甲', applicationName: '应用甲', tenantStatus: 'active', applicationStatus: 'active' },
|
||||||
|
};
|
||||||
|
const empty = { current: null, matched: [], inherited: [], serverTime: '2026-09-06T00:00:00Z' };
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
api.editor.mockResolvedValue(empty);
|
||||||
|
api.list.mockResolvedValue({ items: [], total: 0, page: 1, pageSize: 20, serverTime: empty.serverTime });
|
||||||
|
api.options.mockResolvedValue({
|
||||||
|
items: [{ id: 'a1', tenantId: 't1', name: '企业甲 · 应用甲' }],
|
||||||
|
hasMore: false,
|
||||||
|
page: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it('keeps unconfigured common values blank and validates inline before writing', async () => {
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MonitorRuleManager onClose={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled());
|
||||||
|
expect(screen.getByLabelText('1分钟到达率下限(%)')).toHaveValue(null);
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存规则' }));
|
||||||
|
expect(screen.getByText('请至少设置一项到达率下限')).toBeVisible();
|
||||||
|
expect(api.saveRule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
it('shows named rules and pending status separately from current status', async () => {
|
||||||
|
api.list.mockResolvedValue({
|
||||||
|
items: [{ ...rule, config: { ...config, enabled: false }, active: { ...rule, version: 1 } }],
|
||||||
|
total: 1,
|
||||||
|
page: 1,
|
||||||
|
pageSize: 20,
|
||||||
|
serverTime: empty.serverTime,
|
||||||
|
});
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MonitorRuleManager onClose={vi.fn()} />);
|
||||||
|
await user.click(screen.getByRole('tab', { name: '个性化规则' }));
|
||||||
|
expect(await screen.findByText('企业甲 · 应用甲')).toBeVisible();
|
||||||
|
expect(screen.getByText('告警启用')).toBeVisible();
|
||||||
|
expect(screen.getByText('待生效 v2 · 停用')).toBeVisible();
|
||||||
|
});
|
||||||
|
it('detects an existing scope, keeps its name visible, and requires explicit editing', async () => {
|
||||||
|
api.editor.mockResolvedValue({ ...empty, current: rule });
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MonitorRuleEditor onClose={vi.fn()} onBack={vi.fn()} onSaved={vi.fn()} />);
|
||||||
|
await user.click(screen.getByRole('button', { name: '企业应用' }));
|
||||||
|
await user.click(await screen.findByRole('option', { name: '企业甲 · 应用甲' }));
|
||||||
|
expect(await screen.findByRole('button', { name: '编辑已有规则' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '企业应用' })).toHaveTextContent('企业甲 · 应用甲');
|
||||||
|
expect(screen.getByRole('button', { name: '保存规则' })).toBeDisabled();
|
||||||
|
await user.click(screen.getByRole('button', { name: '编辑已有规则' }));
|
||||||
|
expect(screen.queryByRole('button', { name: '企业应用' })).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: '保存规则' })).toBeEnabled();
|
||||||
|
});
|
||||||
|
it('preserves draft on conflict and does not leave without confirmation', async () => {
|
||||||
|
api.editor.mockResolvedValue({ ...empty, current: rule });
|
||||||
|
api.saveRule.mockRejectedValue(new Error('规则已被修改,请刷新后重试'));
|
||||||
|
const back = vi.fn(),
|
||||||
|
user = userEvent.setup();
|
||||||
|
render(<MonitorRuleEditor initial={rule} onClose={vi.fn()} onBack={back} onSaved={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(100));
|
||||||
|
await user.clear(screen.getByLabelText('最低成熟样本量'));
|
||||||
|
await user.type(screen.getByLabelText('最低成熟样本量'), '250');
|
||||||
|
await user.click(screen.getByRole('button', { name: '保存规则' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent('规则已被修改');
|
||||||
|
expect(screen.getByLabelText('最低成熟样本量')).toHaveValue(250);
|
||||||
|
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
||||||
|
await user.click(screen.getByRole('button', { name: '返回列表' }));
|
||||||
|
expect(back).not.toHaveBeenCalled();
|
||||||
|
confirm.mockRestore();
|
||||||
|
});
|
||||||
|
it('restores using the saved rule and displays the no-inheritance outcome', async () => {
|
||||||
|
api.list.mockResolvedValue({ items: [rule], total: 1, page: 1, pageSize: 20, serverTime: empty.serverTime });
|
||||||
|
api.editor.mockResolvedValue({ ...empty, current: rule });
|
||||||
|
api.restore.mockResolvedValue([{ ...rule, version: 3 }]);
|
||||||
|
const user = userEvent.setup();
|
||||||
|
render(<MonitorRuleManager onClose={vi.fn()} />);
|
||||||
|
await user.click(screen.getByRole('tab', { name: '个性化规则' }));
|
||||||
|
await user.click(await screen.findByRole('button', { name: '恢复继承' }));
|
||||||
|
expect(await screen.findByText(/没有可用规则,恢复后不告警/)).toBeVisible();
|
||||||
|
await user.click(screen.getByRole('button', { name: '确认恢复继承' }));
|
||||||
|
expect(api.restore).toHaveBeenCalledWith(rule);
|
||||||
|
expect(api.saveRule).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Button, Input, Modal, Pagination, Select, Table, Tag, type TableColumn } from '@/components/ui';
|
||||||
|
import { MonitorRuleEditor } from './MonitorRuleEditor';
|
||||||
|
import { ruleManagementApi, ruleSource, scopeTitle, time, type ManagedRule, type RuleContext } from './monitorApi';
|
||||||
|
import './MonitorRuleManager.css';
|
||||||
|
|
||||||
|
function RestoreRule({
|
||||||
|
rule,
|
||||||
|
onBack,
|
||||||
|
onSaved,
|
||||||
|
}: {
|
||||||
|
rule: ManagedRule;
|
||||||
|
onBack: () => void;
|
||||||
|
onSaved: (rule: ManagedRule) => void;
|
||||||
|
}) {
|
||||||
|
const [context, setContext] = useState<RuleContext>(),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[busy, setBusy] = useState(false),
|
||||||
|
[retry, setRetry] = useState(0);
|
||||||
|
useEffect(() => {
|
||||||
|
const abort = new AbortController();
|
||||||
|
setContext(undefined);
|
||||||
|
setError('');
|
||||||
|
void ruleManagementApi
|
||||||
|
.editor(rule.scope, abort.signal)
|
||||||
|
.then((value) => {
|
||||||
|
if (!abort.signal.aborted) setContext(value);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!abort.signal.aborted) setError(e.message);
|
||||||
|
});
|
||||||
|
return () => abort.abort();
|
||||||
|
}, [rule, retry]);
|
||||||
|
async function restore() {
|
||||||
|
if (!context?.current || busy) return;
|
||||||
|
setBusy(true);
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const rows = await ruleManagementApi.restore(context.current);
|
||||||
|
onSaved(rows[0] as ManagedRule);
|
||||||
|
} catch (e) {
|
||||||
|
setError(e instanceof Error ? e.message : '恢复继承失败');
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
title="恢复继承"
|
||||||
|
onClose={() => {
|
||||||
|
if (!busy) onBack();
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" disabled={busy} onClick={onBack}>
|
||||||
|
取消
|
||||||
|
</Button>
|
||||||
|
<Button disabled={busy || !context} onClick={() => void restore()}>
|
||||||
|
{busy ? '处理中…' : '确认恢复继承'}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="monitor-rules">
|
||||||
|
<p>
|
||||||
|
移除“{scopeTitle(rule)}”的{ruleSource(rule)}覆盖,历史版本保留。
|
||||||
|
</p>
|
||||||
|
{!context && !error && <p role="status">正在查询继承关系…</p>}
|
||||||
|
{context && (
|
||||||
|
<>
|
||||||
|
<p>
|
||||||
|
该范围下层规则:
|
||||||
|
{context.inherited.length
|
||||||
|
? context.inherited
|
||||||
|
.map((r) => `${ruleSource(r)} · ${scopeTitle(r)}(${r.config.enabled ? '启用' : '停用'})`)
|
||||||
|
.join(' → ')
|
||||||
|
: '没有可用规则,恢复后不告警'}
|
||||||
|
。
|
||||||
|
</p>
|
||||||
|
<p>更具体的规则仍按“应用+签名 > 签名 > 应用 > 通用”匹配。恢复操作下一评估周期生效。</p>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div role="alert">
|
||||||
|
{error}
|
||||||
|
<Button variant="ghost" onClick={() => setRetry(retry + 1)}>
|
||||||
|
重新加载
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MonitorRuleManager({ onClose }: { onClose: () => void }) {
|
||||||
|
const [tab, setTab] = useState<'common' | 'custom'>('common');
|
||||||
|
const [editor, setEditor] = useState<ManagedRule | 'new' | null>(null),
|
||||||
|
[restore, setRestore] = useState<ManagedRule | null>(null);
|
||||||
|
const [keyword, setKeyword] = useState(''),
|
||||||
|
[kind, setKind] = useState(''),
|
||||||
|
[page, setPage] = useState(1),
|
||||||
|
[refresh, setRefresh] = useState(0);
|
||||||
|
const [items, setItems] = useState<ManagedRule[]>([]),
|
||||||
|
[total, setTotal] = useState(0),
|
||||||
|
[serverTime, setServerTime] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false),
|
||||||
|
[error, setError] = useState(''),
|
||||||
|
[notice, setNotice] = useState('');
|
||||||
|
useEffect(() => {
|
||||||
|
if (tab !== 'custom' || editor || restore) return;
|
||||||
|
const abort = new AbortController();
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setLoading(true);
|
||||||
|
setError('');
|
||||||
|
void ruleManagementApi
|
||||||
|
.list({ keyword, kind, page }, abort.signal)
|
||||||
|
.then((result) => {
|
||||||
|
if (abort.signal.aborted) return;
|
||||||
|
if (page > 1 && !result.items.length) {
|
||||||
|
setPage(Math.max(1, Math.ceil(result.total / 20)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setItems(result.items);
|
||||||
|
setTotal(result.total);
|
||||||
|
setServerTime(result.serverTime);
|
||||||
|
})
|
||||||
|
.catch((e: Error) => {
|
||||||
|
if (!abort.signal.aborted) setError(e.message);
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!abort.signal.aborted) setLoading(false);
|
||||||
|
});
|
||||||
|
}, 250);
|
||||||
|
return () => {
|
||||||
|
abort.abort();
|
||||||
|
clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [tab, editor, restore, keyword, kind, page, refresh]);
|
||||||
|
function saved(rule: ManagedRule) {
|
||||||
|
setNotice(`已保存 v${rule.version},${time(rule.effectiveAt)} 生效`);
|
||||||
|
setEditor(null);
|
||||||
|
setRestore(null);
|
||||||
|
setRefresh((n) => n + 1);
|
||||||
|
}
|
||||||
|
if (restore) return <RestoreRule rule={restore} onBack={() => setRestore(null)} onSaved={saved} />;
|
||||||
|
if (tab === 'common' || editor)
|
||||||
|
return (
|
||||||
|
<MonitorRuleEditor
|
||||||
|
key={tab === 'common' ? `common-${refresh}` : editor === 'new' ? 'new' : editor?.id}
|
||||||
|
common={tab === 'common'}
|
||||||
|
initial={editor && editor !== 'new' ? editor : undefined}
|
||||||
|
onClose={onClose}
|
||||||
|
onBack={() => {
|
||||||
|
setTab('custom');
|
||||||
|
setEditor(null);
|
||||||
|
}}
|
||||||
|
onSaved={(rule) => {
|
||||||
|
saved(rule);
|
||||||
|
setTab('custom');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
const columns: TableColumn<ManagedRule>[] = [
|
||||||
|
{
|
||||||
|
key: 'scope',
|
||||||
|
title: '适用范围',
|
||||||
|
width: '260px',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="ui-table__long-text">
|
||||||
|
<strong>{scopeTitle(r)}</strong>
|
||||||
|
<p>{ruleSource(r)}</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'thresholds',
|
||||||
|
title: '阈值摘要',
|
||||||
|
width: '210px',
|
||||||
|
render: (r) => (
|
||||||
|
<div>
|
||||||
|
<p>{r.config.minSamples} 条成熟样本</p>
|
||||||
|
<p>
|
||||||
|
{r.config.thresholds
|
||||||
|
.map((n, i) => `${['1分', '5分', '20分'][i]} ${n === null ? '未启用' : `${n}%`}`)
|
||||||
|
.join(' / ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'status',
|
||||||
|
title: '状态与生效时间',
|
||||||
|
width: '220px',
|
||||||
|
render: (r) => {
|
||||||
|
const pending = new Date(r.effectiveAt) > new Date(serverTime),
|
||||||
|
active = pending ? r.active : r;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tag tone={active && !active.config.deleted && active.config.enabled ? 'success' : 'neutral'}>
|
||||||
|
{!active || active.config.deleted ? '未生效覆盖' : active.config.enabled ? '告警启用' : '告警停用'}
|
||||||
|
</Tag>
|
||||||
|
<p>
|
||||||
|
{pending
|
||||||
|
? `待生效 v${r.version} · ${r.config.deleted ? '恢复继承' : r.config.enabled ? '启用' : '停用'}`
|
||||||
|
: `v${r.version}`}
|
||||||
|
</p>
|
||||||
|
<p>{time(r.effectiveAt)}</p>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'actions',
|
||||||
|
title: '操作',
|
||||||
|
width: '165px',
|
||||||
|
render: (r) => (
|
||||||
|
<div className="monitor-rules__actions">
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditor(r)}>
|
||||||
|
编辑
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" disabled={Boolean(r.config.deleted)} onClick={() => setRestore(r)}>
|
||||||
|
恢复继承
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
open
|
||||||
|
size="xl"
|
||||||
|
title="整体兜底 · 阈值设置"
|
||||||
|
onClose={onClose}
|
||||||
|
footer={
|
||||||
|
<Button variant="ghost" onClick={onClose}>
|
||||||
|
关闭
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="monitor-rules">
|
||||||
|
<div role="tablist" aria-label="规则类别" className="monitor-rules__tabs">
|
||||||
|
<button role="tab" aria-selected={false} type="button" onClick={() => setTab('common')}>
|
||||||
|
通用规则
|
||||||
|
</button>
|
||||||
|
<button role="tab" aria-selected type="button">
|
||||||
|
个性化规则
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{notice && (
|
||||||
|
<p role="status" className="monitor-rules__notice">
|
||||||
|
{notice}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="monitor-rules__toolbar">
|
||||||
|
<Input
|
||||||
|
aria-label="搜索规则"
|
||||||
|
placeholder="搜索企业、应用或签名"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKeyword(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Select
|
||||||
|
aria-label="筛选覆盖类型"
|
||||||
|
value={kind}
|
||||||
|
options={[
|
||||||
|
{ value: '', label: '全部覆盖类型' },
|
||||||
|
{ value: 'application', label: '应用覆盖' },
|
||||||
|
{ value: 'signature', label: '签名覆盖' },
|
||||||
|
{ value: 'combined', label: '应用+签名覆盖' },
|
||||||
|
]}
|
||||||
|
onChange={(e) => {
|
||||||
|
setKind(e.target.value);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button onClick={() => setEditor('new')}>新增覆盖</Button>
|
||||||
|
</div>
|
||||||
|
<p className="monitor-rules__hint">
|
||||||
|
应用+签名 > 签名 > 应用 > 通用,整套覆盖;新增时预填当前可继承的配置。
|
||||||
|
</p>
|
||||||
|
{error ? (
|
||||||
|
<div role="alert">
|
||||||
|
{error}
|
||||||
|
<Button variant="ghost" onClick={() => setRefresh(refresh + 1)}>
|
||||||
|
重试
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : loading ? (
|
||||||
|
<p role="status">正在加载规则…</p>
|
||||||
|
) : (
|
||||||
|
<Table
|
||||||
|
columns={columns}
|
||||||
|
data={items}
|
||||||
|
rowKey="id"
|
||||||
|
pagination={false}
|
||||||
|
emptyText="暂无个性化规则,可新增覆盖;未覆盖范围沿用通用规则。"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<Pagination
|
||||||
|
page={page}
|
||||||
|
total={total}
|
||||||
|
totalPages={Math.max(1, Math.ceil(total / 20))}
|
||||||
|
previousDisabled={loading || page === 1}
|
||||||
|
nextDisabled={loading || page * 20 >= total}
|
||||||
|
onPrevious={() => setPage(page - 1)}
|
||||||
|
onNext={() => setPage(page + 1)}
|
||||||
|
onPageChange={setPage}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -26,6 +26,32 @@ export type Rule = {
|
|||||||
version: number;
|
version: number;
|
||||||
effectiveAt: string;
|
effectiveAt: string;
|
||||||
};
|
};
|
||||||
|
export type ManagedRule = Rule & { names: Record<string, string | null>; active?: Rule | null };
|
||||||
|
export type RuleOption = { id: string; name: string; tenantId: string };
|
||||||
|
export type RuleContext = {
|
||||||
|
current: ManagedRule | null;
|
||||||
|
matched: ManagedRule[];
|
||||||
|
inherited: ManagedRule[];
|
||||||
|
serverTime: string;
|
||||||
|
};
|
||||||
|
export const ruleManagementApi = {
|
||||||
|
list: (query: Record<string, string | number | undefined>, signal?: AbortSignal) =>
|
||||||
|
request<Page<ManagedRule> & { serverTime: string }>(withQuery('/admin/sending-monitor/rule-management', query), {
|
||||||
|
signal,
|
||||||
|
}),
|
||||||
|
options: (query: Record<string, string | number | undefined>, signal?: AbortSignal) =>
|
||||||
|
request<{ items: RuleOption[]; page: number; hasMore: boolean }>(
|
||||||
|
withQuery('/admin/sending-monitor/rule-options', query),
|
||||||
|
{ signal },
|
||||||
|
),
|
||||||
|
editor: (scope: Scope, signal?: AbortSignal) =>
|
||||||
|
request<RuleContext>(withQuery('/admin/sending-monitor/rule-editor', scope), { signal }),
|
||||||
|
restore: (rule: Rule) =>
|
||||||
|
request<Rule[]>(`/admin/sending-monitor/rules/${rule.id}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ version: rule.version }),
|
||||||
|
}),
|
||||||
|
};
|
||||||
export type Metric = {
|
export type Metric = {
|
||||||
seconds: number;
|
seconds: number;
|
||||||
success: number;
|
success: number;
|
||||||
@@ -136,3 +162,12 @@ export const monitorApi = {
|
|||||||
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
|
request<Page<Alert>>(withQuery('/admin/sending-monitor/alerts', { page, state }), { signal }),
|
||||||
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
|
read: (id: string) => request(`/admin/sending-monitor/alerts/${id}/read`, { method: 'POST' }),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const scopeTitle = (rule: ManagedRule) =>
|
||||||
|
[
|
||||||
|
rule.names?.tenantName ?? (rule.scope.tenantId ? '企业已删除' : ''),
|
||||||
|
rule.names?.applicationName ?? (rule.scope.applicationId ? '应用已删除' : ''),
|
||||||
|
rule.names?.signatureName ?? (rule.scope.signatureId ? '签名已删除' : ''),
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' · ') || '全局默认';
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import type { Config } from './monitorApi';
|
||||||
|
export type RuleDraft = { min: string; thresholds: string[]; bad: string; good: string; enabled: boolean };
|
||||||
|
export const ruleDraft = (config?: Config): RuleDraft => ({
|
||||||
|
min: config ? String(config.minSamples) : '',
|
||||||
|
thresholds: config?.thresholds.map((n) => (n === null ? '' : String(n))) ?? ['', '', ''],
|
||||||
|
bad: String(config?.consecutiveBad ?? 1),
|
||||||
|
good: String(config?.consecutiveGood ?? 2),
|
||||||
|
enabled: config?.enabled ?? false,
|
||||||
|
});
|
||||||
|
export function draftErrors(draft: RuleDraft) {
|
||||||
|
const errors: Record<string, string> = {};
|
||||||
|
const min = Number(draft.min);
|
||||||
|
if (!Number.isSafeInteger(min) || min < 1 || min > 100000000) errors.min = '请输入1~100000000之间的整数';
|
||||||
|
let previous = -1;
|
||||||
|
draft.thresholds.forEach((text, i) => {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
const n = Number(text);
|
||||||
|
if (!Number.isFinite(n) || n < 0 || n > 100 || Math.abs(n * 100 - Math.round(n * 100)) > 1e-7)
|
||||||
|
errors[`threshold${i}`] = '请输入0~100,最多两位小数';
|
||||||
|
else if (n < previous) errors[`threshold${i}`] = '较长时限的下限不能低于较短时限';
|
||||||
|
previous = n;
|
||||||
|
});
|
||||||
|
if (draft.thresholds.every((s) => !s.trim())) errors.threshold0 = '请至少设置一项到达率下限';
|
||||||
|
for (const key of ['bad', 'good'] as const)
|
||||||
|
if (!Number.isInteger(Number(draft[key])) || Number(draft[key]) < 1 || Number(draft[key]) > 5)
|
||||||
|
errors[key] = '请输入1~5之间的整数';
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
@@ -269,6 +269,12 @@
|
|||||||
"owners": ["src/apps/admin/AdminMonitorPage.tsx"],
|
"owners": ["src/apps/admin/AdminMonitorPage.tsx"],
|
||||||
"stylelintLegacy": false,
|
"stylelintLegacy": false,
|
||||||
"roots": ["sending-monitor"]
|
"roots": ["sending-monitor"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"file": "src/apps/admin/sending-monitor/MonitorRuleManager.css",
|
||||||
|
"owners": ["src/apps/admin/sending-monitor/MonitorRuleManager.tsx"],
|
||||||
|
"stylelintLegacy": false,
|
||||||
|
"roots": ["monitor-rules"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
// Real Prisma/service integration in an isolated schema. No public business writes or SMS.
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import pg from '../../api/node_modules/pg/lib/index.js';
|
||||||
|
import prisma from '../../api/node_modules/@prisma/client/default.js';
|
||||||
|
import adapter from '../../api/node_modules/@prisma/adapter-pg/dist/index.js';
|
||||||
|
import service from '../../api/dist/sending-monitor/sending-monitor.module.js';
|
||||||
|
import management from '../../api/dist/sending-monitor/monitor-rule-management.service.js';
|
||||||
|
|
||||||
|
process.env.TZ = 'UTC';
|
||||||
|
const connectionString = process.env.QA_DATABASE_URL || process.env.DATABASE_URL;
|
||||||
|
if (!connectionString) throw Error('QA_DATABASE_URL is required');
|
||||||
|
const schema = `qa_monitor_rules_${randomUUID().replaceAll('-', '')}`;
|
||||||
|
assert.match(schema, /^qa_monitor_rules_[a-f0-9]{32}$/);
|
||||||
|
const admin = new pg.Client({ connectionString });
|
||||||
|
let client,
|
||||||
|
checks = 0;
|
||||||
|
await admin.connect();
|
||||||
|
try {
|
||||||
|
await admin.query(`CREATE SCHEMA "${schema}"`);
|
||||||
|
for (const table of [
|
||||||
|
'Tenant',
|
||||||
|
'SmsApplication',
|
||||||
|
'SmsSignature',
|
||||||
|
'SendingMonitorRule',
|
||||||
|
'SendingMonitorRuleVersion',
|
||||||
|
'OperationLog',
|
||||||
|
])
|
||||||
|
await admin.query(`CREATE TABLE "${schema}"."${table}" (LIKE public."${table}" INCLUDING ALL)`);
|
||||||
|
const pool = new pg.Pool({ connectionString, max: 2, options: `-c search_path=${schema} -c timezone=UTC` });
|
||||||
|
client = new prisma.PrismaClient({ adapter: new adapter.PrismaPg(pool, { schema, disposeExternalPool: true }) });
|
||||||
|
assert.equal((await client.$queryRawUnsafe('SELECT current_schema() name'))[0].name, schema);
|
||||||
|
for (const id of ['t1', 't2']) await client.tenant.create({ data: { id, name: `企业${id}`, code: id } });
|
||||||
|
for (let i = 0; i < 23; i++)
|
||||||
|
await client.smsApplication.create({
|
||||||
|
data: {
|
||||||
|
id: `a${i}`,
|
||||||
|
tenantId: 't1',
|
||||||
|
name: `应用${String(i).padStart(2, '0')}`,
|
||||||
|
cmppAccount: `isolated${i}`,
|
||||||
|
cmppEnterpriseCode: 'qa',
|
||||||
|
secretHash: 'not-a-real-credential',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.smsApplication.create({
|
||||||
|
data: {
|
||||||
|
id: 'b1',
|
||||||
|
tenantId: 't2',
|
||||||
|
name: '企业乙应用',
|
||||||
|
cmppAccount: 'isolated-b',
|
||||||
|
cmppEnterpriseCode: 'qa',
|
||||||
|
secretHash: 'not-a-real-credential',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
await client.smsSignature.create({ data: { id: 's1', tenantId: 't1', applicationId: 'a0', name: '同名签名' } });
|
||||||
|
await client.smsSignature.create({ data: { id: 's2', tenantId: 't2', applicationId: 'b1', name: '同名签名' } });
|
||||||
|
const sut = new service.SendingMonitorService(client),
|
||||||
|
reads = new management.MonitorRuleManagementService(client);
|
||||||
|
const options = await reads.options({ kind: 'application', keyword: '企业t1' });
|
||||||
|
assert.equal(options.items.length, 20);
|
||||||
|
assert.equal(options.hasMore, true);
|
||||||
|
assert(options.items.every((x) => x.name.includes('企业t1') && x.tenantId === 't1'));
|
||||||
|
checks++;
|
||||||
|
const next = await reads.options({ kind: 'application', keyword: '企业t1', page: '2' });
|
||||||
|
assert.equal(next.items.length, 3);
|
||||||
|
assert.equal(next.hasMore, false);
|
||||||
|
assert(next.items.every((x) => !options.items.some((old) => old.id === x.id)));
|
||||||
|
checks++;
|
||||||
|
assert.deepEqual(
|
||||||
|
(await reads.options({ kind: 'signature', tenantId: 't1', applicationId: 'a0' })).items.map((x) => x.id),
|
||||||
|
['s1'],
|
||||||
|
);
|
||||||
|
assert.equal((await reads.options({ kind: 'signature', tenantId: 't1', applicationId: 'b1' })).items.length, 0);
|
||||||
|
checks++;
|
||||||
|
await assert.rejects(reads.options({ kind: 'signature' }), (e) => e.getStatus() === 400);
|
||||||
|
await assert.rejects(reads.list({ page: '0' }), (e) => e.getStatus() === 400);
|
||||||
|
await assert.rejects(reads.editor({ tenantId: 't1' }), (e) => e.getStatus() === 400);
|
||||||
|
checks++;
|
||||||
|
const config = { enabled: true, minSamples: 100, thresholds: [90, 95, 98], consecutiveBad: 1, consecutiveGood: 2 };
|
||||||
|
const save = async (scope, values = config, version = 0) =>
|
||||||
|
(await sut.saveRule({ type: 'overall', scope, config: values, version }, 'qa-rule-manager'))[0];
|
||||||
|
const global = await save({});
|
||||||
|
for (let i = 0; i < 23; i++) await save({ tenantId: 't1', applicationId: `a${i}` });
|
||||||
|
const signature = await save({ tenantId: 't1', signatureId: 's1' });
|
||||||
|
const combinedScope = { tenantId: 't1', applicationId: 'a0', signatureId: 's1' };
|
||||||
|
const combined = await save(combinedScope);
|
||||||
|
assert.equal((await reads.list({})).total, 25);
|
||||||
|
assert.equal((await reads.list({ page: '2' })).items.length, 5);
|
||||||
|
assert.equal((await reads.list({ keyword: '应用00', kind: 'combined' })).items[0].names.signatureName, '同名签名');
|
||||||
|
checks++;
|
||||||
|
const pending = await reads.editor(combinedScope);
|
||||||
|
assert.equal(pending.current.version, 1);
|
||||||
|
assert.equal(pending.matched.length, 0);
|
||||||
|
checks++;
|
||||||
|
await client.$executeRawUnsafe(
|
||||||
|
`UPDATE "SendingMonitorRuleVersion" SET "effectiveAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '1 minute'`,
|
||||||
|
);
|
||||||
|
await client.$executeRawUnsafe(
|
||||||
|
`UPDATE "SendingMonitorRule" SET "effectiveAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '1 minute'`,
|
||||||
|
);
|
||||||
|
const effective = await reads.editor(combinedScope);
|
||||||
|
assert.deepEqual(
|
||||||
|
effective.matched.map((r) => Object.keys(r.scope).length),
|
||||||
|
[3, 2, 2, 0],
|
||||||
|
);
|
||||||
|
assert.equal(effective.inherited[0].ruleId, signature.id);
|
||||||
|
assert.equal(effective.matched[3].ruleId, global.id);
|
||||||
|
checks++;
|
||||||
|
const newScope = await reads.editor({ tenantId: 't2', applicationId: 'b1' });
|
||||||
|
assert.equal(newScope.current, null);
|
||||||
|
assert.equal(newScope.inherited[0].ruleId, global.id);
|
||||||
|
checks++;
|
||||||
|
await assert.rejects(save({ tenantId: 't1', applicationId: 'b1' }), (e) => e.getStatus() === 400);
|
||||||
|
await assert.rejects(save({ tenantId: 't1', applicationId: 'a0', signatureId: 's2' }), (e) => e.getStatus() === 400);
|
||||||
|
checks++;
|
||||||
|
const disabled = await save(combinedScope, { ...config, enabled: false }, 1);
|
||||||
|
const pendingDisable = await reads.list({ kind: 'combined' });
|
||||||
|
assert.equal(pendingDisable.items[0].config.enabled, false);
|
||||||
|
assert.equal(pendingDisable.items[0].active.config.enabled, true);
|
||||||
|
checks++;
|
||||||
|
await client.$executeRawUnsafe(
|
||||||
|
`UPDATE "SendingMonitorRuleVersion" SET "effectiveAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '1 minute'`,
|
||||||
|
);
|
||||||
|
assert.equal((await reads.editor(combinedScope)).matched[0].config.enabled, false);
|
||||||
|
checks++;
|
||||||
|
const race = await Promise.allSettled([
|
||||||
|
sut.restoreRule(combined.id, disabled.version, 'qa-rule-manager'),
|
||||||
|
sut.restoreRule(combined.id, disabled.version, 'qa-rule-manager'),
|
||||||
|
]);
|
||||||
|
assert.equal(race.filter((r) => r.status === 'fulfilled').length, 1);
|
||||||
|
assert.equal(race.find((r) => r.status === 'rejected').reason.getStatus(), 409);
|
||||||
|
checks++;
|
||||||
|
await client.$executeRawUnsafe(
|
||||||
|
`UPDATE "SendingMonitorRuleVersion" SET "effectiveAt"=(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')-interval '1 minute'`,
|
||||||
|
);
|
||||||
|
assert.equal((await reads.editor(combinedScope)).matched[0].ruleId, signature.id);
|
||||||
|
checks++;
|
||||||
|
await client.smsApplication.update({ where: { id: 'a1' }, data: { status: 'deleted' } });
|
||||||
|
await client.smsSignature.update({ where: { id: 's1' }, data: { auditStatus: 'deleted' } });
|
||||||
|
assert(!(await reads.options({ kind: 'application' })).items.some((r) => r.id === 'a1'));
|
||||||
|
assert.equal((await reads.options({ kind: 'signature', tenantId: 't1' })).items.length, 0);
|
||||||
|
const historical = (await reads.list({ keyword: '应用01' })).items[0];
|
||||||
|
assert.equal(historical.names.applicationStatus, 'deleted');
|
||||||
|
assert.equal(historical.names.applicationName, '应用01');
|
||||||
|
await assert.rejects(save(historical.scope, config, historical.version), (e) => e.getStatus() === 400);
|
||||||
|
await sut.restoreRule(historical.id, historical.version, 'qa-rule-manager');
|
||||||
|
checks++;
|
||||||
|
const oldCount = await client.sendingMonitorRuleVersion.count();
|
||||||
|
await client.$executeRawUnsafe(
|
||||||
|
`ALTER TABLE "OperationLog" ADD CONSTRAINT qa_reject CHECK ("resource"<>'sending_monitor') NOT VALID`,
|
||||||
|
);
|
||||||
|
await assert.rejects(sut.restoreRule(signature.id, signature.version, 'qa-rule-manager'));
|
||||||
|
assert.equal(await client.sendingMonitorRuleVersion.count(), oldCount);
|
||||||
|
checks++;
|
||||||
|
console.log(JSON.stringify({ checks, realPrisma: true, schemaIsolated: true, publicBusinessWrites: 0 }));
|
||||||
|
} finally {
|
||||||
|
if (client) await client.$disconnect();
|
||||||
|
await admin.query(`DROP SCHEMA "${schema}" CASCADE`);
|
||||||
|
await admin.end();
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user