fix: simplify balance billing and govern operation logs
This commit is contained in:
@@ -249,11 +249,13 @@ function createPrismaMock() {
|
||||
updateMany: jest.fn().mockResolvedValue({ count: 1 }),
|
||||
},
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findUnique: jest.fn().mockResolvedValue(null),
|
||||
upsert: jest.fn().mockResolvedValue({
|
||||
id: 'recover-1',
|
||||
tenantId: 'tenant-1',
|
||||
applicationId: 'app-1',
|
||||
account: '100001',
|
||||
gatewayInstanceId: 'gateway-a',
|
||||
state: 'waiting_connection',
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: new Date('2026-07-08T12:00:30.000Z'),
|
||||
@@ -341,7 +343,7 @@ describe('SendChainService', () => {
|
||||
expect.objectContaining({ phoneNumber: '13800000002', status: 'queued', billingUnits: 1, amountCents: 3, queuePriority: 'normal' }),
|
||||
]),
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, smsUnits: 2, relatedId: 'task-1' }));
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 6, relatedId: 'task-1' }));
|
||||
expect(service.enqueueBatchTask).toHaveBeenCalledWith('task-1');
|
||||
});
|
||||
|
||||
@@ -376,7 +378,7 @@ describe('SendChainService', () => {
|
||||
dispatched: 1,
|
||||
results: [{ taskId: 'task-1', status: 'queued', enqueued: 1 }],
|
||||
});
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
|
||||
expect(billing.freeze).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { batchTaskId: 'task-1', status: 'scheduled' },
|
||||
data: { status: 'queued' },
|
||||
@@ -854,8 +856,8 @@ describe('SendChainService', () => {
|
||||
where: { id: 'record-1' },
|
||||
data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }),
|
||||
});
|
||||
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'task-1' }));
|
||||
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, smsUnits: 1, relatedId: 'MSG-1' }));
|
||||
expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' }));
|
||||
expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'MSG-1' }));
|
||||
expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }),
|
||||
});
|
||||
@@ -1412,12 +1414,39 @@ describe('SendChainService', () => {
|
||||
}));
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
action: 'gateway.downstream_recovery_status_sync',
|
||||
action: 'gateway.downstream_recovery_status_changed',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
}),
|
||||
}));
|
||||
});
|
||||
|
||||
it('does not append recovery audit logs when only periodic timestamps change', async () => {
|
||||
const { service, prisma } = createService();
|
||||
prisma.gatewayDownstreamRecoveryStatus.findUnique.mockResolvedValue({
|
||||
state: 'waiting_connection',
|
||||
gatewayInstanceId: 'gateway-a',
|
||||
lockOwner: 'gateway-a',
|
||||
failureCategory: 'client_disconnected',
|
||||
lastError: 'downstream client is not connected',
|
||||
lastSkipReason: null,
|
||||
});
|
||||
|
||||
await service.recordGatewayDownstreamRecoveryStatus({
|
||||
account: '100001',
|
||||
gatewayInstanceId: 'gateway-a',
|
||||
state: 'waiting_connection',
|
||||
lastAttemptAt: '2026-07-08T12:01:00.000Z',
|
||||
nextRetryAt: '2026-07-08T12:11:00.000Z',
|
||||
attemptCount: 3,
|
||||
lockOwner: 'gateway-a',
|
||||
lockExpiresAt: '2026-07-08T12:01:30.000Z',
|
||||
lastError: 'downstream client is not connected',
|
||||
});
|
||||
|
||||
expect(prisma.gatewayDownstreamRecoveryStatus.upsert).toHaveBeenCalled();
|
||||
expect(prisma.operationLog.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks downstream delivery as failed after reaching retry limit', async () => {
|
||||
const { service, prisma } = createService();
|
||||
const previous = process.env.CMPP_DOWNSTREAM_MAX_RETRIES;
|
||||
|
||||
@@ -279,10 +279,9 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('企业账户余额、套餐余量或授信额度不足');
|
||||
throw new BadRequestException('企业账户余额不足');
|
||||
}
|
||||
}
|
||||
const task = await this.prisma.smsBatchTask.create({
|
||||
@@ -305,11 +304,10 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
createdById: data.createdById,
|
||||
},
|
||||
});
|
||||
if (shouldReserveBalance && billing.amountCents + billing.totalBillingUnits > 0) {
|
||||
if (shouldReserveBalance && billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: data.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '发送任务创建冻结',
|
||||
@@ -637,16 +635,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
take: 100000,
|
||||
});
|
||||
const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0);
|
||||
const smsUnits = messages.reduce((sum, message) => sum + message.billingUnits, 0);
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents, smsUnits });
|
||||
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
|
||||
if (!accountCheck.canSend) {
|
||||
throw new BadRequestException('定时任务到点时企业账户余额、套餐余量或授信额度不足');
|
||||
throw new BadRequestException('定时任务到点时企业账户余额不足');
|
||||
}
|
||||
if (amountCents + smsUnits > 0) {
|
||||
if (amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: task.tenantId,
|
||||
amountCents,
|
||||
smsUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: '定时任务到点冻结',
|
||||
@@ -1114,9 +1110,21 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
const recoveryStatuses = (this.prisma as PrismaService & {
|
||||
gatewayDownstreamRecoveryStatus: {
|
||||
findUnique: (args: Record<string, unknown>) => Promise<any>;
|
||||
upsert: (args: Record<string, unknown>) => Promise<any>;
|
||||
};
|
||||
}).gatewayDownstreamRecoveryStatus;
|
||||
const previous = await recoveryStatuses.findUnique({
|
||||
where: { account },
|
||||
select: {
|
||||
state: true,
|
||||
gatewayInstanceId: true,
|
||||
lockOwner: true,
|
||||
failureCategory: true,
|
||||
lastError: true,
|
||||
lastSkipReason: true,
|
||||
},
|
||||
});
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: account },
|
||||
select: { id: true, tenantId: true, name: true },
|
||||
@@ -1167,27 +1175,30 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
lockOwner?: string | null;
|
||||
lockExpiresAt?: Date | null;
|
||||
};
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
action: 'gateway.downstream_recovery_status_sync',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
account,
|
||||
state: updated.state,
|
||||
lockOwner: normalizedUpdated.lockOwner,
|
||||
lockExpiresAt: normalizedUpdated.lockExpiresAt,
|
||||
attemptCount: updated.attemptCount,
|
||||
nextRetryAt: updated.nextRetryAt,
|
||||
failureCategory: normalizedUpdated.failureCategory,
|
||||
applicationId: updated.applicationId,
|
||||
applicationName: application?.name,
|
||||
lastError: updated.lastError,
|
||||
lastSkipReason: updated.lastSkipReason,
|
||||
if (hasRecoveryAuditStateChanged(previous, updated)) {
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId: updated.tenantId ?? undefined,
|
||||
action: 'gateway.downstream_recovery_status_changed',
|
||||
resource: 'gateway_downstream_recovery_status',
|
||||
resourceId: updated.id,
|
||||
detail: {
|
||||
account,
|
||||
previousState: previous?.state ?? null,
|
||||
state: updated.state,
|
||||
gatewayInstanceId: updated.gatewayInstanceId,
|
||||
lockOwner: normalizedUpdated.lockOwner,
|
||||
attemptCount: updated.attemptCount,
|
||||
nextRetryAt: updated.nextRetryAt,
|
||||
failureCategory: normalizedUpdated.failureCategory,
|
||||
applicationId: updated.applicationId,
|
||||
applicationName: application?.name,
|
||||
lastError: updated.lastError,
|
||||
lastSkipReason: updated.lastSkipReason,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
@@ -1701,16 +1712,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
} else {
|
||||
if (billing.amountCents + billing.totalBillingUnits > 0) {
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 模板不匹配待审核短信冻结',
|
||||
@@ -1764,16 +1773,14 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const accountCheck = await this.billing.checkAccount({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
});
|
||||
if (!accountCheck.canSend) {
|
||||
await reject('BALANCE', '企业账户余额、套餐余量或授信额度不足');
|
||||
await reject('BALANCE', '企业账户余额不足');
|
||||
} else {
|
||||
if (billing.amountCents + billing.totalBillingUnits > 0) {
|
||||
if (billing.amountCents > 0) {
|
||||
await this.billing.freeze({
|
||||
tenantId: application.tenantId,
|
||||
amountCents: billing.amountCents,
|
||||
smsUnits: billing.totalBillingUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: task.id,
|
||||
remark: 'CMPP 入站短信冻结',
|
||||
@@ -2253,16 +2260,15 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
amountCents: number;
|
||||
}) {
|
||||
const amountCents = message.amountCents ?? 0;
|
||||
const smsUnits = message.billingUnits ?? 0;
|
||||
const billingUnits = message.billingUnits ?? 0;
|
||||
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
|
||||
if (exists?.billingStatus === 'charged') {
|
||||
return;
|
||||
}
|
||||
if (amountCents + smsUnits > 0) {
|
||||
if (amountCents > 0) {
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
smsUnits,
|
||||
relatedType: 'sms_batch_task',
|
||||
relatedId: message.batchTaskId,
|
||||
remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`,
|
||||
@@ -2271,7 +2277,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const transaction = await this.billing.charge({
|
||||
tenantId: message.tenantId,
|
||||
amountCents,
|
||||
smsUnits,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: '提交成功扣费',
|
||||
@@ -2283,7 +2288,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
messageId: message.messageId,
|
||||
phoneNumber: message.phoneNumber,
|
||||
contentLength: [...message.content].length,
|
||||
billingUnits: smsUnits,
|
||||
billingUnits,
|
||||
unitPrice: message.unitPrice ?? 0,
|
||||
amountCents,
|
||||
billingStatus: 'charged',
|
||||
@@ -2300,7 +2305,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
|
||||
if ((message.amountCents ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
|
||||
@@ -2316,7 +2321,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
await this.billing.release({
|
||||
tenantId: message.tenantId,
|
||||
amountCents: message.amountCents,
|
||||
smsUnits: message.billingUnits,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark: `${remark}: ${message.messageId}`,
|
||||
@@ -2327,7 +2331,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number },
|
||||
remark: string,
|
||||
) {
|
||||
if ((message.amountCents ?? 0) + (message.billingUnits ?? 0) <= 0) {
|
||||
if ((message.amountCents ?? 0) <= 0) {
|
||||
return;
|
||||
}
|
||||
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
|
||||
@@ -2341,7 +2345,6 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
const transaction = await this.billing.refund({
|
||||
tenantId: message.tenantId,
|
||||
amountCents: message.amountCents,
|
||||
smsUnits: message.billingUnits,
|
||||
relatedType: 'sms_message_record',
|
||||
relatedId: message.messageId,
|
||||
remark,
|
||||
@@ -2919,6 +2922,17 @@ function octetString(value: string, fixedLength: number) {
|
||||
return value + '\0'.repeat(fixedLength - value.length);
|
||||
}
|
||||
|
||||
function hasRecoveryAuditStateChanged(
|
||||
previous: Record<string, unknown> | null,
|
||||
current: Record<string, unknown>,
|
||||
) {
|
||||
if (!previous) {
|
||||
return true;
|
||||
}
|
||||
return ['state', 'gatewayInstanceId', 'lockOwner', 'failureCategory', 'lastError', 'lastSkipReason']
|
||||
.some((key) => (previous[key] ?? null) !== (current[key] ?? null));
|
||||
}
|
||||
|
||||
function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusDto) {
|
||||
const explicit = String(data.failureCategory ?? '').trim();
|
||||
if (explicit) {
|
||||
|
||||
Reference in New Issue
Block a user