fix: paginate operational list pages

This commit is contained in:
hectorzhao
2026-07-28 23:20:13 +08:00
parent 87c5b1eccc
commit b8560372cc
40 changed files with 1208 additions and 422 deletions
@@ -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')
+23 -1
View File
@@ -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);
+137 -3
View File
@@ -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([