fix: paginate operational list pages
This commit is contained in:
@@ -42,8 +42,10 @@ export class AdminOperationsController {
|
||||
@Query('queuedAtFrom') queuedAtFrom?: string,
|
||||
@Query('queuedAtTo') queuedAtTo?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.listMessages({
|
||||
return this.operations.listMessagesPage({
|
||||
tenantId,
|
||||
applicationId,
|
||||
channelId,
|
||||
@@ -55,9 +57,40 @@ export class AdminOperationsController {
|
||||
queuedAtFrom,
|
||||
queuedAtTo,
|
||||
status,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('messages/export')
|
||||
async exportMessages(
|
||||
@Query('tenantId') tenantId: string | undefined,
|
||||
@Query('applicationId') applicationId: string | undefined,
|
||||
@Query('channelId') channelId: string | undefined,
|
||||
@Query('phoneNumber') phoneNumber: string | undefined,
|
||||
@Query('contentKeyword') contentKeyword: string | undefined,
|
||||
@Query('channelKeyword') channelKeyword: string | undefined,
|
||||
@Query('queuedAtFrom') queuedAtFrom: string | undefined,
|
||||
@Query('queuedAtTo') queuedAtTo: string | undefined,
|
||||
@Query('status') status: string | undefined,
|
||||
@Res() response: DownloadResponse,
|
||||
) {
|
||||
const exported = await this.operations.exportMessages({
|
||||
tenantId,
|
||||
applicationId,
|
||||
channelId,
|
||||
phoneNumber,
|
||||
contentKeyword,
|
||||
channelKeyword,
|
||||
queuedAtFrom,
|
||||
queuedAtTo,
|
||||
status,
|
||||
});
|
||||
response.setHeader('Content-Type', 'text/csv; charset=utf-8');
|
||||
response.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(exported.fileName)}`);
|
||||
response.send(`\uFEFF${exported.content}`);
|
||||
}
|
||||
|
||||
@Get('message-segment-audits')
|
||||
messageSegmentAudits(
|
||||
@Query('messageId') messageId?: string,
|
||||
@@ -67,8 +100,10 @@ export class AdminOperationsController {
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string) {
|
||||
return this.operations.listUplinkMessages({ tenantId, channelId });
|
||||
listUplinkMessages(@Query('tenantId') tenantId?: string, @Query('channelId') channelId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return page || pageSize
|
||||
? this.operations.listUplinkMessagesPage({ tenantId, channelId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) })
|
||||
: this.operations.listUplinkMessages({ tenantId, channelId, phoneNumber, keyword, startTime, endTime });
|
||||
}
|
||||
|
||||
@Post('uplink-messages/:id/claim')
|
||||
|
||||
@@ -27,13 +27,32 @@ export class ClientOperationsController {
|
||||
@Query('messageId') messageId?: string,
|
||||
@Query('phoneNumber') phoneNumber?: string,
|
||||
@Query('status') status?: string,
|
||||
@Query('contentKeyword') contentKeyword?: string,
|
||||
@Query('queuedAtFrom') queuedAtFrom?: string,
|
||||
@Query('queuedAtTo') queuedAtTo?: string,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
) {
|
||||
return this.operations.listClientMessages({ tenantId, applicationId, taskId, messageId, phoneNumber, status });
|
||||
return this.operations.listClientMessagesPage({
|
||||
tenantId,
|
||||
applicationId,
|
||||
taskId,
|
||||
messageId,
|
||||
phoneNumber,
|
||||
status,
|
||||
contentKeyword,
|
||||
queuedAtFrom,
|
||||
queuedAtTo,
|
||||
page: Number(page),
|
||||
pageSize: Number(pageSize),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('uplink-messages')
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string) {
|
||||
return this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
|
||||
listUplinkMessages(@TenantId() tenantId?: string, @Query('channelId') channelId?: string, @Query('applicationId') applicationId?: string, @Query('phoneNumber') phoneNumber?: string, @Query('keyword') keyword?: string, @Query('startTime') startTime?: string, @Query('endTime') endTime?: string, @Query('page') page?: string, @Query('pageSize') pageSize?: string) {
|
||||
return page || pageSize
|
||||
? this.operations.listUplinkMessagesPage({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime, page: Number(page), pageSize: Number(pageSize) }, true)
|
||||
: this.operations.listClientUplinkMessages({ tenantId, applicationId, phoneNumber, keyword, startTime, endTime });
|
||||
}
|
||||
|
||||
@Get('dashboard')
|
||||
|
||||
@@ -15,9 +15,10 @@ function createPrismaMock() {
|
||||
},
|
||||
smsMessageRecord: {
|
||||
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
|
||||
count: jest.fn().mockResolvedValue(51),
|
||||
groupBy: jest.fn().mockResolvedValue([{ status: 'delivered', _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }]),
|
||||
aggregate: jest.fn().mockResolvedValue({ _count: { _all: 2 }, _sum: { amountCents: 20, billingUnits: 2 } }),
|
||||
},
|
||||
},
|
||||
smsReceiptRecord: {
|
||||
findMany: jest.fn().mockResolvedValue([]),
|
||||
},
|
||||
@@ -254,6 +255,27 @@ describe('OperationsService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('paginates message records in PostgreSQL and limits heavy relations to the requested page', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.listMessagesPage({ tenantId: 'tenant-1', page: 2, pageSize: 25 })).resolves.toEqual({
|
||||
items: [{ messageId: 'MSG-1' }],
|
||||
total: 51,
|
||||
page: 2,
|
||||
pageSize: 25,
|
||||
});
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||
skip: 25,
|
||||
take: 25,
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
}));
|
||||
expect(prisma.smsMessageRecord.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns uplink messages with tenant and channel display data', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -16,6 +16,8 @@ export interface MessageQuery {
|
||||
status?: string;
|
||||
queuedAtFrom?: string;
|
||||
queuedAtTo?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export interface TraceQuery extends MessageQuery {
|
||||
@@ -122,12 +124,117 @@ export class OperationsService {
|
||||
});
|
||||
}
|
||||
|
||||
async listMessagesPage(query: MessageQuery) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 25)));
|
||||
const where = messageWhere(query);
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
include: {
|
||||
tenant: { select: { id: true, name: true } },
|
||||
application: { select: { id: true, name: true } },
|
||||
channel: { select: { id: true, name: true, srcId: true } },
|
||||
submitRecords: {
|
||||
select: {
|
||||
id: true,
|
||||
submitId: true,
|
||||
channelId: true,
|
||||
channelGroupId: true,
|
||||
channelGroupName: true,
|
||||
gatewayMessageId: true,
|
||||
submitStatus: true,
|
||||
submittedAt: true,
|
||||
createdAt: true,
|
||||
channel: { select: { id: true, name: true } },
|
||||
channelGroup: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
receiptRecords: {
|
||||
select: {
|
||||
id: true,
|
||||
messageId: true,
|
||||
gatewayMessageId: true,
|
||||
receiptStatus: true,
|
||||
rawStatus: true,
|
||||
errorCode: true,
|
||||
errorMessage: true,
|
||||
deliveredAt: true,
|
||||
createdAt: true,
|
||||
channelId: true,
|
||||
channel: { select: { id: true, name: true } },
|
||||
},
|
||||
},
|
||||
downstreamDeliveries: {
|
||||
where: { deliveryType: 'receipt' },
|
||||
select: { id: true, deliveryType: true, status: true, deliveredAt: true, lastError: true },
|
||||
},
|
||||
},
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.smsMessageRecord.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async exportMessages(query: MessageQuery) {
|
||||
const items = await this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
select: {
|
||||
messageId: true,
|
||||
queuedAt: true,
|
||||
phoneNumber: true,
|
||||
province: true,
|
||||
carrier: true,
|
||||
billingUnits: true,
|
||||
amountCents: true,
|
||||
status: true,
|
||||
submitStatus: true,
|
||||
deliveredAt: true,
|
||||
content: true,
|
||||
tenant: { select: { name: true } },
|
||||
application: { select: { name: true } },
|
||||
channel: { select: { name: true } },
|
||||
},
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
});
|
||||
const rows = [
|
||||
['消息编号', '企业', '应用', '提交时间', '手机号', '地区', '运营商', '计费条数', '金额', '通道', '状态', '回执时间', '短信内容'],
|
||||
...items.map((item) => [
|
||||
item.messageId,
|
||||
item.tenant?.name ?? '',
|
||||
item.application?.name ?? '',
|
||||
item.queuedAt.toISOString(),
|
||||
item.phoneNumber,
|
||||
item.province ?? '',
|
||||
item.carrier ?? '',
|
||||
String(item.billingUnits),
|
||||
String(moneyToNumber(item.amountCents)),
|
||||
item.channel?.name ?? '',
|
||||
item.status === 'submit_failed' || ['rejected', 'timeout'].includes(item.submitStatus ?? '') ? 'submit_failed' : item.status,
|
||||
item.deliveredAt?.toISOString() ?? '',
|
||||
item.content,
|
||||
]),
|
||||
];
|
||||
return {
|
||||
fileName: `sms-records-${formatExportTimestamp(new Date())}.csv`,
|
||||
content: rows.map((row) => row.map((cell) => escapeCsvCell(String(cell))).join(',')).join('\n'),
|
||||
};
|
||||
}
|
||||
|
||||
async listClientMessages(query: MessageQuery) {
|
||||
const items = await this.listMessages(query);
|
||||
return items.map(clientMessageView);
|
||||
}
|
||||
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
|
||||
async listClientMessagesPage(query: MessageQuery) {
|
||||
const result = await this.listMessagesPage(query);
|
||||
return { ...result, items: result.items.map(clientMessageView) };
|
||||
}
|
||||
|
||||
listUplinkMessages(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
|
||||
return this.prisma.smsUplinkMessage.findMany({
|
||||
where: {
|
||||
tenantId: query.tenantId,
|
||||
@@ -152,15 +259,42 @@ export class OperationsService {
|
||||
},
|
||||
},
|
||||
orderBy: { receivedAt: 'desc' },
|
||||
take: 500,
|
||||
skip: query.page && query.pageSize ? (query.page - 1) * query.pageSize : undefined,
|
||||
take: query.pageSize ?? 500,
|
||||
});
|
||||
}
|
||||
|
||||
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string }) {
|
||||
async listClientUplinkMessages(query: { tenantId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }) {
|
||||
const items = await this.listUplinkMessages(query);
|
||||
return items.map(clientUplinkView);
|
||||
}
|
||||
|
||||
async listUplinkMessagesPage(query: { tenantId?: string; channelId?: string; applicationId?: string; phoneNumber?: string; keyword?: string; startTime?: string; endTime?: string; page?: number; pageSize?: number }, clientView = false) {
|
||||
const page = Math.max(1, Math.floor(Number(query.page) || 1));
|
||||
const pageSize = Math.min(100, Math.max(1, Math.floor(Number(query.pageSize) || 10)));
|
||||
const where: Prisma.SmsUplinkMessageWhereInput = {
|
||||
tenantId: query.tenantId,
|
||||
channelId: query.channelId,
|
||||
applicationId: query.applicationId,
|
||||
phoneNumber: query.phoneNumber ? { contains: query.phoneNumber } : undefined,
|
||||
content: query.keyword ? { contains: query.keyword } : undefined,
|
||||
receivedAt: query.startTime || query.endTime ? {
|
||||
gte: query.startTime ? new Date(query.startTime) : undefined,
|
||||
lte: query.endTime ? new Date(query.endTime) : undefined,
|
||||
} : undefined,
|
||||
};
|
||||
const [rawItems, total] = await Promise.all([
|
||||
this.listUplinkMessages({ ...query, page, pageSize }),
|
||||
this.prisma.smsUplinkMessage.count({ where }),
|
||||
]);
|
||||
return {
|
||||
items: clientView ? rawItems.map(clientUplinkView) : rawItems,
|
||||
total,
|
||||
page,
|
||||
pageSize,
|
||||
};
|
||||
}
|
||||
|
||||
async monitor(query: { tenantId?: string; channelId?: string }) {
|
||||
const where = messageWhere(query);
|
||||
const [byStatus, recentMessages, recentReceipts, recentUplinks] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user