feat: restore credit limits and harden SMS sending
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
|
||||
import { ApiTags } from '@nestjs/swagger';
|
||||
import { TenantId } from '../common/tenant-id.decorator';
|
||||
import { RequireRecentAuthentication } from '../auth/require-recent-authentication.decorator';
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
CreateSmsBillingRecordDto,
|
||||
CreateTenantAccountDto,
|
||||
EstimateSmsCostDto,
|
||||
UpdateCreditLimitDto,
|
||||
} from './billing.service';
|
||||
|
||||
@ApiTags('billing')
|
||||
@@ -27,6 +28,12 @@ export class BillingController {
|
||||
return this.billing.createAccount(body);
|
||||
}
|
||||
|
||||
@Post('accounts/:tenantId/credit-limit')
|
||||
@RequireRecentAuthentication()
|
||||
updateCreditLimit(@Param('tenantId') tenantId: string, @Body() body: UpdateCreditLimitDto) {
|
||||
return this.billing.updateCreditLimit(tenantId, body);
|
||||
}
|
||||
|
||||
@Get('recharges')
|
||||
listRechargeOrders(@TenantId() tenantId?: string) {
|
||||
return this.billing.listRechargeOrders(tenantId);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { BillingService } from './billing.service';
|
||||
|
||||
function createPrismaMock() {
|
||||
const accountState = { tenantId: 'tenant-1', balanceCents: 1000 };
|
||||
const accountState = { id: 'account-1', tenantId: 'tenant-1', balanceCents: 1000, creditCents: 0 };
|
||||
return {
|
||||
accountState,
|
||||
tenantAccount: {
|
||||
@@ -9,7 +9,8 @@ function createPrismaMock() {
|
||||
create: jest.fn(),
|
||||
upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })),
|
||||
update: jest.fn().mockImplementation(({ data }) => {
|
||||
accountState.balanceCents = data.balanceCents;
|
||||
if (data.balanceCents !== undefined) accountState.balanceCents = data.balanceCents;
|
||||
if (data.creditCents !== undefined) accountState.creditCents = data.creditCents;
|
||||
return Promise.resolve({ ...accountState });
|
||||
}),
|
||||
},
|
||||
@@ -62,7 +63,7 @@ describe('BillingService', () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('checks only the cash balance before sending', async () => {
|
||||
it('allows sending only when cash balance plus credit is greater than zero', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new BillingService(prisma as never);
|
||||
|
||||
@@ -70,8 +71,23 @@ describe('BillingService', () => {
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||
);
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual(
|
||||
expect.objectContaining({ canSend: false }),
|
||||
expect.objectContaining({ availableAmount: 1000, canSend: true }),
|
||||
);
|
||||
await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' });
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 0, balanceCents: 1000, creditCents: -1000, canSend: false }),
|
||||
);
|
||||
await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' });
|
||||
await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual(
|
||||
expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: true }),
|
||||
);
|
||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度必须为整数金额');
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'billing.credit_limit_updated',
|
||||
detail: expect.objectContaining({ previousCreditCents: 0, creditCents: -1000 }),
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
it('creates cash recharge orders and account transactions without plans', async () => {
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantAccountDto {
|
||||
tenantId: string;
|
||||
balanceCents?: number;
|
||||
creditCents?: number;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface UpdateCreditLimitDto {
|
||||
creditCents: number;
|
||||
operatorId?: string;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
export interface CreateAccountTransactionDto {
|
||||
tenantId: string;
|
||||
transactionType: string;
|
||||
@@ -80,14 +87,40 @@ export class BillingService {
|
||||
}
|
||||
|
||||
createAccount(data: CreateTenantAccountDto) {
|
||||
assertCreditAmount(data.creditCents ?? 0);
|
||||
const createData: Prisma.TenantAccountUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
balanceCents: data.balanceCents ?? 0,
|
||||
creditCents: data.creditCents ?? 0,
|
||||
status: data.status ?? 'active',
|
||||
};
|
||||
return this.prisma.tenantAccount.create({ data: createData });
|
||||
}
|
||||
|
||||
async updateCreditLimit(tenantId: string, data: UpdateCreditLimitDto) {
|
||||
assertCreditAmount(data.creditCents);
|
||||
const account = await this.getAccountOrCreate(tenantId);
|
||||
const updated = await this.prisma.tenantAccount.update({
|
||||
where: { tenantId },
|
||||
data: { creditCents: data.creditCents },
|
||||
});
|
||||
await this.prisma.operationLog.create({
|
||||
data: {
|
||||
tenantId,
|
||||
userId: data.operatorId,
|
||||
action: 'billing.credit_limit_updated',
|
||||
resource: 'tenant_account',
|
||||
resourceId: account.id,
|
||||
detail: {
|
||||
previousCreditCents: account.creditCents,
|
||||
creditCents: data.creditCents,
|
||||
remark: data.remark,
|
||||
} as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return updated;
|
||||
}
|
||||
|
||||
listRechargeOrders(tenantId?: string) {
|
||||
return this.prisma.rechargeOrder.findMany({
|
||||
where: tenantId ? { tenantId } : undefined,
|
||||
@@ -195,12 +228,14 @@ export class BillingService {
|
||||
async checkAccount(data: BillingActionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const requiredAmount = data.amountCents ?? 0;
|
||||
const availableAmount = account.balanceCents;
|
||||
const availableAmount = account.balanceCents + account.creditCents;
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
requiredAmount,
|
||||
availableAmount,
|
||||
canSend: availableAmount >= requiredAmount,
|
||||
balanceCents: account.balanceCents,
|
||||
creditCents: account.creditCents,
|
||||
canSend: availableAmount > 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -296,7 +331,7 @@ export class BillingService {
|
||||
return this.prisma.tenantAccount.upsert({
|
||||
where: { tenantId },
|
||||
update: {},
|
||||
create: { tenantId, balanceCents: 0, status: 'active' },
|
||||
create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -324,6 +359,12 @@ export class BillingService {
|
||||
}
|
||||
}
|
||||
|
||||
function assertCreditAmount(creditCents: number) {
|
||||
if (!Number.isInteger(creditCents)) {
|
||||
throw new BadRequestException('授信额度必须为整数金额(分)');
|
||||
}
|
||||
}
|
||||
|
||||
function estimateBillingUnits(content: string) {
|
||||
const length = [...content].length;
|
||||
if (length <= 70) {
|
||||
|
||||
@@ -1654,18 +1654,49 @@ describe('SendChainService', () => {
|
||||
await expect(service.batchRequeueDownstreamDeliveries([])).rejects.toThrow('请选择至少一条下游投递记录');
|
||||
});
|
||||
|
||||
it('marks 72 hour unknown receipts as timeout', async () => {
|
||||
const { service, prisma } = createService();
|
||||
it('marks submitted or unknown messages without a final receipt for 72 hours as timeout and refunds them', async () => {
|
||||
const { service, prisma, billing } = createService();
|
||||
prisma.smsMessageRecord.findMany.mockResolvedValue([
|
||||
{ id: 'record-1', batchTaskId: 'task-1' },
|
||||
{ id: 'record-2', batchTaskId: 'task-1' },
|
||||
{ id: 'record-1', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-1', amountCents: 3, billingUnits: 1 },
|
||||
{ id: 'record-2', tenantId: 'tenant-1', batchTaskId: 'task-1', messageId: 'MSG-2', amountCents: 3, billingUnits: 1 },
|
||||
]);
|
||||
prisma.smsBillingRecord.findFirst
|
||||
.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-1', billingStatus: 'charged' })
|
||||
.mockResolvedValueOnce(null).mockResolvedValueOnce({ id: 'bill-2', billingStatus: 'charged' });
|
||||
|
||||
await expect(service.markUnknownTimeout({ olderThanHours: 72 })).resolves.toEqual({ timeout: 2 });
|
||||
expect(prisma.smsMessageRecord.findMany).toHaveBeenCalledWith({
|
||||
where: {
|
||||
tenantId: { not: null },
|
||||
status: { in: ['submitted', 'unknown'] },
|
||||
submittedAt: { lte: expect.any(Date) },
|
||||
},
|
||||
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
|
||||
take: 10000,
|
||||
});
|
||||
expect(prisma.smsMessageRecord.updateMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ['record-1', 'record-2'] } },
|
||||
where: { id: 'record-1', status: { in: ['submitted', 'unknown'] } },
|
||||
data: expect.objectContaining({ status: 'timeout', errorMessage: '72小时未收到明确回执,自动转超时' }),
|
||||
});
|
||||
expect(billing.refund).toHaveBeenCalledTimes(2);
|
||||
expect(prisma.smsBatchTask.update).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('starts the automatic receipt-timeout scan after application startup', async () => {
|
||||
jest.useFakeTimers();
|
||||
const previousEnabled = process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
||||
const { service } = createService();
|
||||
const scan = jest.spyOn(service, 'markUnknownTimeout').mockResolvedValue({ timeout: 0 });
|
||||
try {
|
||||
process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = 'true';
|
||||
service.onModuleInit();
|
||||
await jest.advanceTimersByTimeAsync(60_000);
|
||||
expect(scan).toHaveBeenCalledWith({});
|
||||
await service.onModuleDestroy();
|
||||
} finally {
|
||||
if (previousEnabled === undefined) delete process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED;
|
||||
else process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED = previousEnabled;
|
||||
jest.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Injectable, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { Queue, Worker } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
@@ -218,6 +218,9 @@ const GATEWAY_SUBMIT_STREAM = 'gateway.submit.commands';
|
||||
const DEFAULT_DOWNSTREAM_RETRY_DELAY_MS = 60_000;
|
||||
const DEFAULT_DOWNSTREAM_RETRY_MAX_DELAY_MS = 30 * 60_000;
|
||||
const DEFAULT_DOWNSTREAM_MAX_RETRIES = 10;
|
||||
const DEFAULT_RECEIPT_TIMEOUT_HOURS = 72;
|
||||
const DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS = 5 * 60_000;
|
||||
const RECEIPT_TIMEOUT_INITIAL_DELAY_MS = 60_000;
|
||||
const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||
priority: 1,
|
||||
normal: 100,
|
||||
@@ -225,10 +228,14 @@ const BULLMQ_PRIORITY: Record<QueuePriority, number> = {
|
||||
|
||||
@Injectable()
|
||||
export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
private readonly logger = new Logger(SendChainService.name);
|
||||
private redis?: IORedis;
|
||||
private sendQueue?: Queue<SendJob, unknown, 'send-message'>;
|
||||
private gatewayQueue?: Queue;
|
||||
private worker?: Worker<SendJob>;
|
||||
private receiptTimeoutInitialTimer?: ReturnType<typeof setTimeout>;
|
||||
private receiptTimeoutIntervalTimer?: ReturnType<typeof setInterval>;
|
||||
private receiptTimeoutScanRunning = false;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
@@ -240,9 +247,20 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
if (process.env.API_ENABLE_SEND_WORKER === 'true') {
|
||||
this.startWorker();
|
||||
}
|
||||
if (process.env.SMS_RECEIPT_TIMEOUT_SCAN_ENABLED !== 'false') {
|
||||
this.receiptTimeoutInitialTimer = setTimeout(() => void this.runReceiptTimeoutScan(), RECEIPT_TIMEOUT_INITIAL_DELAY_MS);
|
||||
this.receiptTimeoutInitialTimer.unref?.();
|
||||
this.receiptTimeoutIntervalTimer = setInterval(
|
||||
() => void this.runReceiptTimeoutScan(),
|
||||
positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS, DEFAULT_RECEIPT_TIMEOUT_SCAN_INTERVAL_MS),
|
||||
);
|
||||
this.receiptTimeoutIntervalTimer.unref?.();
|
||||
}
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
if (this.receiptTimeoutInitialTimer) clearTimeout(this.receiptTimeoutInitialTimer);
|
||||
if (this.receiptTimeoutIntervalTimer) clearInterval(this.receiptTimeoutIntervalTimer);
|
||||
await this.worker?.close();
|
||||
await this.sendQueue?.close();
|
||||
await this.gatewayQueue?.close();
|
||||
@@ -1807,30 +1825,47 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async markUnknownTimeout(data: TimeoutUnknownDto) {
|
||||
const olderThanHours = data.olderThanHours ?? 72;
|
||||
const olderThanHours = data.olderThanHours ?? positiveInteger(process.env.SMS_RECEIPT_TIMEOUT_HOURS, DEFAULT_RECEIPT_TIMEOUT_HOURS);
|
||||
const cutoff = new Date(Date.now() - olderThanHours * 60 * 60 * 1000);
|
||||
const candidates = await this.prisma.smsMessageRecord.findMany({
|
||||
where: {
|
||||
status: 'unknown',
|
||||
deliveredAt: { lte: cutoff },
|
||||
tenantId: { not: null },
|
||||
status: { in: ['submitted', 'unknown'] },
|
||||
submittedAt: { lte: cutoff },
|
||||
},
|
||||
select: { id: true, batchTaskId: true },
|
||||
select: { id: true, tenantId: true, batchTaskId: true, messageId: true, amountCents: true, billingUnits: true },
|
||||
take: 10000,
|
||||
});
|
||||
await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: { in: candidates.map((candidate) => candidate.id) } },
|
||||
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: '72小时未收到明确回执,自动转超时' },
|
||||
});
|
||||
const timedOutTaskIds = new Set<string>();
|
||||
let timeout = 0;
|
||||
for (const candidate of candidates) {
|
||||
const message = await this.prisma.smsMessageRecord.findUnique({ where: { id: candidate.id } });
|
||||
if (message?.tenantId) {
|
||||
await this.refundMessage(message as typeof message & { tenantId: string }, '72小时未收到明确回执,自动超时退款');
|
||||
}
|
||||
if (!candidate.tenantId) continue;
|
||||
const transitioned = await this.prisma.smsMessageRecord.updateMany({
|
||||
where: { id: candidate.id, status: { in: ['submitted', 'unknown'] } },
|
||||
data: { status: 'timeout', timeoutAt: new Date(), errorMessage: `${olderThanHours}小时未收到明确回执,自动转超时` },
|
||||
});
|
||||
if (transitioned.count !== 1) continue;
|
||||
timeout += 1;
|
||||
await this.refundMessage(candidate as typeof candidate & { tenantId: string }, `${olderThanHours}小时未收到明确回执,自动超时退款`);
|
||||
if (candidate.batchTaskId) timedOutTaskIds.add(candidate.batchTaskId);
|
||||
}
|
||||
for (const batchTaskId of new Set(candidates.map((candidate) => candidate.batchTaskId).filter((value): value is string => Boolean(value)))) {
|
||||
for (const batchTaskId of timedOutTaskIds) {
|
||||
await this.refreshTaskProgress(batchTaskId);
|
||||
}
|
||||
return { timeout: candidates.length };
|
||||
return { timeout };
|
||||
}
|
||||
|
||||
private async runReceiptTimeoutScan() {
|
||||
if (this.receiptTimeoutScanRunning) return;
|
||||
this.receiptTimeoutScanRunning = true;
|
||||
try {
|
||||
const result = await this.markUnknownTimeout({});
|
||||
if (result.timeout > 0) this.logger.log(`Marked ${result.timeout} messages as receipt timeout and refunded charged messages`);
|
||||
} catch (error) {
|
||||
this.logger.error('Receipt timeout scan failed', error instanceof Error ? error.stack : String(error));
|
||||
} finally {
|
||||
this.receiptTimeoutScanRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async submitMessageToGateway(
|
||||
@@ -2883,6 +2918,11 @@ function isProvinceChannel(item: { province?: string | null; channel: { sendRegi
|
||||
return itemProvince === target || sendRegion === target;
|
||||
}
|
||||
|
||||
function positiveInteger(value: string | undefined, fallback: number) {
|
||||
const parsed = Number(value);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function bullmqConnection() {
|
||||
const redisUrl = new URL(process.env.REDIS_URL ?? 'redis://127.0.0.1:6379');
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user