feat: optimize signature workflows and high-frequency queries
This commit is contained in:
@@ -99,6 +99,11 @@ export class AdminOperationsController {
|
||||
response.send(`\uFEFF${exported.content}`);
|
||||
}
|
||||
|
||||
@Get('messages/:id')
|
||||
getMessage(@Param('id') id: string) {
|
||||
return this.operations.getMessage(id);
|
||||
}
|
||||
|
||||
@Get('message-segment-audits')
|
||||
messageSegmentAudits(
|
||||
@Query('messageId') messageId?: string,
|
||||
|
||||
@@ -18,6 +18,7 @@ function createPrismaMock() {
|
||||
},
|
||||
smsMessageRecord: {
|
||||
findMany: jest.fn().mockResolvedValue([{ messageId: 'MSG-1' }]),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'message-1', messageId: 'MSG-1', submitRecords: [], receiptRecords: [], downstreamDeliveries: [] }),
|
||||
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 } }),
|
||||
@@ -311,7 +312,7 @@ describe('OperationsService', () => {
|
||||
}));
|
||||
});
|
||||
|
||||
it('paginates message records in PostgreSQL and limits heavy relations to the requested page', async () => {
|
||||
it('paginates message summaries without preloading detail relations', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
@@ -326,7 +327,17 @@ describe('OperationsService', () => {
|
||||
skip: 25,
|
||||
take: 25,
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
select: expect.objectContaining({
|
||||
id: true,
|
||||
content: true,
|
||||
hasDrainageContent: true,
|
||||
tenant: { select: { id: true, name: true } },
|
||||
}),
|
||||
}));
|
||||
const call = prisma.smsMessageRecord.findMany.mock.calls.at(-1)?.[0];
|
||||
expect(call.select).not.toHaveProperty('submitRecords');
|
||||
expect(call.select).not.toHaveProperty('receiptRecords');
|
||||
expect(call.select).not.toHaveProperty('downstreamDeliveries');
|
||||
expect(prisma.smsMessageRecord.count).toHaveBeenCalledWith({
|
||||
where: expect.objectContaining({ tenantId: 'tenant-1' }),
|
||||
});
|
||||
@@ -359,6 +370,21 @@ describe('OperationsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('loads heavy message relations only for one requested detail', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new OperationsService(prisma as never);
|
||||
|
||||
await expect(service.getMessage('message-1')).resolves.toEqual(expect.objectContaining({ id: 'message-1' }));
|
||||
expect(prisma.smsMessageRecord.findUnique).toHaveBeenCalledWith(expect.objectContaining({
|
||||
where: { id: 'message-1' },
|
||||
include: expect.objectContaining({
|
||||
submitRecords: expect.any(Object),
|
||||
receiptRecords: expect.any(Object),
|
||||
downstreamDeliveries: expect.any(Object),
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('returns the matched message record and the distinct uplink gateway message id to the client view', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsUplinkMessage.findMany.mockResolvedValue([{
|
||||
|
||||
@@ -48,6 +48,10 @@ export class OperationsService {
|
||||
return this.messagesQueries.listMessagesPage(query);
|
||||
}
|
||||
|
||||
async getMessage(id: string) {
|
||||
return this.messagesQueries.getMessage(id);
|
||||
}
|
||||
|
||||
async exportMessages(query: MessageQuery) {
|
||||
return this.messagesQueries.exportMessages(query);
|
||||
}
|
||||
|
||||
@@ -45,44 +45,26 @@ async listMessagesPage(query: MessageQuery) {
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.smsMessageRecord.findMany({
|
||||
where,
|
||||
include: {
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
channelId: true,
|
||||
messageId: true,
|
||||
phoneNumber: true,
|
||||
carrier: true,
|
||||
province: true,
|
||||
content: true,
|
||||
hasDrainageContent: true,
|
||||
drainageDetection: true,
|
||||
billingUnits: true,
|
||||
amountCents: true,
|
||||
status: true,
|
||||
submitStatus: true,
|
||||
queuedAt: true,
|
||||
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 },
|
||||
},
|
||||
channel: { select: { id: true, name: true } },
|
||||
},
|
||||
orderBy: [{ queuedAt: 'desc' }, { id: 'desc' }],
|
||||
skip: (page - 1) * pageSize,
|
||||
@@ -92,6 +74,53 @@ async listMessagesPage(query: MessageQuery) {
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getMessage(id: string) {
|
||||
const item = await this.prisma.smsMessageRecord.findUnique({
|
||||
where: { id },
|
||||
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 },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (!item) throw new NotFoundException('Message record not found');
|
||||
return item;
|
||||
}
|
||||
async exportMessages(query: MessageQuery) {
|
||||
const items = await this.prisma.smsMessageRecord.findMany({
|
||||
where: messageWhere(query),
|
||||
|
||||
@@ -82,6 +82,21 @@ export class AdminSmsConfigController {
|
||||
return this.smsConfig.listSignatureOptions(tenantId);
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures/:id')
|
||||
getSignature(@Param('id') id: string) {
|
||||
return this.smsConfig.getSignature(id);
|
||||
}
|
||||
|
||||
@Get('enterprise-signatures/:id/report-targets')
|
||||
getSignatureReportTargets(@Param('id') id: string) {
|
||||
return this.smsConfig.getSignatureReportTargets(id);
|
||||
}
|
||||
|
||||
@Get('drainage-infos/:id/report-targets')
|
||||
getDrainageReportTargets(@Param('id') id: string) {
|
||||
return this.smsConfig.getDrainageReportTargets(id);
|
||||
}
|
||||
|
||||
@Post('enterprise-signatures')
|
||||
createSignature(@Body() body: CreateSmsSignatureDto) {
|
||||
return this.smsConfig.createSignature(body, { initialAuditStatus: 'approved' });
|
||||
|
||||
@@ -72,12 +72,13 @@ export class SmsSignatureService {
|
||||
private readonly reportValidation: SmsReportValidationService,
|
||||
private readonly audit: SmsAuditService,
|
||||
) {}
|
||||
async listSignatures(queryOrTenantId?: string | SignatureListQuery) {
|
||||
async listSignatures(queryOrTenantId?: string | SignatureListQuery, summaryOnly = false) {
|
||||
const query = typeof queryOrTenantId === 'string' ? { tenantId: queryOrTenantId } : (queryOrTenantId ?? {});
|
||||
const signatureSort =
|
||||
query.signatureSort === 'asc' || query.signatureSort === 'desc' ? query.signatureSort : undefined;
|
||||
const signatures = await this.prisma.smsSignature.findMany({
|
||||
where: {
|
||||
id: query.signatureId,
|
||||
tenantId: query.tenantId,
|
||||
auditStatus: query.status && query.status !== 'all' ? query.status : { not: 'deleted' },
|
||||
tenant: query.enterpriseKeyword ? { name: { contains: query.enterpriseKeyword } } : undefined,
|
||||
@@ -105,12 +106,54 @@ export class SmsSignatureService {
|
||||
]
|
||||
: undefined,
|
||||
},
|
||||
include: {
|
||||
materials: true,
|
||||
tenant: true,
|
||||
application: true,
|
||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
|
||||
reportTasks: { include: { channel: true, drainageInfo: true } },
|
||||
select: {
|
||||
id: true,
|
||||
tenantId: true,
|
||||
applicationId: true,
|
||||
name: true,
|
||||
purpose: true,
|
||||
drainageInfo: true,
|
||||
auditStatus: true,
|
||||
reportStatus: true,
|
||||
rejectReason: true,
|
||||
materialVersion: true,
|
||||
pendingReport: true,
|
||||
reportChangedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
materials: !summaryOnly,
|
||||
tenant: { select: { id: true, name: true, code: true, status: true } },
|
||||
application: { select: { id: true, tenantId: true, name: true, status: true } },
|
||||
drainageItems: {
|
||||
where: { auditStatus: { not: 'deleted' } },
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
select: {
|
||||
id: true,
|
||||
siteName: true,
|
||||
url: true,
|
||||
remark: true,
|
||||
reportValues: !summaryOnly,
|
||||
auditStatus: true,
|
||||
rejectReason: true,
|
||||
submittedAt: true,
|
||||
reviewedAt: true,
|
||||
createdAt: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
},
|
||||
reportTasks: {
|
||||
select: {
|
||||
id: true,
|
||||
signatureId: true,
|
||||
channelId: true,
|
||||
carrier: true,
|
||||
status: true,
|
||||
approvedAt: true,
|
||||
approvalScope: true,
|
||||
reportType: true,
|
||||
drainageItemId: true,
|
||||
},
|
||||
},
|
||||
reportBatchItems: {
|
||||
where: { batch: { status: { in: ['completed', 'partial_failed'] } } },
|
||||
select: { reportType: true, materialVersion: true, snapshot: true },
|
||||
@@ -130,7 +173,28 @@ export class SmsSignatureService {
|
||||
const routes = applicationIds.length
|
||||
? await this.prisma.channelRouteRule.findMany({
|
||||
where: { applicationId: { in: applicationIds }, status: 'active' },
|
||||
include: { group: { include: { items: { include: { channel: { include: { reportFields: true } } } } } } },
|
||||
select: {
|
||||
applicationId: true,
|
||||
group: {
|
||||
select: {
|
||||
status: true,
|
||||
items: {
|
||||
select: {
|
||||
channel: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
carrier: true,
|
||||
carriers: true,
|
||||
status: true,
|
||||
reportFields: { select: { status: true, reportType: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
: [];
|
||||
const hasCommonDrainageFields = await this.prisma.commonReportField
|
||||
@@ -208,7 +272,7 @@ export class SmsSignatureService {
|
||||
createdAt: item.createdAt.toISOString(),
|
||||
updatedAt: item.updatedAt.toISOString(),
|
||||
}));
|
||||
return {
|
||||
const view = {
|
||||
...signatureView,
|
||||
name: normalizeSmsSignature(signature.name),
|
||||
drainageInfo: { ...legacyPayload, links: drainageLinks },
|
||||
@@ -330,6 +394,20 @@ export class SmsSignatureService {
|
||||
}),
|
||||
),
|
||||
};
|
||||
if (!summaryOnly) return view;
|
||||
const {
|
||||
materials: _materials,
|
||||
reportTasks: _reportTasks,
|
||||
reportTargets: _reportTargets,
|
||||
drainageReportTargets: _drainageReportTargets,
|
||||
...summary
|
||||
} = view;
|
||||
return {
|
||||
...summary,
|
||||
drainageInfo: {
|
||||
links: drainageLinks.map(({ reportValues: _reportValues, ...link }) => link),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@@ -365,12 +443,33 @@ export class SmsSignatureService {
|
||||
: undefined,
|
||||
};
|
||||
const [items, total] = await Promise.all([
|
||||
this.listSignatures({ ...query, page, pageSize }),
|
||||
this.listSignatures({ ...query, page, pageSize }, true),
|
||||
this.prisma.smsSignature.count({ where }),
|
||||
]);
|
||||
return { items, total, page, pageSize };
|
||||
}
|
||||
|
||||
async getSignature(id: string) {
|
||||
const [item] = await this.listSignatures({ signatureId: id });
|
||||
if (!item || item.auditStatus === 'deleted') throw new NotFoundException('Signature not found');
|
||||
return item;
|
||||
}
|
||||
|
||||
async getSignatureReportTargets(id: string) {
|
||||
const item = await this.getSignature(id);
|
||||
return 'reportTargets' in item ? item.reportTargets ?? [] : [];
|
||||
}
|
||||
|
||||
async getDrainageReportTargets(id: string) {
|
||||
const drainage = await this.prisma.smsDrainageInfo.findUnique({
|
||||
where: { id },
|
||||
select: { id: true, signatureId: true, auditStatus: true },
|
||||
});
|
||||
if (!drainage || drainage.auditStatus === 'deleted') throw new NotFoundException('Drainage info not found');
|
||||
const signature = await this.getSignature(drainage.signatureId);
|
||||
return 'drainageReportTargets' in signature ? signature.drainageReportTargets?.[id] ?? [] : [];
|
||||
}
|
||||
|
||||
listSignatureOptions(tenantId?: string) {
|
||||
return this.prisma.smsSignature.findMany({
|
||||
where: { tenantId, auditStatus: { not: 'deleted' } },
|
||||
|
||||
@@ -136,6 +136,7 @@ export interface ApplicationListQuery {
|
||||
}
|
||||
|
||||
export interface SignatureListQuery {
|
||||
signatureId?: string;
|
||||
tenantId?: string;
|
||||
keyword?: string;
|
||||
status?: string;
|
||||
|
||||
@@ -796,12 +796,12 @@ describe('SmsConfigService', () => {
|
||||
name: { contains: '签名' },
|
||||
drainageItems: expect.objectContaining({ some: expect.objectContaining({ OR: expect.any(Array) }) }),
|
||||
}),
|
||||
include: expect.objectContaining({
|
||||
select: expect.objectContaining({
|
||||
materials: true,
|
||||
tenant: true,
|
||||
application: true,
|
||||
drainageItems: { where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } },
|
||||
reportTasks: { include: { channel: true, drainageInfo: true } },
|
||||
tenant: { select: { id: true, name: true, code: true, status: true } },
|
||||
application: { select: { id: true, tenantId: true, name: true, status: true } },
|
||||
drainageItems: expect.objectContaining({ where: { auditStatus: { not: 'deleted' } }, orderBy: { updatedAt: 'desc' } }),
|
||||
reportTasks: expect.objectContaining({ select: expect.any(Object) }),
|
||||
reportBatchItems: expect.any(Object),
|
||||
}),
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
@@ -1215,6 +1215,21 @@ describe('SmsConfigService', () => {
|
||||
expect(prisma.smsDrainageInfo.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ submittedAt: expectedRange }) }));
|
||||
});
|
||||
|
||||
it('returns a signature page summary without edit materials or report target arrays', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsSignature.count.mockResolvedValue(1);
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
const result = await service.listSignaturesPage({ page: 1, pageSize: 10 });
|
||||
|
||||
expect(result.total).toBe(1);
|
||||
expect(result.items[0]).toEqual(expect.objectContaining({ id: 'sig-1', name: '【签名A】' }));
|
||||
expect(result.items[0]).not.toHaveProperty('materials');
|
||||
expect(result.items[0]).not.toHaveProperty('reportTasks');
|
||||
expect(result.items[0]).not.toHaveProperty('reportTargets');
|
||||
expect(result.items[0]).not.toHaveProperty('drainageReportTargets');
|
||||
});
|
||||
|
||||
it.each([
|
||||
'【带 空格】',
|
||||
' 【外部空格】',
|
||||
|
||||
@@ -123,6 +123,18 @@ export class SmsConfigService implements OnModuleInit, OnModuleDestroy {
|
||||
return this.signatures.listSignaturesPage(query);
|
||||
}
|
||||
|
||||
async getSignature(id: string) {
|
||||
return this.signatures.getSignature(id);
|
||||
}
|
||||
|
||||
async getSignatureReportTargets(id: string) {
|
||||
return this.signatures.getSignatureReportTargets(id);
|
||||
}
|
||||
|
||||
async getDrainageReportTargets(id: string) {
|
||||
return this.signatures.getDrainageReportTargets(id);
|
||||
}
|
||||
|
||||
listSignatureOptions(tenantId?: string) {
|
||||
return this.signatures.listSignatureOptions(tenantId);
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ export class TenantsController {
|
||||
return this.tenants.listManagementRows();
|
||||
}
|
||||
|
||||
@Get('options')
|
||||
listOptions() {
|
||||
return this.tenants.listOptions();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
get(@Param('id') id: string) {
|
||||
return this.tenants.get(id);
|
||||
|
||||
@@ -44,6 +44,19 @@ function createPrismaMock() {
|
||||
}
|
||||
|
||||
describe('TenantsService', () => {
|
||||
it('returns lightweight non-deleted tenant options', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
await service.listOptions();
|
||||
|
||||
expect(prisma.tenant.findMany).toHaveBeenCalledWith({
|
||||
where: { status: { not: 'deleted' } },
|
||||
select: { id: true, name: true, code: true, status: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('creates tenants with a real enterprise profile', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new TenantsService(prisma as never);
|
||||
|
||||
@@ -44,6 +44,14 @@ export class TenantsService {
|
||||
}).then((items) => items.map(withEnterpriseProfile));
|
||||
}
|
||||
|
||||
listOptions() {
|
||||
return this.prisma.tenant.findMany({
|
||||
where: { status: { not: 'deleted' } },
|
||||
select: { id: true, name: true, code: true, status: true },
|
||||
orderBy: [{ name: 'asc' }, { id: 'asc' }],
|
||||
});
|
||||
}
|
||||
|
||||
async listManagementRows() {
|
||||
const sinceToday = startOfToday();
|
||||
const [tenants, accounts, todaySpendGroups, todayRefundGroups] = await Promise.all([
|
||||
|
||||
Reference in New Issue
Block a user