fix: align reporting fields queries and disk monitoring

This commit is contained in:
hectorzhao
2026-08-31 12:19:39 +08:00
parent 119d57772e
commit b0deef5e6e
37 changed files with 596 additions and 110 deletions
@@ -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([
+37 -1
View File
@@ -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;
+3 -3
View File
@@ -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' });
+1 -1
View File
@@ -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);