fix: connect operations pages to real backend

This commit is contained in:
hectorzhao
2026-07-02 15:16:34 +08:00
parent ab421cf8a7
commit 321cf716f2
22 changed files with 1255 additions and 429 deletions
+10
View File
@@ -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 () => {
+18 -2
View File
@@ -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);
+13 -2
View File
@@ -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) });
}
}
+2 -3
View File
@@ -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 {}
+52 -3
View File
@@ -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',
}),
],
}),
);
});
});
+187 -1
View File
@@ -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',
}),
});
});
});
+145 -3
View File
@@ -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';
}