fix: polish channel groups and add production deployment
This commit is contained in:
@@ -6,7 +6,6 @@ import {
|
||||
BillingActionDto,
|
||||
CreateManualRechargeDto,
|
||||
CreateRechargeOrderDto,
|
||||
CreateAccountTransactionDto,
|
||||
CreateBillingPlanDto,
|
||||
CreateBillingRuleDto,
|
||||
CreateSmsBillingRecordDto,
|
||||
@@ -39,16 +38,6 @@ export class BillingController {
|
||||
return this.billing.createAccount(body);
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
listTransactions(@TenantId() tenantId?: string) {
|
||||
return this.billing.listTransactions(tenantId);
|
||||
}
|
||||
|
||||
@Post('transactions')
|
||||
createTransaction(@Body() body: CreateAccountTransactionDto) {
|
||||
return this.billing.createTransaction(body);
|
||||
}
|
||||
|
||||
@Get('recharges')
|
||||
listRechargeOrders(@TenantId() tenantId?: string) {
|
||||
return this.billing.listRechargeOrders(tenantId);
|
||||
@@ -135,11 +124,6 @@ export class ClientBillingController {
|
||||
return this.billing.listPlans();
|
||||
}
|
||||
|
||||
@Get('transactions')
|
||||
listTransactions(@TenantId() tenantId?: string) {
|
||||
return this.billing.listTransactions(tenantId);
|
||||
}
|
||||
|
||||
@Post('orders')
|
||||
createRechargeOrder(@Body() body: CreateRechargeOrderDto) {
|
||||
return this.billing.createRechargeOrder(body);
|
||||
|
||||
@@ -124,28 +124,6 @@ export class BillingService {
|
||||
return this.prisma.tenantAccount.create({ data: createData });
|
||||
}
|
||||
|
||||
listTransactions(tenantId?: string) {
|
||||
return this.prisma.accountTransaction.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
createTransaction(data: CreateAccountTransactionDto) {
|
||||
const createData: Prisma.AccountTransactionUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
transactionType: data.transactionType,
|
||||
amountCents: data.amountCents ?? 0,
|
||||
smsUnits: data.smsUnits ?? 0,
|
||||
balanceAfter: data.balanceAfter ?? 0,
|
||||
relatedType: data.relatedType,
|
||||
relatedId: data.relatedId,
|
||||
remark: data.remark,
|
||||
};
|
||||
return this.prisma.accountTransaction.create({ data: createData });
|
||||
}
|
||||
|
||||
listRechargeOrders(tenantId?: string) {
|
||||
return this.prisma.rechargeOrder.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
|
||||
@@ -67,7 +67,7 @@ function createPrismaMock() {
|
||||
channelHealthMetric: { findMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
findMany: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72 }),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320 }),
|
||||
create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'group-1', ...data })),
|
||||
delete: jest.fn().mockResolvedValue({ id: 'group-1', code: 'G-MOBILE', name: '移动组' }),
|
||||
},
|
||||
@@ -156,7 +156,7 @@ describe('ChannelsService', () => {
|
||||
passwordCipher: 'secret',
|
||||
srcId: '10690000',
|
||||
});
|
||||
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 });
|
||||
await service.createGroup({ code: 'G-MOBILE', name: '移动组', carrier: 'mobile', retryEnabled: true, retryTimeLimitMinutes: 750 });
|
||||
await service.createRouteRule({ tenantId: 'tenant-1', applicationId: 'app-1', groupId: 'group-1', carrier: 'mobile' });
|
||||
|
||||
expect(prisma.smsChannel.create).toHaveBeenCalledWith({
|
||||
@@ -188,7 +188,7 @@ describe('ChannelsService', () => {
|
||||
body: expect.stringContaining('"messageType":"ConnectChannel"'),
|
||||
}));
|
||||
expect(prisma.smsChannelGroup.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 24 }),
|
||||
data: expect.objectContaining({ carrier: 'mobile', retryEnabled: true, retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
|
||||
});
|
||||
expect(prisma.channelRouteRule.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
@@ -331,7 +331,7 @@ describe('ChannelsService', () => {
|
||||
name: '移动组更新',
|
||||
carrier: 'mobile',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 24,
|
||||
retryTimeLimitMinutes: 750,
|
||||
items: [
|
||||
{ channelId: 'channel-sd', carrier: 'mobile', province: '山东', priority: 10 },
|
||||
{ channelId: 'channel-national', carrier: 'mobile', priority: 1 },
|
||||
@@ -340,6 +340,20 @@ describe('ChannelsService', () => {
|
||||
|
||||
expect(prisma.$transaction).toHaveBeenCalled();
|
||||
|
||||
const transactionCallback = prisma.$transaction.mock.calls[0][0];
|
||||
const tx = {
|
||||
smsChannelGroupItem: { deleteMany: jest.fn(), createMany: jest.fn() },
|
||||
smsChannelGroup: {
|
||||
update: jest.fn(),
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'group-1' }),
|
||||
},
|
||||
};
|
||||
await transactionCallback(tx);
|
||||
expect(tx.smsChannelGroup.update).toHaveBeenCalledWith({
|
||||
where: { id: 'group-1' },
|
||||
data: expect.objectContaining({ retryTimeLimitHours: 13, retryTimeLimitMinutes: 750 }),
|
||||
});
|
||||
|
||||
await expect(service.updateGroup('group-1', {
|
||||
carrier: 'mobile',
|
||||
items: [
|
||||
|
||||
@@ -33,6 +33,7 @@ export interface CreateChannelGroupDto {
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
}
|
||||
|
||||
export interface CreateChannelGroupItemDto {
|
||||
@@ -54,6 +55,7 @@ export interface UpdateChannelGroupDto {
|
||||
status?: string;
|
||||
retryEnabled?: boolean;
|
||||
retryTimeLimitHours?: number;
|
||||
retryTimeLimitMinutes?: number;
|
||||
items?: Array<Omit<CreateChannelGroupItemDto, 'groupId'>>;
|
||||
}
|
||||
|
||||
@@ -175,7 +177,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
listChannels() {
|
||||
return this.prisma.smsChannel.findMany({ orderBy: { createdAt: 'desc' }, take: 100 });
|
||||
return this.prisma.smsChannel.findMany({
|
||||
include: { connectionStates: true },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
@@ -574,17 +580,14 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
listGroups() {
|
||||
return this.prisma.smsChannelGroup.findMany({
|
||||
include: { items: { include: { channel: true }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
include: { items: { include: { channel: { include: { connectionStates: true } } }, orderBy: [{ province: 'asc' }, { priority: 'asc' }] } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
}
|
||||
|
||||
createGroup(data: CreateChannelGroupDto) {
|
||||
const retryTimeLimitHours = data.retryTimeLimitHours ?? 72;
|
||||
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
|
||||
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
|
||||
}
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(data.retryTimeLimitMinutes, data.retryTimeLimitHours, 720);
|
||||
const carrier = normalizeBusinessCarrier(data.carrier);
|
||||
return this.prisma.smsChannelGroup.create({
|
||||
data: {
|
||||
@@ -594,7 +597,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
description: data.description,
|
||||
status: data.status ?? 'active',
|
||||
retryEnabled: data.retryEnabled ?? true,
|
||||
retryTimeLimitHours,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -659,10 +663,11 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!current) {
|
||||
throw new NotFoundException('Channel group not found');
|
||||
}
|
||||
const retryTimeLimitHours = data.retryTimeLimitHours ?? current.retryTimeLimitHours;
|
||||
if (!Number.isInteger(retryTimeLimitHours) || retryTimeLimitHours <= 0 || retryTimeLimitHours > 72) {
|
||||
throw new BadRequestException('retryTimeLimitHours must be an integer between 1 and 72');
|
||||
}
|
||||
const retryTimeLimitMinutes = normalizeRetryTimeLimitMinutes(
|
||||
data.retryTimeLimitMinutes,
|
||||
data.retryTimeLimitHours,
|
||||
current.retryTimeLimitMinutes ?? current.retryTimeLimitHours * 60,
|
||||
);
|
||||
const carrier = data.carrier ? normalizeBusinessCarrier(data.carrier) : normalizeBusinessCarrier(current.carrier);
|
||||
const items = data.items ?? [];
|
||||
const channelIds = [...new Set(items.map((item) => item.channelId))];
|
||||
@@ -681,7 +686,8 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
description: data.description,
|
||||
status: data.status ?? current.status,
|
||||
retryEnabled: data.retryEnabled ?? current.retryEnabled,
|
||||
retryTimeLimitHours,
|
||||
retryTimeLimitHours: Math.ceil(retryTimeLimitMinutes / 60),
|
||||
retryTimeLimitMinutes,
|
||||
},
|
||||
});
|
||||
if (items.length > 0) {
|
||||
@@ -1220,6 +1226,14 @@ function deriveReceiptStatus(rowCount: number, successCount: number, failedCount
|
||||
return 'completed';
|
||||
}
|
||||
|
||||
function normalizeRetryTimeLimitMinutes(minutes: number | undefined, hours: number | undefined, fallbackMinutes: number) {
|
||||
const value = minutes ?? (hours === undefined ? fallbackMinutes : hours * 60);
|
||||
if (!Number.isInteger(value) || value <= 0 || value > 72 * 60) {
|
||||
throw new BadRequestException('retryTimeLimitMinutes must be an integer between 1 and 4320');
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function normalizeBusinessCarrier(carrier?: string | null) {
|
||||
const normalized = normalizeChannelCarrier(carrier);
|
||||
if (!['mobile', 'unicom', 'telecom'].includes(normalized)) {
|
||||
|
||||
@@ -49,6 +49,7 @@ function createPrismaMock() {
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 72,
|
||||
retryTimeLimitMinutes: 4320,
|
||||
items: [{ id: 'item-1', groupId: 'group-1', channelId: 'channel-1', carrier: 'mobile', priority: 1, province: null, channel }],
|
||||
},
|
||||
};
|
||||
@@ -379,7 +380,7 @@ describe('SendChainService', () => {
|
||||
applicationId: 'app-1',
|
||||
groupId: 'group-1',
|
||||
carrier: 'mobile',
|
||||
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, items: [] },
|
||||
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: false, retryTimeLimitHours: 72, retryTimeLimitMinutes: 4320, items: [] },
|
||||
});
|
||||
|
||||
await service.handleSubmitResult({
|
||||
@@ -400,6 +401,53 @@ describe('SendChainService', () => {
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
});
|
||||
|
||||
it('stops failed receipt retry after the configured minute limit', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsBillingRecord.findFirst
|
||||
.mockResolvedValueOnce(null)
|
||||
.mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' });
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue({
|
||||
id: 'record-1',
|
||||
tenantId: 'tenant-1',
|
||||
batchTaskId: 'task-1',
|
||||
applicationId: 'app-1',
|
||||
templateId: 'tpl-1',
|
||||
messageId: 'MSG-1',
|
||||
phoneNumber: '13800000001',
|
||||
content: 'hello',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
status: 'submitted',
|
||||
amountCents: 3,
|
||||
billingUnits: 1,
|
||||
unitPrice: 3,
|
||||
queuedAt: new Date(Date.now() - 90 * 60_000),
|
||||
});
|
||||
prisma.channelRouteRule.findFirst.mockResolvedValue({
|
||||
id: 'route-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
groupId: 'group-1',
|
||||
carrier: 'mobile',
|
||||
group: { id: 'group-1', carrier: 'mobile', status: 'active', retryEnabled: true, retryTimeLimitHours: 2, retryTimeLimitMinutes: 75, items: [] },
|
||||
});
|
||||
|
||||
await service.handleReceipt({
|
||||
messageId: 'MSG-1',
|
||||
channelId: 'channel-1',
|
||||
gatewayMessageId: 'GW-1',
|
||||
receiptStatus: 'undelivered',
|
||||
rawStatus: 'UNDELIV',
|
||||
});
|
||||
|
||||
expect(prisma.smsSubmitRecord.create).not.toHaveBeenCalled();
|
||||
expect(billing.refund).toHaveBeenCalledWith(expect.objectContaining({ remark: '最终失败退款' }));
|
||||
expect(prisma.smsMessageRecord.update).toHaveBeenCalledWith({
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ status: 'failed', receiptStatus: 'undelivered' }),
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let stale failed receipts overwrite a later delivered message', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsMessageRecord.findFirst.mockResolvedValue({
|
||||
@@ -467,6 +515,7 @@ describe('SendChainService', () => {
|
||||
status: 'active',
|
||||
retryEnabled: true,
|
||||
retryTimeLimitHours: 72,
|
||||
retryTimeLimitMinutes: 4320,
|
||||
items: [
|
||||
{
|
||||
id: 'wrong-item',
|
||||
|
||||
@@ -751,12 +751,13 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
take: 200,
|
||||
});
|
||||
const attemptedChannelIds = attempts.map((attempt) => attempt.channelId);
|
||||
const ageHours = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 3_600_000;
|
||||
if (ageHours >= 72) {
|
||||
const ageMinutes = (Date.now() - new Date(message.queuedAt ?? Date.now()).getTime()) / 60_000;
|
||||
if (ageMinutes >= 72 * 60) {
|
||||
return null;
|
||||
}
|
||||
const route = await this.findApplicationRoute(message.tenantId, message.applicationId ?? undefined, await this.identifyCarrier(message.phoneNumber));
|
||||
if (!route.group.retryEnabled || ageHours >= Math.min(route.group.retryTimeLimitHours, 72)) {
|
||||
const retryTimeLimitMinutes = Math.min(route.group.retryTimeLimitMinutes ?? route.group.retryTimeLimitHours * 60, 72 * 60);
|
||||
if (!route.group.retryEnabled || ageMinutes >= retryTimeLimitMinutes) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user