fix: connect operations pages to real backend
This commit is contained in:
@@ -35,6 +35,9 @@ function createPrismaMock() {
|
||||
findMany: jest.fn(),
|
||||
create: jest.fn(),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -131,6 +134,13 @@ describe('BillingService', () => {
|
||||
relatedType: 'recharge_order',
|
||||
}),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'billing.manual_recharge',
|
||||
resource: 'recharge_order',
|
||||
resourceId: 'order-1',
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
|
||||
|
||||
@@ -199,8 +199,8 @@ export class BillingService {
|
||||
return order;
|
||||
}
|
||||
|
||||
createManualRecharge(data: CreateManualRechargeDto) {
|
||||
return this.createRechargeOrder({
|
||||
async createManualRecharge(data: CreateManualRechargeDto) {
|
||||
const order = await this.createRechargeOrder({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: data.amountCents,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
@@ -208,6 +208,22 @@ export class BillingService {
|
||||
operatorId: data.operatorId,
|
||||
remark: data.remark,
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
userId: data.operatorId,
|
||||
action: 'billing.manual_recharge',
|
||||
resource: 'recharge_order',
|
||||
resourceId: order.id,
|
||||
detail: {
|
||||
amountCents: data.amountCents,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
orderNo: order.orderNo,
|
||||
remark: data.remark,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return order;
|
||||
}
|
||||
|
||||
estimateSmsCost(data: EstimateSmsCostDto) {
|
||||
|
||||
@@ -92,6 +92,14 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('ChannelsService', () => {
|
||||
it('rejects incomplete channel creation input with readable 400 errors', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
expect(() => service.createChannel({ name: '缺字段通道' } as never)).toThrow('Missing required channel fields');
|
||||
expect(prisma.smsChannel.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates CMPP channels and route rules with first-version defaults', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new ChannelsService(prisma as never);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
@@ -127,6 +127,17 @@ export class ChannelsService {
|
||||
}
|
||||
|
||||
createChannel(data: CreateChannelDto) {
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
});
|
||||
if (missingFields.length > 0) {
|
||||
throw new BadRequestException(`Missing required channel fields: ${missingFields.join(', ')}`);
|
||||
}
|
||||
const gatewayPort = Number(data.gatewayPort);
|
||||
if (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
}
|
||||
return this.prisma.smsChannel.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
@@ -134,7 +145,7 @@ export class ChannelsService {
|
||||
carrier: data.carrier,
|
||||
protocol: data.protocol ?? 'CMPP',
|
||||
gatewayHost: data.gatewayHost,
|
||||
gatewayPort: data.gatewayPort,
|
||||
gatewayPort,
|
||||
enterpriseCode: data.enterpriseCode,
|
||||
account: data.account,
|
||||
passwordCipher: data.passwordCipher,
|
||||
|
||||
@@ -39,6 +39,11 @@ export class AdminOperationsController {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
}
|
||||
|
||||
@Get('dashboard/statistics')
|
||||
dashboardStatistics(@Query('tenantId') tenantId?: string) {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
}
|
||||
|
||||
@Get('statistics')
|
||||
statistics(@Query('tenantId') tenantId?: string, @Query('groupBy') groupBy?: string) {
|
||||
return this.operations.statistics({ tenantId, groupBy });
|
||||
@@ -72,3 +77,22 @@ export class AdminOperationsController {
|
||||
}
|
||||
}
|
||||
|
||||
@ApiTags('admin-system-logs')
|
||||
@Controller('admin/system-logs')
|
||||
export class AdminSystemLogsController {
|
||||
constructor(private readonly operations: OperationsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('userId') userId?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('level') level?: string,
|
||||
@Query('module') module?: string,
|
||||
@Query('range') range?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.systemLogs({ tenantId, userId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,5 +22,22 @@ export class ClientOperationsController {
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId });
|
||||
}
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
dashboard(@TenantId() tenantId?: string) {
|
||||
return this.operations.dashboard({ tenantId });
|
||||
}
|
||||
|
||||
@Get('system-logs')
|
||||
systemLogs(
|
||||
@TenantId() tenantId?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('level') level?: string,
|
||||
@Query('module') module?: string,
|
||||
@Query('range') range?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.systemLogs({ tenantId, keyword, level, module, range, page: Number(page), pageSize: Number(pageSize) });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
import { AdminOperationsController } from './admin-operations.controller';
|
||||
import { AdminOperationsController, AdminSystemLogsController } from './admin-operations.controller';
|
||||
import { ClientOperationsController } from './client-operations.controller';
|
||||
import { OperationsService } from './operations.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminOperationsController, ClientOperationsController],
|
||||
controllers: [AdminOperationsController, AdminSystemLogsController, ClientOperationsController],
|
||||
providers: [OperationsService],
|
||||
exports: [OperationsService],
|
||||
})
|
||||
export class OperationsModule {}
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { OperationsService } from './operations.service';
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
smsBatchTask: {
|
||||
findMany: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'task-1', taskNo: 'BATCH-1' }]),
|
||||
count: jest.fn().mockResolvedValue(3),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
@@ -25,12 +25,38 @@ function createPrismaMock() {
|
||||
accountTransaction: {
|
||||
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: -20, smsUnits: -2 } }),
|
||||
},
|
||||
tenantAccount: {
|
||||
findMany: jest.fn().mockResolvedValue([{ tenantId: 'tenant-1', balanceCents: 1000, tenant: { name: '租户A' } }]),
|
||||
},
|
||||
rechargeOrder: {
|
||||
findMany: jest.fn().mockResolvedValue([{ id: 'order-1', tenantId: 'tenant-1', amountCents: 1000 }]),
|
||||
},
|
||||
smsTemplate: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
smsSignature: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
enterpriseCertification: {
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }]),
|
||||
},
|
||||
operationLog: {
|
||||
findMany: jest.fn(),
|
||||
groupBy: jest.fn(),
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'log-1',
|
||||
tenantId: 'tenant-1',
|
||||
tenant: { name: '租户A' },
|
||||
user: { displayName: '运营' },
|
||||
action: 'billing.manual_recharge',
|
||||
resource: 'recharge_order',
|
||||
resourceId: 'order-1',
|
||||
detail: { amountCents: 1000 },
|
||||
createdAt: new Date('2026-07-02T01:00:00.000Z'),
|
||||
}]),
|
||||
count: jest.fn().mockResolvedValue(1),
|
||||
groupBy: jest.fn().mockResolvedValue([{ resource: 'recharge_order', _count: { _all: 1 } }]),
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -72,6 +98,7 @@ describe('OperationsService', () => {
|
||||
expect.objectContaining({
|
||||
taskCount: 3,
|
||||
uplinkCount: 1,
|
||||
pendingAuditCount: 6,
|
||||
gatewayConnections: [{ status: 'online', _count: { _all: 1 }, _sum: { currentConnections: 2, desiredConnections: 2 } }],
|
||||
}),
|
||||
);
|
||||
@@ -104,4 +131,26 @@ describe('OperationsService', () => {
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns paginated operation logs with normalized detail cards', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.systemLogs({ tenantId: 'tenant-1', keyword: '充值', page: 1, pageSize: 5 })).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
total: 1,
|
||||
page: 1,
|
||||
pageSize: 5,
|
||||
modules: ['recharge_order'],
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
level: 'success',
|
||||
tenant: '租户A',
|
||||
module: 'recharge_order',
|
||||
action: 'billing.manual_recharge',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,17 @@ export interface TraceQuery extends MessageQuery {
|
||||
messageId?: string;
|
||||
}
|
||||
|
||||
export interface OperationLogQuery {
|
||||
tenantId?: string;
|
||||
userId?: string;
|
||||
keyword?: string;
|
||||
level?: string;
|
||||
module?: string;
|
||||
range?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class OperationsService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
@@ -71,8 +82,22 @@ export class OperationsService {
|
||||
}
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const sinceToday = startOfToday();
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const [taskCount, messageGroups, uplinkCount, billingAggregate, transactionAggregate, connectionGroups] = await Promise.all([
|
||||
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
||||
const [
|
||||
taskCount,
|
||||
messageGroups,
|
||||
todayMessageGroups,
|
||||
uplinkCount,
|
||||
billingAggregate,
|
||||
transactionAggregate,
|
||||
connectionGroups,
|
||||
pendingAuditCount,
|
||||
tenantAccounts,
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
@@ -80,6 +105,12 @@ export class OperationsService {
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
by: ['status'],
|
||||
where: todayMessageWhereClause,
|
||||
_count: { _all: true },
|
||||
_sum: { amountCents: true, billingUnits: true },
|
||||
}),
|
||||
this.prisma.smsUplinkMessage.count({ where: { tenantId: query.tenantId } }),
|
||||
this.prisma.smsBillingRecord.aggregate({
|
||||
where: { tenantId: query.tenantId },
|
||||
@@ -97,14 +128,50 @@ export class OperationsService {
|
||||
_count: { _all: true },
|
||||
_sum: { currentConnections: true, desiredConnections: true },
|
||||
}),
|
||||
this.countPendingAudits(query.tenantId),
|
||||
this.prisma.tenantAccount.findMany({
|
||||
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||
include: { tenant: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 20,
|
||||
}),
|
||||
this.prisma.smsBatchTask.findMany({
|
||||
where: query.tenantId ? { tenantId: query.tenantId } : undefined,
|
||||
include: { application: true, messages: { take: 1, include: { channel: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
this.prisma.rechargeOrder.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
payMethod: 'manual_topup',
|
||||
},
|
||||
include: { tenant: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 10,
|
||||
}),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
return {
|
||||
taskCount,
|
||||
messageStatus: messageGroups,
|
||||
today: {
|
||||
sent: todayTotals.total,
|
||||
delivered: todayTotals.delivered,
|
||||
failed: todayTotals.failed,
|
||||
unknown: todayTotals.unknown,
|
||||
successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0,
|
||||
spendCents: todayTotals.amountCents,
|
||||
billingUnits: todayTotals.billingUnits,
|
||||
},
|
||||
uplinkCount,
|
||||
billing: billingAggregate,
|
||||
transactions: transactionAggregate,
|
||||
gatewayConnections: connectionGroups,
|
||||
pendingAuditCount,
|
||||
accounts: tenantAccounts,
|
||||
recentTasks,
|
||||
recentRecharges,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -142,6 +209,51 @@ export class OperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
async systemLogs(query: OperationLogQuery) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 10)));
|
||||
const where: Prisma.OperationLogWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
userId: query.userId,
|
||||
createdAt: createdAtRange(query.range),
|
||||
resource: query.module && query.module !== 'all' ? query.module : undefined,
|
||||
OR: query.keyword ? [
|
||||
{ action: { contains: query.keyword } },
|
||||
{ resource: { contains: query.keyword } },
|
||||
{ resourceId: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
{ user: { displayName: { contains: query.keyword } } },
|
||||
{ user: { username: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
};
|
||||
const [items, total, modules] = await Promise.all([
|
||||
this.prisma.operationLog.findMany({
|
||||
where,
|
||||
include: { tenant: true, user: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.operationLog.count({ where }),
|
||||
this.prisma.operationLog.groupBy({
|
||||
by: ['resource'],
|
||||
where: { tenantId: query.tenantId },
|
||||
_count: { _all: true },
|
||||
orderBy: { resource: 'asc' },
|
||||
}),
|
||||
]);
|
||||
const normalizedItems = items
|
||||
.map((item) => normalizeOperationLog(item))
|
||||
.filter((item) => !query.level || query.level === 'all' || item.level === query.level);
|
||||
return {
|
||||
items: normalizedItems,
|
||||
total: query.level && query.level !== 'all' ? normalizedItems.length : total,
|
||||
page,
|
||||
pageSize,
|
||||
modules: modules.map((item) => item.resource),
|
||||
};
|
||||
}
|
||||
|
||||
auditSummary(query: { tenantId?: string }) {
|
||||
return this.prisma.operationLog.groupBy({
|
||||
by: ['action', 'resource'],
|
||||
@@ -223,6 +335,15 @@ export class OperationsService {
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private countPendingAudits(tenantId?: string) {
|
||||
return Promise.all([
|
||||
this.prisma.smsTemplate.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.smsSignature.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
this.prisma.enterpriseCertification.count({ where: { tenantId, status: 'pending' } }),
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId, auditStatus: 'pending' } }),
|
||||
]).then((counts) => counts.reduce((sum, value) => sum + value, 0));
|
||||
}
|
||||
}
|
||||
|
||||
function messageWhere(query: MessageQuery): Prisma.SmsMessageRecordWhereInput {
|
||||
@@ -245,3 +366,68 @@ function normalizeGroupBy(groupBy?: string) {
|
||||
}
|
||||
return 'channelId';
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function createdAtRange(range?: string): Prisma.DateTimeFilter | undefined {
|
||||
if (!range || range === 'all') {
|
||||
return undefined;
|
||||
}
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
if (range === '7d') {
|
||||
date.setDate(date.getDate() - 6);
|
||||
} else if (range === '30d') {
|
||||
date.setDate(date.getDate() - 29);
|
||||
}
|
||||
return { gte: date };
|
||||
}
|
||||
|
||||
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | null; billingUnits: number | null } }>) {
|
||||
return groups.reduce(
|
||||
(summary, group) => {
|
||||
const count = group._count._all;
|
||||
summary.total += count;
|
||||
summary.amountCents += group._sum.amountCents ?? 0;
|
||||
summary.billingUnits += group._sum.billingUnits ?? 0;
|
||||
if (group.status === 'delivered') {
|
||||
summary.delivered += count;
|
||||
} else if (['undelivered', 'submit_failed', 'timeout', 'failed', 'rejected'].includes(group.status)) {
|
||||
summary.failed += count;
|
||||
} else if (group.status === 'unknown') {
|
||||
summary.unknown += count;
|
||||
}
|
||||
return summary;
|
||||
},
|
||||
{ total: 0, delivered: 0, failed: 0, unknown: 0, amountCents: 0, billingUnits: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeOperationLog(log: Prisma.OperationLogGetPayload<{ include: { tenant: true; user: true } }>) {
|
||||
const detail = (log.detail ?? {}) as Record<string, unknown>;
|
||||
const result = String(detail.result ?? detail.status ?? '');
|
||||
const level = result.includes('fail') || log.action.includes('failed') || log.action.includes('reject')
|
||||
? 'error'
|
||||
: log.action.includes('warning') || log.action.includes('risk')
|
||||
? 'warning'
|
||||
: log.action.includes('approve') || log.action.includes('recharge') || log.action.includes('connected')
|
||||
? 'success'
|
||||
: 'info';
|
||||
return {
|
||||
id: log.id,
|
||||
time: log.createdAt,
|
||||
level,
|
||||
tenant: log.tenant?.name ?? (log.tenantId ? log.tenantId : '平台'),
|
||||
module: log.resource,
|
||||
operator: log.user?.displayName ?? log.user?.username ?? log.userId ?? 'system',
|
||||
action: log.action,
|
||||
resourceId: log.resourceId ?? '',
|
||||
detail,
|
||||
ip: log.ipAddress ?? '',
|
||||
userAgent: log.userAgent ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { Body, Controller, Delete, Get, Param, Post, Query } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { ReviewDto, SmsConfigService, StatusChangeDto } from './sms-config.service';
|
||||
|
||||
@@ -8,8 +8,28 @@ export class AdminSmsConfigController {
|
||||
constructor(private readonly smsConfig: SmsConfigService) {}
|
||||
|
||||
@Get('enterprise-applications')
|
||||
listApplications(@Query('tenantId') tenantId?: string) {
|
||||
return this.smsConfig.listApplications(tenantId);
|
||||
listApplications(@Query('tenantId') tenantId?: string, @Query('keyword') keyword?: string) {
|
||||
return this.smsConfig.listApplications({ tenantId, keyword, includeConnections: true });
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id/connections')
|
||||
listApplicationConnections(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.listApplicationConnections(applicationId);
|
||||
}
|
||||
|
||||
@Get('enterprise-applications/:id/cmpp-params')
|
||||
getApplicationCmppParams(@Param('id') applicationId: string) {
|
||||
return this.smsConfig.getApplicationCmppParams(applicationId);
|
||||
}
|
||||
|
||||
@Post('enterprise-applications/:id/connections/:connectionId/disconnect')
|
||||
disconnectApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body);
|
||||
}
|
||||
|
||||
@Delete('enterprise-applications/:id/connections/:connectionId')
|
||||
deleteApplicationConnection(@Param('id') applicationId: string, @Param('connectionId') connectionId: string, @Body() body: StatusChangeDto) {
|
||||
return this.smsConfig.disconnectApplicationConnection(applicationId, connectionId, body);
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures')
|
||||
|
||||
@@ -2,6 +2,24 @@ import { SmsConfigService } from './sms-config.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
return {
|
||||
smsApplication: {
|
||||
findMany: jest.fn().mockResolvedValue([{
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
name: '应用A',
|
||||
status: 'active',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
messageRecords: [{ status: 'delivered' }, { status: 'undelivered' }],
|
||||
}]),
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
name: '应用A',
|
||||
status: 'active',
|
||||
secretHash: 'secret-hash',
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
}),
|
||||
},
|
||||
smsSignature: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'sig-1', tenantId: 'tenant-1', auditStatus: 'pending' }),
|
||||
update: jest.fn(),
|
||||
@@ -17,6 +35,27 @@ function createPrismaMock() {
|
||||
user: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
},
|
||||
cmppConnectionState: {
|
||||
findMany: jest.fn().mockResolvedValue([{ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1', status: 'online', currentConnections: 1, desiredConnections: 1 }]),
|
||||
findFirst: jest.fn().mockResolvedValue({ channelId: 'channel-1', connectionId: 'conn-a', tenantId: 'tenant-1' }),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'conn-state-1', ...data })),
|
||||
},
|
||||
smsChannel: {
|
||||
findFirst: jest.fn().mockResolvedValue({
|
||||
id: 'channel-1',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 7890,
|
||||
enterpriseCode: 'EC',
|
||||
account: 'sp',
|
||||
passwordCipher: 'cipher',
|
||||
srcId: '10690000',
|
||||
cmppVersion: '3.0',
|
||||
config: { maxConnections: 2 },
|
||||
}),
|
||||
},
|
||||
operationLog: {
|
||||
create: jest.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,4 +71,51 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.smsSignature.update).not.toHaveBeenCalled();
|
||||
expect(prisma.auditRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists enterprise applications with real CMPP connection state', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.listApplications({ includeConnections: true })).resolves.toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'app-1',
|
||||
cmppStatus: 'connected',
|
||||
sentToday: 2,
|
||||
deliveryRate: 50,
|
||||
cmppConnections: [expect.objectContaining({ connectionId: 'conn-a' })],
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('returns CMPP params from persisted application and channel config', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
tenantName: '租户A',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 7890,
|
||||
maxConnections: 2,
|
||||
}));
|
||||
});
|
||||
|
||||
it('disconnects application CMPP connections and writes operation logs', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await service.disconnectApplicationConnection('app-1', 'conn-a', { status: 'disconnected', reason: 'manual' });
|
||||
|
||||
expect(prisma.cmppConnectionState.update).toHaveBeenCalledWith({
|
||||
where: { channelId_connectionId: { channelId: 'channel-1', connectionId: 'conn-a' } },
|
||||
data: expect.objectContaining({ status: 'disconnected', currentConnections: 0, lastError: 'manual' }),
|
||||
});
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'cmpp_connection.disconnected',
|
||||
resource: 'cmpp_connection',
|
||||
resourceId: 'channel-1:conn-a',
|
||||
}),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -57,16 +57,56 @@ export interface TemplateListQuery {
|
||||
keyword?: string;
|
||||
}
|
||||
|
||||
export interface ApplicationListQuery {
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
includeConnections?: boolean;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class SmsConfigService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
listApplications(tenantId?: string) {
|
||||
async listApplications(queryOrTenantId?: string | ApplicationListQuery) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : queryOrTenantId ?? {};
|
||||
return this.prisma.smsApplication.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
include: { ipAllowlist: true },
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
OR: query.keyword ? [
|
||||
{ name: { contains: query.keyword } },
|
||||
{ tenant: { name: { contains: query.keyword } } },
|
||||
] : undefined,
|
||||
},
|
||||
include: {
|
||||
tenant: true,
|
||||
ipAllowlist: true,
|
||||
messageRecords: { where: { queuedAt: { gte: startOfToday() } }, take: 1000 },
|
||||
},
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
}).then(async (applications) => {
|
||||
if (!query.includeConnections) {
|
||||
return applications;
|
||||
}
|
||||
const tenantIds = [...new Set(applications.map((application) => application.tenantId))];
|
||||
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId: { in: tenantIds } },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 500,
|
||||
});
|
||||
return applications.map((application) => {
|
||||
const appConnections = connections.filter((connection) => connection.tenantId === application.tenantId);
|
||||
const todayTotal = application.messageRecords.length;
|
||||
const delivered = application.messageRecords.filter((message) => message.status === 'delivered').length;
|
||||
return {
|
||||
...application,
|
||||
cmppConnections: appConnections,
|
||||
cmppStatus: normalizeApplicationCmppStatus(appConnections, application.status),
|
||||
sentToday: todayTotal,
|
||||
deliveryRate: todayTotal > 0 ? Number(((delivered / todayTotal) * 100).toFixed(1)) : 0,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -121,6 +161,89 @@ export class SmsConfigService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
async listApplicationConnections(applicationId: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const connections = await this.prisma.cmppConnectionState.findMany({
|
||||
where: { tenantId: application.tenantId },
|
||||
include: { channel: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
return {
|
||||
application,
|
||||
connections,
|
||||
summary: {
|
||||
desiredConnections: connections.reduce((sum, connection) => sum + connection.desiredConnections, 0),
|
||||
currentConnections: connections.reduce((sum, connection) => sum + connection.currentConnections, 0),
|
||||
status: normalizeApplicationCmppStatus(connections, application.status),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getApplicationCmppParams(applicationId: string) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { id: applicationId },
|
||||
include: { tenant: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findFirst({
|
||||
where: { status: { not: 'deleted' } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return {
|
||||
applicationId: application.id,
|
||||
applicationName: application.name,
|
||||
tenantId: application.tenantId,
|
||||
tenantName: application.tenant.name,
|
||||
appCode: application.id,
|
||||
gatewayHost: channel?.gatewayHost ?? '',
|
||||
gatewayPort: channel?.gatewayPort ?? 0,
|
||||
enterpriseCode: channel?.enterpriseCode ?? application.tenant.code,
|
||||
account: channel?.account ?? application.tenant.code,
|
||||
passwordCipher: channel?.passwordCipher ?? application.secretHash,
|
||||
srcId: channel?.srcId ?? '',
|
||||
maxConnections: channel?.config && typeof channel.config === 'object' && 'maxConnections' in channel.config ? Number(channel.config.maxConnections) : 1,
|
||||
heartbeatSeconds: 30,
|
||||
windowSize: 16,
|
||||
protocolVersion: channel?.cmppVersion ?? '3.0',
|
||||
};
|
||||
}
|
||||
|
||||
async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) {
|
||||
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const connection = await this.prisma.cmppConnectionState.findFirst({
|
||||
where: { tenantId: application.tenantId, connectionId },
|
||||
});
|
||||
if (!connection) {
|
||||
throw new NotFoundException('Connection not found');
|
||||
}
|
||||
const updated = await this.prisma.cmppConnectionState.update({
|
||||
where: { channelId_connectionId: { channelId: connection.channelId, connectionId } },
|
||||
data: {
|
||||
status: 'disconnected',
|
||||
currentConnections: 0,
|
||||
lastDisconnectedAt: new Date(),
|
||||
lastError: data.reason,
|
||||
},
|
||||
});
|
||||
await this.writeOperationLog(application.tenantId, data.operatorId, 'cmpp_connection.disconnected', 'cmpp_connection', `${connection.channelId}:${connectionId}`, {
|
||||
applicationId,
|
||||
reason: data.reason,
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
listSignatures(tenantId?: string) {
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
@@ -407,3 +530,22 @@ function inferTemplateVariables(content: string): TemplateVariableInput[] {
|
||||
const matches = content.match(/\$\{[a-zA-Z0-9_]+\}/g) ?? [];
|
||||
return [...new Set(matches)].map((match) => ({ name: match.slice(2, -1), required: true }));
|
||||
}
|
||||
|
||||
function startOfToday() {
|
||||
const date = new Date();
|
||||
date.setHours(0, 0, 0, 0);
|
||||
return date;
|
||||
}
|
||||
|
||||
function normalizeApplicationCmppStatus(connections: Array<{ status: string; currentConnections: number }>, applicationStatus: string) {
|
||||
if (applicationStatus !== 'active') {
|
||||
return 'inactive';
|
||||
}
|
||||
if (connections.some((connection) => ['online', 'connected', 'open'].includes(connection.status) && connection.currentConnections > 0)) {
|
||||
return 'connected';
|
||||
}
|
||||
if (connections.some((connection) => ['auth_failed', 'heartbeat_timeout', 'reconnecting'].includes(connection.status))) {
|
||||
return 'degraded';
|
||||
}
|
||||
return 'disconnected';
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user