fix: harden admin and CMPP delivery workflows
This commit is contained in:
@@ -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()];
|
||||
|
||||
Reference in New Issue
Block a user