fix: align reporting fields queries and disk monitoring
This commit is contained in:
@@ -172,4 +172,9 @@ export class DictionariesController {
|
||||
deleteCommonReportField(@Param('id') id: string) {
|
||||
return this.dictionaries.deleteCommonReportField(id);
|
||||
}
|
||||
|
||||
@Put('common-report-fields/:id')
|
||||
updateCommonReportField(@Param('id') id: string, @Body() body: CreateCommonReportFieldDto, @CurrentSessionUserId() operatorId?: string) {
|
||||
return this.dictionaries.updateCommonReportField(id, body, operatorId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,24 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('DictionariesService', () => {
|
||||
it('edits common configuration in place with an audit trail and rejects duplicate or inactive fields', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const existing = { id: 'common-1', drainageFieldId: 'field-1', reportType: 'signature', required: false };
|
||||
prisma.commonReportField.findUnique.mockImplementation(({ where }: { where: { id?: string } }) => Promise.resolve(where.id ? existing : null) as never);
|
||||
prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'active' } as never);
|
||||
const tx = { commonReportField: { update: jest.fn().mockResolvedValue({ ...existing, required: true }) }, operationLog: { create: jest.fn() } };
|
||||
prisma.$transaction.mockImplementation((callback) => callback(tx));
|
||||
const service = new DictionariesService(prisma as never);
|
||||
const body = { drainageFieldId: 'field-2', reportType: 'drainage' as const, required: true };
|
||||
await service.updateCommonReportField('common-1', body, 'admin-1');
|
||||
expect(tx.commonReportField.update).toHaveBeenCalledWith(expect.objectContaining({ where: { id: 'common-1' }, data: { ...body, sortOrder: undefined } }));
|
||||
expect(tx.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'common_report_field.update', userId: 'admin-1' }) }));
|
||||
prisma.commonReportField.findUnique.mockResolvedValue({ id: 'other' } as never);
|
||||
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已配置');
|
||||
prisma.drainageField.findUnique.mockResolvedValue({ id: 'field-2', status: 'inactive' } as never);
|
||||
await expect(service.updateCommonReportField('common-1', body)).rejects.toThrow('已停用');
|
||||
await expect(service.updateCommonReportField('common-1', { ...body, required: 'false' as never })).rejects.toThrow('无效');
|
||||
});
|
||||
it('builds the enterprise province and city library from distinct real phone segment regions', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, ConflictException, Injectable, Optional } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Injectable, NotFoundException, Optional } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { PhoneRoutingLookupService } from './phone-routing-lookup.service';
|
||||
@@ -545,6 +545,42 @@ export class DictionariesService {
|
||||
return this.prisma.commonReportField.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async updateCommonReportField(id: string, data: CreateCommonReportFieldDto, operatorId?: string) {
|
||||
if (!['signature', 'drainage'].includes(data.reportType) || typeof data.required !== 'boolean') {
|
||||
throw new BadRequestException('资料用途或是否必填无效');
|
||||
}
|
||||
if (data.sortOrder !== undefined && !Number.isInteger(data.sortOrder)) {
|
||||
throw new BadRequestException('排序值必须为整数');
|
||||
}
|
||||
const existing = await this.prisma.commonReportField.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('通用字段配置不存在');
|
||||
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
|
||||
if (!field || field.status !== 'active') throw new BadRequestException('报备字段库字段不存在或已停用');
|
||||
const duplicate = await this.prisma.commonReportField.findUnique({
|
||||
where: { drainageFieldId_reportType: { drainageFieldId: field.id, reportType: data.reportType } },
|
||||
});
|
||||
if (duplicate && duplicate.id !== id) throw new ConflictException('该字段已配置为对应类型的通用字段');
|
||||
try {
|
||||
return await this.prisma.$transaction(async (tx) => {
|
||||
const updated = await tx.commonReportField.update({
|
||||
where: { id },
|
||||
data: { drainageFieldId: field.id, reportType: data.reportType, required: data.required, sortOrder: data.sortOrder },
|
||||
include: { drainageField: true },
|
||||
});
|
||||
await tx.operationLog.create({ data: {
|
||||
userId: operatorId, action: 'common_report_field.update', resource: 'common_report_field', resourceId: id,
|
||||
detail: { before: { drainageFieldId: existing.drainageFieldId, reportType: existing.reportType, required: existing.required }, after: { drainageFieldId: field.id, reportType: data.reportType, required: data.required } },
|
||||
} });
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2002') {
|
||||
throw new ConflictException('该字段已配置为对应类型的通用字段');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
|
||||
@@ -12,6 +12,10 @@ describe('InfrastructureAlertSettingsService', () => {
|
||||
expect(rules).toContain('threshold: "120秒"');
|
||||
expect(rules).toContain('redis_memory_max_bytes > 0');
|
||||
expect(rules).toContain('sum(increase(cmpp_api_http_requests_total');
|
||||
expect(rules).not.toContain('mountpoint="/"');
|
||||
expect(rules).toContain('device=~"/dev/.+"');
|
||||
expect(rules).toContain('{{ $labels.mountpoint }}');
|
||||
expect(rules).toContain('{{ $labels.device }}');
|
||||
});
|
||||
|
||||
it('rejects unknown keys and warning thresholds that are not below critical', () => {
|
||||
|
||||
@@ -13,7 +13,7 @@ const execFileAsync = promisify(execFile);
|
||||
export const ALERT_THRESHOLD_DEFINITIONS = [
|
||||
{ key: 'hostCpu', label: '主机 CPU 使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)', names: ['HostCpuUsageWarning', 'HostCpuUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
||||
{ key: 'hostMemory', label: '主机内存使用率', unit: '%', min: 1, max: 100, step: 1, warning: 85, critical: 95, expr: '(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100', names: ['HostMemoryUsageWarning', 'HostMemoryUsageCritical'], service: 'host', durations: ['10m', '5m'] },
|
||||
{ key: 'hostDisk', label: '根磁盘使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '(1 - node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}) * 100', names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] },
|
||||
{ key: 'hostDisk', label: '磁盘(所有挂载点)使用率', unit: '%', min: 1, max: 100, step: 1, warning: 80, critical: 90, expr: '(1 - node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}) * 100', names: ['HostRootDiskUsageWarning', 'HostRootDiskUsageCritical'], service: 'host', durations: ['15m', '5m'] },
|
||||
{ key: 'apiError', label: 'API 5xx 错误率', unit: '%', min: 0.1, max: 100, step: 0.1, warning: 1, critical: 5, expr: '100 * sum(rate(cmpp_api_http_requests_total{status=~"5.."}[5m])) / clamp_min(sum(rate(cmpp_api_http_requests_total[5m])), 0.001)', guard: 'sum(increase(cmpp_api_http_requests_total{status=~"5.."}[5m])) >= 5', names: ['CmppApiHttpErrorRateWarning', 'CmppApiHttpErrorRateCritical'], service: 'api', durations: ['5m', '5m'] },
|
||||
{ key: 'apiLatency', label: 'API P95 响应时间', unit: '秒', min: 0.1, max: 60, step: 0.1, warning: 1, critical: 3, expr: 'histogram_quantile(0.95, sum by (le) (rate(cmpp_api_http_request_duration_seconds_bucket[10m])))', names: ['CmppApiLatencyWarning', 'CmppApiLatencyCritical'], service: 'api', durations: ['10m', '5m'] },
|
||||
{ key: 'apiEventLoop', label: 'API 事件循环 P99', unit: '秒', min: 0.01, max: 10, step: 0.01, warning: 0.2, critical: 1, expr: 'cmpp_api_nodejs_event_loop_lag_p99_seconds', names: ['CmppApiEventLoopLagWarning', 'CmppApiEventLoopLagCritical'], service: 'api', durations: ['10m', '5m'] },
|
||||
@@ -113,7 +113,8 @@ export class InfrastructureAlertSettingsService {
|
||||
// 低样本量与“未配置容量上限”必须继续作为固定保护条件,避免单次错误或除零结果触发伪告警。
|
||||
const guard = 'guard' in definition ? ` and (${definition.guard})` : '';
|
||||
const expr = isWarning ? `(${definition.expr} > ${values[0]}) and (${definition.expr} <= ${values[1]})${guard}` : `(${definition.expr} > ${values[1]})${guard}`;
|
||||
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`);
|
||||
const diskLocation = definition.key === 'hostDisk' ? ' 挂载点:{{ $labels.mountpoint }};设备:{{ $labels.device }}。' : '';
|
||||
lines.push(` - alert: ${definition.names[index]}`, ` expr: ${expr}`, ` for: ${definition.durations[index]}`, ' labels:', ` severity: ${isWarning ? 'warning' : 'critical'}`, ` service: ${definition.service}`, ' annotations:', ` summary: "${definition.label}${isWarning ? '达到警告阈值' : '达到严重阈值'}"`, ` description: "${definition.label}持续超过${values[index]}${definition.unit}。${diskLocation}"`, ' currentValue: "{{ $value }}"', ` threshold: "${values[index]}${definition.unit}"`);
|
||||
}
|
||||
}
|
||||
return `${lines.join('\n')}\n`;
|
||||
|
||||
@@ -41,6 +41,17 @@ export type InfrastructureAlert = {
|
||||
};
|
||||
|
||||
export type InfrastructureMonitoringOverview = {
|
||||
disks: Array<{
|
||||
id: string;
|
||||
instance: string;
|
||||
device: string;
|
||||
mountpoint: string;
|
||||
filesystem: string;
|
||||
usagePercent: number | null;
|
||||
totalBytes: number | null;
|
||||
availableBytes: number | null;
|
||||
trend: InfrastructureMetricPoint[];
|
||||
}>;
|
||||
available: boolean;
|
||||
range: InfrastructureMonitoringRange;
|
||||
collectedAt: string;
|
||||
|
||||
@@ -108,6 +108,30 @@ describe('InfrastructureMonitoringService', () => {
|
||||
expect(result.trends.cpuUsagePercent).toEqual([]);
|
||||
expect(result.serviceMetrics.every((item) => item.available === false)).toBe(true);
|
||||
expect(result.error).not.toContain('ECONNREFUSED');
|
||||
expect(result.disks).toEqual([]);
|
||||
});
|
||||
|
||||
it('keeps system, data and additional disks distinct regardless of Prometheus series order', async () => {
|
||||
const metrics = [
|
||||
{ instance: 'host:9100', device: '/dev/sdb1', mountpoint: '/data', fstype: 'ext4' },
|
||||
{ instance: 'host:9100', device: '/dev/sda2', mountpoint: '/', fstype: 'ext4' },
|
||||
{ instance: 'host:9100', device: '/dev/nvme1n1p1', mountpoint: '/archive', fstype: 'xfs' },
|
||||
];
|
||||
jest.spyOn(global, 'fetch').mockImplementation(async (input) => {
|
||||
const url = new URL(String(input));
|
||||
const query = url.searchParams.get('query') ?? '';
|
||||
if (url.pathname.endsWith('/alerts')) return success({ alerts: [] });
|
||||
if (!query.includes('node_filesystem_')) return success({ result: [] });
|
||||
expect(query).not.toContain('mountpoint="/"');
|
||||
if (url.pathname.endsWith('/query_range')) return success({ result: [...metrics].reverse().map((metric) => ({ metric, values: [[1765000060, metric.mountpoint === '/' ? '91' : '12']] })) });
|
||||
return success({ result: metrics.map((metric) => ({ metric, value: [1765000060, query.startsWith('(1') ? (metric.mountpoint === '/' ? '91' : '12') : query.includes('avail') ? '9' : '100'] })) });
|
||||
});
|
||||
const result = await new InfrastructureMonitoringService(new ConfigService(), prisma as never).overview('1h');
|
||||
expect(result.disks.map((disk) => disk.mountpoint)).toEqual(['/', '/archive', '/data']);
|
||||
expect(result.disks[0]).toMatchObject({ usagePercent: 91, totalBytes: 100, availableBytes: 9, trend: [{ timestamp: new Date(1765000060000).toISOString(), value: 91 }] });
|
||||
expect(result.disks[2].trend[0].value).toBe(12);
|
||||
expect(result.metrics.diskUsagePercent).toBe(91);
|
||||
expect(result.trends.diskUsagePercent[0].value).toBe(91);
|
||||
});
|
||||
|
||||
it('excludes only the current alert occurrence after the current administrator marks it read', async () => {
|
||||
|
||||
@@ -47,9 +47,9 @@ const QUERIES = {
|
||||
memoryUsagePercent: '(1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) * 100',
|
||||
memoryTotalBytes: 'node_memory_MemTotal_bytes',
|
||||
memoryAvailableBytes: 'node_memory_MemAvailable_bytes',
|
||||
diskUsagePercent: '(1 - (node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"} / node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"})) * 100',
|
||||
diskTotalBytes: 'node_filesystem_size_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
||||
diskAvailableBytes: 'node_filesystem_avail_bytes{mountpoint="/",fstype!~"tmpfs|overlay|squashfs"}',
|
||||
diskUsagePercent: '(1 - (node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"} / node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"})) * 100',
|
||||
diskTotalBytes: 'node_filesystem_size_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}',
|
||||
diskAvailableBytes: 'node_filesystem_avail_bytes{device=~"/dev/.+",fstype!~"tmpfs|devtmpfs|overlay|squashfs|ramfs"}',
|
||||
networkReceiveBytesPerSecond: 'sum(rate(node_network_receive_bytes_total{device!~"lo"}[5m]))',
|
||||
networkTransmitBytesPerSecond: 'sum(rate(node_network_transmit_bytes_total{device!~"lo"}[5m]))',
|
||||
load1: 'node_load1',
|
||||
@@ -132,6 +132,15 @@ function matrixValues(response: PrometheusQueryResponse): InfrastructureMetricPo
|
||||
});
|
||||
}
|
||||
|
||||
function diskIdentity(metric: Record<string, string>) {
|
||||
return JSON.stringify([metric.instance ?? '', metric.device ?? '', metric.mountpoint ?? '', metric.fstype ?? '']);
|
||||
}
|
||||
|
||||
// Preserve the legacy scalar fields as root-only; never silently pick the first disk.
|
||||
function rootSeries(response: PrometheusQueryResponse): PrometheusQueryResponse {
|
||||
return { ...response, data: { result: (response.data?.result ?? []).filter((item) => item.metric.mountpoint === '/') } };
|
||||
}
|
||||
|
||||
function emptyMetrics(): InfrastructureMonitoringOverview['metrics'] {
|
||||
return {
|
||||
cpuUsagePercent: null,
|
||||
@@ -200,7 +209,8 @@ export class InfrastructureMonitoringService {
|
||||
activeAlerts: alerts.length,
|
||||
},
|
||||
metrics: instant.metrics,
|
||||
trends,
|
||||
trends: trends.metrics,
|
||||
disks: instant.disks.map((disk) => ({ ...disk, trend: trends.disks.get(disk.id) ?? [] })),
|
||||
services,
|
||||
serviceMetrics,
|
||||
alerts,
|
||||
@@ -263,8 +273,21 @@ export class InfrastructureMonitoringService {
|
||||
const keys = Object.keys(emptyMetrics()) as Array<keyof InfrastructureMonitoringOverview['metrics']>;
|
||||
const responses = await Promise.all([...keys.map((key) => this.query(QUERIES[key])), this.query(QUERIES.lastSampleAt)]);
|
||||
const metrics = emptyMetrics();
|
||||
keys.forEach((key, index) => { metrics[key] = vectorValue(responses[index]); });
|
||||
return { metrics, lastSampleAt: vectorValue(responses[responses.length - 1]) };
|
||||
keys.forEach((key, index) => { metrics[key] = vectorValue(key.startsWith('disk') ? rootSeries(responses[index]) : responses[index]); });
|
||||
const diskSamples = (key: keyof typeof metrics) => responses[keys.indexOf(key)].data?.result ?? [];
|
||||
const usage = new Map(diskSamples('diskUsagePercent').map((item) => [diskIdentity(item.metric), finiteNumber(item.value?.[1])]));
|
||||
const available = new Map(diskSamples('diskAvailableBytes').map((item) => [diskIdentity(item.metric), finiteNumber(item.value?.[1])]));
|
||||
const disks = diskSamples('diskTotalBytes')
|
||||
.filter((item) => item.metric.mountpoint && (finiteNumber(item.value?.[1]) ?? 0) > 0)
|
||||
.map((item) => ({
|
||||
id: diskIdentity(item.metric), instance: item.metric.instance ?? '', device: item.metric.device ?? '',
|
||||
mountpoint: item.metric.mountpoint, filesystem: item.metric.fstype ?? '',
|
||||
totalBytes: finiteNumber(item.value?.[1]),
|
||||
availableBytes: available.get(diskIdentity(item.metric)) ?? null,
|
||||
usagePercent: usage.get(diskIdentity(item.metric)) ?? null,
|
||||
}))
|
||||
.sort((left, right) => left.instance.localeCompare(right.instance) || (left.mountpoint === '/' ? -1 : right.mountpoint === '/' ? 1 : left.mountpoint.localeCompare(right.mountpoint)));
|
||||
return { metrics, disks, lastSampleAt: vectorValue(responses[responses.length - 1]) };
|
||||
}
|
||||
|
||||
private async loadTrends(range: InfrastructureMonitoringRange) {
|
||||
@@ -273,7 +296,12 @@ export class InfrastructureMonitoringService {
|
||||
const start = end - config.seconds;
|
||||
const keys = Object.keys(emptyTrends()) as Array<keyof InfrastructureMonitoringOverview['trends']>;
|
||||
const responses = await Promise.all(keys.map((key) => this.queryRange(QUERIES[key], start, end, config.step)));
|
||||
return Object.fromEntries(keys.map((key, index) => [key, matrixValues(responses[index])])) as InfrastructureMonitoringOverview['trends'];
|
||||
return {
|
||||
metrics: Object.fromEntries(keys.map((key, index) => [key, matrixValues(key === 'diskUsagePercent' ? rootSeries(responses[index]) : responses[index])])) as InfrastructureMonitoringOverview['trends'],
|
||||
disks: new Map((responses[keys.indexOf('diskUsagePercent')].data?.result ?? []).map((item) => [
|
||||
diskIdentity(item.metric), matrixValues({ status: 'success', data: { result: [item] } }),
|
||||
])),
|
||||
};
|
||||
}
|
||||
|
||||
private parseServices(response: PrometheusQueryResponse): InfrastructureServiceStatus[] {
|
||||
@@ -356,6 +384,7 @@ export class InfrastructureMonitoringService {
|
||||
error: 'Prometheus监控数据当前不可用,请检查采集与服务状态',
|
||||
summary: { overallStatus: 'unknown', serviceTotal: services.length, serviceHealthy: 0, warningAlerts: 0, criticalAlerts: 0, activeAlerts: 0 },
|
||||
metrics: emptyMetrics(),
|
||||
disks: [],
|
||||
trends: emptyTrends(),
|
||||
services,
|
||||
serviceMetrics: SERVICE_METRIC_DEFINITIONS.map((group) => ({ key: group.key, name: group.name, available: false, metrics: [] })),
|
||||
|
||||
@@ -167,8 +167,9 @@ export class SmsApplicationConfigService {
|
||||
const merged = new Map<string, MergedReportField>();
|
||||
const routeChannels = new Map<string, { id: string; code: string; name: string; groupId: string; groupName: string }>();
|
||||
for (const route of routes) {
|
||||
if (!route.group) continue;
|
||||
if (!route.group || route.group.status === 'deleted') continue;
|
||||
for (const item of route.group.items) {
|
||||
if (item.channel.status === 'deleted') continue;
|
||||
if (!routeChannels.has(item.channel.id)) {
|
||||
routeChannels.set(item.channel.id, {
|
||||
id: item.channel.id,
|
||||
@@ -181,6 +182,17 @@ export class SmsApplicationConfigService {
|
||||
}
|
||||
}
|
||||
for (const configured of commonFields) {
|
||||
const existing = merged.get(configured.drainageField.id);
|
||||
if (existing) {
|
||||
existing.required ||= configured.required;
|
||||
if (!existing.reportTypes.includes(configured.reportType)) existing.reportTypes.push(configured.reportType);
|
||||
if (!existing.commonReportTypes.includes(configured.reportType)) existing.commonReportTypes.push(configured.reportType);
|
||||
for (const channel of existing.channels) {
|
||||
channel.required ||= configured.required;
|
||||
if (channel.reportType !== configured.reportType) channel.reportType = 'both';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
merged.set(configured.drainageField.id, {
|
||||
id: configured.drainageField.id,
|
||||
code: configured.drainageField.code,
|
||||
@@ -199,8 +211,9 @@ export class SmsApplicationConfigService {
|
||||
});
|
||||
}
|
||||
for (const route of routes) {
|
||||
if (!route.group) continue;
|
||||
if (!route.group || route.group.status === 'deleted') continue;
|
||||
for (const item of route.group.items) {
|
||||
if (item.channel.status === 'deleted') continue;
|
||||
for (const configured of item.channel.reportFields) {
|
||||
if (configured.status !== 'active' || !configured.drainageField || configured.drainageField.status !== 'active') continue;
|
||||
if (reportType && configured.reportType !== 'both' && configured.reportType !== reportType) continue;
|
||||
|
||||
@@ -181,7 +181,7 @@ export class SmsSignatureService {
|
||||
id: signatureId,
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
@@ -305,7 +305,7 @@ export class SmsSignatureService {
|
||||
const filteredWhere: Prisma.SmsSignatureWhereInput = {
|
||||
tenantId,
|
||||
applicationId: query.applicationId,
|
||||
auditStatus: query.status || { notIn: ['deleted', 'disabled'] },
|
||||
auditStatus: { notIn: ['deleted', 'disabled'], ...(query.status && query.status !== 'all' ? { equals: query.status } : {}) },
|
||||
OR: query.keyword?.trim() ? [
|
||||
{ name: { contains: query.keyword.trim() } },
|
||||
{ purpose: { contains: query.keyword.trim() } },
|
||||
@@ -427,7 +427,7 @@ export class SmsSignatureService {
|
||||
|
||||
async submitSignature(signatureId: string, tenantId?: string) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
|
||||
if (!signature || signature.auditStatus === 'deleted' || (tenantId && signature.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
|
||||
|
||||
@@ -114,6 +114,7 @@ function createPrismaMock() {
|
||||
create: jest.fn().mockResolvedValue({ id: 'drainage-record-1' }),
|
||||
},
|
||||
smsTemplate: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'tpl-1',
|
||||
tenantId: 'tenant-1',
|
||||
@@ -1024,6 +1025,56 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never lets client status filters bypass deleted signature exclusion and keeps template history filtered', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findMany.mockResolvedValue([]);
|
||||
prisma.smsSignature.groupBy.mockResolvedValue([] as never);
|
||||
prisma.smsTemplate.findMany.mockResolvedValue([]);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
for (const status of ['deleted', 'all', 'approved']) {
|
||||
await service.getClientSignatureWorkspace('tenant-1', { status });
|
||||
const filter = { notIn: ['deleted', 'disabled'], ...(status !== 'all' ? { equals: status } : {}) };
|
||||
expect(prisma.smsSignature.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: filter }) }));
|
||||
expect(prisma.smsSignature.count).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ auditStatus: filter }) }));
|
||||
}
|
||||
await service.listClientTemplates('tenant-1', true);
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ tenantId: 'tenant-1', auditStatus: { not: 'deleted' } }) }));
|
||||
await service.listTemplatesPage({ tenantId: 'tenant-1', status: 'all', page: 1, pageSize: 10 });
|
||||
expect(prisma.smsTemplate.count).toHaveBeenLastCalledWith(expect.objectContaining({ where: expect.objectContaining({ auditStatus: { not: 'deleted' } }) }));
|
||||
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-deleted', tenantId: 'tenant-1', auditStatus: 'deleted' } as never);
|
||||
prisma.smsTemplate.findUnique.mockResolvedValue({ id: 'tpl-deleted', tenantId: 'tenant-1', auditStatus: 'deleted' } as never);
|
||||
await expect(service.submitSignature('sig-deleted', 'tenant-1')).rejects.toThrow('not found');
|
||||
await expect(service.submitTemplate('tpl-deleted', 'tenant-1')).rejects.toThrow('not found');
|
||||
});
|
||||
|
||||
it('uses exactly the same editable common fields in client and admin signature forms', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const field = { id: 'field-common', code: 'license', name: '主体证明', fieldType: 'file', description: '最新配置', status: 'active' };
|
||||
prisma.commonReportField.findMany.mockResolvedValue([{ id: 'common-1', reportType: 'signature', required: true, drainageField: field }]);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
for (const applicationId of [undefined, 'app-1']) {
|
||||
const admin = await service.getApplicationReportFields(applicationId, 'signature');
|
||||
const client = await service.getClientApplicationReportFields(applicationId, 'signature');
|
||||
expect(client).toEqual(admin.map((item) => Object.fromEntries(Object.entries(item).filter(([key]) => !['channels', 'commonReportTypes'].includes(key)))));
|
||||
expect(client[0]).toMatchObject({ code: 'license', name: '主体证明', fieldType: 'file', required: true, description: '最新配置' });
|
||||
}
|
||||
prisma.commonReportField.findMany.mockResolvedValue([{ id: 'common-1', reportType: 'signature', required: false, drainageField: field }]);
|
||||
expect((await service.getClientApplicationReportFields(undefined, 'signature'))[0].required).toBe(false);
|
||||
});
|
||||
|
||||
it('keeps both common field purposes in material snapshots and ignores deleted channel requirements', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const field = { id: 'field-1', code: 'license', name: '主体证明', fieldType: 'file', status: 'active' };
|
||||
prisma.commonReportField.findMany.mockResolvedValue([
|
||||
{ reportType: 'signature', required: true, drainageField: field },
|
||||
{ reportType: 'drainage', required: false, drainageField: field },
|
||||
]);
|
||||
prisma.channelRouteRule.findMany.mockResolvedValue([{ group: { id: 'group-1', items: [{ channel: { id: 'deleted-channel', status: 'deleted', reportFields: [{ status: 'active', reportType: 'signature', required: true, drainageField: { ...field, id: 'old', code: 'old' } }] } }] } }] as never);
|
||||
const result = await new SmsConfigService(prisma as never).getApplicationReportFields('app-1');
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toMatchObject({ required: true, reportTypes: ['signature', 'drainage'], commonReportTypes: ['signature', 'drainage'], channels: [] });
|
||||
});
|
||||
|
||||
it('accepts a scheme-less drainage URL and synchronizes the compatibility name', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
|
||||
|
||||
@@ -176,7 +176,7 @@ export class SmsTemplateService {
|
||||
|
||||
async submitTemplate(templateId: string, tenantId?: string) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template || (tenantId && template.tenantId !== tenantId)) {
|
||||
if (!template || template.auditStatus === 'deleted' || (tenantId && template.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
await this.validateTemplateSignature(template.signatureId, template.tenantId, template.applicationId, template.content);
|
||||
|
||||
Reference in New Issue
Block a user