fix: harden real backend admin workflows and ui
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import {
|
||||
ChannelsService,
|
||||
@@ -14,6 +14,8 @@ import {
|
||||
CreateReportTaskDto,
|
||||
CreateRouteRuleDto,
|
||||
UpsertConnectionStateDto,
|
||||
UpdateChannelDto,
|
||||
UpdateChannelGroupDto,
|
||||
} from './channels.service';
|
||||
|
||||
@ApiTags('channels')
|
||||
@@ -31,6 +33,11 @@ export class ChannelsController {
|
||||
return this.channels.createChannel(body);
|
||||
}
|
||||
|
||||
@Put('channels/:id')
|
||||
updateChannel(@Param('id') channelId: string, @Body() body: UpdateChannelDto) {
|
||||
return this.channels.updateChannel(channelId, body);
|
||||
}
|
||||
|
||||
@Post('channels/:id/test')
|
||||
testChannel(@Param('id') channelId: string) {
|
||||
return this.channels.testChannel(channelId);
|
||||
@@ -86,6 +93,11 @@ export class ChannelsController {
|
||||
return this.channels.createGroup(body);
|
||||
}
|
||||
|
||||
@Put('channel-groups/:id')
|
||||
updateGroup(@Param('id') groupId: string, @Body() body: UpdateChannelGroupDto) {
|
||||
return this.channels.updateGroup(groupId, body);
|
||||
}
|
||||
|
||||
@Post('channel-groups/items')
|
||||
addGroupItem(@Body() body: CreateChannelGroupItemDto) {
|
||||
return this.channels.addGroupItem(body);
|
||||
|
||||
@@ -27,6 +27,14 @@ function createPrismaMock() {
|
||||
smsChannel: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'channel-copy', ...data })),
|
||||
},
|
||||
smsChannelGroup: {
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', items: [] }),
|
||||
},
|
||||
smsChannelGroupItem: {
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
},
|
||||
signatureReportMaterial: {
|
||||
findMany: jest.fn().mockResolvedValue([{ signatureId: 'sig-1', fieldCode: 'license', fieldValue: '营业执照', fileObjectId: 'file-1' }]),
|
||||
createMany: jest.fn(),
|
||||
@@ -44,10 +52,12 @@ function createPrismaMock() {
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active' }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||
},
|
||||
smsChannelGroupItem: {
|
||||
deleteMany: jest.fn(),
|
||||
createMany: jest.fn(),
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-item-1', ...data })),
|
||||
},
|
||||
@@ -144,6 +154,52 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates CMPP channel configuration without requiring password changes', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.updateChannel('channel-1', {
|
||||
name: '主通道-编辑',
|
||||
gatewayHost: '10.0.0.1',
|
||||
gatewayPort: 27890,
|
||||
carrier: 'all',
|
||||
sendRegion: '全国',
|
||||
account: 'sp-new',
|
||||
srcId: '10690001',
|
||||
unitPrice: 4,
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
id: 'channel-1',
|
||||
name: '主通道-编辑',
|
||||
gatewayHost: '10.0.0.1',
|
||||
}));
|
||||
|
||||
expect(prisma.smsChannel.update).toHaveBeenCalledWith({
|
||||
where: { id: 'channel-1' },
|
||||
data: expect.objectContaining({
|
||||
name: '主通道-编辑',
|
||||
gatewayHost: '10.0.0.1',
|
||||
gatewayPort: 27890,
|
||||
carrier: 'all',
|
||||
passwordCipher: undefined,
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'sms_channel.update',
|
||||
resource: 'sms_channel',
|
||||
resourceId: 'channel-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects invalid channel update ports', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await expect(service.updateChannel('channel-1', { gatewayPort: 70000 })).rejects.toThrow('gatewayPort must be an integer between 1 and 65535');
|
||||
expect(prisma.smsChannel.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects direct single-channel route rules', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -214,6 +270,36 @@ describe('ChannelsService', () => {
|
||||
.rejects.toThrow('同一通道组内全国通道优先级不能重复');
|
||||
});
|
||||
|
||||
it('updates channel groups and replaces items with backend validation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
prisma.smsChannel.findMany.mockResolvedValue([
|
||||
{ id: 'channel-sd', carrier: 'mobile', sendRegion: '山东' },
|
||||
{ id: 'channel-national', carrier: 'all', sendRegion: '全国' },
|
||||
]);
|
||||
|
||||
await service.updateGroup('group-1', {
|
||||
name: '移动组更新',
|
||||
carrier: 'mobile',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 24,
|
||||
items: [
|
||||
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 },
|
||||
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
await expect(service.updateGroup('group-1', {
|
||||
carrier: 'mobile',
|
||||
items: [
|
||||
{ channelId: 'channel-sd', carrier: 'mobile', priority: 1 },
|
||||
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
|
||||
],
|
||||
})).rejects.toThrow('同一通道组内全国通道优先级不能重复');
|
||||
});
|
||||
|
||||
it('requires route rule carrier to match the channel group carrier', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
@@ -265,11 +351,42 @@ describe('ChannelsService', () => {
|
||||
});
|
||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'report-task-1' },
|
||||
data: { status: 'rejected', reason: 'one rejected' },
|
||||
data: { status: 'partial', reason: 'one rejected' },
|
||||
});
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1' },
|
||||
data: { reportStatus: 'rejected' },
|
||||
data: { reportStatus: 'partial' },
|
||||
});
|
||||
});
|
||||
|
||||
it('parses text receipt imports and derives report task status', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
await service.importReportReceipt('report-task-1', {
|
||||
fileObjectId: 'file-1',
|
||||
fileName: 'receipt.csv',
|
||||
fileContent: 'phone,status\n13800138000,success\n13900139000,failed\n13700137000,通过',
|
||||
reason: 'carrier receipt',
|
||||
});
|
||||
|
||||
expect(prisma.reportReceiptImport.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
fileObjectId: 'file-1',
|
||||
fileName: 'receipt.csv',
|
||||
rowCount: 3,
|
||||
successCount: 2,
|
||||
failedCount: 1,
|
||||
result: expect.objectContaining({ hasHeader: true, rows: expect.any(Array) }),
|
||||
}),
|
||||
});
|
||||
expect(prisma.channelSignatureReportTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'report-task-1' },
|
||||
data: { status: 'partial', reason: 'carrier receipt' },
|
||||
});
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1' },
|
||||
data: { reportStatus: 'partial' },
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,8 @@ export interface CreateChannelDto {
|
||||
config?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateChannelDto = Partial<CreateChannelDto>;
|
||||
|
||||
export interface CreateChannelGroupDto {
|
||||
code: string;
|
||||
name: string;
|
||||
@@ -42,6 +44,17 @@ export interface CreateChannelGroupItemDto {
|
||||
rateLimitPerSecond?: number;
|
||||
}
|
||||
|
||||
export interface UpdateChannelGroupDto {
|
||||
code?: string;
|
||||
name?: string;
|
||||
carrier?: string;
|
||||
description?: string;
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
|
||||
}
|
||||
|
||||
export interface CreateRouteRuleDto {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
@@ -88,6 +101,8 @@ export interface CreateReportExportDto {
|
||||
export interface CreateReceiptImportDto {
|
||||
fileObjectId?: string;
|
||||
fileName: string;
|
||||
fileContent?: string;
|
||||
delimiter?: ',' | '\t';
|
||||
rowCount?: number;
|
||||
successCount?: number;
|
||||
failedCount?: number;
|
||||
@@ -164,6 +179,61 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateChannel(channelId: string, data: UpdateChannelDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort);
|
||||
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
const updated = await this.prisma.smsChannel.update({
|
||||
where: { id: channelId },
|
||||
data: {
|
||||
code: data.code,
|
||||
name: data.name,
|
||||
carrier: data.carrier,
|
||||
sendRegion: data.sendRegion,
|
||||
protocol: data.protocol,
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
srcId: data.srcId,
|
||||
cmppVersion: data.cmppVersion,
|
||||
rateLimitPerSecond: data.rateLimitPerSecond,
|
||||
unitPrice: data.unitPrice,
|
||||
status: data.status,
|
||||
config: data.config as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
action: 'sms_channel.update',
|
||||
resource: 'sms_channel',
|
||||
resourceId: channelId,
|
||||
detail: {
|
||||
before: {
|
||||
code: channel.code,
|
||||
name: channel.name,
|
||||
carrier: channel.carrier,
|
||||
sendRegion: channel.sendRegion,
|
||||
gatewayHost: channel.gatewayHost,
|
||||
gatewayPort: channel.gatewayPort,
|
||||
enterpriseCode: channel.enterpriseCode,
|
||||
account: channel.account,
|
||||
srcId: channel.srcId,
|
||||
unitPrice: channel.unitPrice,
|
||||
},
|
||||
after: data,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
async changeChannelStatus(channelId: string, data: ChangeChannelStatusDto) {
|
||||
const channel = await this.prisma.smsChannel.findUnique({ where: { id: channelId } });
|
||||
if (!channel) {
|
||||
@@ -381,7 +451,7 @@ export class ChannelsService {
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: true } } },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
@@ -461,6 +531,57 @@ export class ChannelsService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateGroup(groupId: string, data: UpdateChannelGroupDto) {
|
||||
const current = await this.prisma.smsChannelGroup.findUnique({ where: { id: groupId } });
|
||||
if (!current) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const retryTimeLimitHours = data.retryTimeLimitHours ?? current.retryTimeLimitHours;
|
||||
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
|
||||
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
|
||||
}
|
||||
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
|
||||
const items = data.items ?? [];
|
||||
const channelIds = [...new Set(items.map((item) => item.channelId))];
|
||||
const channels = await this.prisma.smsChannel.findMany({ where: { id: { in: channelIds } } });
|
||||
const channelById = new Map(channels.map((channel) => [channel.id, channel]));
|
||||
validateGroupItems(carrier, items, channelById);
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.smsChannelGroupItem.deleteMany({ where: { groupId } });
|
||||
await tx.smsChannelGroup.update({
|
||||
where: { id: groupId },
|
||||
data: {
|
||||
code: data.code ?? current.code,
|
||||
name: data.name ?? current.name,
|
||||
carrier,
|
||||
description: data.description,
|
||||
status: data.status ?? current.status,
|
||||
retryEnabled: data.retryEnabled ?? current.retryEnabled,
|
||||
retryTimeLimitHours,
|
||||
},
|
||||
});
|
||||
if (items.length > 0) {
|
||||
await tx.smsChannelGroupItem.createMany({
|
||||
data: items.map((item) => ({
|
||||
groupId,
|
||||
channelId: item.channelId,
|
||||
carrier,
|
||||
province: item.province,
|
||||
priority: item.priority ?? 100,
|
||||
weight: item.weight ?? 1,
|
||||
isBackup: item.isBackup ?? false,
|
||||
rateLimitPerSecond: item.rateLimitPerSecond,
|
||||
})),
|
||||
});
|
||||
}
|
||||
return tx.smsChannelGroup.findUnique({
|
||||
where: { id: groupId },
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
listRouteRules() {
|
||||
return this.prisma.channelRouteRule.findMany({
|
||||
include: { group: true, channel: true },
|
||||
@@ -600,17 +721,21 @@ export class ChannelsService {
|
||||
|
||||
async importReportReceipt(taskId: string, data: CreateReceiptImportDto) {
|
||||
const task = await this.getReportTaskOrThrow(taskId);
|
||||
const statusAfter = data.statusAfter ?? (data.failedCount && data.failedCount > 0 ? 'rejected' : 'approved');
|
||||
const parsed = data.fileContent ? parseReceiptContent(data.fileContent, data.delimiter) : undefined;
|
||||
const rowCount = data.rowCount ?? parsed?.rowCount ?? 0;
|
||||
const successCount = data.successCount ?? parsed?.successCount ?? 0;
|
||||
const failedCount = data.failedCount ?? parsed?.failedCount ?? 0;
|
||||
const statusAfter = data.statusAfter ?? deriveReceiptStatus(rowCount, successCount, failedCount);
|
||||
const imported = await this.prisma.reportReceiptImport.create({
|
||||
data: {
|
||||
taskId,
|
||||
fileObjectId: data.fileObjectId,
|
||||
fileName: data.fileName,
|
||||
rowCount: data.rowCount ?? 0,
|
||||
successCount: data.successCount ?? 0,
|
||||
failedCount: data.failedCount ?? 0,
|
||||
rowCount,
|
||||
successCount,
|
||||
failedCount,
|
||||
status: 'imported',
|
||||
result: data.result as Prisma.InputJsonValue | undefined,
|
||||
result: (data.result ?? parsed?.result) as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
});
|
||||
await this.updateReportTaskStatus(taskId, task.channelId, task.status, statusAfter, 'receipt_import', data.reason);
|
||||
@@ -690,6 +815,107 @@ function normalizeConnectionAction(status: string) {
|
||||
return 'updated';
|
||||
}
|
||||
|
||||
function parseReceiptContent(content: string, delimiter?: ',' | '\t') {
|
||||
const lines = content.replace(/^\uFEFF/, '').split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
|
||||
if (lines.length === 0) {
|
||||
throw new BadRequestException('Receipt file is empty');
|
||||
}
|
||||
const separator = delimiter ?? (lines[0].includes('\t') ? '\t' : ',');
|
||||
const firstCells = splitReceiptLine(lines[0], separator);
|
||||
const hasHeader = firstCells.some((cell) => ['phone', 'mobile', 'status', 'result', '手机号', '号码', '状态', '结果'].includes(cell.toLowerCase()));
|
||||
const header = hasHeader ? firstCells : [];
|
||||
const rows = hasHeader ? lines.slice(1) : lines;
|
||||
const statusIndex = findReceiptStatusIndex(header);
|
||||
let successCount = 0;
|
||||
let failedCount = 0;
|
||||
const resultRows = rows.map((line, index) => {
|
||||
const cells = splitReceiptLine(line, separator);
|
||||
const rawStatus = cells[statusIndex] ?? cells[cells.length - 1] ?? '';
|
||||
const normalizedStatus = normalizeReceiptStatus(rawStatus);
|
||||
if (normalizedStatus === 'success') {
|
||||
successCount += 1;
|
||||
} else {
|
||||
failedCount += 1;
|
||||
}
|
||||
return {
|
||||
rowNumber: (hasHeader ? index + 2 : index + 1),
|
||||
phone: cells[0] ?? '',
|
||||
status: normalizedStatus,
|
||||
rawStatus,
|
||||
raw: cells,
|
||||
};
|
||||
});
|
||||
return {
|
||||
rowCount: resultRows.length,
|
||||
successCount,
|
||||
failedCount,
|
||||
result: {
|
||||
delimiter: separator === '\t' ? 'tab' : 'comma',
|
||||
hasHeader,
|
||||
rows: resultRows,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function splitReceiptLine(line: string, delimiter: ',' | '\t') {
|
||||
if (delimiter === '\t') {
|
||||
return line.split('\t').map((cell) => stripReceiptCell(cell));
|
||||
}
|
||||
const cells: string[] = [];
|
||||
let current = '';
|
||||
let quoted = false;
|
||||
for (let index = 0; index < line.length; index += 1) {
|
||||
const char = line[index];
|
||||
const next = line[index + 1];
|
||||
if (char === '"' && quoted && next === '"') {
|
||||
current += '"';
|
||||
index += 1;
|
||||
} else if (char === '"') {
|
||||
quoted = !quoted;
|
||||
} else if (char === ',' && !quoted) {
|
||||
cells.push(stripReceiptCell(current));
|
||||
current = '';
|
||||
} else {
|
||||
current += char;
|
||||
}
|
||||
}
|
||||
cells.push(stripReceiptCell(current));
|
||||
return cells;
|
||||
}
|
||||
|
||||
function stripReceiptCell(value: string) {
|
||||
return value.trim().replace(/^"|"$/g, '').trim();
|
||||
}
|
||||
|
||||
function findReceiptStatusIndex(header: string[]) {
|
||||
if (header.length === 0) {
|
||||
return 1;
|
||||
}
|
||||
const index = header.findIndex((cell) => ['status', 'result', '状态', '结果'].includes(cell.toLowerCase()));
|
||||
return index >= 0 ? index : Math.max(0, header.length - 1);
|
||||
}
|
||||
|
||||
function normalizeReceiptStatus(value: string) {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
if (['success', 'succeeded', 'approved', 'completed', 'ok', 'pass', 'passed', '通过', '成功', '已完成', '报备成功'].includes(normalized)) {
|
||||
return 'success';
|
||||
}
|
||||
if (['failed', 'fail', 'rejected', 'reject', 'error', 'no', 'denied', '驳回', '失败', '不通过', '拒绝', '报备失败'].includes(normalized)) {
|
||||
return 'failed';
|
||||
}
|
||||
return 'failed';
|
||||
}
|
||||
|
||||
function deriveReceiptStatus(rowCount: number, successCount: number, failedCount: number) {
|
||||
if (rowCount <= 0 || successCount <= 0) {
|
||||
return 'failed';
|
||||
}
|
||||
if (failedCount > 0) {
|
||||
return 'partial';
|
||||
}
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
function normalizeBusinessCarrier(carrier?: string | null) {
|
||||
const normalized = normalizeChannelCarrier(carrier);
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
|
||||
@@ -720,6 +946,49 @@ function isRegionCompatible(channelRegion: string | null | undefined, itemProvin
|
||||
return normalizeRegion(channelRegion) === normalizeRegion(itemProvince);
|
||||
}
|
||||
|
||||
function validateGroupItems(
|
||||
groupCarrier: string,
|
||||
items: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>,
|
||||
channels: Map<string, { id: string; carrier?: string | null; sendRegion?: string | null }>,
|
||||
) {
|
||||
const channelIds = new Set<string>();
|
||||
const provinces = new Set<string>();
|
||||
const nationalPriorities = new Set<number>();
|
||||
for (const item of items) {
|
||||
const itemCarrier = item.carrier ? normalizeBusinessCarrier(item.carrier) : groupCarrier;
|
||||
if (itemCarrier !== groupCarrier) {
|
||||
throw new BadRequestException('Channel group items must use the same carrier as the channel group');
|
||||
}
|
||||
const channel = channels.get(item.channelId);
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (channelIds.has(item.channelId)) {
|
||||
throw new BadRequestException('通道组内不能重复配置同一通道');
|
||||
}
|
||||
channelIds.add(item.channelId);
|
||||
if (!isChannelCarrierCompatible(channel.carrier, groupCarrier)) {
|
||||
throw new BadRequestException('Channel carrier is not compatible with the channel group carrier');
|
||||
}
|
||||
if (item.province) {
|
||||
const province = normalizeRegion(item.province);
|
||||
if (provinces.has(province)) {
|
||||
throw new BadRequestException('同一通道组内同一省份只能配置一个通道');
|
||||
}
|
||||
provinces.add(province);
|
||||
if (!isRegionCompatible(channel.sendRegion, item.province)) {
|
||||
throw new BadRequestException('Province route must use a channel with the same sendRegion');
|
||||
}
|
||||
} else {
|
||||
const priority = item.priority ?? 100;
|
||||
if (nationalPriorities.has(priority)) {
|
||||
throw new BadRequestException('同一通道组内全国通道优先级不能重复');
|
||||
}
|
||||
nationalPriorities.add(priority);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLinkEvent(action: string) {
|
||||
if (action.includes('connected')) {
|
||||
return '新建';
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Post, UploadedFile, UseInterceptors } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { CreateFileObjectDto, CreatePresignedUploadDto, FilesService } from './files.service';
|
||||
|
||||
type UploadedMultipartFile = {
|
||||
originalname: string;
|
||||
mimetype: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
};
|
||||
|
||||
@ApiTags('files')
|
||||
@Controller('admin/files')
|
||||
export class FilesController {
|
||||
@@ -22,4 +30,10 @@ export class FilesController {
|
||||
createPresignedUpload(@Body() body: CreatePresignedUploadDto) {
|
||||
return this.files.createPresignedUpload(body);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(FileInterceptor('file', { limits: { fileSize: 20 * 1024 * 1024 } }))
|
||||
upload(@UploadedFile() file: UploadedMultipartFile, @Body('purpose') purpose: string, @Body('prefix') prefix?: string, @TenantId() tenantId?: string) {
|
||||
return this.files.upload({ tenantId, purpose: purpose || 'general', prefix }, file);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { FilesService } from './files.service';
|
||||
|
||||
describe('FilesService', () => {
|
||||
it('uploads file content to object storage before creating FileObject metadata', async () => {
|
||||
const prisma = {
|
||||
fileObject: {
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'file-1', ...data })),
|
||||
},
|
||||
};
|
||||
const objectStorage = {
|
||||
getBucket: jest.fn().mockReturnValue('cmpp-platform'),
|
||||
putObject: jest.fn().mockResolvedValue({ etag: 'etag-1' }),
|
||||
presignedPutObject: jest.fn(),
|
||||
};
|
||||
const service = new FilesService(prisma as never, objectStorage as never);
|
||||
const file = {
|
||||
originalname: '营业执照.png',
|
||||
mimetype: 'image/png',
|
||||
size: 12,
|
||||
buffer: Buffer.from('file-content'),
|
||||
};
|
||||
|
||||
await expect(service.upload({ tenantId: 'tenant-1', purpose: 'signature_material', prefix: 'signature-materials/sig-1' }, file))
|
||||
.resolves.toEqual(expect.objectContaining({
|
||||
id: 'file-1',
|
||||
tenantId: 'tenant-1',
|
||||
bucket: 'cmpp-platform',
|
||||
fileName: '营业执照.png',
|
||||
contentType: 'image/png',
|
||||
purpose: 'signature_material',
|
||||
}));
|
||||
|
||||
expect(objectStorage.putObject).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^signature-materials\/sig-1\/\d+-[a-f0-9-]+-营业执照\.png$/),
|
||||
file.buffer,
|
||||
12,
|
||||
'image/png',
|
||||
);
|
||||
expect(prisma.fileObject.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
bucket: 'cmpp-platform',
|
||||
fileName: '营业执照.png',
|
||||
contentType: 'image/png',
|
||||
sizeBytes: BigInt(12),
|
||||
purpose: 'signature_material',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ObjectStorageService } from './object-storage.service';
|
||||
|
||||
@@ -19,6 +20,12 @@ export interface CreatePresignedUploadDto {
|
||||
expiresInSeconds?: number;
|
||||
}
|
||||
|
||||
export interface UploadFileDto {
|
||||
tenantId?: string;
|
||||
purpose: string;
|
||||
prefix?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class FilesService {
|
||||
constructor(
|
||||
@@ -57,4 +64,19 @@ export class FilesService {
|
||||
expiresInSeconds: data.expiresInSeconds ?? 3600,
|
||||
};
|
||||
}
|
||||
|
||||
async upload(data: UploadFileDto, file: { originalname: string; mimetype: string; size: number; buffer: Buffer }) {
|
||||
const safeName = file.originalname.replace(/[^\w.\-\u4e00-\u9fa5]/g, '_');
|
||||
const objectKey = `${data.prefix ?? data.purpose}/${Date.now()}-${randomUUID()}-${safeName}`;
|
||||
await this.objectStorage.putObject(objectKey, file.buffer, file.size, file.mimetype || 'application/octet-stream');
|
||||
return this.create({
|
||||
tenantId: data.tenantId,
|
||||
bucket: this.objectStorage.getBucket(),
|
||||
objectKey,
|
||||
fileName: file.originalname,
|
||||
contentType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
purpose: data.purpose,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,12 @@ export class ObjectStorageService {
|
||||
return this.client.presignedPutObject(this.bucket, objectKey, expirySeconds);
|
||||
}
|
||||
|
||||
putObject(objectKey: string, content: Buffer, sizeBytes: number, contentType: string) {
|
||||
return this.client.putObject(this.bucket, objectKey, content, sizeBytes, {
|
||||
'Content-Type': contentType,
|
||||
});
|
||||
}
|
||||
|
||||
getBucket() {
|
||||
return this.bucket;
|
||||
}
|
||||
|
||||
@@ -23,10 +23,11 @@ export class AdminOperationsController {
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('channelId') channelId?: string,
|
||||
@Query('taskId') taskId?: string,
|
||||
@Query('messageId') messageId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, phoneNumber, status });
|
||||
return this.operations.listMessages({ tenantId, applicationId, channelId, taskId, messageId, phoneNumber, status });
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
|
||||
@@ -14,8 +14,20 @@ export class ClientOperationsController {
|
||||
}
|
||||
|
||||
@Get('batch-tasks/:id/messages')
|
||||
listTaskMessages(@Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||
return this.operations.listMessages({ taskId, phoneNumber });
|
||||
listTaskMessages(@TenantId() tenantId: string | undefined, @Param('id') taskId: string, @Query('phoneNumber') phoneNumber?: string) {
|
||||
return this.operations.listMessages({ tenantId, taskId, phoneNumber });
|
||||
}
|
||||
|
||||
@Get('messages')
|
||||
listMessages(
|
||||
@TenantId() tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('taskId') taskId?: string,
|
||||
@Query('messageId') messageId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: string,
|
||||
) {
|
||||
return this.operations.listMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status });
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
|
||||
@@ -71,6 +71,7 @@ describe('OperationsService', () => {
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
taskId: 'task-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
status: 'delivered',
|
||||
});
|
||||
@@ -81,15 +82,30 @@ describe('OperationsService', () => {
|
||||
applicationId: 'app-1',
|
||||
channelId: 'channel-1',
|
||||
batchTaskId: 'task-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
status: 'delivered',
|
||||
},
|
||||
include: { submitRecords: true, receiptRecords: true },
|
||||
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns uplink messages with tenant and channel display data', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await service.listUplinkMessages({ tenantId: 'tenant-1', channelId: 'channel-1' });
|
||||
|
||||
expect(prisma.smsUplinkMessage.findMany).toHaveBeenCalledWith({
|
||||
where: { tenantId: 'tenant-1', channelId: 'channel-1' },
|
||||
include: { tenant: true, channel: true },
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
});
|
||||
|
||||
it('builds dashboard and statistics aggregates', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface MessageQuery {
|
||||
applicationId?: string;
|
||||
channelId?: string;
|
||||
taskId?: string;
|
||||
messageId?: string;
|
||||
phoneNumber?: string;
|
||||
status?: string;
|
||||
}
|
||||
@@ -42,7 +43,7 @@ export class OperationsService {
|
||||
listMessages(query: MessageQuery) {
|
||||
return this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
include: { submitRecords: true, receiptRecords: true },
|
||||
include: { tenant: true, application: true, channel: true, submitRecords: true, receiptRecords: true },
|
||||
orderBy: { queuedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
@@ -51,6 +52,7 @@ export class OperationsService {
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string }) {
|
||||
return this.prisma.smsUplinkMessage.findMany({
|
||||
where: { tenantId: query.tenantId, channelId: query.channelId },
|
||||
include: { tenant: true, channel: true },
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
@@ -352,6 +354,7 @@ function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
applicationId: query.applicationId,
|
||||
channelId: query.channelId,
|
||||
batchTaskId: query.taskId,
|
||||
messageId: query.messageId,
|
||||
phoneNumber: query.phoneNumber,
|
||||
status: query.status,
|
||||
};
|
||||
|
||||
@@ -37,6 +37,11 @@ export class AdminSendChainController {
|
||||
return this.sendChain.enqueueBatchTask(taskId);
|
||||
}
|
||||
|
||||
@Post('batch-tasks/:id/terminate')
|
||||
terminateTask(@Param('id') taskId: string) {
|
||||
return this.sendChain.terminateBatchTask(taskId);
|
||||
}
|
||||
|
||||
@Post('scheduled/dispatch-due')
|
||||
dispatchDueScheduledTasks() {
|
||||
return this.sendChain.dispatchDueScheduledTasks();
|
||||
|
||||
@@ -244,6 +244,23 @@ describe('SendChainService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('terminates non-final tasks by canceling unsubmitted messages', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.smsBatchTask.findUnique.mockResolvedValue({ id: 'task-1', status: 'sending' });
|
||||
service['refreshTaskProgress'] = jest.fn().mockResolvedValue(undefined);
|
||||
|
||||
await service.terminateBatchTask('task-1');
|
||||
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { batchTaskId: 'task-1', status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } },
|
||||
data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' },
|
||||
});
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalledWith({
|
||||
where: { id: 'task-1' },
|
||||
data: { status: 'canceled', canceledAt: expect.any(Date), rejectReason: '运营终止任务' },
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks sending when enterprise certification is not approved', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.tenant.findUnique.mockResolvedValue({ id: 'tenant-1', status: 'active', certificationStatus: 'rejected' });
|
||||
|
||||
@@ -237,7 +237,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
listBatchTasks(tenantId?: string, status?: string) {
|
||||
return this.prisma.smsBatchTask.findMany({
|
||||
where: { tenantId, status },
|
||||
include: { apiRequests: true },
|
||||
include: {
|
||||
tenant: true,
|
||||
application: true,
|
||||
template: true,
|
||||
apiRequests: true,
|
||||
messages: { include: { channel: true }, orderBy: { queuedAt: 'asc' }, take: 100000 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
@@ -388,6 +394,25 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
async terminateBatchTask(taskId: string) {
|
||||
const task = await this.prisma.smsBatchTask.findUnique({ where: { id: taskId } });
|
||||
if (!task) {
|
||||
throw new NotFoundException('SMS batch task not found');
|
||||
}
|
||||
if (['finished', 'completed', 'failed', 'canceled', 'rejected'].includes(task.status)) {
|
||||
throw new BadRequestException('SMS batch task is already final');
|
||||
}
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { batchTaskId: taskId, status: { in: ['ready', 'queued', 'scheduled', 'submit_queued'] } },
|
||||
data: { status: 'canceled', errorMessage: '运营终止任务,未提交号码停止发送' },
|
||||
});
|
||||
await this.refreshTaskProgress(taskId);
|
||||
return this.prisma.smsBatchTask.update({
|
||||
where: { id: taskId },
|
||||
data: { status: 'canceled', canceledAt: new Date(), rejectReason: '运营终止任务' },
|
||||
});
|
||||
}
|
||||
|
||||
async dispatchDueScheduledTasks(now = new Date()) {
|
||||
const tasks = await this.prisma.smsBatchTask.findMany({
|
||||
where: { status: 'scheduled', scheduledAt: { lte: now } },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Put, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
|
||||
import { CreateSmsApplicationDto, CreateSmsSignatureDto, CreateSmsTemplateDto, ReplaceApplicationRouteRulesDto, ReviewDto, SmsConfigService, StatusChangeDto, UpdateSmsApplicationDto, UpdateSmsSignatureDto, UpdateSmsTemplateDto } from './sms-config.service';
|
||||
|
||||
@ApiTags('admin-sms-config')
|
||||
@Controller('admin')
|
||||
@@ -12,6 +12,26 @@ export class AdminSmsConfigController {
|
||||
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id')
|
||||
getApplication(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.getApplication(applicationId);
|
||||
}
|
||||
|
||||
@Post('enterprise-applications')
|
||||
createApplication(@Body() body: CreateSmsApplicationDto) {
|
||||
return this.smsConfig.createApplication(body);
|
||||
}
|
||||
|
||||
@Put('enterprise-applications/:id')
|
||||
updateApplication(@Param('id') applicationId: string, @Body() body: UpdateSmsApplicationDto) {
|
||||
return this.smsConfig.updateApplication(applicationId, body);
|
||||
}
|
||||
|
||||
@Put('enterprise-applications/:id/route-rules')
|
||||
replaceApplicationRouteRules(@Param('id') applicationId: string, @Body() body: ReplaceApplicationRouteRulesDto) {
|
||||
return this.smsConfig.replaceApplicationRouteRules(applicationId, body);
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id/connections')
|
||||
listApplicationConnections(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.listApplicationConnections(applicationId);
|
||||
@@ -33,8 +53,18 @@ export class AdminSmsConfigController {
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures')
|
||||
listSignatures(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listSignatures(tenantId);
|
||||
listSignatures(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string, @Query('status') status?: string) {
|
||||
return this.smsConfig.listSignatures({ tenantId, keyword, status });
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures')
|
||||
createSignature(@Body() body: CreateSmsSignatureDto) {
|
||||
return this.smsConfig.createSignature(body);
|
||||
}
|
||||
|
||||
@Put('enterprise-signatures/:id')
|
||||
updateSignature(@Param('id') signatureId: string, @Body() body: UpdateSmsSignatureDto) {
|
||||
return this.smsConfig.updateSignature(signatureId, body);
|
||||
}
|
||||
|
||||
@Get('enterprise-templates')
|
||||
@@ -42,6 +72,16 @@ export class AdminSmsConfigController {
|
||||
return this.smsConfig.listTemplates({ tenantId, status, keyword });
|
||||
}
|
||||
|
||||
@Post('enterprise-templates')
|
||||
createTemplate(@Body() body: CreateSmsTemplateDto) {
|
||||
return this.smsConfig.createTemplate(body);
|
||||
}
|
||||
|
||||
@Put('enterprise-templates/:id')
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) {
|
||||
return this.smsConfig.updateTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Get('audit-records')
|
||||
listAuditRecords(@Query('targetType') targetType?: string, @Query('targetId') targetId?: string) {
|
||||
return this.smsConfig.listAuditRecords(targetType, targetId);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import {
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
CreateSmsTemplateDto,
|
||||
StatusChangeDto,
|
||||
SmsConfigService,
|
||||
UpdateSmsTemplateDto,
|
||||
} from './sms-config.service';
|
||||
|
||||
@ApiTags('client-sms-config')
|
||||
@@ -25,6 +26,11 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.createApplication(body);
|
||||
}
|
||||
|
||||
@Get('applications/:id/cmpp-params')
|
||||
getApplicationCmppParams(@Param('id') applicationId: string, @TenantId() tenantId?: string) {
|
||||
return this.smsConfig.getApplicationCmppParams(applicationId, tenantId);
|
||||
}
|
||||
|
||||
@Post('applications/:id/secret/reset')
|
||||
resetApplicationSecret(@Param('id') applicationId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.resetApplicationSecret(applicationId, body);
|
||||
@@ -70,6 +76,11 @@ export class ClientSmsConfigController {
|
||||
return this.smsConfig.createTemplate(body);
|
||||
}
|
||||
|
||||
@Put('templates/:id')
|
||||
updateTemplate(@Param('id') templateId: string, @Body() body: UpdateSmsTemplateDto) {
|
||||
return this.smsConfig.updateTemplate(templateId, body);
|
||||
}
|
||||
|
||||
@Post('templates/:id/submit')
|
||||
submitTemplate(@Param('id') templateId: string) {
|
||||
return this.smsConfig.submitTemplate(templateId);
|
||||
|
||||
@@ -19,14 +19,59 @@ function createPrismaMock() {
|
||||
secretHash: 'secret-hash',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-new', ...data })),
|
||||
},
|
||||
smsApplicationIpAllowlist: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
smsChannelGroup: {
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'group-mobile', carrier: 'mobile' },
|
||||
{ id: 'group-unicom', carrier: 'unicom' },
|
||||
]),
|
||||
},
|
||||
channelRouteRule: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' },
|
||||
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
|
||||
]),
|
||||
},
|
||||
smsSignature: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'sig-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
name: '签名A',
|
||||
purpose: '行业通知',
|
||||
auditStatus: 'pending',
|
||||
drainageInfo: { carrierStatus: { mobile: 'approved', unicom: 'pending', telecom: 'filing' }, links: [] },
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
application: { id: 'app-1', name: '应用A' },
|
||||
materials: [],
|
||||
}]),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
update: jest.fn(),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'sig-1', tenantId: 'tenant-1', ...data })),
|
||||
},
|
||||
smsTemplate: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'tpl-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
name: '模板A',
|
||||
content: '您好${name}',
|
||||
auditStatus: 'pending',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
application: { id: 'app-1', name: '应用A' },
|
||||
signature: { id: 'sig-1', name: '签名A' },
|
||||
variables: [{ name: 'name', required: true }],
|
||||
}]),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'tpl-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
update: jest.fn(),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-new', ...data })),
|
||||
},
|
||||
auditRecord: {
|
||||
create: jest.fn(),
|
||||
@@ -56,6 +101,28 @@ function createPrismaMock() {
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
$transaction: jest.fn((callback) => callback({
|
||||
smsApplication: {
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
||||
},
|
||||
smsApplicationIpAllowlist: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
channelRouteRule: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'rule-1', applicationId: 'app-1', groupId: 'group-mobile', carrier: 'mobile', priority: 10, status: 'active' },
|
||||
{ id: 'rule-2', applicationId: 'app-1', groupId: 'group-unicom', carrier: 'unicom', priority: 20, status: 'active' },
|
||||
]),
|
||||
},
|
||||
templateVariable: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
smsTemplate: {
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'tpl-1', tenantId: 'tenant-1', ...data })),
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,6 +167,88 @@ describe('SmsConfigService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('updates enterprise application profile and allowlist through a transaction', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
smsApplication: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'app-1', name: '新应用', ipAllowlist: [{ ipCidr: '10.0.0.1/32' }] }),
|
||||
},
|
||||
smsApplicationIpAllowlist: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
channelRouteRule: prisma.channelRouteRule,
|
||||
};
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, ipAllowlist: ['10.0.0.1/32'] }))
|
||||
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
|
||||
|
||||
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
|
||||
expect(tx.smsApplication.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'app-1' },
|
||||
data: expect.objectContaining({
|
||||
name: '新应用',
|
||||
customerUnitPrice: 300,
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('replaces application carrier channel-group routes with carrier validation', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
channelRouteRule: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
createMany: jest.fn().mockResolvedValue({ count: 2 }),
|
||||
findMany: jest.fn().mockResolvedValue([
|
||||
{ id: 'rule-1', carrier: 'mobile', groupId: 'group-mobile' },
|
||||
{ id: 'rule-2', carrier: 'unicom', groupId: 'group-unicom' },
|
||||
]),
|
||||
},
|
||||
};
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.replaceApplicationRouteRules('app-1', {
|
||||
routes: [
|
||||
{ carrier: 'mobile', groupId: 'group-mobile' },
|
||||
{ carrier: 'unicom', groupId: 'group-unicom' },
|
||||
],
|
||||
})).resolves.toEqual([
|
||||
expect.objectContaining({ carrier: 'mobile' }),
|
||||
expect.objectContaining({ carrier: 'unicom' }),
|
||||
]);
|
||||
|
||||
expect(tx.channelRouteRule.deleteMany).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', channelId: null, province: null },
|
||||
});
|
||||
expect(tx.channelRouteRule.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'mobile', groupId: 'group-mobile', priority: 10 }),
|
||||
expect.objectContaining({ tenantId: 'tenant-1', applicationId: 'app-1', carrier: 'unicom', groupId: 'group-unicom', priority: 20 }),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects application routes when channel-group carrier does not match', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.replaceApplicationRouteRules('app-1', {
|
||||
routes: [{ carrier: 'telecom', groupId: 'group-mobile' }],
|
||||
})).rejects.toThrow('channel group carrier must match route carrier');
|
||||
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hides CMPP params when the application belongs to another tenant', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
|
||||
});
|
||||
|
||||
it('disconnects application CMPP connections and writes operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
@@ -118,4 +267,112 @@ describe('SmsConfigService', () => {
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('lists enterprise signatures with keyword filters and real relations', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listSignatures({ keyword: '签名A' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
tenant: expect.objectContaining({ name: '租户A' }),
|
||||
application: expect.objectContaining({ name: '应用A' }),
|
||||
}),
|
||||
]);
|
||||
expect(prisma.smsSignature.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: expect.any(Array),
|
||||
}),
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
}));
|
||||
});
|
||||
|
||||
it('updates enterprise signature drainage info through the admin API path', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateSignature('sig-1', {
|
||||
name: '签名B',
|
||||
auditStatus: 'approved',
|
||||
drainageInfo: {
|
||||
carrierStatus: { mobile: 'approved', unicom: 'approved', telecom: 'approved' },
|
||||
links: [{ id: 'drain-1', siteName: '官网', url: 'https://example.com' }],
|
||||
},
|
||||
})).resolves.toEqual(expect.objectContaining({
|
||||
id: 'sig-1',
|
||||
name: '签名B',
|
||||
auditStatus: 'approved',
|
||||
}));
|
||||
|
||||
expect(prisma.smsSignature.update).toHaveBeenCalledWith({
|
||||
where: { id: 'sig-1' },
|
||||
data: expect.objectContaining({
|
||||
name: '签名B',
|
||||
auditStatus: 'approved',
|
||||
drainageInfo: expect.objectContaining({
|
||||
carrierStatus: expect.objectContaining({ mobile: 'approved' }),
|
||||
}),
|
||||
}),
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('lists enterprise templates with real relations and excludes deleted by default', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listTemplates({ keyword: '模板A' })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tpl-1',
|
||||
tenant: expect.objectContaining({ name: '租户A' }),
|
||||
application: expect.objectContaining({ name: '应用A' }),
|
||||
signature: expect.objectContaining({ name: '签名A' }),
|
||||
}),
|
||||
]);
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({
|
||||
auditStatus: { not: 'deleted' },
|
||||
OR: expect.any(Array),
|
||||
}),
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
}));
|
||||
});
|
||||
|
||||
it('updates enterprise templates and rebuilds template variables', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const tx = {
|
||||
templateVariable: {
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
smsTemplate: {
|
||||
update: jest.fn().mockResolvedValue({ id: 'tpl-1', name: '模板B', variables: [{ name: 'code' }] }),
|
||||
},
|
||||
};
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateTemplate('tpl-1', {
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
name: '模板B',
|
||||
content: '验证码${code}',
|
||||
variables: [{ name: 'code', example: '123456', required: true }],
|
||||
})).resolves.toEqual(expect.objectContaining({ id: 'tpl-1', name: '模板B' }));
|
||||
|
||||
expect(tx.templateVariable.deleteMany).toHaveBeenCalledWith({ where: { templateId: 'tpl-1' } });
|
||||
expect(tx.smsTemplate.update).toHaveBeenCalledWith({
|
||||
where: { id: 'tpl-1' },
|
||||
data: expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
signatureId: 'sig-1',
|
||||
name: '模板B',
|
||||
content: '验证码${code}',
|
||||
variables: {
|
||||
create: [{ name: 'code', example: '123456', required: true }],
|
||||
},
|
||||
}),
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,19 @@ export interface CreateSmsApplicationDto {
|
||||
ipAllowlist?: string[];
|
||||
}
|
||||
|
||||
export type UpdateSmsApplicationDto = Partial<Omit<CreateSmsApplicationDto, 'tenantId'>> & {
|
||||
status?: string;
|
||||
};
|
||||
|
||||
export interface ReplaceApplicationRouteRulesDto {
|
||||
routes: Array<{
|
||||
carrier: string;
|
||||
groupId: string;
|
||||
priority?: number;
|
||||
status?: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CreateSmsSignatureDto {
|
||||
tenantId: string;
|
||||
applicationId?: string;
|
||||
@@ -23,6 +36,10 @@ export interface CreateSmsSignatureDto {
|
||||
drainageInfo?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export type UpdateSmsSignatureDto = Partial<Omit<CreateSmsSignatureDto, 'tenantId'>> & {
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface CreateSignatureMaterialDto {
|
||||
signatureId: string;
|
||||
fileObjectId?: string;
|
||||
@@ -41,6 +58,10 @@ export interface CreateSmsTemplateDto {
|
||||
variables?: Array<{ name: string; example?: string; required?: boolean }>;
|
||||
}
|
||||
|
||||
export type UpdateSmsTemplateDto = Partial<Omit<CreateSmsTemplateDto, 'tenantId'>> & {
|
||||
auditStatus?: string;
|
||||
};
|
||||
|
||||
export interface ReviewDto {
|
||||
reviewerId?: string;
|
||||
reason?: string;
|
||||
@@ -111,6 +132,20 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async getApplication(applicationId: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
},
|
||||
});
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
return application;
|
||||
}
|
||||
|
||||
createApplication(data: CreateSmsApplicationDto) {
|
||||
const secret = randomBytes(24).toString('hex');
|
||||
return this.prisma.smsApplication.create({
|
||||
@@ -132,6 +167,97 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateApplication(applicationId: string, data: UpdateSmsApplicationDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (data.ipAllowlist) {
|
||||
await tx.smsApplicationIpAllowlist.deleteMany({ where: { applicationId } });
|
||||
}
|
||||
return tx.smsApplication.update({
|
||||
where: { id: applicationId },
|
||||
data: {
|
||||
name: data.name,
|
||||
scene: data.scene,
|
||||
callbackUrl: data.callbackUrl,
|
||||
dailyLimit: data.dailyLimit,
|
||||
customerUnitPrice: data.customerUnitPrice,
|
||||
maxPhonesPerTask: data.maxPhonesPerTask,
|
||||
templateMismatchMode: data.templateMismatchMode,
|
||||
status: data.status,
|
||||
ipAllowlist: data.ipAllowlist ? {
|
||||
create: data.ipAllowlist.map((ipCidr) => ({ ipCidr })),
|
||||
} : undefined,
|
||||
},
|
||||
include: { tenant: true, ipAllowlist: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async replaceApplicationRouteRules(applicationId: string, data: ReplaceApplicationRouteRulesDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const routes = data.routes ?? [];
|
||||
if (routes.length === 0) {
|
||||
throw new BadRequestException('At least one carrier channel group is required');
|
||||
}
|
||||
|
||||
const carriers = new Set<string>();
|
||||
routes.forEach((route) => {
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(route.carrier)) {
|
||||
throw new BadRequestException('carrier must be mobile, unicom or telecom');
|
||||
}
|
||||
if (carriers.has(route.carrier)) {
|
||||
throw new BadRequestException('Duplicate carrier route is not allowed');
|
||||
}
|
||||
carriers.add(route.carrier);
|
||||
});
|
||||
|
||||
const groups = await this.prisma.smsChannelGroup.findMany({
|
||||
where: { id: { in: routes.map((route) => route.groupId) }, status: { not: 'deleted' } },
|
||||
select: { id: true, carrier: true },
|
||||
});
|
||||
const groupMap = new Map(groups.map((group) => [group.id, group]));
|
||||
routes.forEach((route) => {
|
||||
const group = groupMap.get(route.groupId);
|
||||
if (!group) {
|
||||
throw new BadRequestException(`channel group ${route.groupId} does not exist`);
|
||||
}
|
||||
if (group.carrier !== route.carrier) {
|
||||
throw new BadRequestException('channel group carrier must match route carrier');
|
||||
}
|
||||
});
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
await tx.channelRouteRule.deleteMany({
|
||||
where: {
|
||||
applicationId,
|
||||
channelId: null,
|
||||
province: null,
|
||||
},
|
||||
});
|
||||
await tx.channelRouteRule.createMany({
|
||||
data: routes.map((route, index) => ({
|
||||
tenantId: application.tenantId,
|
||||
applicationId,
|
||||
groupId: route.groupId,
|
||||
carrier: route.carrier,
|
||||
priority: route.priority ?? (index + 1) * 10,
|
||||
status: route.status ?? 'active',
|
||||
})),
|
||||
});
|
||||
return tx.channelRouteRule.findMany({
|
||||
where: { applicationId, channelId: null, province: null, status: { not: 'deleted' } },
|
||||
orderBy: [{ priority: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async resetApplicationSecret(applicationId: string, data: StatusChangeDto = {}) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
@@ -188,12 +314,12 @@ export class SmsConfigService {
|
||||
};
|
||||
}
|
||||
|
||||
async getApplicationCmppParams(applicationId: string) {
|
||||
async getApplicationCmppParams(applicationId: string, tenantId?: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application) {
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findFirst({
|
||||
@@ -246,10 +372,20 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
listSignatures(tenantId?: string) {
|
||||
listSignatures(queryOrTenantId?: string | { tenantId?: string; keyword?: string; status?: string }) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { materials: true },
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ purpose: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ application: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
@@ -267,6 +403,24 @@ export class SmsConfigService {
|
||||
});
|
||||
}
|
||||
|
||||
async updateSignature(signatureId: string, data: UpdateSmsSignatureDto) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: signatureId } });
|
||||
if (!signature) {
|
||||
throw new NotFoundException('Signature not found');
|
||||
}
|
||||
return this.prisma.smsSignature.update({
|
||||
where: { id: signatureId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
name: data.name,
|
||||
purpose: data.purpose,
|
||||
auditStatus: data.auditStatus,
|
||||
drainageInfo: data.drainageInfo as Prisma.InputJsonValue | undefined,
|
||||
},
|
||||
include: { materials: true, tenant: true, application: true },
|
||||
});
|
||||
}
|
||||
|
||||
createSignatureMaterial(data: CreateSignatureMaterialDto) {
|
||||
return this.prisma.signatureMaterial.create({
|
||||
data: {
|
||||
@@ -305,7 +459,7 @@ export class SmsConfigService {
|
||||
return this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : undefined,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ content: { contains: query.keyword } },
|
||||
@@ -338,7 +492,52 @@ export class SmsConfigService {
|
||||
})),
|
||||
},
|
||||
},
|
||||
include: { variables: true },
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
}
|
||||
|
||||
async updateTemplate(templateId: string, data: UpdateSmsTemplateDto) {
|
||||
const template = await this.prisma.smsTemplate.findUnique({ where: { id: templateId } });
|
||||
if (!template) {
|
||||
throw new NotFoundException('Template not found');
|
||||
}
|
||||
if (data.applicationId) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: data.applicationId }, select: { tenantId: true } });
|
||||
if (!application || application.tenantId !== template.tenantId) {
|
||||
throw new BadRequestException('applicationId does not belong to the template tenant');
|
||||
}
|
||||
}
|
||||
if (data.signatureId) {
|
||||
const signature = await this.prisma.smsSignature.findUnique({ where: { id: data.signatureId }, select: { tenantId: true } });
|
||||
if (!signature || signature.tenantId !== template.tenantId) {
|
||||
throw new BadRequestException('signatureId does not belong to the template tenant');
|
||||
}
|
||||
}
|
||||
const variables = data.variables ?? (data.content ? inferTemplateVariables(data.content) : undefined);
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
if (variables) {
|
||||
await tx.templateVariable.deleteMany({ where: { templateId } });
|
||||
}
|
||||
return tx.smsTemplate.update({
|
||||
where: { id: templateId },
|
||||
data: {
|
||||
applicationId: data.applicationId,
|
||||
signatureId: data.signatureId,
|
||||
name: data.name,
|
||||
content: data.content,
|
||||
category: data.category,
|
||||
auditStatus: data.auditStatus,
|
||||
billingUnits: data.content ? estimateBillingUnits(data.content) : undefined,
|
||||
variables: variables ? {
|
||||
create: variables.map((variable) => ({
|
||||
name: variable.name,
|
||||
example: variable.example,
|
||||
required: variable.required ?? true,
|
||||
})),
|
||||
} : undefined,
|
||||
},
|
||||
include: { variables: true, application: true, tenant: true, signature: true },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
import { NotFoundException } from '@nestjs/common';
|
||||
import { TenantsService } from './tenants.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const tenant = {
|
||||
id: 'tenant-1',
|
||||
name: '测试企业',
|
||||
code: 'TENANT001',
|
||||
status: 'active',
|
||||
createdAt: new Date('2026-07-03T00:00:00.000Z'),
|
||||
updatedAt: new Date('2026-07-03T00:00:00.000Z'),
|
||||
};
|
||||
return {
|
||||
tenant: {
|
||||
findMany: jest.fn().mockResolvedValue([{ ...tenant, enterpriseCertifications: [] }]),
|
||||
findUnique: jest.fn().mockResolvedValue({ ...tenant, enterpriseCertifications: [] }),
|
||||
create: jest.fn().mockResolvedValue(tenant),
|
||||
update: jest.fn().mockResolvedValue(tenant),
|
||||
},
|
||||
enterpriseCertification: {
|
||||
findFirst: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockResolvedValue({ id: 'cert-1' }),
|
||||
update: jest.fn().mockResolvedValue({ id: 'cert-1' }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('TenantsService', () => {
|
||||
it('creates tenants with a real enterprise profile', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
await service.create({
|
||||
name: '测试企业',
|
||||
code: 'TENANT001',
|
||||
creditCode: '91370000123456789X',
|
||||
province: '山东',
|
||||
city: '济南',
|
||||
address: '历下区测试路 1 号',
|
||||
contactName: '张三',
|
||||
contactIdCard: '370100199001010011',
|
||||
contactPhone: '13800000000',
|
||||
contactEmail: 'contact@example.com',
|
||||
});
|
||||
|
||||
expect(prisma.enterpriseCertification.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
tenantId: 'tenant-1',
|
||||
companyName: '测试企业',
|
||||
licenseNo: '91370000123456789X',
|
||||
contactName: '张三',
|
||||
contactPhone: '13800000000',
|
||||
status: 'approved',
|
||||
materials: expect.objectContaining({
|
||||
province: '山东',
|
||||
city: '济南',
|
||||
address: '历下区测试路 1 号',
|
||||
contactIdCard: '370100199001010011',
|
||||
contactEmail: 'contact@example.com',
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('throws 404 when updating or deleting a missing tenant', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.tenant.findUnique.mockResolvedValue(null);
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
await expect(service.update('missing', { name: '不存在' })).rejects.toBeInstanceOf(NotFoundException);
|
||||
await expect(service.delete('missing')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(prisma.tenant.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,16 +1,35 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantDto {
|
||||
name: string;
|
||||
code: string;
|
||||
status?: string;
|
||||
creditCode?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
address?: string;
|
||||
contactName?: string;
|
||||
contactIdCard?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
photoFileObjectId?: string;
|
||||
}
|
||||
|
||||
export interface UpdateTenantDto {
|
||||
name?: string;
|
||||
code?: string;
|
||||
status?: string;
|
||||
creditCode?: string;
|
||||
province?: string;
|
||||
city?: string;
|
||||
address?: string;
|
||||
contactName?: string;
|
||||
contactIdCard?: string;
|
||||
contactPhone?: string;
|
||||
contactEmail?: string;
|
||||
photoFileObjectId?: string;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -19,27 +38,35 @@ export class TenantsService {
|
||||
|
||||
list() {
|
||||
return this.prisma.tenant.findMany({
|
||||
include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}).then((items) => items.map(withEnterpriseProfile));
|
||||
}
|
||||
|
||||
get(id: string) {
|
||||
return this.prisma.tenant.findUnique({ where: { id } });
|
||||
}
|
||||
|
||||
create(data: CreateTenantDto) {
|
||||
return this.prisma.tenant.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
code: data.code,
|
||||
status: data.status ?? 'active',
|
||||
},
|
||||
return this.prisma.tenant.findUnique({
|
||||
where: { id },
|
||||
include: { enterpriseCertifications: { orderBy: { submittedAt: 'desc' }, take: 1 } },
|
||||
}).then((tenant) => {
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
return withEnterpriseProfile(tenant);
|
||||
});
|
||||
}
|
||||
|
||||
update(id: string, data: UpdateTenantDto) {
|
||||
return this.prisma.tenant.update({
|
||||
async create(data: CreateTenantDto) {
|
||||
const tenant = await this.prisma.tenant.create({
|
||||
data: { name: data.name, code: data.code, status: data.status ?? 'active' },
|
||||
});
|
||||
await this.upsertProfile(tenant.id, data);
|
||||
return this.get(tenant.id);
|
||||
}
|
||||
|
||||
async update(id: string, data: UpdateTenantDto) {
|
||||
await this.ensureTenant(id);
|
||||
await this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data: {
|
||||
name: data.name,
|
||||
@@ -47,9 +74,12 @@ export class TenantsService {
|
||||
status: data.status,
|
||||
},
|
||||
});
|
||||
await this.upsertProfile(id, data);
|
||||
return this.get(id);
|
||||
}
|
||||
|
||||
changeStatus(id: string, status: string) {
|
||||
async changeStatus(id: string, status: string) {
|
||||
await this.ensureTenant(id);
|
||||
return this.prisma.tenant.update({
|
||||
where: { id },
|
||||
data: { status },
|
||||
@@ -59,4 +89,77 @@ export class TenantsService {
|
||||
delete(id: string) {
|
||||
return this.changeStatus(id, 'deleted');
|
||||
}
|
||||
|
||||
private async ensureTenant(id: string) {
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id } });
|
||||
if (!tenant) {
|
||||
throw new NotFoundException('Tenant not found');
|
||||
}
|
||||
return tenant;
|
||||
}
|
||||
|
||||
private async upsertProfile(tenantId: string, data: CreateTenantDto | UpdateTenantDto) {
|
||||
const hasProfileData = ['creditCode', 'province', 'city', 'address', 'contactName', 'contactIdCard', 'contactPhone', 'contactEmail', 'photoFileObjectId']
|
||||
.some((key) => data[key as keyof (CreateTenantDto | UpdateTenantDto)] !== undefined);
|
||||
if (!hasProfileData) {
|
||||
return;
|
||||
}
|
||||
const latest = await this.prisma.enterpriseCertification.findFirst({
|
||||
where: { tenantId },
|
||||
orderBy: { submittedAt: 'desc' },
|
||||
});
|
||||
const materials = cleanObject({
|
||||
...(latest?.materials && typeof latest.materials === 'object' && !Array.isArray(latest.materials) ? latest.materials as Record<string, unknown> : {}),
|
||||
province: data.province,
|
||||
city: data.city,
|
||||
address: data.address,
|
||||
contactIdCard: data.contactIdCard,
|
||||
contactEmail: data.contactEmail,
|
||||
photoFileObjectId: data.photoFileObjectId,
|
||||
});
|
||||
const profileData = {
|
||||
companyName: data.name ?? latest?.companyName ?? tenantId,
|
||||
licenseNo: data.creditCode,
|
||||
contactName: data.contactName,
|
||||
contactPhone: data.contactPhone,
|
||||
materials: materials as Prisma.InputJsonValue,
|
||||
status: 'approved',
|
||||
};
|
||||
if (latest) {
|
||||
await this.prisma.enterpriseCertification.update({
|
||||
where: { id: latest.id },
|
||||
data: profileData,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await this.prisma.enterpriseCertification.create({
|
||||
data: { tenantId, ...profileData },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function cleanObject(value: Record<string, unknown>) {
|
||||
return Object.fromEntries(Object.entries(value).filter(([, item]) => item !== undefined));
|
||||
}
|
||||
|
||||
function withEnterpriseProfile<T extends { enterpriseCertifications?: Array<{ licenseNo?: string | null; contactName?: string | null; contactPhone?: string | null; materials?: Prisma.JsonValue | null }> }>(tenant: T) {
|
||||
const [profile] = tenant.enterpriseCertifications ?? [];
|
||||
const materials = profile?.materials && typeof profile.materials === 'object' && !Array.isArray(profile.materials)
|
||||
? profile.materials as Record<string, unknown>
|
||||
: {};
|
||||
const { enterpriseCertifications, ...rest } = tenant;
|
||||
return {
|
||||
...rest,
|
||||
enterpriseProfile: profile ? {
|
||||
creditCode: profile.licenseNo ?? '',
|
||||
province: String(materials.province ?? ''),
|
||||
city: String(materials.city ?? ''),
|
||||
address: String(materials.address ?? ''),
|
||||
contactName: profile.contactName ?? '',
|
||||
contactIdCard: String(materials.contactIdCard ?? ''),
|
||||
contactPhone: profile.contactPhone ?? '',
|
||||
contactEmail: String(materials.contactEmail ?? ''),
|
||||
photoFileObjectId: String(materials.photoFileObjectId ?? ''),
|
||||
} : null,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user