feat: improve application access and money precision

This commit is contained in:
hectorzhao
2026-07-16 17:54:05 +08:00
parent 9d5c507007
commit faa716b8d0
49 changed files with 1699 additions and 489 deletions
@@ -73,6 +73,7 @@ function createPrismaMock() {
secretHash: 'secret-hash',
status: 'active',
interfaceEnabled: true,
cmppMaxConnections: 2,
queuePriority: 'normal',
ipAllowlist: [{ ipCidr: '127.0.0.1/32' }],
tenant: { id: 'tenant-1', status: 'active', certificationStatus: 'approved' },
@@ -520,6 +521,7 @@ describe('SendChainService', () => {
})).resolves.toEqual(expect.objectContaining({
account: '100001',
enterpriseCode: 'SP0001',
maxConnections: 2,
status: 'authenticated',
}));
});
+23 -51
View File
@@ -4,9 +4,10 @@ import { Queue, Worker } from 'bullmq';
import IORedis from 'ioredis';
import { randomUUID } from 'node:crypto';
import { createHash } from 'node:crypto';
import { isIP } from 'node:net';
import { setTimeout as sleep } from 'node:timers/promises';
import { BillingService } from '../billing/billing.service';
import { isIpAllowed } from '../common/ip-allowlist';
import { moneyToNumber } from '../common/money';
import { PrismaService } from '../prisma/prisma.service';
import { RiskReviewService } from '../risk-review/risk-review.service';
import { OpenApiService } from '../open-api/open-api.service';
@@ -681,7 +682,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
select: { id: true, amountCents: true, billingUnits: true },
take: 100000,
});
const amountCents = messages.reduce((sum, message) => sum + message.amountCents, 0);
const amountCents = messages.reduce((sum, message) => sum + moneyToNumber(message.amountCents), 0);
const accountCheck = await this.billing.checkAccount({ tenantId: task.tenantId, amountCents });
if (!accountCheck.canSend) {
throw new BadRequestException('定时任务到点时企业账户余额不足');
@@ -1754,7 +1755,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
return {
@@ -1763,6 +1764,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
}
@@ -1772,7 +1774,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!application) {
throw new BadRequestException('CMPP account is invalid');
}
if (data.remoteIp && !isApplicationIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
if (!/^1[3-9]\d{9}$/.test(data.phoneNumber)) {
@@ -1781,7 +1783,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
const clientSrcId = validateInboundApplicationSrcId(data.srcId, application);
const template = await this.resolveInboundTemplateCandidate(application.id, data.content);
const templateVariables = template ? matchTemplateContent(template.content, data.content) ?? {} : {};
const unitPrice = application.customerUnitPrice ?? 0;
const unitPrice = moneyToNumber(application.customerUnitPrice);
const queuePriority = normalizeQueuePriority(application.queuePriority);
const billing = this.billing.estimateSmsCost({
tenantId: application.tenantId,
@@ -2089,7 +2091,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
submitId,
submitStatus: 'queued',
costUnitPrice: channel.unitPrice ?? 0,
costAmountCents: (channel.unitPrice ?? 0) * Math.max(1, message.billingUnits ?? 1),
costAmountCents: moneyToNumber(channel.unitPrice) * Math.max(1, message.billingUnits ?? 1),
},
});
await this.prisma.smsMessageRecord.update({
@@ -2241,7 +2243,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
throw new NotFoundException('无已报备通过且在线的可用通道');
}
return {
channel: selected.channel,
channel: { ...selected.channel, unitPrice: moneyToNumber(selected.channel.unitPrice) },
carrier,
province,
groupId: route.groupId,
@@ -2321,7 +2323,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
if (!application || application.tenantId !== tenantId) {
return 0;
}
return application.customerUnitPrice ?? 0;
return moneyToNumber(application.customerUnitPrice);
}
private async resolveQueuePriority(tenantId: string, applicationId?: string): Promise<QueuePriority> {
@@ -2533,10 +2535,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
phoneNumber: string;
content: string;
billingUnits: number;
unitPrice: number;
amountCents: number;
unitPrice: number | bigint;
amountCents: number | bigint;
}) {
const amountCents = message.amountCents ?? 0;
const amountCents = moneyToNumber(message.amountCents);
const unitPrice = moneyToNumber(message.unitPrice);
const billingUnits = message.billingUnits ?? 0;
const exists = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId } });
if (exists?.billingStatus === 'charged') {
@@ -2566,7 +2569,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
phoneNumber: message.phoneNumber,
contentLength: [...message.content].length,
billingUnits,
unitPrice: message.unitPrice ?? 0,
unitPrice,
amountCents,
billingStatus: 'charged',
transactionId: transaction.id,
@@ -2579,10 +2582,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async releaseMessageReservation(
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number; billingUnits: number },
message: { tenantId: string; batchTaskId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
remark: string,
) {
if ((message.amountCents ?? 0) <= 0) {
const amountCents = moneyToNumber(message.amountCents);
if (amountCents <= 0) {
return;
}
const charged = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'charged' } });
@@ -2597,7 +2601,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
await this.billing.release({
tenantId: message.tenantId,
amountCents: message.amountCents,
amountCents,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark: `${remark}: ${message.messageId}`,
@@ -2605,10 +2609,11 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
private async refundMessage(
message: { tenantId: string; messageId: string; amountCents: number; billingUnits: number },
message: { tenantId: string; messageId: string; amountCents: number | bigint; billingUnits: number },
remark: string,
) {
if ((message.amountCents ?? 0) <= 0) {
const amountCents = moneyToNumber(message.amountCents);
if (amountCents <= 0) {
return;
}
const refunded = await this.prisma.smsBillingRecord.findFirst({ where: { messageId: message.messageId, billingStatus: 'refunded' } });
@@ -2621,7 +2626,7 @@ export class SendChainService implements OnModuleInit, OnModuleDestroy {
}
const transaction = await this.billing.refund({
tenantId: message.tenantId,
amountCents: message.amountCents,
amountCents,
relatedType: 'sms_message_record',
relatedId: message.messageId,
remark,
@@ -3329,36 +3334,3 @@ function normalizeRecoveryFailureCategory(data: GatewayDownstreamRecoveryStatusD
}
return data.state ? 'unknown' : null;
}
function isApplicationIpAllowed(remoteIp: string, allowlist: string[]) {
const normalizedRemoteIp = normalizeIp(remoteIp);
if (allowlist.length === 0) {
return true;
}
return allowlist.some((rule) => ipMatchesRule(normalizedRemoteIp, rule));
}
function ipMatchesRule(remoteIp: string, rule: string) {
const normalizedRule = normalizeIp(rule.trim());
if (!normalizedRule) {
return false;
}
if (!normalizedRule.includes('/')) {
return remoteIp === normalizedRule;
}
const [network, prefixText] = normalizedRule.split('/');
const prefix = Number(prefixText);
if (!Number.isInteger(prefix) || prefix < 0 || prefix > 32 || isIP(remoteIp) !== 4 || isIP(network) !== 4) {
return false;
}
const mask = prefix === 0 ? 0 : (0xffffffff << (32 - prefix)) >>> 0;
return (ipv4ToInt(remoteIp) & mask) === (ipv4ToInt(network) & mask);
}
function normalizeIp(value: string) {
return value.replace(/^::ffff:/, '').trim();
}
function ipv4ToInt(value: string) {
return value.split('.').reduce((result, part) => ((result << 8) + Number(part)) >>> 0, 0);
}