feat: improve application access and money precision
This commit is contained in:
@@ -61,6 +61,16 @@ describe('BillingService', () => {
|
||||
amountCents: 20,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(
|
||||
service.estimateSmsCost({ tenantId: 'tenant-1', content: '四位小数单价', phoneCount: 2, unitPrice: 325 }),
|
||||
).toEqual(
|
||||
expect.objectContaining({
|
||||
unitPrice: 325,
|
||||
totalBillingUnits: 2,
|
||||
amountCents: 650,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows sending only when cash balance plus credit is greater than zero', async () => {
|
||||
@@ -81,7 +91,7 @@ describe('BillingService', () => {
|
||||
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('授信额度必须为整数金额');
|
||||
await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位');
|
||||
expect(prisma.operationLog.create).toHaveBeenCalledWith({
|
||||
data: expect.objectContaining({
|
||||
action: 'billing.credit_limit_updated',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { BadRequestException, Injectable } from '@nestjs/common';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantAccountDto {
|
||||
@@ -87,7 +88,8 @@ export class BillingService {
|
||||
}
|
||||
|
||||
createAccount(data: CreateTenantAccountDto) {
|
||||
assertCreditAmount(data.creditCents ?? 0);
|
||||
assertMoneyUnits(data.balanceCents ?? 0, '账户余额', { allowNegative: true });
|
||||
assertMoneyUnits(data.creditCents ?? 0, '授信额度', { allowNegative: true });
|
||||
const createData: Prisma.TenantAccountUncheckedCreateInput = {
|
||||
tenantId: data.tenantId,
|
||||
balanceCents: data.balanceCents ?? 0,
|
||||
@@ -98,7 +100,7 @@ export class BillingService {
|
||||
}
|
||||
|
||||
async updateCreditLimit(tenantId: string, data: UpdateCreditLimitDto) {
|
||||
assertCreditAmount(data.creditCents);
|
||||
assertMoneyUnits(data.creditCents, '授信额度', { allowNegative: true });
|
||||
const account = await this.getAccountOrCreate(tenantId);
|
||||
const updated = await this.prisma.tenantAccount.update({
|
||||
where: { tenantId },
|
||||
@@ -112,7 +114,7 @@ export class BillingService {
|
||||
resource: 'tenant_account',
|
||||
resourceId: account.id,
|
||||
detail: {
|
||||
previousCreditCents: account.creditCents,
|
||||
previousCreditCents: moneyToNumber(account.creditCents),
|
||||
creditCents: data.creditCents,
|
||||
remark: data.remark,
|
||||
} as Prisma.InputJsonValue,
|
||||
@@ -148,7 +150,7 @@ export class BillingService {
|
||||
},
|
||||
select: { relatedId: true, balanceAfter: true },
|
||||
});
|
||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, transaction.balanceAfter]));
|
||||
const balanceAfterByOrderId = new Map(transactions.map((transaction) => [transaction.relatedId, moneyToNumber(transaction.balanceAfter)]));
|
||||
|
||||
return orders.map((order) => ({
|
||||
...order,
|
||||
@@ -158,6 +160,7 @@ export class BillingService {
|
||||
|
||||
async createRechargeOrder(data: CreateRechargeOrderDto) {
|
||||
const amountCents = data.amountCents;
|
||||
assertMoneyUnits(amountCents, '充值金额', { allowNegative: true, allowZero: false });
|
||||
const order = await this.prisma.rechargeOrder.create({
|
||||
data: {
|
||||
tenantId: data.tenantId,
|
||||
@@ -211,7 +214,10 @@ export class BillingService {
|
||||
estimateSmsCost(data: EstimateSmsCostDto) {
|
||||
const billingUnits = estimateBillingUnits(data.content);
|
||||
const unitPrice = data.unitPrice ?? 0;
|
||||
assertMoneyUnits(unitPrice, '短信单价');
|
||||
const totalUnits = billingUnits * data.phoneCount;
|
||||
const amountCents = totalUnits * unitPrice;
|
||||
assertMoneyUnits(amountCents, '短信计费金额');
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
applicationId: data.applicationId,
|
||||
@@ -221,20 +227,22 @@ export class BillingService {
|
||||
billingUnitsPerMessage: billingUnits,
|
||||
totalBillingUnits: totalUnits,
|
||||
unitPrice,
|
||||
amountCents: totalUnits * unitPrice,
|
||||
amountCents,
|
||||
};
|
||||
}
|
||||
|
||||
async checkAccount(data: BillingActionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const requiredAmount = data.amountCents ?? 0;
|
||||
const availableAmount = account.balanceCents + account.creditCents;
|
||||
const balanceCents = moneyToNumber(account.balanceCents);
|
||||
const creditCents = moneyToNumber(account.creditCents);
|
||||
const availableAmount = balanceCents + creditCents;
|
||||
return {
|
||||
tenantId: data.tenantId,
|
||||
requiredAmount,
|
||||
availableAmount,
|
||||
balanceCents: account.balanceCents,
|
||||
creditCents: account.creditCents,
|
||||
balanceCents,
|
||||
creditCents,
|
||||
canSend: availableAmount > 0,
|
||||
};
|
||||
}
|
||||
@@ -316,6 +324,7 @@ export class BillingService {
|
||||
}
|
||||
|
||||
createRule(data: CreateBillingRuleDto) {
|
||||
assertMoneyUnits(data.unitPrice, '计费规则单价');
|
||||
return this.prisma.billingRule.create({
|
||||
data: {
|
||||
code: data.code,
|
||||
@@ -337,7 +346,7 @@ export class BillingService {
|
||||
|
||||
private async applyAccountDelta(data: CreateAccountTransactionDto) {
|
||||
const account = await this.getAccountOrCreate(data.tenantId);
|
||||
const nextBalance = account.balanceCents + (data.amountCents ?? 0);
|
||||
const nextBalance = moneyToNumber(account.balanceCents) + (data.amountCents ?? 0);
|
||||
await this.prisma.tenantAccount.update({
|
||||
where: { tenantId: data.tenantId },
|
||||
data: {
|
||||
@@ -359,12 +368,6 @@ 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) {
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Queue } from 'bullmq';
|
||||
import IORedis from 'ioredis';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { assertMoneyUnits, moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateChannelDto {
|
||||
@@ -230,6 +231,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
async createChannel(data: CreateChannelDto) {
|
||||
assertMoneyUnits(data.unitPrice ?? 0, '通道单价');
|
||||
const missingFields = ['code', 'name', 'gatewayHost', 'gatewayPort', 'account', 'passwordCipher', 'srcId'].filter((field) => {
|
||||
const value = data[field as keyof CreateChannelDto];
|
||||
return value === undefined || value === null || value === '';
|
||||
@@ -275,6 +277,9 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
if (!channel) {
|
||||
throw new NotFoundException('Channel not found');
|
||||
}
|
||||
if (data.unitPrice !== undefined) {
|
||||
assertMoneyUnits(data.unitPrice, '通道单价');
|
||||
}
|
||||
const gatewayPort = data.gatewayPort === undefined ? undefined : Number(data.gatewayPort);
|
||||
if (gatewayPort !== undefined && (!Number.isInteger(gatewayPort) || gatewayPort <= 0 || gatewayPort > 65535)) {
|
||||
throw new BadRequestException('gatewayPort must be an integer between 1 and 65535');
|
||||
@@ -323,7 +328,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
enterpriseCode: channel.enterpriseCode,
|
||||
account: channel.account,
|
||||
srcId: channel.srcId,
|
||||
unitPrice: channel.unitPrice,
|
||||
unitPrice: moneyToNumber(channel.unitPrice),
|
||||
},
|
||||
after: data,
|
||||
} as Prisma.InputJsonValue,
|
||||
@@ -502,7 +507,7 @@ export class ChannelsService implements OnModuleInit, OnModuleDestroy {
|
||||
submitId,
|
||||
submitStatus: 'queued',
|
||||
costUnitPrice: channel.unitPrice,
|
||||
costAmountCents: channel.unitPrice * messageRecord.billingUnits,
|
||||
costAmountCents: moneyToNumber(channel.unitPrice) * messageRecord.billingUnits,
|
||||
},
|
||||
});
|
||||
const command = buildChannelTestSubmitCommand({
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { isIP } from 'node:net';
|
||||
|
||||
export function isIpAllowed(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);
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
|
||||
export const MONEY_UNITS_PER_YUAN = 10_000;
|
||||
|
||||
export function moneyToNumber(value: number | bigint | null | undefined) {
|
||||
if (value === null || value === undefined) return 0;
|
||||
const result = typeof value === 'bigint' ? Number(value) : value;
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new RangeError('金额超过 JavaScript 安全整数范围');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function moneyUnitsToYuan(value: number | bigint | null | undefined) {
|
||||
return moneyToNumber(value) / MONEY_UNITS_PER_YUAN;
|
||||
}
|
||||
|
||||
export function moneyUnitsToFixedYuan(value: number | bigint | null | undefined) {
|
||||
return moneyUnitsToYuan(value).toFixed(4);
|
||||
}
|
||||
|
||||
export function assertMoneyUnits(
|
||||
value: number,
|
||||
label: string,
|
||||
options: { allowNegative?: boolean; allowZero?: boolean } = {},
|
||||
) {
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new BadRequestException(`${label}最多支持人民币小数点后 4 位,且不能超过安全金额范围`);
|
||||
}
|
||||
if (!options.allowNegative && value < 0) {
|
||||
throw new BadRequestException(`${label}不能为负数`);
|
||||
}
|
||||
if (options.allowZero === false && value === 0) {
|
||||
throw new BadRequestException(`${label}不能为 0`);
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,17 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { AppModule } from './app.module';
|
||||
import { OpenApiModule } from './open-api/open-api.module';
|
||||
|
||||
Object.defineProperty(BigInt.prototype, 'toJSON', {
|
||||
configurable: true,
|
||||
value(this: bigint) {
|
||||
const result = Number(this);
|
||||
if (!Number.isSafeInteger(result)) {
|
||||
throw new RangeError('金额超过 JavaScript 安全整数范围');
|
||||
}
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule, { rawBody: true });
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
|
||||
export interface MessageQuery {
|
||||
tenantId?: string;
|
||||
@@ -268,7 +269,7 @@ export class OperationsService {
|
||||
unknown: todayTotals.unknown,
|
||||
successRate: todayTotals.total > 0 ? Number(((todayTotals.delivered / todayTotals.total) * 100).toFixed(1)) : 0,
|
||||
spendCents: todayTotals.amountCents,
|
||||
returnedCents: transactionAggregate._sum.amountCents ?? 0,
|
||||
returnedCents: moneyToNumber(transactionAggregate._sum.amountCents),
|
||||
billingUnits: todayTotals.billingUnits,
|
||||
},
|
||||
uplinkCount,
|
||||
@@ -794,9 +795,9 @@ export class OperationsService {
|
||||
_sum: { amountCents: true },
|
||||
}),
|
||||
]);
|
||||
const messageAmount = messages._sum.amountCents ?? 0;
|
||||
const billingAmount = billing._sum.amountCents ?? 0;
|
||||
const transactionAmount = transactions._sum.amountCents ?? 0;
|
||||
const messageAmount = moneyToNumber(messages._sum.amountCents);
|
||||
const billingAmount = moneyToNumber(billing._sum.amountCents);
|
||||
const transactionAmount = moneyToNumber(transactions._sum.amountCents);
|
||||
return {
|
||||
messages,
|
||||
billing,
|
||||
@@ -1014,12 +1015,12 @@ function formatExportTimestamp(date: Date) {
|
||||
return `${parts[0]}${parts[1]}${parts[2]}-${parts[3]}${parts[4]}${parts[5]}`;
|
||||
}
|
||||
|
||||
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | null; billingUnits: number | null } }>) {
|
||||
function summarizeMessageGroups(groups: Array<{ status: string; _count: { _all: number }; _sum: { amountCents: number | bigint | null; billingUnits: number | null } }>) {
|
||||
return groups.reduce(
|
||||
(summary, group) => {
|
||||
const count = group._count._all;
|
||||
summary.total += count;
|
||||
summary.amountCents += group._sum.amountCents ?? 0;
|
||||
summary.amountCents += moneyToNumber(group._sum.amountCents);
|
||||
summary.billingUnits += group._sum.billingUnits ?? 0;
|
||||
if (group.status === 'delivered') {
|
||||
summary.delivered += count;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { moneyUnitsToFixedYuan } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
const SHANGHAI_OFFSET_MS = 8 * 60 * 60 * 1000;
|
||||
@@ -79,7 +80,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
async exportProfit(query: ReportListQuery) {
|
||||
const { dimensionType, where } = profitWhere(query);
|
||||
const items = await this.prisma.dailyProfitReport.findMany({ where, orderBy: [{ reportDate: 'desc' }, { dimensionName: 'asc' }] });
|
||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '消费金额(分)', '成本金额(分)', '利润(分)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, item.revenueCents, item.costCents, item.profitCents, (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
return csvExport(`利润报表-${dimensionType === 'channel' ? '通道' : '企业应用'}`, ['发送日期', '统计维度', '企业', '发送条数', '成功条数', '消费金额(元)', '成本金额(元)', '利润(元)', '利润率(%)', '生成时间'], items.map((item) => [dateKey(item.reportDate), item.dimensionName, item.tenantName ?? '', item.sentUnits, item.successUnits, moneyUnitsToFixedYuan(item.revenueCents), moneyUnitsToFixedYuan(item.costCents), moneyUnitsToFixedYuan(item.profitCents), (item.profitRateBps / 100).toFixed(2), formatCsvDate(item.generatedAt)]));
|
||||
}
|
||||
|
||||
async exportQuality(query: ReportListQuery) {
|
||||
@@ -141,11 +142,11 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
WITH billing AS (
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue
|
||||
FROM "SmsBillingRecord"
|
||||
GROUP BY "messageId"
|
||||
), costs AS (
|
||||
SELECT submit."messageRecordId", SUM(submit."costAmountCents")::integer AS cost
|
||||
SELECT submit."messageRecordId", SUM(submit."costAmountCents")::bigint AS cost
|
||||
FROM "SmsSubmitRecord" submit
|
||||
WHERE submit."submitStatus" = 'accepted'
|
||||
GROUP BY submit."messageRecordId"
|
||||
@@ -168,9 +169,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
NULL,
|
||||
COALESCE(SUM(message."billingUnits"), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message.status = 'delivered' OR message."receiptStatus" = 'delivered' THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(billing.revenue), 0)::integer,
|
||||
COALESCE(SUM(costs.cost), 0)::integer,
|
||||
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::integer,
|
||||
COALESCE(SUM(billing.revenue), 0)::bigint,
|
||||
COALESCE(SUM(costs.cost), 0)::bigint,
|
||||
(COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(billing.revenue), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(billing.revenue), 0) - COALESCE(SUM(costs.cost), 0)) * 10000.0 / SUM(billing.revenue))::integer END,
|
||||
CURRENT_TIMESTAMP,
|
||||
@@ -187,7 +188,7 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
|
||||
await tx.$executeRaw(Prisma.sql`
|
||||
WITH billing AS (
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::integer AS revenue
|
||||
SELECT "messageId", SUM(CASE WHEN "billingStatus" = 'charged' THEN "amountCents" ELSE 0 END)::bigint AS revenue
|
||||
FROM "SmsBillingRecord"
|
||||
GROUP BY "messageId"
|
||||
)
|
||||
@@ -214,9 +215,9 @@ export class ReportsService implements OnModuleInit, OnModuleDestroy {
|
||||
AND receipt."channelId" = submit."channelId"
|
||||
AND receipt."receiptStatus" = 'delivered'
|
||||
) THEN message."billingUnits" ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::integer,
|
||||
COALESCE(SUM(submit."costAmountCents"), 0)::integer,
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::integer,
|
||||
COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0)::bigint,
|
||||
COALESCE(SUM(submit."costAmountCents"), 0)::bigint,
|
||||
(COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0))::bigint,
|
||||
CASE WHEN COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) = 0 THEN 0
|
||||
ELSE ROUND((COALESCE(SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END), 0) - COALESCE(SUM(submit."costAmountCents"), 0)) * 10000.0 /
|
||||
SUM(CASE WHEN message."submitId" = submit."submitId" THEN billing.revenue ELSE 0 END))::integer END,
|
||||
|
||||
@@ -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',
|
||||
}));
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ function createPrismaMock() {
|
||||
interfaceType: 'cmpp20',
|
||||
queuePriority: 'normal',
|
||||
secretHash: '0123456789abcdef',
|
||||
ipAllowlist: [],
|
||||
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
|
||||
}),
|
||||
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
|
||||
@@ -261,9 +262,11 @@ describe('SmsConfigService', () => {
|
||||
expect(applications.map((application) => application.id)).toEqual(['app-3', 'app-2', 'app-1']);
|
||||
});
|
||||
|
||||
it('returns CMPP params from persisted application and channel config', async () => {
|
||||
it('returns CMPP params from the public inbound Gateway config instead of an upstream channel', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
process.env.CMPP_PUBLIC_HOST = 'cmpp.example.com';
|
||||
process.env.CMPP_PUBLIC_PORT = '17891';
|
||||
|
||||
await expect(service.getApplicationCmppParams('app-1')).resolves.toEqual(expect.objectContaining({
|
||||
applicationId: 'app-1',
|
||||
@@ -275,14 +278,17 @@ describe('SmsConfigService', () => {
|
||||
applicationExtension: '0001',
|
||||
accessNumberFillEnabled: true,
|
||||
accessNumberFillPrefix: '00',
|
||||
gatewayHost: '127.0.0.1',
|
||||
gatewayPort: 17890,
|
||||
gatewayHost: 'cmpp.example.com',
|
||||
gatewayPort: 17891,
|
||||
interfaceEnabled: true,
|
||||
interfaceType: 'cmpp20',
|
||||
maxConnections: 2,
|
||||
windowSize: 32,
|
||||
protocolVersion: 'CMPP2.0',
|
||||
}));
|
||||
expect(prisma.smsChannel.findFirst).not.toHaveBeenCalled();
|
||||
delete process.env.CMPP_PUBLIC_HOST;
|
||||
delete process.env.CMPP_PUBLIC_PORT;
|
||||
});
|
||||
|
||||
it('creates enterprise applications with persisted queue priority', async () => {
|
||||
@@ -428,7 +434,9 @@ describe('SmsConfigService', () => {
|
||||
prisma.$transaction.mockImplementationOnce((callback: (client: typeof tx) => unknown) => callback(tx));
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 300, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
|
||||
await expect(service.updateApplication('app-1', { customerUnitPrice: 325.5 }))
|
||||
.rejects.toThrow('客户单价最多支持人民币小数点后 4 位');
|
||||
await expect(service.updateApplication('app-1', { name: '新应用', customerUnitPrice: 325, queuePriority: 'priority', ipAllowlist: ['10.0.0.1/32'] }))
|
||||
.resolves.toEqual(expect.objectContaining({ id: 'app-1', name: '新应用' }));
|
||||
|
||||
expect(tx.smsApplicationIpAllowlist.deleteMany).toHaveBeenCalledWith({ where: { applicationId: 'app-1' } });
|
||||
@@ -437,7 +445,7 @@ describe('SmsConfigService', () => {
|
||||
data: expect.objectContaining({
|
||||
name: '新应用',
|
||||
cmppEnterpriseCode: '100001',
|
||||
customerUnitPrice: 300,
|
||||
customerUnitPrice: 325,
|
||||
queuePriority: 'priority',
|
||||
ipAllowlist: { create: [{ ipCidr: '10.0.0.1/32' }] },
|
||||
}),
|
||||
@@ -498,6 +506,17 @@ describe('SmsConfigService', () => {
|
||||
await expect(service.getApplicationCmppParams('app-1', 'tenant-2')).rejects.toThrow('Application not found');
|
||||
});
|
||||
|
||||
it('forbids client CMPP parameter access when the interface is not enabled', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1', tenantId: 'tenant-1', interfaceEnabled: false,
|
||||
tenant: { id: 'tenant-1', name: '租户A' },
|
||||
});
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.getApplicationCmppParams('app-1', 'tenant-1')).rejects.toThrow('该企业应用未开通 CMPP 接口');
|
||||
});
|
||||
|
||||
it('records Gateway downstream CMPP connection and heartbeat events against the real application account', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
@@ -531,6 +550,31 @@ describe('SmsConfigService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects connection heartbeats after an IP allowlist change or connection-limit reduction', async () => {
|
||||
const prisma = createPrismaMock();
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
|
||||
interfaceEnabled: true, status: 'active', ipAllowlist: [{ ipCidr: '10.0.0.0/24' }],
|
||||
});
|
||||
prisma.cmppDownstreamConnection.findUnique.mockResolvedValue({ id: 'downstream-2', connectedAt: new Date() });
|
||||
const service = new SmsConfigService(prisma as never);
|
||||
|
||||
await expect(service.recordDownstreamConnectionEvent({
|
||||
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP source IP is not in application allowlist');
|
||||
|
||||
prisma.smsApplication.findUnique.mockResolvedValue({
|
||||
id: 'app-1', tenantId: 'tenant-1', cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 1,
|
||||
interfaceEnabled: true, status: 'active', ipAllowlist: [],
|
||||
});
|
||||
prisma.cmppDownstreamConnection.findMany.mockResolvedValue([
|
||||
{ connectionId: 'gateway-1-1' }, { connectionId: 'gateway-1-2' },
|
||||
]);
|
||||
await expect(service.recordDownstreamConnectionEvent({
|
||||
account: '100001', connectionId: 'gateway-1-2', status: 'heartbeat', remoteIp: '127.0.0.1',
|
||||
})).rejects.toThrow('CMPP connection limit exceeded (1)');
|
||||
});
|
||||
|
||||
it.each([
|
||||
['heartbeat', 'lastHeartbeatAt'],
|
||||
['submit', 'lastSubmitAt'],
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomInt, randomUUID } from 'node:crypto';
|
||||
import { isIpAllowed } from '../common/ip-allowlist';
|
||||
import { assertMoneyUnits } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateSmsApplicationDto {
|
||||
@@ -358,6 +360,7 @@ export class SmsConfigService {
|
||||
}
|
||||
|
||||
async createApplication(data: CreateSmsApplicationDto) {
|
||||
assertMoneyUnits(data.customerUnitPrice ?? 0, '客户单价');
|
||||
const secret = normalizeApplicationPassword(data.passwordCipher);
|
||||
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
|
||||
const interfaceType = normalizeApplicationInterfaceType(data.interfaceType);
|
||||
@@ -402,6 +405,9 @@ export class SmsConfigService {
|
||||
if (!application) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
if (data.customerUnitPrice !== undefined) {
|
||||
assertMoneyUnits(data.customerUnitPrice, '客户单价');
|
||||
}
|
||||
const queuePriority = data.queuePriority === undefined
|
||||
? undefined
|
||||
: normalizeApplicationQueuePriority(data.queuePriority);
|
||||
@@ -587,18 +593,17 @@ export class SmsConfigService {
|
||||
if (!application || (tenantId && application.tenantId !== tenantId)) {
|
||||
throw new NotFoundException('Application not found');
|
||||
}
|
||||
const channel = await this.prisma.smsChannel.findFirst({
|
||||
where: { status: { not: 'deleted' } },
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
if (tenantId && !application.interfaceEnabled) {
|
||||
throw new ForbiddenException('该企业应用未开通 CMPP 接口');
|
||||
}
|
||||
return {
|
||||
applicationId: application.id,
|
||||
applicationName: application.name,
|
||||
tenantId: application.tenantId,
|
||||
tenantName: application.tenant.name,
|
||||
appCode: application.id,
|
||||
gatewayHost: channel?.gatewayHost ?? '',
|
||||
gatewayPort: channel?.gatewayPort ?? 0,
|
||||
gatewayHost: process.env.CMPP_PUBLIC_HOST?.trim() || '127.0.0.1',
|
||||
gatewayPort: getPositiveIntegerEnv('CMPP_PUBLIC_PORT', 17890),
|
||||
enterpriseCode: application.cmppEnterpriseCode,
|
||||
account: application.cmppAccount,
|
||||
passwordCipher: application.secretHash,
|
||||
@@ -648,7 +653,7 @@ export class SmsConfigService {
|
||||
async recordDownstreamConnectionEvent(data: GatewayDownstreamConnectionEventDto) {
|
||||
const application = await this.prisma.smsApplication.findUnique({
|
||||
where: { cmppAccount: data.account },
|
||||
select: { id: true, tenantId: true, cmppEnterpriseCode: true },
|
||||
include: { ipAllowlist: true },
|
||||
});
|
||||
if (!application) {
|
||||
throw new BadRequestException('CMPP account does not reference an application');
|
||||
@@ -670,6 +675,22 @@ export class SmsConfigService {
|
||||
});
|
||||
return { connectionId: data.connectionId, status: 'disconnected', deleted: Boolean(existing) };
|
||||
}
|
||||
if (!application.interfaceEnabled || application.status !== 'active') {
|
||||
throw new ForbiddenException('CMPP interface is disabled for this application');
|
||||
}
|
||||
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
|
||||
throw new ForbiddenException('CMPP source IP is not in application allowlist');
|
||||
}
|
||||
const activeConnections = await this.prisma.cmppDownstreamConnection.findMany({
|
||||
where: { applicationId: application.id, status: 'connected' },
|
||||
select: { connectionId: true },
|
||||
orderBy: [{ connectedAt: 'asc' }, { connectionId: 'asc' }],
|
||||
});
|
||||
const allowedConnectionIds = activeConnections.slice(0, application.cmppMaxConnections).map((item) => item.connectionId);
|
||||
if ((!existing && activeConnections.length >= application.cmppMaxConnections)
|
||||
|| (existing && activeConnections.length > application.cmppMaxConnections && !allowedConnectionIds.includes(data.connectionId))) {
|
||||
throw new ForbiddenException(`CMPP connection limit exceeded (${application.cmppMaxConnections})`);
|
||||
}
|
||||
const payload = {
|
||||
tenantId: application.tenantId,
|
||||
applicationId: application.id,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { moneyToNumber } from '../common/money';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
export interface CreateTenantDto {
|
||||
@@ -64,8 +65,8 @@ export class TenantsService {
|
||||
}),
|
||||
]);
|
||||
const accountsByTenant = new Map(accounts.map((account) => [account.tenantId, account]));
|
||||
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
|
||||
const todayRefundByTenant = new Map(todayRefundGroups.map((group) => [group.tenantId, group._sum.amountCents ?? 0]));
|
||||
const todaySpendByTenant = new Map(todaySpendGroups.map((group) => [group.tenantId, moneyToNumber(group._sum.amountCents)]));
|
||||
const todayRefundByTenant = new Map(todayRefundGroups.map((group) => [group.tenantId, moneyToNumber(group._sum.amountCents)]));
|
||||
return tenants.map((tenant) => ({
|
||||
...withEnterpriseProfile(tenant),
|
||||
account: accountsByTenant.get(tenant.id) ?? null,
|
||||
|
||||
Reference in New Issue
Block a user