feat: complete drainage review and admin search workflows

This commit is contained in:
hectorzhao
2026-07-14 09:58:18 +08:00
parent dec2430b7e
commit 6421671259
33 changed files with 1107 additions and 255 deletions
@@ -0,0 +1,78 @@
CREATE TABLE "SmsDrainageInfo" (
"id" TEXT NOT NULL,
"tenantId" TEXT NOT NULL,
"signatureId" TEXT NOT NULL,
"applicationId" TEXT,
"siteName" TEXT NOT NULL,
"url" TEXT NOT NULL,
"remark" TEXT,
"reportValues" JSONB,
"auditStatus" TEXT NOT NULL DEFAULT 'pending',
"rejectReason" TEXT,
"submittedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"reviewedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SmsDrainageInfo_pkey" PRIMARY KEY ("id")
);
CREATE TEMP TABLE drainage_backfill_map AS
SELECT
signature."id" AS "signatureId",
COALESCE(NULLIF(item.value->>'id', ''), item.ordinality::text) AS "oldId",
'drain_' || md5(signature."id" || ':' || COALESCE(NULLIF(item.value->>'id', ''), item.ordinality::text)) AS "newId",
signature."tenantId" AS "tenantId",
signature."applicationId" AS "applicationId",
item.value AS item
FROM "SmsSignature" signature
CROSS JOIN LATERAL jsonb_array_elements(COALESCE(signature."drainageInfo"->'links', '[]'::jsonb)) WITH ORDINALITY AS item(value, ordinality);
INSERT INTO "SmsDrainageInfo" (
"id", "tenantId", "signatureId", "applicationId", "siteName", "url", "remark", "reportValues",
"auditStatus", "submittedAt", "createdAt", "updatedAt"
)
SELECT
"newId",
"tenantId",
"signatureId",
"applicationId",
COALESCE(NULLIF(item->>'siteName', ''), '未命名站点'),
COALESCE(item->>'url', ''),
NULLIF(item->>'remark', ''),
COALESCE(item->'reportValues', '{}'::jsonb),
CASE WHEN item->>'auditStatus' IN ('pending', 'approved', 'rejected') THEN item->>'auditStatus' ELSE 'pending' END,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP,
CURRENT_TIMESTAMP
FROM drainage_backfill_map;
UPDATE "ChannelSignatureReportTask" task
SET "drainageItemId" = map."newId"
FROM drainage_backfill_map map
WHERE task."signatureId" = map."signatureId"
AND task."reportType" = 'drainage'
AND task."drainageItemId" = map."oldId";
UPDATE "DrainageReportMaterial" material
SET "drainageItemId" = map."newId"
FROM drainage_backfill_map map
WHERE material."signatureId" = map."signatureId"
AND material."drainageItemId" = map."oldId";
DROP TABLE drainage_backfill_map;
CREATE INDEX "SmsDrainageInfo_tenantId_auditStatus_updatedAt_idx"
ON "SmsDrainageInfo"("tenantId", "auditStatus", "updatedAt");
CREATE INDEX "SmsDrainageInfo_signatureId_auditStatus_idx"
ON "SmsDrainageInfo"("signatureId", "auditStatus");
CREATE INDEX "SmsDrainageInfo_applicationId_idx"
ON "SmsDrainageInfo"("applicationId");
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_tenantId_fkey"
FOREIGN KEY ("tenantId") REFERENCES "Tenant"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_signatureId_fkey"
FOREIGN KEY ("signatureId") REFERENCES "SmsSignature"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "SmsDrainageInfo" ADD CONSTRAINT "SmsDrainageInfo_applicationId_fkey"
FOREIGN KEY ("applicationId") REFERENCES "SmsApplication"("id") ON DELETE SET NULL ON UPDATE CASCADE;
ALTER TABLE "ChannelSignatureReportTask" ADD CONSTRAINT "ChannelSignatureReportTask_drainageItemId_fkey"
FOREIGN KEY ("drainageItemId") REFERENCES "SmsDrainageInfo"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
@@ -0,0 +1,2 @@
ALTER TABLE "ChannelSignatureReportRecord"
ADD COLUMN "sourceEntry" TEXT NOT NULL DEFAULT 'system';
+31
View File
@@ -26,6 +26,7 @@ model Tenant {
smsBillingRecords SmsBillingRecord[]
smsApplications SmsApplication[]
smsSignatures SmsSignature[]
smsDrainageInfos SmsDrainageInfo[]
smsTemplates SmsTemplate[]
auditRecords AuditRecord[]
riskRules RiskRule[]
@@ -366,6 +367,7 @@ model SmsApplication {
tenant Tenant @relation(fields: [tenantId], references: [id])
ipAllowlist SmsApplicationIpAllowlist[]
signatures SmsSignature[]
drainageInfos SmsDrainageInfo[]
templates SmsTemplate[]
enterpriseBlacklists EnterpriseBlacklist[]
sendTasks SmsSendTask[]
@@ -413,6 +415,7 @@ model SmsSignature {
templates SmsTemplate[]
reportMaterials SignatureReportMaterial[]
drainageReportMaterials DrainageReportMaterial[]
drainageItems SmsDrainageInfo[]
reportTasks ChannelSignatureReportTask[]
messageRecords SmsMessageRecord[]
@@ -420,6 +423,32 @@ model SmsSignature {
@@index([tenantId, reportStatus])
}
model SmsDrainageInfo {
id String @id @default(cuid())
tenantId String
signatureId String
applicationId String?
siteName String
url String
remark String?
reportValues Json?
auditStatus String @default("pending")
rejectReason String?
submittedAt DateTime @default(now())
reviewedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
tenant Tenant @relation(fields: [tenantId], references: [id])
signature SmsSignature @relation(fields: [signatureId], references: [id], onDelete: Cascade)
application SmsApplication? @relation(fields: [applicationId], references: [id])
reportTasks ChannelSignatureReportTask[]
@@index([tenantId, auditStatus, updatedAt])
@@index([signatureId, auditStatus])
@@index([applicationId])
}
model SignatureMaterial {
id String @id @default(cuid())
signatureId String
@@ -730,6 +759,7 @@ model ChannelSignatureReportTask {
signature SmsSignature @relation(fields: [signatureId], references: [id])
channel SmsChannel @relation(fields: [channelId], references: [id])
drainageInfo SmsDrainageInfo? @relation(fields: [drainageItemId], references: [id])
records ChannelSignatureReportRecord[]
exportFiles ReportExportFile[]
receiptImports ReportReceiptImport[]
@@ -749,6 +779,7 @@ model ChannelSignatureReportRecord {
statusAfter String
reason String?
operatorId String?
sourceEntry String @default("system")
createdAt DateTime @default(now())
task ChannelSignatureReportTask @relation(fields: [taskId], references: [id], onDelete: Cascade)
+2 -2
View File
@@ -146,8 +146,8 @@ export class ChannelsController {
}
@Get('report-tasks')
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string) {
return this.channels.listReportTasks(tenantId, status, channelId);
listReportTasks(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('channelId') channelId?: string, @Query('reportType') reportType?: string) {
return this.channels.listReportTasks(tenantId, status, channelId, reportType);
}
@Post('report-tasks/generate')
+9 -4
View File
@@ -108,6 +108,9 @@ function createPrismaMock() {
findUnique: jest.fn().mockResolvedValue(reportTask),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ ...reportTask, ...data })),
},
smsDrainageInfo: {
findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }),
},
channelSignatureReportRecord: {
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'record-1', ...data })),
findMany: jest.fn(),
@@ -183,8 +186,8 @@ describe('ChannelsService', () => {
await service.listReportTasks(undefined, undefined, 'channel-1');
expect(prisma.channelSignatureReportTask.findMany).toHaveBeenCalledWith({
where: { tenantId: undefined, status: undefined, channelId: 'channel-1' },
include: { signature: true, channel: true },
where: { tenantId: undefined, status: undefined, channelId: 'channel-1', reportType: undefined },
include: { signature: true, channel: true, drainageInfo: true },
orderBy: { createdAt: 'desc' },
});
});
@@ -197,6 +200,7 @@ describe('ChannelsService', () => {
update: jest.fn().mockResolvedValue({ id: 'sig-1', reportStatus: 'approved' }),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'task-1', signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }),
@@ -209,10 +213,10 @@ describe('ChannelsService', () => {
prisma.$transaction.mockImplementation((callback) => callback(tx));
const service = new ChannelsService(prisma as never);
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认' })).resolves.toEqual([
await expect(service.changeReportTaskStatuses({ items: [{ signatureId: 'sig-1', channelId: 'channel-1', status: 'approved' }], reason: '运营商确认', sourceEntry: 'enterprise_signature' })).resolves.toEqual([
expect.objectContaining({ signatureId: 'sig-1', reportStatus: 'approved' }),
]);
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved' }) });
expect(tx.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'manual_status_change', statusBefore: 'reporting', statusAfter: 'approved', sourceEntry: 'enterprise_signature' }) });
expect(tx.smsSignature.update).toHaveBeenCalledWith({ where: { id: 'sig-1' }, data: { reportStatus: 'approved' } });
});
@@ -224,6 +228,7 @@ describe('ChannelsService', () => {
update: jest.fn(),
},
smsChannel: { findUnique: jest.fn().mockResolvedValue({ id: 'channel-1', carrier: 'mobile', status: 'active' }) },
smsDrainageInfo: { findUnique: jest.fn().mockResolvedValue({ id: 'drain-1', signatureId: 'sig-1', auditStatus: 'approved' }) },
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue({ id: 'drainage-task-1', signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'reporting' }),
update: jest.fn().mockResolvedValue({ id: 'drainage-task-1', status: 'approved' }),
+28 -6
View File
@@ -107,6 +107,7 @@ export interface ChangeReportTaskStatusesDto {
items: Array<{ signatureId: string; channelId: string; status: string; reportType?: 'signature' | 'drainage'; drainageItemId?: string }>;
reason?: string;
operatorId?: string;
sourceEntry?: 'enterprise_signature' | 'report_task' | 'channel_report';
}
export interface CreateReportExportDto {
@@ -912,6 +913,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
}
async createReportField(data: CreateReportFieldDto) {
if (!data.drainageFieldId) throw new BadRequestException('drainageFieldId is required');
const field = await this.prisma.drainageField.findUnique({ where: { id: data.drainageFieldId } });
if (!field || field.status !== 'active') {
throw new BadRequestException('报备字段库字段不存在或已停用');
@@ -966,10 +968,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
});
}
listReportTasks(tenantId?: string, status?: string, channelId?: string) {
listReportTasks(tenantId?: string, status?: string, channelId?: string, reportType?: string) {
return this.prisma.channelSignatureReportTask.findMany({
where: { tenantId, status, channelId },
include: { signature: true, channel: true },
where: { tenantId, status, channelId, reportType },
include: { signature: true, channel: true, drainageInfo: true },
orderBy: { createdAt: 'desc' },
});
}
@@ -977,13 +979,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
async createReportTask(data: CreateReportTaskDto) {
const reportType = data.reportType ?? 'signature';
if (reportType === 'drainage' && !data.drainageItemId) throw new BadRequestException('drainageItemId is required');
if (reportType === 'drainage') {
const drainageInfo = await this.prisma.smsDrainageInfo.findUnique({ where: { id: data.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== data.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
throw new BadRequestException('引流信息通道报备任务由运营审核通过后按应用路由自动生成');
}
const task = await this.prisma.channelSignatureReportTask.create({
data: {
tenantId: data.tenantId,
signatureId: data.signatureId,
channelId: data.channelId,
reportType,
drainageItemId: reportType === 'drainage' ? data.drainageItemId : undefined,
drainageItemId: undefined,
createdById: data.createdById,
status: 'pending',
},
@@ -998,6 +1006,10 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
for (const item of data.items) {
if (!allowed.has(item.status)) throw new BadRequestException('unsupported report task status');
}
const sourceEntry = data.sourceEntry ?? 'report_task';
if (!['enterprise_signature', 'report_task', 'channel_report'].includes(sourceEntry)) {
throw new BadRequestException('unsupported report task source entry');
}
return this.prisma.$transaction(async (tx) => {
const signatureIds = [...new Set(data.items.filter((item) => (item.reportType ?? 'signature') === 'signature').map((item) => item.signatureId))];
const drainageResults: Array<{ signatureId: string; reportType: 'drainage'; drainageItemId: string; channelId: string; status: string }> = [];
@@ -1007,11 +1019,17 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
const signature = await tx.smsSignature.findUnique({ where: { id: item.signatureId } });
const channel = await tx.smsChannel.findUnique({ where: { id: item.channelId } });
if (!signature || !channel) throw new NotFoundException('Signature or channel not found');
if (reportType === 'drainage') {
const drainageInfo = await tx.smsDrainageInfo.findUnique({ where: { id: item.drainageItemId! } });
if (!drainageInfo || drainageInfo.signatureId !== item.signatureId) throw new NotFoundException('Drainage info not found');
if (drainageInfo.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能修改通道报备状态');
}
const existing = await tx.channelSignatureReportTask.findFirst({ where: { signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : null } });
if (reportType === 'drainage' && !existing) throw new BadRequestException('引流信息通道报备任务不存在,请先完成运营审核');
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: item.status, reason: data.reason } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: signature.tenantId, signatureId: item.signatureId, channelId: item.channelId, reportType, drainageItemId: reportType === 'drainage' ? item.drainageItemId : undefined, status: item.status, reason: data.reason, createdById: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: item.channelId, action: 'manual_status_change', statusBefore: existing?.status, statusAfter: item.status, reason: data.reason, operatorId: data.operatorId, sourceEntry } });
if (reportType === 'drainage') drainageResults.push({ signatureId: item.signatureId, reportType, drainageItemId: item.drainageItemId!, channelId: item.channelId, status: item.status });
}
const summaries = [];
@@ -1086,15 +1104,19 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
listReportRecords(taskId?: string, channelId?: string) {
return this.prisma.channelSignatureReportRecord.findMany({
where: { taskId, channelId },
include: { channel: true, task: { include: { signature: true, drainageInfo: true } } },
orderBy: { createdAt: 'desc' },
});
}
private async getReportTaskOrThrow(taskId: string) {
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId } });
const task = await this.prisma.channelSignatureReportTask.findUnique({ where: { id: taskId }, include: { drainageInfo: true } });
if (!task) {
throw new NotFoundException('Report task not found');
}
if (task.reportType === 'drainage' && task.drainageInfo?.auditStatus !== 'approved') {
throw new BadRequestException('引流信息审核通过后才能处理通道报备任务');
}
return task;
}
@@ -80,8 +80,8 @@ export class DictionariesController {
}
@Get('blacklists/enterprise')
listEnterpriseBlacklist(@Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.dictionaries.listEnterpriseBlacklist({ tenantId, applicationId, keyword, status });
listEnterpriseBlacklist(@Query('tenantId') tenantId?: string, @Query('applicationId') applicationId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('phoneNumber') phoneNumber?: string, @Query('reasonKeyword') reasonKeyword?: string) {
return this.dictionaries.listEnterpriseBlacklist({ tenantId, applicationId, keyword, status, enterpriseKeyword, applicationKeyword, phoneNumber, reasonKeyword });
}
@Post('blacklists/enterprise')
@@ -73,7 +73,7 @@ describe('DictionariesService', () => {
await service.listSensitiveWords({ keyword: '贷款', status: 'active' });
await service.listGlobalBlacklist({ keyword: '138', status: 'active' });
await service.listEnterpriseBlacklist({ tenantId: 'tenant-1', applicationId: 'app-1', keyword: '投诉', status: 'active' });
await service.listEnterpriseBlacklist({ enterpriseKeyword: '租户', applicationKeyword: '应用', phoneNumber: '138', reasonKeyword: '投诉', status: 'active' });
expect(prisma.sensitiveWord.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
@@ -82,7 +82,7 @@ describe('DictionariesService', () => {
where: expect.objectContaining({ status: 'active', OR: expect.any(Array) }),
}));
expect(prisma.enterpriseBlacklist.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', status: 'active', OR: expect.any(Array) }),
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, application: { name: { contains: '应用' } }, phoneNumber: { contains: '138' }, reason: { contains: '投诉' } }),
include: { tenant: true, application: true },
}));
});
@@ -64,6 +64,10 @@ export interface DictionaryListQuery {
applicationId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
phoneNumber?: string;
reasonKeyword?: string;
}
@Injectable()
@@ -203,6 +207,10 @@ export class DictionariesService {
tenantId: query.tenantId,
applicationId: query.applicationId,
status: query.status && query.status !== 'all' ? query.status : undefined,
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
reason: query.reasonKeyword ? { contains: query.reasonKeyword } : undefined,
OR: query.keyword ? [
{ phoneNumber: { contains: query.keyword } },
{ reason: { contains: query.keyword } },
@@ -40,6 +40,9 @@ function createPrismaMock() {
smsSignature: {
count: jest.fn().mockResolvedValue(1),
},
smsDrainageInfo: {
count: jest.fn().mockResolvedValue(0),
},
enterpriseCertification: {
count: jest.fn().mockResolvedValue(1),
},
@@ -261,6 +264,7 @@ describe('OperationsService', () => {
enterpriseCertifications: 1,
smsAudits: 2,
signatures: 1,
drainageInfos: 0,
templates: 1,
total: 5,
},
+4 -2
View File
@@ -731,14 +731,16 @@ export class OperationsService {
return Promise.all([
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.smsDrainageInfo.count({ where: { tenantId, auditStatus: 'pending' } }),
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
this.prisma.smsSendTask.count({ where: { tenantId, status: 'pending_review' } }),
]).then(([templates, signatures, enterpriseCertifications, smsAudits]) => ({
]).then(([templates, signatures, drainageInfos, enterpriseCertifications, smsAudits]) => ({
templates,
signatures,
drainageInfos,
enterpriseCertifications,
smsAudits,
total: templates + signatures + enterpriseCertifications + smsAudits,
total: templates + signatures + drainageInfos + enterpriseCertifications + smsAudits,
}));
}
@@ -1,6 +1,6 @@
import { Body, Controller, Get, Param, Post, Put, Query } from '@nestjs/common';
import { ApiTags } from '@nestjs/swagger';
import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
import { CreateSmsApplicationDto, CreateSmsDrainageInfoDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsDrainageInfoDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
@ApiTags('admin-sms-config')
@Controller('admin')
@@ -8,8 +8,8 @@ export class AdminSmsConfigController {
constructor(private readonly smsConfig: SmsConfigService) {}
@Get('enterprise-applications')
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('status') status?: string) {
return this.smsConfig.listApplications({ tenantId, keyword, enterpriseKeyword, applicationKeyword, status, includeConnections: true });
}
@Get('enterprise-applications/:id')
@@ -48,8 +48,8 @@ export class AdminSmsConfigController {
}
@Get('enterprise-signatures')
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
return this.smsConfig.listSignatures({ tenantId, keyword, status });
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('signatureKeyword') signatureKeyword?: string, @Query('drainageKeyword') drainageKeyword?: string) {
return this.smsConfig.listSignatures({ tenantId, keyword, status, enterpriseKeyword, applicationKeyword, signatureKeyword, drainageKeyword });
}
@Post('enterprise-signatures')
@@ -62,9 +62,39 @@ export class AdminSmsConfigController {
return this.smsConfig.updateSignature(signatureId, body);
}
@Get('drainage-infos')
listDrainageInfos(@Query('tenantId') tenantId?: string, @Query('signatureId') signatureId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listDrainageInfos({ tenantId, signatureId, status, keyword });
}
@Post('enterprise-signatures/:id/drainage-infos')
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto) {
return this.smsConfig.createDrainageInfo(signatureId, body, { initialAuditStatus: 'approved' });
}
@Put('drainage-infos/:id')
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto) {
return this.smsConfig.updateDrainageInfo(itemId, body, { initialAuditStatus: 'approved' });
}
@Post('drainage-infos/:id/approve')
approveDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
return this.smsConfig.approveDrainageInfo(itemId, body);
}
@Post('drainage-infos/:id/reject')
rejectDrainageInfo(@Param('id') itemId: string, @Body() body: ReviewDto) {
return this.smsConfig.rejectDrainageInfo(itemId, body);
}
@Post('drainage-infos/:id/status')
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.changeDrainageInfoStatus(itemId, body);
}
@Get('enterprise-templates')
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string) {
return this.smsConfig.listTemplates({ tenantId, status, keyword });
listTemplates(@Query('tenantId') tenantId?: string, @Query('status') status?: string, @Query('keyword') keyword?: string, @Query('enterpriseKeyword') enterpriseKeyword?: string, @Query('applicationKeyword') applicationKeyword?: string, @Query('nameKeyword') nameKeyword?: string, @Query('contentKeyword') contentKeyword?: string) {
return this.smsConfig.listTemplates({ tenantId, status, keyword, enterpriseKeyword, applicationKeyword, nameKeyword, contentKeyword });
}
@Post('enterprise-templates')
@@ -4,11 +4,13 @@ import { TenantId } from '../common/tenant-id.decorator';
import {
CreateSignatureMaterialDto,
CreateSmsApplicationDto,
CreateSmsDrainageInfoDto,
CreateSmsSignatureDto,
CreateSmsTemplateDto,
StatusChangeDto,
SmsConfigService,
UpdateSmsTemplateDto,
UpdateSmsDrainageInfoDto,
} from './sms-config.service';
@ApiTags('client-sms-config')
@@ -31,6 +33,11 @@ export class ClientSmsConfigController {
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
}
@Get('applications/:id/report-fields')
getApplicationReportFields(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
return this.smsConfig.getApplication(applicationId, tenantId).then(() => this.smsConfig.getApplicationReportFields(applicationId, 'drainage'));
}
@Post('applications/:id/secret/reset')
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
return this.smsConfig.resetApplicationSecret(applicationId, body);
@@ -56,6 +63,26 @@ export class ClientSmsConfigController {
return this.smsConfig.createSignatureMaterial({ ...body, signatureId });
}
@Get('drainage-infos')
listDrainageInfos(@TenantId() tenantId?: string) {
return this.smsConfig.listDrainageInfos({ tenantId });
}
@Post('signatures/:id/drainage-infos')
createDrainageInfo(@Param('id') signatureId: string, @Body() body: CreateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
return this.smsConfig.createDrainageInfo(signatureId, body, {}, tenantId);
}
@Put('drainage-infos/:id')
updateDrainageInfo(@Param('id') itemId: string, @Body() body: UpdateSmsDrainageInfoDto, @TenantId() tenantId?: string) {
return this.smsConfig.updateDrainageInfo(itemId, body, {}, tenantId);
}
@Post('drainage-infos/:id/status')
changeDrainageInfoStatus(@Param('id') itemId: string, @Body() body: StatusChangeDto, @TenantId() tenantId?: string) {
return this.smsConfig.changeDrainageInfoStatus(itemId, body, tenantId);
}
@Post('signatures/:id/submit')
submitSignature(@Param('id') signatureId: string) {
return this.smsConfig.submitSignature(signatureId);
+67 -20
View File
@@ -64,6 +64,8 @@ function createPrismaMock() {
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
application: { id: 'app-1', name: '应用A' },
materials: [],
drainageItems: [],
reportTasks: [],
}]),
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-new', tenantId: 'tenant-1', ...data })),
@@ -74,11 +76,20 @@ function createPrismaMock() {
},
drainageReportMaterial: {
upsert: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
create: jest.fn().mockResolvedValue({ id: 'drainage-report-value-1' }),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
smsDrainageInfo: {
findMany: jest.fn().mockResolvedValue([]),
findUnique: jest.fn().mockResolvedValue(null),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', createdAt: new Date(), updatedAt: new Date(), submittedAt: new Date(), ...data })),
},
channelSignatureReportTask: {
findFirst: jest.fn().mockResolvedValue(null),
findMany: jest.fn().mockResolvedValue([]),
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'drainage-task-1', ...data })),
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
},
channelSignatureReportRecord: {
@@ -188,7 +199,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listApplications({ includeConnections: true })).resolves.toEqual([
await expect(service.listApplications({ includeConnections: true, enterpriseKeyword: '租户', applicationKeyword: '应用', status: 'active' })).resolves.toEqual([
expect.objectContaining({
id: 'app-1',
cmppStatus: 'connected',
@@ -199,6 +210,7 @@ describe('SmsConfigService', () => {
}),
]);
expect(prisma.smsApplication.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({ status: 'active', tenant: { name: { contains: '租户' } }, name: { contains: '应用' } }),
include: { tenant: true, ipAllowlist: true },
}));
expect(prisma.smsApplication.findMany.mock.calls[0][0]).not.toHaveProperty('take');
@@ -427,7 +439,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listSignatures({ keyword: '签名A' })).resolves.toEqual([
await expect(service.listSignatures({ enterpriseKeyword: '租户', applicationKeyword: '应用', signatureKeyword: '签名', drainageKeyword: '官网' })).resolves.toEqual([
expect.objectContaining({
id: 'sig-1',
tenant: expect.objectContaining({ name: '租户A' }),
@@ -437,9 +449,18 @@ describe('SmsConfigService', () => {
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
auditStatus: { not: 'deleted' },
OR: expect.any(Array),
tenant: { name: { contains: '租户' } },
application: { name: { contains: '应用' } },
name: { contains: '签名' },
drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }),
}),
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
include: {
materials: true,
tenant: true,
application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } },
},
}));
});
@@ -473,7 +494,7 @@ describe('SmsConfigService', () => {
]);
});
it('validates and persists dynamic signature and drainage report values by channel', async () => {
it('validates and persists dynamic signature report values by channel without bypassing drainage audit', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'draft' });
prisma.smsSignature.update.mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', ...data }));
@@ -498,25 +519,48 @@ describe('SmsConfigService', () => {
applicationId: 'app-1',
drainageInfo: {
signatureReportValues: { license: { fileObjectId: 'file-1', fileName: 'license.pdf' } },
links: [{ id: 'drain-1', reportValues: { site_owner: '企业A' } }],
links: [{ id: 'legacy-drain-1', reportValues: { site_owner: '企业A' } }],
},
});
expect(prisma.signatureReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', fieldCode: 'license', fileObjectId: 'file-1' }),
}));
expect(prisma.drainageReportMaterial.upsert).toHaveBeenCalledWith(expect.objectContaining({
create: expect.objectContaining({ signatureId: 'sig-1', drainageItemId: 'drain-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }),
}));
expect(prisma.drainageReportMaterial.deleteMany).toHaveBeenCalledWith({
where: { signatureId: 'sig-1', drainageItemId: { notIn: ['drain-1'] } },
});
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({
data: expect.objectContaining({ signatureId: 'sig-1', channelId: 'channel-1', reportType: 'drainage', drainageItemId: 'drain-1', status: 'pending' }),
});
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({
data: expect.objectContaining({ taskId: 'drainage-task-1', action: 'create', statusAfter: 'pending' }),
});
expect(prisma.drainageReportMaterial.create).not.toHaveBeenCalled();
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('creates client drainage info as pending without generating channel report tasks', async () => {
const prisma = createPrismaMock();
prisma.smsSignature.findUnique.mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', applicationId: 'app-1', auditStatus: 'approved' });
prisma.channelRouteRule.findMany.mockResolvedValue([]);
const service = new SmsConfigService(prisma as never);
await expect(service.createDrainageInfo('sig-1', { siteName: '官网', url: 'https://example.com', reportValues: {} }, {}, 'tenant-1'))
.resolves.toEqual(expect.objectContaining({ id: 'drainage-1', auditStatus: 'pending' }));
expect(prisma.auditRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ targetType: 'sms_drainage_info', action: 'submit', statusAfter: 'pending' }) });
expect(prisma.channelSignatureReportTask.create).not.toHaveBeenCalled();
});
it('creates real drainage materials and channel tasks after operations approval', async () => {
const prisma = createPrismaMock();
const pendingItem = { id: 'drainage-1', tenantId: 'tenant-1', signatureId: 'sig-1', applicationId: 'app-1', siteName: '官网', url: 'https://example.com', reportValues: { site_owner: '企业A' }, auditStatus: 'pending' };
const approvedItem = { ...pendingItem, auditStatus: 'approved', signature: { id: 'sig-1', applicationId: 'app-1' } };
prisma.smsDrainageInfo.findUnique.mockResolvedValueOnce(pendingItem).mockResolvedValueOnce(approvedItem);
prisma.smsDrainageInfo.update.mockResolvedValue({ ...approvedItem, tenant: {}, application: {} });
prisma.channelRouteRule.findMany.mockResolvedValue([{
id: 'route-1', priority: 10,
group: { id: 'group-1', name: '默认通道组', items: [{ channel: { id: 'channel-1', code: 'CH-1', name: '通道一', reportFields: [{ status: 'active', required: true, reportType: 'drainage', drainageField: { id: 'field-2', code: 'site_owner', name: '网站主体', fieldType: 'string', description: null, status: 'active' } }] } }] },
}] as never);
prisma.$transaction.mockImplementation((callback) => callback(prisma));
const service = new SmsConfigService(prisma as never);
await service.approveDrainageInfo('drainage-1', {});
expect(prisma.drainageReportMaterial.create).toHaveBeenCalledWith({ data: expect.objectContaining({ drainageItemId: 'drainage-1', channelId: 'channel-1', fieldCode: 'site_owner', fieldValue: '企业A' }) });
expect(prisma.channelSignatureReportTask.create).toHaveBeenCalledWith({ data: expect.objectContaining({ reportType: 'drainage', drainageItemId: 'drainage-1', status: 'pending' }) });
expect(prisma.channelSignatureReportRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ action: 'audit_approved_create', statusAfter: 'pending' }) });
});
it('creates admin signatures with an approved initial audit status', async () => {
@@ -565,7 +609,7 @@ describe('SmsConfigService', () => {
const prisma = createPrismaMock();
const service = new SmsConfigService(prisma as never);
await expect(service.listTemplates({ keyword: '模板A' })).resolves.toEqual([
await expect(service.listTemplates({ enterpriseKeyword: '租户', applicationKeyword: '应用', nameKeyword: '模板', contentKeyword: '验证码' })).resolves.toEqual([
expect.objectContaining({
id: 'tpl-1',
tenant: expect.objectContaining({ name: '租户A' }),
@@ -576,7 +620,10 @@ describe('SmsConfigService', () => {
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
where: expect.objectContaining({
auditStatus: { not: 'deleted' },
OR: expect.any(Array),
tenant: { name: { contains: '租户' } },
application: { name: { contains: '应用' } },
name: { contains: '模板' },
content: { contains: '验证码' },
}),
include: { variables: true, application: true, tenant: true, signature: true },
}));
+296 -77
View File
@@ -52,6 +52,22 @@ export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantI
auditStatus?: string;
};
export interface CreateSmsDrainageInfoDto {
siteName: string;
url: string;
remark?: string;
reportValues?: Record<string, unknown>;
}
export type UpdateSmsDrainageInfoDto = Partial<CreateSmsDrainageInfoDto>;
export interface DrainageInfoListQuery {
tenantId?: string;
signatureId?: string;
status?: string;
keyword?: string;
}
export interface CreateSignatureMaterialDto {
signatureId: string;
fileObjectId?: string;
@@ -93,14 +109,31 @@ export interface TemplateListQuery {
tenantId?: string;
status?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
nameKeyword?: string;
contentKeyword?: string;
}
export interface ApplicationListQuery {
tenantId?: string;
keyword?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
status?: string;
includeConnections?: boolean;
}
export interface SignatureListQuery {
tenantId?: string;
keyword?: string;
status?: string;
enterpriseKeyword?: string;
applicationKeyword?: string;
signatureKeyword?: string;
drainageKeyword?: string;
}
export interface GatewayDownstreamConnectionEventDto {
account: string;
connectionId: string;
@@ -130,6 +163,9 @@ export class SmsConfigService {
const applications = await this.prisma.smsApplication.findMany({
where: {
tenantId: query.tenantId,
status: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
name: query.applicationKeyword ? { contains: query.applicationKeyword } : undefined,
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ tenant: { name: { contains: query.keyword } } },
@@ -171,7 +207,7 @@ export class SmsConfigService {
});
}
async getApplication(applicationId: string) {
async getApplication(applicationId: string, tenantId?: string) {
const application = await this.prisma.smsApplication.findUnique({
where: { id: applicationId },
include: {
@@ -179,7 +215,7 @@ export class SmsConfigService {
ipAllowlist: true,
},
});
if (!application) {
if (!application || (tenantId && application.tenantId !== tenantId)) {
throw new NotFoundException('Application not found');
}
return application;
@@ -574,12 +610,25 @@ export class SmsConfigService {
});
}
async listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
const signatures = await this.prisma.smsSignature.findMany({
where: {
tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.signatureKeyword ? { contains: query.signatureKeyword } : undefined,
drainageItems: query.drainageKeyword ? {
some: {
auditStatus: { not: 'deleted' },
OR: [
{ siteName: { contains: query.drainageKeyword } },
{ url: { contains: query.drainageKeyword } },
{ remark: { contains: query.drainageKeyword } },
],
},
} : undefined,
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ purpose: { contains: query.keyword } },
@@ -587,7 +636,13 @@ export class SmsConfigService {
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { materials: true, tenant: true, application: true, reportTasks: { include: { channel: true } } },
include: {
materials: true,
tenant: true,
application: true,
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
reportTasks: { include: { channel: true, drainageInfo: true } },
},
orderBy: { createdAt: 'desc' },
});
const applicationIds = signatures.map((signature) => signature.applicationId).filter((id): id is string => Boolean(id));
@@ -595,24 +650,43 @@ export class SmsConfigService {
where: { applicationId: { in: applicationIds }, status: 'active' },
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
}) : [];
return signatures.map((signature) => ({
return signatures.map((signature) => {
const legacyPayload = isRecord(signature.drainageInfo) ? signature.drainageInfo : {};
const drainageLinks = signature.drainageItems.map((item) => ({
id: item.id,
siteName: item.siteName,
url: item.url,
remark: item.remark ?? '',
reportValues: isRecord(item.reportValues) ? item.reportValues : {},
auditStatus: item.auditStatus,
rejectReason: item.rejectReason,
submittedAt: item.submittedAt.toISOString(),
reviewedAt: item.reviewedAt?.toISOString(),
createdAt: item.createdAt.toISOString(),
updatedAt: item.updatedAt.toISOString(),
}));
return {
...signature,
drainageInfo: { ...legacyPayload, links: drainageLinks },
reportTargets: (() => {
const channels = routes.filter((route) => route.applicationId === signature.applicationId && route.group).flatMap((route) => route.group!.items.map((item) => item.channel)).filter((channel) => channel.status !== 'deleted');
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'signature').map((task) => [task.channelId, task]));
return [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }));
})(),
drainageReportTargets: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
drainageReportTargets: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
.filter((channel) => channel.status !== 'deleted' && channel.reportFields.some((field) => field.status === 'active' && ['drainage', 'both'].includes(field.reportType)));
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].map((channel) => ({ channel, channelId: channel.id, status: taskByChannel.get(channel.id)?.status ?? 'pending', taskId: taskByChannel.get(channel.id)?.id }))];
return [drainageItemId, [...new Map(channels.map((channel) => [channel.id, channel])).values()].flatMap((channel) => {
const task = taskByChannel.get(channel.id);
return task ? [{ channel, channelId: channel.id, status: task.status, taskId: task.id }] : [];
})];
})),
drainageCarrierReportSummary: Object.fromEntries((Array.isArray((signature.drainageInfo as Record<string, unknown> | null)?.links) ? (signature.drainageInfo as Record<string, unknown>).links as Array<Record<string, unknown>> : []).map((link) => {
const drainageItemId = String(link.id ?? '');
drainageCarrierReportSummary: Object.fromEntries(signature.drainageItems.map((drainageItem) => {
const drainageItemId = drainageItem.id;
const channels = routes
.filter((route) => route.applicationId === signature.applicationId && route.group)
.flatMap((route) => route.group!.items.map((item) => item.channel))
@@ -621,7 +695,7 @@ export class SmsConfigService {
const taskByChannel = new Map((signature.reportTasks ?? []).filter((task) => task.reportType === 'drainage' && task.drainageItemId === drainageItemId).map((task) => [task.channelId, task]));
return [drainageItemId, Object.fromEntries(['mobile', 'unicom', 'telecom'].map((carrier) => {
const carrierTargets = targets.filter((channel) => channel.carrier === carrier || channel.carrier === 'all');
const statuses = carrierTargets.map((channel) => taskByChannel.get(channel.id)?.status ?? 'pending');
const statuses = carrierTargets.flatMap((channel) => taskByChannel.get(channel.id)?.status ? [taskByChannel.get(channel.id)!.status] : []);
const approved = statuses.filter((status) => status === 'approved').length;
const status = !statuses.length ? 'not_applicable' : approved === statuses.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: statuses.length }];
@@ -636,7 +710,8 @@ export class SmsConfigService {
const status = !targets.length ? 'not_applicable' : approved === targets.length ? 'approved' : statuses.some((item) => ['failed', 'rejected'].includes(item)) ? 'failed' : statuses.some((item) => ['reporting', 'exporting'].includes(item)) || approved ? 'reporting' : statuses.some((item) => item === 'waiting_material') ? 'waiting_material' : 'pending';
return [carrier, { status, approved, total: targets.length }];
})),
}));
};
});
}
async createSignature(data: CreateSmsSignatureDto, options: CreateSmsSignatureOptions = {}) {
@@ -691,6 +766,118 @@ export class SmsConfigService {
return updated;
}
listDrainageInfos(query: DrainageInfoListQuery = {}) {
return this.prisma.smsDrainageInfo.findMany({
where: {
tenantId: query.tenantId,
signatureId: query.signatureId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
OR: query.keyword ? [
{ siteName: { contains: query.keyword } },
{ url: { contains: query.keyword } },
{ signature: { name: { contains: query.keyword } } },
{ tenant: { name: { contains: query.keyword } } },
{ application: { name: { contains: query.keyword } } },
] : undefined,
},
include: { tenant: true, signature: true, application: true, reportTasks: { include: { channel: true } } },
orderBy: { updatedAt: 'desc' },
});
}
async createDrainageInfo(signatureId: string, data: CreateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
if (!signature) throw new NotFoundException('Signature not found');
if (tenantId && signature.tenantId !== tenantId) throw new NotFoundException('Signature not found');
if (signature.auditStatus !== 'approved') throw new BadRequestException('签名审核通过后才能新增引流信息');
if (!data.siteName?.trim() || !data.url?.trim()) throw new BadRequestException('siteName and url are required');
await this.validateDrainageReportValues(signature.applicationId ?? undefined, data.reportValues);
const auditStatus = options.initialAuditStatus ?? 'pending';
const item = await this.prisma.smsDrainageInfo.create({
data: {
tenantId: signature.tenantId,
signatureId,
applicationId: signature.applicationId,
siteName: data.siteName.trim(),
url: data.url.trim(),
remark: data.remark,
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
auditStatus,
reviewedAt: auditStatus === 'approved' ? new Date() : undefined,
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: item.tenantId,
targetType: 'sms_drainage_info',
targetId: item.id,
action: auditStatus === 'approved' ? 'admin_create_approved' : 'submit',
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端新建引流信息自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.activateDrainageReporting(item.id);
return item;
}
async updateDrainageInfo(itemId: string, data: UpdateSmsDrainageInfoDto, options: CreateSmsSignatureOptions = {}, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
if (current.auditStatus === 'deleted') throw new BadRequestException('已删除的引流信息不能修改');
if (data.siteName !== undefined && !data.siteName.trim()) throw new BadRequestException('siteName is required');
if (data.url !== undefined && !data.url.trim()) throw new BadRequestException('url is required');
const applicationId = current.signature.applicationId ?? current.applicationId ?? undefined;
await this.validateDrainageReportValues(applicationId, data.reportValues ?? (isRecord(current.reportValues) ? current.reportValues : {}));
const auditStatus = options.initialAuditStatus ?? 'pending';
const updated = await this.prisma.smsDrainageInfo.update({
where: { id: itemId },
data: {
applicationId,
siteName: data.siteName?.trim(),
url: data.url?.trim(),
remark: data.remark,
reportValues: data.reportValues as Prisma.InputJsonValue | undefined,
auditStatus,
rejectReason: null,
submittedAt: new Date(),
reviewedAt: auditStatus === 'approved' ? new Date() : null,
},
include: { tenant: true, signature: true, application: true },
});
await this.createAuditRecord({
tenantId: current.tenantId,
targetType: 'sms_drainage_info',
targetId: itemId,
action: auditStatus === 'approved' ? 'admin_update_approved' : 'update_submit',
statusBefore: current.auditStatus,
statusAfter: auditStatus,
reason: auditStatus === 'approved' ? '运营端修改引流信息并自动审核通过' : undefined,
});
if (auditStatus === 'approved') await this.activateDrainageReporting(itemId);
else await this.suspendDrainageReporting(itemId, '引流信息修改后等待运营审核');
return updated;
}
approveDrainageInfo(itemId: string, data: ReviewDto) {
return this.reviewDrainageInfo(itemId, 'approved', 'approve', data);
}
rejectDrainageInfo(itemId: string, data: ReviewDto) {
return this.reviewDrainageInfo(itemId, 'rejected', 'reject', data);
}
async changeDrainageInfoStatus(itemId: string, data: StatusChangeDto, tenantId?: string) {
const current = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!current) throw new NotFoundException('Drainage info not found');
if (tenantId && current.tenantId !== tenantId) throw new NotFoundException('Drainage info not found');
const status = data.status ?? 'deleted';
if (tenantId && status !== 'deleted') throw new BadRequestException('客户端只能删除引流信息,不能直接修改审核状态');
const updated = await this.prisma.smsDrainageInfo.update({ where: { id: itemId }, data: { auditStatus: status } });
if (status === 'deleted') await this.suspendDrainageReporting(itemId, data.reason ?? '引流信息已删除', 'abandoned');
await this.createAuditRecord({ tenantId: current.tenantId, targetType: 'sms_drainage_info', targetId: itemId, action: status, statusBefore: current.auditStatus, statusAfter: status, reason: data.reason });
return updated;
}
private async withReportRequirementSnapshot(applicationId?: string, drainageInfo?: Record<string, unknown>) {
if (!drainageInfo || !applicationId) return drainageInfo;
const fields = await this.getApplicationReportFields(applicationId);
@@ -716,21 +903,6 @@ export class SmsConfigService {
if (!applicationId || !drainageInfo) return;
const fields = await this.getApplicationReportFields(applicationId);
const signatureValues = isRecord(drainageInfo.signatureReportValues) ? drainageInfo.signatureReportValues : {};
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
const drainageItemIds = links.map((link) => String(link.id ?? '')).filter(Boolean);
await this.prisma.drainageReportMaterial.deleteMany({
where: {
signatureId,
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
},
});
await this.prisma.channelSignatureReportTask.deleteMany({
where: {
signatureId,
reportType: 'drainage',
...(drainageItemIds.length > 0 ? { drainageItemId: { notIn: drainageItemIds } } : {}),
},
});
for (const field of fields.filter((item) => item.reportTypes.some((type) => type === 'signature' || type === 'both'))) {
const value = reportValueParts(signatureValues[field.code]);
for (const channel of field.channels) {
@@ -741,46 +913,6 @@ export class SmsConfigService {
});
}
}
const drainageFields = fields.filter((item) => item.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const drainageChannels = new Map(drainageFields.flatMap((item) => item.channels).map((channel) => [channel.id, channel]));
const signatureOwner = links.length > 0
? await this.prisma.smsSignature.findUnique({ where: { id: signatureId }, select: { tenantId: true } })
: null;
for (const link of links) {
const drainageItemId = String(link.id ?? '');
const values = isRecord(link.reportValues) ? link.reportValues : {};
if (!drainageItemId) continue;
for (const channel of drainageChannels.values()) {
const existingTask = await this.prisma.channelSignatureReportTask.findFirst({
where: { signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId },
});
if (!existingTask && signatureOwner) {
const task = await this.prisma.channelSignatureReportTask.create({
data: { tenantId: signatureOwner.tenantId, signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId, status: 'pending' },
});
await this.prisma.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: 'create', statusAfter: 'pending' },
});
}
}
for (const field of drainageFields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await this.prisma.drainageReportMaterial.upsert({
where: {
signatureId_drainageItemId_channelId_fieldCode: {
signatureId,
drainageItemId,
channelId: channel.id,
fieldCode: field.code,
},
},
update: value,
create: { signatureId, drainageItemId, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
}
}
private async validateSignatureReportValues(applicationId?: string, drainageInfo?: Record<string, unknown>) {
@@ -793,19 +925,68 @@ export class SmsConfigService {
if (missingSignature.length > 0) {
throw new BadRequestException(`缺少必填签名报备资料:${missingSignature.map((field) => field.name).join('、')}`);
}
const drainageFields = fields.filter(
(field) => field.required && field.reportTypes.some((type) => type === 'drainage' || type === 'both'),
);
const links = Array.isArray(drainageInfo.links) ? drainageInfo.links.filter(isRecord) : [];
for (const link of links) {
const values = isRecord(link.reportValues) ? link.reportValues : {};
const missing = drainageFields.filter((field) => !hasReportValue(values[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
}
private async validateDrainageReportValues(applicationId?: string, reportValues: Record<string, unknown> = {}) {
if (!applicationId) return;
const fields = await this.getApplicationReportFields(applicationId, 'drainage');
const missing = fields.filter((field) => field.required && !hasReportValue(reportValues[field.code]));
if (missing.length > 0) {
throw new BadRequestException(`引流信息缺少必填报备资料:${missing.map((field) => field.name).join('、')}`);
}
}
private async activateDrainageReporting(itemId: string) {
const item = await this.prisma.smsDrainageInfo.findUnique({ where: { id: itemId }, include: { signature: true } });
if (!item) throw new NotFoundException('Drainage info not found');
if (item.auditStatus !== 'approved') throw new BadRequestException('引流信息审核通过后才能进入通道报备');
const applicationId = item.signature.applicationId ?? item.applicationId ?? undefined;
if (!applicationId) return;
const fields = (await this.getApplicationReportFields(applicationId, 'drainage'))
.filter((field) => field.reportTypes.some((type) => type === 'drainage' || type === 'both'));
const channels = new Map(fields.flatMap((field) => field.channels).map((channel) => [channel.id, channel]));
const values = isRecord(item.reportValues) ? item.reportValues : {};
await this.prisma.$transaction(async (tx) => {
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
for (const field of fields) {
const value = reportValueParts(values[field.code]);
for (const channel of field.channels) {
await tx.drainageReportMaterial.create({
data: { signatureId: item.signatureId, drainageItemId: item.id, channelId: channel.id, fieldCode: field.code, ...value },
});
}
}
const existingTasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
const existingByChannel = new Map(existingTasks.map((task) => [task.channelId, task]));
for (const channel of channels.values()) {
const existing = existingByChannel.get(channel.id);
const task = existing
? await tx.channelSignatureReportTask.update({ where: { id: existing.id }, data: { status: 'pending', reason: null } })
: await tx.channelSignatureReportTask.create({ data: { tenantId: item.tenantId, signatureId: item.signatureId, channelId: channel.id, reportType: 'drainage', drainageItemId: item.id, status: 'pending' } });
await tx.channelSignatureReportRecord.create({
data: { taskId: task.id, channelId: channel.id, action: existing ? 'audit_approved_reset' : 'audit_approved_create', statusBefore: existing?.status, statusAfter: 'pending', reason: '引流信息运营审核通过' },
});
}
for (const task of existingTasks.filter((current) => !channels.has(current.channelId) && current.status !== 'abandoned')) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: 'abandoned', reason: '应用当前路由已不包含此通道' } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'route_removed', statusBefore: task.status, statusAfter: 'abandoned', reason: '应用当前路由已不包含此通道' } });
}
});
}
private async suspendDrainageReporting(itemId: string, reason: string, statusAfter = 'waiting_review') {
await this.prisma.$transaction(async (tx) => {
const item = await tx.smsDrainageInfo.findUnique({ where: { id: itemId } });
if (!item) throw new NotFoundException('Drainage info not found');
await tx.drainageReportMaterial.deleteMany({ where: { signatureId: item.signatureId, drainageItemId: item.id } });
const tasks = await tx.channelSignatureReportTask.findMany({ where: { drainageItemId: item.id, reportType: 'drainage' } });
for (const task of tasks.filter((current) => current.status !== statusAfter)) {
await tx.channelSignatureReportTask.update({ where: { id: task.id }, data: { status: statusAfter, reason } });
await tx.channelSignatureReportRecord.create({ data: { taskId: task.id, channelId: task.channelId, action: 'audit_suspended', statusBefore: task.status, statusAfter, reason } });
}
});
}
createSignatureMaterial(data: CreateSignatureMaterialDto) {
return this.prisma.signatureMaterial.create({
data: {
@@ -845,6 +1026,10 @@ export class SmsConfigService {
where: {
tenantId: query.tenantId,
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
application: query.applicationKeyword ? { name: { contains: query.applicationKeyword } } : undefined,
name: query.nameKeyword ? { contains: query.nameKeyword } : undefined,
content: query.contentKeyword ? { contains: query.contentKeyword } : undefined,
OR: query.keyword ? [
{ name: { contains: query.keyword } },
{ content: { contains: query.keyword } },
@@ -1030,6 +1215,40 @@ export class SmsConfigService {
return updated;
}
private 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.activateDrainageReporting(itemId);
else await this.suspendDrainageReporting(itemId, data.reason?.trim() || '引流信息运营审核驳回');
return updated;
}
private async reviewTemplate(templateId: string, statusAfter: string, action: string, data: ReviewDto) {
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
if (!template) {