feat: add phone frequency controls and modularize codebase

This commit is contained in:
hectorzhao
2026-07-31 22:25:23 +08:00
parent 0af671b4ed
commit ca4f591a13
216 changed files with 41579 additions and 23694 deletions
+177
View File
@@ -0,0 +1,177 @@
import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { randomInt, randomUUID } from 'node:crypto';
import { isIpAllowed } from '../common/ip-allowlist';
import { assertMoneyUnits } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { automaticDeliveryMode } from '../open-api/delivery-mode';
import type { ApplicationListQuery, CreateSignatureMaterialDto, CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsSignatureOptions, CreateSmsTemplateDto, CreateSmsTemplateOptions, DrainageInfoListQuery, GatewayDownstreamConnectionEventDto, ReplaceApplicationRouteRulesDto, ReviewDto, SignatureListQuery, StatusChangeDto, TemplateListQuery, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.contracts';
import { APPLICATION_DISABLE_GRACE_MS, DEFAULT_APPLICATION_DISABLE_SCAN_INTERVAL_MS, DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS, UNRESOLVED_DOWNSTREAM_STATUSES, type TemplateVariableInput, estimateBillingUnits, generateApplicationPassword, getPositiveInteger, getPositiveIntegerEnv, hasReportValue, inferTemplateVariables, isRecord, normalizeApplicationCmppStatus, normalizeApplicationInterfaceType, normalizeApplicationPassword, normalizeApplicationQueuePriority, normalizeCmppAccessNumberConfig, normalizeSmsSignature, parseGatewayDate, reportValueParts, startOfToday, validateAndNormalizeTemplateVariables, validateCompleteSmsSignature } from './sms-config.helpers';
import { SmsApplicationLifecycleService } from './application-lifecycle.service';
import { SmsReportValidationService } from './report-validation.service';
/** R3 domain service. Kept framework-agnostic and composed behind SmsConfigService. */
export class SmsAuditService {
constructor(private readonly prisma: PrismaService, private readonly lifecycle: SmsApplicationLifecycleService, private readonly reportValidation: SmsReportValidationService) {}
listAuditRecords(targetType?: string, targetId?: string) {
return this.prisma.auditRecord.findMany({
where: {
targetType,
targetId,
},
include: {
reviewer: { select: { id: true, username: true, displayName: true } },
},
orderBy: { createdAt: 'desc' },
});
}
approveSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'approved', 'approve', data);
}
rejectSignature(signatureId: string, data: ReviewDto) {
return this.reviewSignature(signatureId, 'rejected', 'reject', data);
}
approveTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'approved', 'approve', data);
}
rejectTemplate(templateId: string, data: ReviewDto) {
return this.reviewTemplate(templateId, 'rejected', 'reject', data);
}
async changeSignatureStatus(signatureId: string, data: StatusChangeDto, tenantId?: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature || (tenantId && signature.tenantId !== tenantId)) {
throw new NotFoundException('Signature not found');
}
const status = data.status ?? 'deleted';
const updated = await this.prisma.smsSignature.update({ where: { id: signatureId }, data: { auditStatus: status } });
await this.lifecycle.writeOperationLog(signature.tenantId, data.operatorId, `sms_signature.${status}`, 'sms_signature', signatureId, {
statusBefore: signature.auditStatus,
statusAfter: status,
reason: data.reason,
});
return updated;
}
async changeTemplateStatus(templateId: string, data: StatusChangeDto, tenantId?: string) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template || (tenantId && template.tenantId !== tenantId)) {
throw new NotFoundException('Template not found');
}
const status = data.status ?? 'deleted';
const updated = await this.prisma.smsTemplate.update({ where: { id: templateId }, data: { auditStatus: status } });
await this.lifecycle.writeOperationLog(template.tenantId, data.operatorId, `sms_template.${status}`, 'sms_template', templateId, {
statusBefore: template.auditStatus,
statusAfter: status,
reason: data.reason,
});
return updated;
}
async reviewSignature(signatureId: string, statusAfter: string, action: string, data: ReviewDto) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) {
throw new NotFoundException('Signature not found');
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsSignature.update({
where: { id: signatureId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null,
},
});
await this.createAuditRecord({
tenantId: signature.tenantId,
targetType: 'sms_signature',
targetId: signatureId,
action,
statusBefore: signature.auditStatus,
statusAfter,
reason: data.reason,
reviewerId,
});
return updated;
}
async reviewDrainageInfo(itemId: string, statusAfter: string, action: string, data: ReviewDto) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
if (!['pending', 'rejected'].includes(item.auditStatus)) {
throw new BadRequestException('只有待审核或已驳回的引流信息可以审核');
}
if (statusAfter === 'rejected' && !data.reason?.trim()) {
throw new BadRequestException('驳回引流信息时必须填写原因');
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsDrainageInfo.update({
where: { id: itemId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason?.trim() : null,
reviewedAt: new Date(),
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action,
statusBefore: item.auditStatus,
statusAfter,
reason: data.reason,
reviewerId,
});
if (statusAfter === 'approved') await this.reportValidation.activateDrainageReporting(itemId);
else await this.reportValidation.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
return updated;
}
async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template) {
throw new NotFoundException('Template not found');
}
const reviewerId = await this.resolveReviewerId(data.reviewerId);
const updated = await this.prisma.smsTemplate.update({
where: { id: templateId },
data: {
auditStatus: statusAfter,
rejectReason: statusAfter === 'rejected' ? data.reason : null,
},
});
await this.createAuditRecord({
tenantId: template.tenantId,
targetType: 'sms_template',
targetId: templateId,
action,
statusBefore: template.auditStatus,
statusAfter,
reason: data.reason,
reviewerId,
});
return updated;
}
async resolveReviewerId(reviewerId?: string) {
if (!reviewerId) {
return undefined;
}
const reviewer = await this.prisma.user.findUnique({ where: { id: reviewerId }, select: { id: true } });
if (!reviewer) {
throw new BadRequestException('reviewerId does not reference an existing user');
}
return reviewerId;
}
createAuditRecord(data: Prisma.AuditRecordUncheckedCreateInput) {
return this.prisma.auditRecord.create({ data });
}
}