fix: harden admin and CMPP delivery workflows
This commit is contained in:
@@ -29,6 +29,11 @@ export class DictionariesController {
|
||||
return this.dictionaries.createPhoneSegment(body);
|
||||
}
|
||||
|
||||
@Delete('phone-segments/:id')
|
||||
deletePhoneSegment(@Param('id') id: string) {
|
||||
return this.dictionaries.deletePhoneSegment(id);
|
||||
}
|
||||
|
||||
@Get('phone-carrier-rules')
|
||||
listPhoneCarrierRules(@Query('keyword') keyword?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return this.dictionaries.listPhoneCarrierRules({ keyword, page: Number(page) || undefined, pageSize: Number(pageSize) || undefined });
|
||||
@@ -108,4 +113,9 @@ export class DictionariesController {
|
||||
createDrainageField(@Body() body: CreateDrainageFieldDto) {
|
||||
return this.dictionaries.createDrainageField(body);
|
||||
}
|
||||
|
||||
@Delete('drainage-fields/:id')
|
||||
deleteDrainageField(@Param('id') id: string) {
|
||||
return this.dictionaries.deleteDrainageField(id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ function createPrismaMock() {
|
||||
phoneSegment: {
|
||||
findMany: jest.fn(),
|
||||
count: jest.fn().mockResolvedValue(3),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'segment-1' }),
|
||||
},
|
||||
phoneCarrierRule: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
@@ -26,7 +27,12 @@ function createPrismaMock() {
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'enterprise-1', ...data })),
|
||||
},
|
||||
drainageField: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'field-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'field-1' }),
|
||||
},
|
||||
channelReportField: {
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
},
|
||||
smsApplication: {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'app-1', tenantId: 'tenant-1' }),
|
||||
@@ -38,6 +44,25 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('DictionariesService', () => {
|
||||
it('deletes a phone segment from the real dictionary table', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await service.deletePhoneSegment('segment-1');
|
||||
|
||||
expect(prisma.phoneSegment.delete).toHaveBeenCalledWith({ where: { id: 'segment-1' } });
|
||||
});
|
||||
|
||||
it('returns drainage field usage counts and blocks deleting fields used by channels', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.drainageField.findMany.mockResolvedValue([{ id: 'field-1', code: 'license', _count: { channelReportFields: 2 } }]);
|
||||
prisma.channelReportField.count.mockResolvedValue(2);
|
||||
const service = new DictionariesService(prisma as never);
|
||||
|
||||
await expect(service.listDrainageFields()).resolves.toEqual([{ id: 'field-1', code: 'license', usageCount: 2 }]);
|
||||
await expect(service.deleteDrainageField('field-1')).rejects.toThrow('不能删除');
|
||||
expect(prisma.drainageField.delete).not.toHaveBeenCalled();
|
||||
});
|
||||
it('paginates phone segments with a real database count', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.phoneSegment.findMany.mockResolvedValue([
|
||||
|
||||
@@ -97,6 +97,10 @@ export class DictionariesService {
|
||||
return this.prisma.phoneSegment.create({ data });
|
||||
}
|
||||
|
||||
deletePhoneSegment(id: string) {
|
||||
return this.prisma.phoneSegment.delete({ where: { id } });
|
||||
}
|
||||
|
||||
async listPhoneCarrierRules(query: PageQuery = {}) {
|
||||
const page = Math.max(1, Number(query.page ?? 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Number(query.pageSize ?? 25)));
|
||||
@@ -258,8 +262,12 @@ export class DictionariesService {
|
||||
return updated;
|
||||
}
|
||||
|
||||
listDrainageFields() {
|
||||
return this.prisma.drainageField.findMany({ orderBy: { createdAt: 'desc' } });
|
||||
async listDrainageFields() {
|
||||
const fields = await this.prisma.drainageField.findMany({
|
||||
include: { _count: { select: { channelReportFields: true } } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return fields.map(({ _count, ...field }) => ({ ...field, usageCount: _count.channelReportFields }));
|
||||
}
|
||||
|
||||
createDrainageField(data: CreateDrainageFieldDto) {
|
||||
@@ -282,6 +290,14 @@ export class DictionariesService {
|
||||
});
|
||||
}
|
||||
|
||||
async deleteDrainageField(id: string) {
|
||||
const usageCount = await this.prisma.channelReportField.count({ where: { drainageFieldId: id } });
|
||||
if (usageCount > 0) {
|
||||
throw new BadRequestException(`该字段已被 ${usageCount} 个通道使用,不能删除`);
|
||||
}
|
||||
return this.prisma.drainageField.delete({ where: { id } });
|
||||
}
|
||||
|
||||
private writeOperationLog(userId: string | undefined, action: string, resource: string, resourceId: string, detail: Record<string, unknown>) {
|
||||
return this.prisma.operationLog.create({
|
||||
data: {
|
||||
|
||||
@@ -154,6 +154,8 @@ export class AdminOperationsController {
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('deliveryType') deliveryType?: string,
|
||||
@Query('createdAtFrom') createdAtFrom?: string,
|
||||
@Query('createdAtTo') createdAtTo?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('keyword') keyword?: string,
|
||||
@Query('page') page?: string,
|
||||
@@ -163,6 +165,8 @@ export class AdminOperationsController {
|
||||
tenantId,
|
||||
applicationId,
|
||||
deliveryType,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
status,
|
||||
keyword,
|
||||
page: Number(page),
|
||||
@@ -175,11 +179,15 @@ export class AdminOperationsController {
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('applicationId') applicationId?: string,
|
||||
@Query('deliveryType') deliveryType?: string,
|
||||
@Query('createdAtFrom') createdAtFrom?: string,
|
||||
@Query('createdAtTo') createdAtTo?: string,
|
||||
) {
|
||||
return this.operations.downstreamDeliveryDashboard({
|
||||
tenantId,
|
||||
applicationId,
|
||||
deliveryType,
|
||||
createdAtFrom,
|
||||
createdAtTo,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -252,6 +252,7 @@ describe('OperationsService', () => {
|
||||
.mockResolvedValueOnce(2)
|
||||
.mockResolvedValueOnce(8)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(1)
|
||||
.mockResolvedValueOnce(2);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -274,11 +275,25 @@ describe('OperationsService', () => {
|
||||
failed: 2,
|
||||
delivered: 8,
|
||||
stalledPending: 1,
|
||||
stalledAck: 1,
|
||||
recentFailed: 2,
|
||||
alertCount: 3,
|
||||
alertCount: 4,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(4, {
|
||||
where: { tenantId: 'tenant-1', status: 'pending', createdAt: { lte: expect.any(Date) } },
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(5, {
|
||||
where: { tenantId: 'tenant-1', status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } },
|
||||
});
|
||||
expect(prisma.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(6, {
|
||||
where: {
|
||||
tenantId: 'tenant-1',
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: expect.any(Date) },
|
||||
},
|
||||
});
|
||||
await service.statistics({ tenantId: 'tenant-1', groupBy: 'application' });
|
||||
|
||||
expect(prisma.smsMessageRecord.groupBy).toHaveBeenCalledWith({
|
||||
@@ -399,6 +414,8 @@ describe('OperationsService', () => {
|
||||
deliveryType: 'receipt',
|
||||
status: 'failed',
|
||||
keyword: '1380',
|
||||
createdAtFrom: '2026-07-01',
|
||||
createdAtTo: '2026-07-15',
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
})).resolves.toEqual({
|
||||
@@ -413,6 +430,10 @@ describe('OperationsService', () => {
|
||||
tenantId: 'tenant-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'failed',
|
||||
createdAt: {
|
||||
gte: new Date('2026-07-01T00:00:00.000+08:00'),
|
||||
lte: new Date('2026-07-15T23:59:59.999+08:00'),
|
||||
},
|
||||
}),
|
||||
include: { tenant: true, application: true, messageRecord: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
@@ -451,6 +472,10 @@ describe('OperationsService', () => {
|
||||
{ applicationId: 'app-1', status: 'delivered', _count: { _all: 5 } },
|
||||
{ applicationId: 'app-2', status: 'pending', _count: { _all: 1 } },
|
||||
{ applicationId: 'app-2', status: 'delivered', _count: { _all: 3 } },
|
||||
])
|
||||
.mockResolvedValueOnce([
|
||||
{ applicationId: 'app-1', _count: { _all: 1 } },
|
||||
{ applicationId: 'app-2', _count: { _all: 1 } },
|
||||
]);
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -482,10 +507,27 @@ describe('OperationsService', () => {
|
||||
{ label: '4次及以上', count: 0 },
|
||||
],
|
||||
topApplications: [
|
||||
{ applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 3 },
|
||||
{ applicationId: 'app-1', name: '应用A', pending: 2, awaitingAck: 0, failed: 1, unconfirmed: 0, rejected: 0, delivered: 5, alertCount: 1 },
|
||||
{ applicationId: 'app-2', name: '应用B', pending: 1, awaitingAck: 0, failed: 0, unconfirmed: 0, rejected: 0, delivered: 3, alertCount: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(prisma.cmppDownstreamDelivery.groupBy).toHaveBeenNthCalledWith(3, {
|
||||
by: ['applicationId'],
|
||||
where: {
|
||||
AND: [
|
||||
{ tenantId: 'tenant-1', applicationId: 'app-1', deliveryType: undefined },
|
||||
{
|
||||
OR: [
|
||||
{ status: 'pending', createdAt: { lte: expect.any(Date) } },
|
||||
{ status: 'awaiting_ack', ackDeadlineAt: { lte: expect.any(Date) } },
|
||||
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: expect.any(Date) } },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
_count: { _all: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('returns paginated downstream recovery statuses', async () => {
|
||||
|
||||
@@ -49,12 +49,16 @@ export interface DownstreamDeliveryQuery {
|
||||
keyword?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}
|
||||
|
||||
export interface DownstreamDeliveryDashboardQuery {
|
||||
tenantId?: string;
|
||||
applicationId?: string;
|
||||
deliveryType?: string;
|
||||
createdAtFrom?: string;
|
||||
createdAtTo?: string;
|
||||
}
|
||||
|
||||
export interface DownstreamRecoveryStatusQuery {
|
||||
@@ -140,6 +144,7 @@ export class OperationsService {
|
||||
|
||||
async dashboard(query: { tenantId?: string }) {
|
||||
const sinceToday = startOfToday();
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const messageWhereClause = messageWhere({ tenantId: query.tenantId });
|
||||
const todayMessageWhereClause = { ...messageWhereClause, queuedAt: { gte: sinceToday } };
|
||||
const [
|
||||
@@ -158,6 +163,7 @@ export class OperationsService {
|
||||
downstreamFailedCount,
|
||||
downstreamDeliveredCount,
|
||||
downstreamStalledPendingCount,
|
||||
downstreamStalledAckCount,
|
||||
downstreamRecentFailedCount,
|
||||
] = await Promise.all([
|
||||
this.prisma.smsBatchTask.count({ where: { tenantId: query.tenantId } }),
|
||||
@@ -225,19 +231,26 @@ export class OperationsService {
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: 'pending',
|
||||
createdAt: { lte: new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000) },
|
||||
createdAt: { lte: downstreamAlertWindow.stalledPendingAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: 'failed',
|
||||
updatedAt: { gte: new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000) },
|
||||
status: 'awaiting_ack',
|
||||
ackDeadlineAt: { lte: downstreamAlertWindow.now },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
const todayTotals = summarizeMessageGroups(todayMessageGroups);
|
||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamRecentFailedCount;
|
||||
const downstreamAlertCount = downstreamStalledPendingCount + downstreamStalledAckCount + downstreamRecentFailedCount;
|
||||
return {
|
||||
taskCount,
|
||||
messageStatus: messageGroups,
|
||||
@@ -261,6 +274,7 @@ export class OperationsService {
|
||||
failed: downstreamFailedCount,
|
||||
delivered: downstreamDeliveredCount,
|
||||
stalledPending: downstreamStalledPendingCount,
|
||||
stalledAck: downstreamStalledAckCount,
|
||||
recentFailed: downstreamRecentFailedCount,
|
||||
alertCount: downstreamAlertCount,
|
||||
},
|
||||
@@ -413,9 +427,8 @@ export class OperationsService {
|
||||
|
||||
async downstreamDeliveryDashboard(query: DownstreamDeliveryDashboardQuery) {
|
||||
const scopedWhere = downstreamDeliveryScopedWhere(query);
|
||||
const stalledPendingAt = new Date(Date.now() - downstreamAlertPendingMinutes() * 60_000);
|
||||
const recentFailedAt = new Date(Date.now() - downstreamAlertRecentFailedHours() * 60 * 60_000);
|
||||
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||
const downstreamAlertWindow = downstreamAlertWindows();
|
||||
const [total, pending, awaitingAck, delivered, failed, unconfirmed, rejected, stalledPending, stalledAck, recentFailed, typeGroups, applicationGroups, applicationAlertGroups, retryZero, retryLow, retryHigh] = await Promise.all([
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: scopedWhere }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'pending' } }),
|
||||
this.prisma.cmppDownstreamDelivery.count({ where: { ...scopedWhere, status: 'awaiting_ack' } }),
|
||||
@@ -427,17 +440,17 @@ export class OperationsService {
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: 'pending',
|
||||
createdAt: { lte: stalledPendingAt },
|
||||
createdAt: { lte: downstreamAlertWindow.stalledPendingAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: new Date() } },
|
||||
where: { ...scopedWhere, status: 'awaiting_ack', ackDeadlineAt: { lte: downstreamAlertWindow.now } },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
status: { in: ['failed', 'unconfirmed', 'rejected'] },
|
||||
updatedAt: { gte: recentFailedAt },
|
||||
updatedAt: { gte: downstreamAlertWindow.recentFailedAt },
|
||||
},
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
@@ -450,6 +463,11 @@ export class OperationsService {
|
||||
where: scopedWhere,
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.groupBy({
|
||||
by: ['applicationId'],
|
||||
where: downstreamAlertWhere(scopedWhere, downstreamAlertWindow),
|
||||
_count: { _all: true },
|
||||
}),
|
||||
this.prisma.cmppDownstreamDelivery.count({
|
||||
where: {
|
||||
...scopedWhere,
|
||||
@@ -480,8 +498,11 @@ export class OperationsService {
|
||||
})
|
||||
: [];
|
||||
const applicationMap = new Map<string, string>(applications.map((item) => [item.id, item.name]));
|
||||
const applicationAlertMap = new Map<string, number>(
|
||||
applicationAlertGroups.map((item) => [item.applicationId, item._count._all]),
|
||||
);
|
||||
const groupedByType = groupDownstreamByType(typeGroups);
|
||||
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap);
|
||||
const groupedByApplication = groupDownstreamByApplication(applicationGroups, applicationMap, applicationAlertMap);
|
||||
|
||||
return {
|
||||
summary: {
|
||||
@@ -844,14 +865,49 @@ function downstreamAlertRecentFailedHours() {
|
||||
return Number.isFinite(value) && value > 0 ? value : 1;
|
||||
}
|
||||
|
||||
function downstreamAlertWindows(now = new Date()) {
|
||||
return {
|
||||
now,
|
||||
stalledPendingAt: new Date(now.getTime() - downstreamAlertPendingMinutes() * 60_000),
|
||||
recentFailedAt: new Date(now.getTime() - downstreamAlertRecentFailedHours() * 60 * 60_000),
|
||||
};
|
||||
}
|
||||
|
||||
function downstreamAlertWhere(
|
||||
scopedWhere: Prisma.CmppDownstreamDeliveryWhereInput,
|
||||
window: ReturnType<typeof downstreamAlertWindows>,
|
||||
): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
return {
|
||||
AND: [
|
||||
scopedWhere,
|
||||
{
|
||||
OR: [
|
||||
{ status: 'pending', createdAt: { lte: window.stalledPendingAt } },
|
||||
{ status: 'awaiting_ack', ackDeadlineAt: { lte: window.now } },
|
||||
{ status: { in: ['failed', 'unconfirmed', 'rejected'] }, updatedAt: { gte: window.recentFailedAt } },
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function downstreamDeliveryScopedWhere(query: DownstreamDeliveryDashboardQuery): Prisma.CmppDownstreamDeliveryWhereInput {
|
||||
const createdAtFrom = parseDateBoundary(query.createdAtFrom, false);
|
||||
const createdAtTo = parseDateBoundary(query.createdAtTo, true);
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
applicationId: query.applicationId,
|
||||
deliveryType: query.deliveryType && query.deliveryType !== 'all' ? query.deliveryType : undefined,
|
||||
createdAt: createdAtFrom || createdAtTo ? { gte: createdAtFrom, lte: createdAtTo } : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function parseDateBoundary(value?: string, endOfDay = false) {
|
||||
if (!value) return undefined;
|
||||
const parsed = new Date(`${value}T${endOfDay ? '23:59:59.999' : '00:00:00.000'}+08:00`);
|
||||
return Number.isNaN(parsed.getTime()) ? undefined : parsed;
|
||||
}
|
||||
|
||||
function downstreamRecoveryStatusWhere(query: DownstreamRecoveryStatusQuery) {
|
||||
return {
|
||||
tenantId: query.tenantId,
|
||||
@@ -943,6 +999,7 @@ function groupDownstreamByType(
|
||||
function groupDownstreamByApplication(
|
||||
groups: Array<{ applicationId: string; status: string; _count: { _all: number } }>,
|
||||
applicationMap: Map<string, string>,
|
||||
applicationAlertMap: Map<string, number>,
|
||||
) {
|
||||
const summaryMap = new Map<string, { applicationId: string; name: string; pending: number; awaitingAck: number; failed: number; unconfirmed: number; rejected: number; delivered: number; alertCount: number }>();
|
||||
groups.forEach((item) => {
|
||||
@@ -970,7 +1027,7 @@ function groupDownstreamByApplication(
|
||||
} else if (item.status === 'delivered') {
|
||||
current.delivered += item._count._all;
|
||||
}
|
||||
current.alertCount = current.pending + current.failed + current.unconfirmed + current.rejected;
|
||||
current.alertCount = applicationAlertMap.get(item.applicationId) ?? 0;
|
||||
summaryMap.set(item.applicationId, current);
|
||||
});
|
||||
return [...summaryMap.values()];
|
||||
|
||||
@@ -94,6 +94,7 @@ function createPrismaMock() {
|
||||
auditStatus: 'approved',
|
||||
signature: { auditStatus: 'approved', reportStatus: 'reporting' },
|
||||
}),
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
smsSignature: {
|
||||
findFirst: jest.fn().mockResolvedValue({ id: 'sig-1', name: '签名', auditStatus: 'approved', reportStatus: 'reporting' }),
|
||||
@@ -571,6 +572,90 @@ describe('SendChainService', () => {
|
||||
expect(riskReview.aggregateTemplateMismatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('matches an inbound CMPP message against configured template variables and passes extracted values to risk review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||
prisma.smsTemplate.findMany.mockResolvedValue([{
|
||||
id: 'tpl-code',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
content: '【航天信息信诺网】您本次操作的验证码是${code},有效时间10分钟。',
|
||||
auditStatus: 'approved',
|
||||
signature: { id: 'sig-1', name: '【航天信息信诺网】', auditStatus: 'approved', reportStatus: 'reporting' },
|
||||
}]);
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '18821203795',
|
||||
content: '【航天信息信诺网】您本次操作的验证码是715021,有效时间10分钟。',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
|
||||
|
||||
expect(prisma.smsTemplate.findMany).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', content: { contains: '${' } },
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
expect(prisma.smsMessageRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ templateId: 'tpl-code', status: 'validating' }),
|
||||
});
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
templateId: 'tpl-code',
|
||||
variables: { code: '715021' },
|
||||
}));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('queues template-mismatched CMPP content when the application uses direct send', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
service.enqueueBatchTask = jest.fn().mockResolvedValue({ taskId: 'task-1', enqueued: 1 });
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
id: 'app-1',
|
||||
tenantId: 'tenant-1',
|
||||
cmppAccount: '100001',
|
||||
status: 'active',
|
||||
interfaceEnabled: true,
|
||||
templateMismatchMode: 'direct_send',
|
||||
customerUnitPrice: 3,
|
||||
queuePriority: 'normal',
|
||||
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
|
||||
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
|
||||
});
|
||||
prisma.smsTemplate.findFirst.mockResolvedValue(null);
|
||||
prisma.smsTemplate.findMany.mockResolvedValue([]);
|
||||
prisma.smsSignature.findFirst.mockResolvedValue({
|
||||
id: 'sig-1',
|
||||
name: '【航天信息信诺网】',
|
||||
auditStatus: 'approved',
|
||||
reportStatus: 'reporting',
|
||||
});
|
||||
|
||||
await expect(service.submitInboundMessage({
|
||||
account: '100001',
|
||||
phoneNumber: '18821203795',
|
||||
content: '【航天信息信诺网】未配置模板但允许直接发送',
|
||||
remoteIp: '127.0.0.1',
|
||||
})).resolves.toEqual(expect.objectContaining({ accepted: true, messageRecordId: 'record-1' }));
|
||||
|
||||
expect(prisma.smsSignature.findFirst).toHaveBeenCalledWith({
|
||||
where: { applicationId: 'app-1', name: '【航天信息信诺网】', auditStatus: 'approved' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
content: '【航天信息信诺网】未配置模板但允许直接发送',
|
||||
}));
|
||||
expect(riskReview.evaluateTask).toHaveBeenCalledWith(expect.not.objectContaining({ templateId: expect.any(String) }));
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: { status: 'queued', signatureId: 'sig-1' },
|
||||
});
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
expect(prisma.smsReceiptRecord.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('aggregates template-mismatched CMPP messages only when the application uses manual review', async () => {
|
||||
const { service, prisma, riskReview } = createService();
|
||||
prisma.smsApplication.findFirst.mockResolvedValue({
|
||||
@@ -1602,6 +1687,29 @@ describe('SendChainService', () => {
|
||||
|
||||
it('requeues downstream delivery through real gateway control path', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'failed',
|
||||
retryCount: 3,
|
||||
manualRetryCount: 1,
|
||||
lastError: 'downstream client is not connected',
|
||||
payload: { account: '100001', messageId: 'MSG-1', phoneNumber: '13800000001', receiptStatus: 'delivered' },
|
||||
application: { cmppAccount: '100001' },
|
||||
});
|
||||
prisma.cmppDownstreamDelivery.update.mockResolvedValueOnce({
|
||||
id: 'delivery-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
messageId: 'MSG-1',
|
||||
deliveryType: 'receipt',
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
manualRetryCount: 2,
|
||||
});
|
||||
service['postGatewayControl'] = jest.fn().mockResolvedValue({ delivered: true });
|
||||
|
||||
await service.requeueDownstreamDelivery('delivery-1');
|
||||
@@ -1624,12 +1732,41 @@ describe('SendChainService', () => {
|
||||
expect(prisma.cmppDownstreamDelivery.update).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: expect.any(Date),
|
||||
acknowledgedAt: null,
|
||||
ackResult: null,
|
||||
ackMessageId: null,
|
||||
deliveredAt: null,
|
||||
}),
|
||||
}));
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
detail: expect.objectContaining({
|
||||
previousStatus: 'failed',
|
||||
previousRetryCount: 3,
|
||||
manualRetryCount: 2,
|
||||
lastRetriedAt: expect.any(Date),
|
||||
}),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects manual requeue while downstream acknowledgement is pending', async () => {
|
||||
const { service, prisma } = createService();
|
||||
service['postGatewayControl'] = jest.fn();
|
||||
prisma.cmppDownstreamDelivery.findUnique.mockResolvedValueOnce({
|
||||
id: 'delivery-1',
|
||||
status: 'awaiting_ack',
|
||||
payload: { account: '100001' },
|
||||
application: { cmppAccount: '100001' },
|
||||
});
|
||||
|
||||
await expect(service.requeueDownstreamDelivery('delivery-1')).rejects.toThrow('该记录正在等待客户端确认,不允许并发重投');
|
||||
expect(prisma.cmppDownstreamDelivery.update).not.toHaveBeenCalled();
|
||||
expect(service['postGatewayControl']).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('supports batch requeue of downstream deliveries', async () => {
|
||||
|
||||
@@ -1263,6 +1263,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!delivery) {
|
||||
throw new NotFoundException('Downstream delivery not found');
|
||||
}
|
||||
if (delivery.status === 'awaiting_ack') {
|
||||
throw new BadRequestException('该记录正在等待客户端确认,不允许并发重投');
|
||||
}
|
||||
const payload = isObjectRecord(delivery.payload) ? { ...delivery.payload } : null;
|
||||
if (!payload) {
|
||||
throw new BadRequestException('下游投递记录缺少可重放 payload');
|
||||
@@ -1277,30 +1280,19 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException(`Unsupported downstream delivery type ${delivery.deliveryType}`);
|
||||
}
|
||||
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: delivery.id,
|
||||
detail: {
|
||||
deliveryType: delivery.deliveryType,
|
||||
applicationId: delivery.applicationId,
|
||||
messageId: delivery.messageId,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const requestPayload = {
|
||||
deliveryId: delivery.id,
|
||||
account: String(payload.account ?? delivery.application?.cmppAccount ?? ''),
|
||||
...payload,
|
||||
};
|
||||
await this.prisma.cmppDownstreamDelivery.update({
|
||||
const retriedAt = new Date();
|
||||
const requeued = await this.prisma.cmppDownstreamDelivery.update({
|
||||
where: { id: delivery.id },
|
||||
data: {
|
||||
status: 'pending',
|
||||
retryCount: 0,
|
||||
manualRetryCount: { increment: 1 },
|
||||
lastRetriedAt: retriedAt,
|
||||
nextRetryAt: null,
|
||||
sentAt: null,
|
||||
acknowledgedAt: null,
|
||||
@@ -1313,6 +1305,23 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: delivery.tenantId,
|
||||
action: 'gateway.downstream_delivery_requeue',
|
||||
resource: 'cmpp_downstream_delivery',
|
||||
resourceId: delivery.id,
|
||||
detail: {
|
||||
deliveryType: delivery.deliveryType,
|
||||
applicationId: delivery.applicationId,
|
||||
messageId: delivery.messageId,
|
||||
previousStatus: delivery.status,
|
||||
previousRetryCount: delivery.retryCount,
|
||||
manualRetryCount: requeued.manualRetryCount,
|
||||
lastRetriedAt: retriedAt,
|
||||
},
|
||||
},
|
||||
});
|
||||
try {
|
||||
const result = await this.postGatewayControl(path, requestPayload) as GatewayControlDeliveryResult;
|
||||
if (result.sent || result.delivered) {
|
||||
@@ -1649,6 +1658,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException('CMPP submit phone number is invalid');
|
||||
}
|
||||
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
|
||||
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
|
||||
const unitPrice = application.customerUnitPrice ?? 0;
|
||||
const queuePriority = normalizeQueuePriority(application.queuePriority);
|
||||
const billing = this.billing.estimateSmsCost({
|
||||
@@ -1707,6 +1717,57 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
await this.recordCmppFailureReceipt(message, code, reason);
|
||||
};
|
||||
const queueAfterRiskChecks = async (options: { templateId?: string; signatureId?: string }) => {
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: options.templateId,
|
||||
content: data.content,
|
||||
variables: options.templateId ? templateVariables : undefined,
|
||||
phones: [data.phoneNumber],
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
return;
|
||||
}
|
||||
if (risk.status === 'pending_review') {
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'pending_review', signatureId: options.signatureId },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
||||
});
|
||||
return;
|
||||
}
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
return;
|
||||
}
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 入站短信冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'queued', signatureId: options.signatureId },
|
||||
});
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
||||
});
|
||||
await this.enqueueBatchTask(task.id);
|
||||
};
|
||||
if (application.status !== 'active' || application.tenant.status !== 'active') {
|
||||
await reject('ACCOUNT', '企业或短信应用已停用');
|
||||
} else if (!application.interfaceEnabled) {
|
||||
@@ -1765,6 +1826,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (!template && application.templateMismatchMode === 'direct_send') {
|
||||
const signature = await this.resolveInboundSignatureCandidate(application.id, data.content);
|
||||
if (!signature) {
|
||||
await reject('SIGNATURE', '短信内容未识别到已审核通过的签名');
|
||||
} else {
|
||||
await queueAfterRiskChecks({ signatureId: signature.id });
|
||||
}
|
||||
} else if (!template) {
|
||||
await reject('TEMPLATE', '短信内容未匹配到已报备模板');
|
||||
} else if (template.auditStatus !== 'approved') {
|
||||
@@ -1772,46 +1840,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
} else if (!template.signature || template.signature.auditStatus !== 'approved') {
|
||||
await reject('SIGNATURE', '短信签名尚未审核通过');
|
||||
} else {
|
||||
const risk = await this.riskReview.evaluateTask({
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
templateId: template.id,
|
||||
content: data.content,
|
||||
phones: [data.phoneNumber],
|
||||
});
|
||||
if (risk.status === 'rejected') {
|
||||
await reject('RISK', risk.reason || '短信被风控拒绝');
|
||||
} else if (risk.status === 'pending_review') {
|
||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'pending_review' } });
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'pending_review', riskTaskId: risk.task?.id, auditStatus: 'pending', reviewReason: risk.reason },
|
||||
});
|
||||
} else {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
} else {
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 入站短信冻结',
|
||||
});
|
||||
}
|
||||
await this.prisma.smsMessageRecord.update({ where: { id: message.id }, data: { status: 'queued' } });
|
||||
await this.prisma.smsBatchTask.update({
|
||||
where: { id: task.id },
|
||||
data: { status: 'ready', riskTaskId: risk.task?.id, auditStatus: 'approved' },
|
||||
});
|
||||
await this.enqueueBatchTask(task.id);
|
||||
}
|
||||
}
|
||||
await queueAfterRiskChecks({ templateId: template.id, signatureId: template.signature.id });
|
||||
}
|
||||
return {
|
||||
accepted: true,
|
||||
@@ -2160,8 +2189,8 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
private resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
return this.prisma.smsTemplate.findFirst({
|
||||
private async resolveInboundTemplateCandidate(applicationId: string, content: string) {
|
||||
const exact = await this.prisma.smsTemplate.findFirst({
|
||||
where: {
|
||||
applicationId,
|
||||
content,
|
||||
@@ -2169,6 +2198,16 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
if (exact) return exact;
|
||||
const variableTemplates = await this.prisma.smsTemplate.findMany({
|
||||
where: {
|
||||
applicationId,
|
||||
content: { contains: '${' },
|
||||
},
|
||||
include: { signature: true },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
});
|
||||
return variableTemplates.find((template) => matchTemplateContent(template.content, content) !== null) ?? null;
|
||||
}
|
||||
|
||||
private resolveInboundSignatureCandidate(applicationId: string, content: string) {
|
||||
@@ -2902,6 +2941,45 @@ function normalizeRegion(region?: string | null) {
|
||||
return String(region ?? '').replace(/省|市|自治区|壮族|回族|维吾尔/g, '').trim();
|
||||
}
|
||||
|
||||
function matchTemplateContent(templateContent: string, actualContent: string) {
|
||||
if (templateContent === actualContent) {
|
||||
return {} as Record<string, string>;
|
||||
}
|
||||
const tokenPattern = /\$\{([a-zA-Z0-9_]+)\}/g;
|
||||
const names: string[] = [];
|
||||
let cursor = 0;
|
||||
let pattern = '^';
|
||||
for (const match of templateContent.matchAll(tokenPattern)) {
|
||||
const index = match.index ?? 0;
|
||||
pattern += escapeRegularExpression(templateContent.slice(cursor, index));
|
||||
pattern += '([\\s\\S]+?)';
|
||||
names.push(match[1]);
|
||||
cursor = index + match[0].length;
|
||||
}
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
pattern += `${escapeRegularExpression(templateContent.slice(cursor))}$`;
|
||||
const matched = new RegExp(pattern, 'u').exec(actualContent);
|
||||
if (!matched) {
|
||||
return null;
|
||||
}
|
||||
const variables: Record<string, string> = {};
|
||||
for (let index = 0; index < names.length; index += 1) {
|
||||
const name = names[index];
|
||||
const value = matched[index + 1];
|
||||
if (variables[name] !== undefined && variables[name] !== value) {
|
||||
return null;
|
||||
}
|
||||
variables[name] = value;
|
||||
}
|
||||
return variables;
|
||||
}
|
||||
|
||||
function escapeRegularExpression(value: string) {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function isNationalChannel(item: { province?: string | null; channel: { sendRegion?: string | null } }) {
|
||||
const itemProvince = normalizeRegion(item.province);
|
||||
const sendRegion = normalizeRegion(item.channel.sendRegion);
|
||||
|
||||
@@ -130,7 +130,8 @@ function createPrismaMock() {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'downstream-1', ...data })),
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'downstream-1' }),
|
||||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||||
},
|
||||
smsMessageRecord: {
|
||||
groupBy: jest.fn().mockResolvedValue([
|
||||
@@ -264,7 +265,7 @@ describe('SmsConfigService', () => {
|
||||
tenantId: 'tenant-1',
|
||||
name: '优先应用',
|
||||
cmppAccount: '123456',
|
||||
cmppEnterpriseCode: 'CUSTOM-EC',
|
||||
cmppEnterpriseCode: '123456',
|
||||
secretHash: '1234567890abcdef',
|
||||
cmppMaxConnections: 3,
|
||||
cmppWindowSize: 32,
|
||||
@@ -343,6 +344,7 @@ describe('SmsConfigService', () => {
|
||||
where: { id: 'app-1' },
|
||||
data: expect.objectContaining({
|
||||
name: '新应用',
|
||||
cmppEnterpriseCode: '100001',
|
||||
customerUnitPrice: 300,
|
||||
queuePriority: 'priority',
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
@@ -467,6 +469,22 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('removes a disconnected downstream session instead of retaining connection history', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-1' });
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.recordDownstreamConnectionEvent({
|
||||
account: '100001',
|
||||
connectionId: 'gateway-1-1',
|
||||
status: 'disconnected',
|
||||
errorMessage: 'client closed',
|
||||
})).resolves.toEqual(expect.objectContaining({ status: 'disconnected', deleted: true }));
|
||||
|
||||
expect(prisma.cmppDownstreamConnection.delete).toHaveBeenCalledWith({ where: { id: 'downstream-1' } });
|
||||
expect(prisma.cmppDownstreamConnection.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('lists enterprise signatures with keyword filters and real relations', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
@@ -185,7 +185,7 @@ export class SmsConfigService {
|
||||
const applicationIds = applications.map((application) => application.id);
|
||||
const [connections, messageStats] = await Promise.all([
|
||||
this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId: { in: applicationIds } },
|
||||
where: { applicationId: { in: applicationIds }, status: 'connected' },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
}),
|
||||
this.prisma.smsMessageRecord.groupBy({
|
||||
@@ -297,7 +297,7 @@ export class SmsConfigService {
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
||||
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
|
||||
const cmppEnterpriseCode = await this.resolveCmppEnterpriseCode(data.cmppEnterpriseCode, data.tenantId);
|
||||
const cmppEnterpriseCode = cmppAccount;
|
||||
return this.prisma.smsApplication.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -337,9 +337,7 @@ export class SmsConfigService {
|
||||
const cmppAccount = data.cmppAccount === undefined
|
||||
? undefined
|
||||
: await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId);
|
||||
const cmppEnterpriseCode = data.cmppEnterpriseCode === undefined
|
||||
? undefined
|
||||
: normalizeEnterpriseCode(data.cmppEnterpriseCode);
|
||||
const cmppEnterpriseCode = cmppAccount ?? application.cmppAccount;
|
||||
const interfaceType = data.interfaceType === undefined
|
||||
? undefined
|
||||
: normalizeApplicationInterfaceType(data.interfaceType);
|
||||
@@ -552,17 +550,6 @@ export class SmsConfigService {
|
||||
throw new BadRequestException('Unable to generate unique CMPP account');
|
||||
}
|
||||
|
||||
private async resolveCmppEnterpriseCode(cmppEnterpriseCode: string | undefined, tenantId: string) {
|
||||
if (cmppEnterpriseCode !== undefined) {
|
||||
return normalizeEnterpriseCode(cmppEnterpriseCode);
|
||||
}
|
||||
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { code: true } });
|
||||
if (!tenant) {
|
||||
throw new BadRequestException('tenantId does not reference an existing tenant');
|
||||
}
|
||||
return normalizeEnterpriseCode(tenant.code);
|
||||
}
|
||||
|
||||
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: data.account },
|
||||
@@ -574,7 +561,20 @@ export class SmsConfigService {
|
||||
const observedAt = parseGatewayDate(data.observedAt) ?? new Date();
|
||||
const connectedAt = parseGatewayDate(data.connectedAt) ?? observedAt;
|
||||
const existing = await this.prisma.cmppDownstreamConnection.findUnique({ where: { connectionId: data.connectionId } });
|
||||
const status = data.status === 'disconnected' ? 'disconnected' : 'connected';
|
||||
if (data.status === 'disconnected') {
|
||||
if (existing) {
|
||||
await this.prisma.cmppDownstreamConnection.delete({ where: { id: existing.id } });
|
||||
}
|
||||
await this.writeOperationLog(application.tenantId, undefined, 'cmpp_downstream_connection.disconnected', 'cmpp_downstream_connection', data.connectionId, {
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status: 'disconnected',
|
||||
errorMessage: data.errorMessage,
|
||||
});
|
||||
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
|
||||
}
|
||||
const payload = {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
@@ -582,18 +582,18 @@ export class SmsConfigService {
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
remoteIp: data.remoteIp,
|
||||
protocol: data.protocol,
|
||||
status,
|
||||
status: 'connected',
|
||||
connectedAt: existing?.connectedAt ?? connectedAt,
|
||||
lastHeartbeatAt: data.status === 'connected' || data.status === 'heartbeat' ? observedAt : existing?.lastHeartbeatAt,
|
||||
lastSubmitAt: data.status === 'submit' ? observedAt : existing?.lastSubmitAt,
|
||||
lastDeliverAt: data.status === 'deliver' ? observedAt : existing?.lastDeliverAt,
|
||||
disconnectedAt: data.status === 'disconnected' ? observedAt : null,
|
||||
lastError: data.status === 'disconnected' ? data.errorMessage ?? existing?.lastError ?? null : null,
|
||||
disconnectedAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
const connection = existing
|
||||
? await this.prisma.cmppDownstreamConnection.update({ where: { id: existing.id }, data: payload })
|
||||
: await this.prisma.cmppDownstreamConnection.create({ data: { connectionId: data.connectionId, ...payload } });
|
||||
if (data.status === 'connected' || data.status === 'disconnected') {
|
||||
if (data.status === 'connected') {
|
||||
await this.writeOperationLog(application.tenantId, undefined, `cmpp_downstream_connection.${data.status}`, 'cmpp_downstream_connection', data.connectionId, {
|
||||
applicationId: application.id,
|
||||
account: data.account,
|
||||
@@ -608,13 +608,8 @@ export class SmsConfigService {
|
||||
async markTimedOutDownstreamConnections(now = new Date()) {
|
||||
const timeoutMs = getPositiveIntegerEnv('CMPP_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS', DEFAULT_DOWNSTREAM_HEARTBEAT_TIMEOUT_MS);
|
||||
const cutoff = new Date(now.getTime() - timeoutMs);
|
||||
return this.prisma.cmppDownstreamConnection.updateMany({
|
||||
return this.prisma.cmppDownstreamConnection.deleteMany({
|
||||
where: { status: 'connected', lastHeartbeatAt: { lt: cutoff } },
|
||||
data: {
|
||||
status: 'heartbeat_timeout',
|
||||
disconnectedAt: now,
|
||||
lastError: `CMPP heartbeat timeout after ${Math.round(timeoutMs / 1000)} seconds`,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1326,17 +1321,6 @@ interface TemplateVariableInput {
|
||||
required?: boolean;
|
||||
}
|
||||
|
||||
function normalizeEnterpriseCode(value: string) {
|
||||
const enterpriseCode = value.trim();
|
||||
if (!enterpriseCode) {
|
||||
throw new BadRequestException('cmppEnterpriseCode is required');
|
||||
}
|
||||
if (enterpriseCode.length > 32) {
|
||||
throw new BadRequestException('cmppEnterpriseCode must be at most 32 characters');
|
||||
}
|
||||
return enterpriseCode;
|
||||
}
|
||||
|
||||
function normalizeApplicationPassword(value: string | undefined) {
|
||||
const password = value?.trim() || generateApplicationPassword();
|
||||
if (password.length !== 16) {
|
||||
|
||||
Reference in New Issue
Block a user