diff --git a/api/prisma/migrations/20260716150000_expand_money_precision_to_four_decimals/migration.sql b/api/prisma/migrations/20260716150000_expand_money_precision_to_four_decimals/migration.sql new file mode 100644 index 0000000..765a11f --- /dev/null +++ b/api/prisma/migrations/20260716150000_expand_money_precision_to_four_decimals/migration.sql @@ -0,0 +1,41 @@ +-- Monetary values were historically stored as integer cents. The platform now +-- stores integer units of 0.0001 yuan. Convert existing values by 100 while +-- widening columns to bigint so balances and report aggregates do not inherit +-- PostgreSQL integer's ~214,748 yuan ceiling at the new scale. + +ALTER TABLE "TenantAccount" + ALTER COLUMN "balanceCents" TYPE BIGINT USING ("balanceCents"::BIGINT * 100), + ALTER COLUMN "creditCents" TYPE BIGINT USING ("creditCents"::BIGINT * 100); + +ALTER TABLE "AccountTransaction" + ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100), + ALTER COLUMN "balanceAfter" TYPE BIGINT USING ("balanceAfter"::BIGINT * 100); + +ALTER TABLE "BillingRule" + ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100); + +ALTER TABLE "RechargeOrder" + ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100); + +ALTER TABLE "SmsBillingRecord" + ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100), + ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100); + +ALTER TABLE "SmsApplication" + ALTER COLUMN "customerUnitPrice" TYPE BIGINT USING ("customerUnitPrice"::BIGINT * 100); + +ALTER TABLE "SmsChannel" + ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100); + +ALTER TABLE "SmsMessageRecord" + ALTER COLUMN "unitPrice" TYPE BIGINT USING ("unitPrice"::BIGINT * 100), + ALTER COLUMN "amountCents" TYPE BIGINT USING ("amountCents"::BIGINT * 100); + +ALTER TABLE "SmsSubmitRecord" + ALTER COLUMN "costUnitPrice" TYPE BIGINT USING ("costUnitPrice"::BIGINT * 100), + ALTER COLUMN "costAmountCents" TYPE BIGINT USING ("costAmountCents"::BIGINT * 100); + +ALTER TABLE "DailyProfitReport" + ALTER COLUMN "revenueCents" TYPE BIGINT USING ("revenueCents"::BIGINT * 100), + ALTER COLUMN "costCents" TYPE BIGINT USING ("costCents"::BIGINT * 100), + ALTER COLUMN "profitCents" TYPE BIGINT USING ("profitCents"::BIGINT * 100); diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 22ebc5b..a17def4 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -269,8 +269,8 @@ model DrainageField { model TenantAccount { id String @id @default(cuid()) tenantId String - balanceCents Int @default(0) - creditCents Int @default(0) + balanceCents BigInt @default(0) + creditCents BigInt @default(0) status String @default("active") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -284,8 +284,8 @@ model AccountTransaction { id String @id @default(cuid()) tenantId String transactionType String - amountCents Int @default(0) - balanceAfter Int @default(0) + amountCents BigInt @default(0) + balanceAfter BigInt @default(0) relatedType String? relatedId String? remark String? @@ -302,7 +302,7 @@ model BillingRule { code String @unique name String chargeBasis String @default("submit_success") - unitPrice Int + unitPrice BigInt status String @default("active") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -312,7 +312,7 @@ model RechargeOrder { id String @id @default(cuid()) tenantId String orderNo String @unique - amountCents Int + amountCents BigInt status String @default("created") payMethod String? paidAt DateTime? @@ -335,8 +335,8 @@ model SmsBillingRecord { phoneNumber String? contentLength Int billingUnits Int - unitPrice Int - amountCents Int + unitPrice BigInt + amountCents BigInt billingStatus String @default("estimated") transactionId String? createdAt DateTime @default(now()) @@ -367,7 +367,7 @@ model SmsApplication { cmppMaxConnections Int @default(1) cmppWindowSize Int @default(16) dailyLimit Int? - customerUnitPrice Int @default(0) + customerUnitPrice BigInt @default(0) queuePriority String @default("normal") maxPhonesPerTask Int @default(1000000) templateMismatchMode String @default("reject") @@ -730,7 +730,7 @@ model SmsChannel { srcId String cmppVersion String @default("2.0") rateLimitPerSecond Int @default(100) - unitPrice Int @default(0) + unitPrice BigInt @default(0) status String @default("active") config Json? createdAt DateTime @default(now()) @@ -1317,8 +1317,8 @@ model SmsMessageRecord { province String? content String billingUnits Int @default(1) - unitPrice Int @default(0) - amountCents Int @default(0) + unitPrice BigInt @default(0) + amountCents BigInt @default(0) queuePriority String @default("normal") channelId String? submitId String? @@ -1389,8 +1389,8 @@ model SmsSubmitRecord { sequenceId Int? gatewayMessageId String? submitStatus String @default("queued") - costUnitPrice Int @default(0) - costAmountCents Int @default(0) + costUnitPrice BigInt @default(0) + costAmountCents BigInt @default(0) errorCode String? errorMessage String? submittedAt DateTime? @@ -1439,9 +1439,9 @@ model DailyProfitReport { channelId String? sentUnits Int @default(0) successUnits Int @default(0) - revenueCents Int @default(0) - costCents Int @default(0) - profitCents Int @default(0) + revenueCents BigInt @default(0) + costCents BigInt @default(0) + profitCents BigInt @default(0) profitRateBps Int @default(0) generatedAt DateTime @default(now()) updatedAt DateTime @updatedAt diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index d313cb4..0cdfb11 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -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', diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index be4c9c5..2a0bef8 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -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) { diff --git a/api/src/channels/channels.service.ts b/api/src/channels/channels.service.ts index 3b22eb2..24acfa0 100644 --- a/api/src/channels/channels.service.ts +++ b/api/src/channels/channels.service.ts @@ -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({ diff --git a/api/src/common/ip-allowlist.ts b/api/src/common/ip-allowlist.ts new file mode 100644 index 0000000..65db8f4 --- /dev/null +++ b/api/src/common/ip-allowlist.ts @@ -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); +} diff --git a/api/src/common/money.ts b/api/src/common/money.ts new file mode 100644 index 0000000..3a85c2f --- /dev/null +++ b/api/src/common/money.ts @@ -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`); + } +} diff --git a/api/src/main.ts b/api/src/main.ts index fe0aeb2..c63d7cb 100644 --- a/api/src/main.ts +++ b/api/src/main.ts @@ -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'); diff --git a/api/src/operations/operations.service.ts b/api/src/operations/operations.service.ts index 3135743..1c00d8a 100644 --- a/api/src/operations/operations.service.ts +++ b/api/src/operations/operations.service.ts @@ -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; diff --git a/api/src/reports/reports.service.ts b/api/src/reports/reports.service.ts index 4d940cd..fe642b6 100644 --- a/api/src/reports/reports.service.ts +++ b/api/src/reports/reports.service.ts @@ -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, diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index 4863329..eba03fd 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -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', })); }); diff --git a/api/src/send-chain/send-chain.service.ts b/api/src/send-chain/send-chain.service.ts index 7b475fb..0b3cd38 100644 --- a/api/src/send-chain/send-chain.service.ts +++ b/api/src/send-chain/send-chain.service.ts @@ -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 { @@ -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); -} diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index 96e875b..b0bc6ef 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -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'], diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index a7e48e9..110968a 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -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, diff --git a/api/src/tenants/tenants.service.ts b/api/src/tenants/tenants.service.ts index f493c90..20f3d8a 100644 --- a/api/src/tenants/tenants.service.ts +++ b/api/src/tenants/tenants.service.ts @@ -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, diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 0864de6..b5ac177 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -125,7 +125,7 @@ 11. 应用 `AppID` 是平台内部应用标识,用于页面展示、复制参数和工单定位,不作为 CMPP bind/login 认证参数。 12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections`;客户侧提交窗口 `cmppWindowSize` 后端保留默认值,当前第一版不在运营端展示或要求运营配置,待 Gateway 入站侧按应用窗口真正限流后再开放为高级配置。 13. 短信应用必须恢复设计基线中的“短信接口”开关,字段为 `interfaceEnabled`,默认开通;关闭后客户端/API 发送链路、客户侧 CMPP Gateway bind/login 和 submit 都必须被真实后端拒绝,不允许只在前端隐藏入口。 -14. 短信应用必须恢复设计基线中的“接口类型”配置,当前第一版仅允许 `CMPP2.0`,字段为 `interfaceType=cmpp20`;HTTP 接口在页面中展示为暂不可选,后端也必须拒绝 `http` 等未实现类型。 +14. CMPP 协议类型当前第一版仅允许 `CMPP2.0`,字段保持 `interfaceType=cmpp20`;HTTP 不写入该字段,而是通过独立的 `SmsApplicationHttpConfig` 总开关和子能力配置开通。前后端仍必须拒绝把 `interfaceType` 直接改成 `http` 等无效协议值。 ### 4.3 签名与引流信息 @@ -359,7 +359,7 @@ 6. 最终失败、超时失败需要退费。 7. 三网通道成本只用于平台内部成本核算,不影响客户扣费金额。 8. 当前版本计费口径固定为提交 accepted 扣费、最终 failed receipt/timeout 退款。 -9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留三位小数;内部仍使用分或最小计费单位持久化,不以展示精度改变账务计算。 +9. 所有面向用户展示的金额、余额、充值金额和单价统一以人民币元展示并固定保留四位小数;内部使用 `0.0001 元`整数金额单位持久化,不以浮点数执行账务计算。 10. API 必须定时扫描提交成功但超过 72 小时仍未收到明确最终回执的短信,转为 timeout 并退还已扣金额;扫描需覆盖 `submitted` 和 `unknown`,且用条件更新避免多实例重复退款。 ## 5. 功能需求 @@ -546,7 +546,7 @@ - 利润报表按发送日期汇总日发送条数、成功条数、消费金额、成本金额、利润和利润率,支持在“企业应用”和“通道”两个统计维度间切换。 - 企业应用维度的消费金额只统计仍为 `charged` 的客户账单,最终失败并退款的短信不再形成收入;成本金额统计该应用短信所有上游 `accepted` 提交的通道成本,包括补发产生的真实额外成本。 - 通道维度按实际上游 `accepted` 提交统计发送量和成本,按同一 Gateway 消息回执统计成功量;客户收入只归属最终有效提交,避免补发时重复计算收入。通道成本单价和成本金额必须在提交记录创建时快照,后续修改通道单价不得改写历史成本。 -- 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额继续使用整数分持久化并按三位小数展示。 +- 利润等于消费金额减成本金额;利润率等于利润除以消费金额,消费金额为 0 时利润率按 0 展示。所有金额使用 `0.0001 元`整数金额单位持久化并按四位小数展示。 - 报表按北京时间 T+1 生成,不生成当天未完整数据;每日刷新时必须在同一事务内重新生成 T-4 至 T-1 四个完整自然日,使 72 小时内到达或变化的回执能够修正发送成功和利润结果。 - API 启动后自动补生成最近四个完整自然日,并按日执行滚动刷新;报表查询支持服务端日期、企业、应用、通道和维度过滤及分页。 - “报表对账”增加“发送质量报表”,包含企业应用、通道、签名、引流信息四个 Tab。每个 Tab 按发送日期和对应维度展示发送条数、成功条数、成功率、平均到达时长,并默认按发送条数从大到小排序。 @@ -1495,6 +1495,7 @@ ### 管理端企业应用配置 - CMPP 与 HTTP 是两套可独立开通的接入能力,不再把 HTTP 作为 `interfaceType` 的互斥选项。运营端在企业应用“接口配置”中维护 HTTP 总开关,以及单条发送、短信状态查询、回执 Webhook、上行 Webhook、上行查询、客户端凭据自助管理等子能力。 +- 企业应用新增/编辑页必须将 CMPP 与 HTTP 配置拆成两个视觉和语义独立的区域:CMPP 区只放协议、账号、扩展码、客户接入号、接口密码、连接数、CMPP 白名单及下游重试;HTTP 区只放 HTTP 子能力、HTTP 白名单、QPS、凭据限制、投递模式和 Webhook 策略。协议关闭时收起该协议参数,只保留独立开关和关闭说明,不得再把两套字段混排在同一表单网格中。 - HTTP 配置独立维护 IP/CIDR 白名单、应用级 QPS、签名时间容差、最多有效凭据数、上行保留/查询范围/分页上限、Webhook 超时和最多尝试次数、生产 HTTPS 约束、客户手工重投权限。 - 回执和上行分别配置 `cmpp/http/both/none` 投递模式。Gateway 产生的回执或上行必须先写入现有真实短信记录,再按模式投递;HTTP 回调不得取代或伪造 Gateway、回执匹配和上行认领链路。 - HTTP 访问密钥和 Webhook 签名密钥使用 `HTTP_API_MASTER_KEY` 派生的 AES-256-GCM 密钥加密保存。Secret 只在创建或轮换当次返回,后续运营端和客户端仅显示末四位;允许同时保留多个有效凭据以完成无停机轮换。 @@ -1515,3 +1516,30 @@ - Webhook 禁止重定向,并在保存和每次投递前解析域名,拒绝环回、私网、链路本地、共享地址和元数据地址。2xx 成功;网络错误、408、429、5xx 可按立即、1 分钟、5 分钟、15 分钟、1 小时、6 小时、24 小时重试;其他 4xx 直接终结。 - PostgreSQL 分别保存 Webhook 事件、投递状态和每次尝试摘要;客户和运营人员可查询,授权后可手工重投。首次投递与重试均由 BullMQ 执行,不得使用浏览器定时器或 localStorage 冒充。 - 客户端“短信基础配置”新增“接口对接”,包含接口概览、访问凭据、回调配置、接口文档、调用与回调记录五个页签;企业应用卡片显示 HTTP 开通状态并跳转。客户端上行列表改为真实服务端条件查询,不再先拉全量数据后仅在浏览器过滤。 + +## 2026-07-16 运营端与客户端移动端适配要求 + +1. 运营端和客户端在宽度不大于 780px 的小屏设备上统一使用顶部栏加左侧抽屉导航。抽屉默认关闭,由顶部菜单按钮打开,支持遮罩、关闭按钮、Esc 和选择菜单后关闭;菜单内容在抽屉内部独立滚动,业务内容不得被完整侧栏挤到页面下方。 +2. 320px、360px、375px、390px 和 768px 常见视口不得出现页面级横向滚动。登录面板、筛选条件、表单、统计卡、操作区和弹窗必须限制在可用宽度内,桌面端既有可折叠侧栏行为保持不变。 +3. 通用数据表格在小屏下改为带字段名称的纵向记录卡片,操作按钮允许换行;不得要求用户横向滚动才能看到状态、失败原因或操作。业务专用的签名、引流、通道报备列表也必须按同一原则重排。 +4. 多列查询条件和报表筛选在小屏下收敛为单列;相关查询、重置和导出按钮保持可见并可换行。通道组配置、手机号段库、HTTP 接口凭据、企业签名报备目标等固定宽度区域必须取消页面级最小宽度。 +5. 移动端顶部栏至少保留导航入口、平台标识、通知和用户菜单;交互控件应具备可读的无障碍名称,抽屉打开状态使用 `aria-expanded` 表达,并尊重系统“减少动态效果”设置。 +6. 客户端彩信签名、彩信模板、彩信发送、彩信任务、彩信详情和上行彩信均未完成真实后端闭环,在功能完成前不得展示“彩信服务”菜单或其子菜单;保留内部路由不代表可向客户开放。 +7. 运营端手机号段库使用平台通用 Breadcrumb、Button、Input、Tabs、Table、Tag、Pagination 和 Modal 实现。Tab 位于标题下方和筛选条件上方;当前 Tab 仅显示自身的真实总数。统计使用紧凑信息带,手机号段突出显示、运营商使用语义标签、删除使用克制的危险操作样式,不得另造一套组件或用大面积统计卡挤压表格。 + +## 2026-07-16 全平台金额精度要求 + +1. 企业应用客户单价、通道成本单价、账户余额、授信额度、充值、消费、返还、短信计费金额以及对账和利润报表中的全部金额,统一精确到人民币小数点后 4 位;输入最多允许 4 位小数,页面及导出文件统一展示 4 位小数。 +2. 数据库和计费链路继续使用整数运算,最小金额单位统一为 `0.0001 元`,即 `1 元 = 10000 金额单位`。历史字段名中的 `Cents` 为兼容既有 API 暂不改名,但其数值语义同步调整为金额单位,不再表示人民币“分”。 +3. PostgreSQL 金额列统一升级为 `BIGINT`。上线迁移时既有按分保存的数据乘以 100,应用换算除数由 100 改为 10000,确保迁移前后实际人民币金额完全一致。 +4. 企业应用单价修改必须写入真实 `SmsApplication.customerUnitPrice`,例如 `0.0325 元/条` 保存为 `325`;后续预估、冻结、扣费、返还和利润统计均使用该整数值,不得在前端或后端再次四舍五入到分。 +5. API 返回 `BIGINT` 金额时仅在 JavaScript 安全整数范围内转换为 JSON number;超过安全整数范围必须显式报错,避免静默丢失金额精度。 + +## 2026-07-16 企业应用接口参数复制与下游接入约束 + +1. 运营端企业应用列表同时提供 CMPP 参数和 HTTP 参数复制;客户端应用列表提供 CMPP 参数复制,客户端“接口对接”页提供 HTTP 参数复制。复制内容必须来自真实应用和 HTTP 配置 API,不得用静态数组、localStorage 或页面默认值冒充。 +2. 客户端仅在应用已开通对应协议时允许复制参数。未开通 CMPP 时按钮不可操作,且客户端直接请求 CMPP 参数 API 必须返回 403;未开通 HTTP 时同样不得复制 HTTP 参数。 +3. 客户侧 CMPP 网关地址和端口是平台对外公布的下游接入地址,分别由 `CMPP_PUBLIC_HOST`、`CMPP_PUBLIC_PORT` 配置,不得读取任一上游短信通道的网关地址。生产默认值为 `8.160.169.106:17890`。 +4. 参数复制必须兼容平台当前 HTTP 页面:优先使用 Clipboard API;浏览器因非安全上下文或权限拒绝时,使用受控 textarea 复制降级,并向用户明确反馈成功或失败,不得无提示失败。 +5. `cmppMaxConnections` 必须在 Gateway 登录时按应用和活动 TCP 会话真实计数并限制,同时由 API 的连接事件校验兜底。连接关闭或异常断开后必须及时释放连接名额并回写断开事件。 +6. CMPP IP/CIDR 白名单必须在登录和连接事件中校验;运营端修改白名单、关闭接口、停用应用或降低最大连接数后,Gateway 应在下一次心跳校验时关闭不再符合条件的存量连接,不能只限制后续 Submit。 diff --git a/docs/production-deployment.md b/docs/production-deployment.md index d50a593..9f4b85e 100644 --- a/docs/production-deployment.md +++ b/docs/production-deployment.md @@ -50,6 +50,8 @@ REPORT_DAILY_REFRESH_ENABLED=true REPORT_REFRESH_INTERVAL_MS=3600000 CMPP_DOWNSTREAM_ACK_TIMEOUT_SECONDS=30 GATEWAY_CMPP_ADDR=0.0.0.0:17890 +CMPP_PUBLIC_HOST=8.160.169.106 +CMPP_PUBLIC_PORT=17890 GATEWAY_STARTUP_RECONNECT_DELAY_MS=1000 OBJECT_STORAGE_DRIVER=minio OBJECT_STORAGE_LOCAL_ROOT=/var/lib/cmpp-platform/object-storage diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index 54f7608..599e49f 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -639,7 +639,7 @@ - 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。 - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`interfaceEnabled`、`interfaceType=cmpp20`、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections` 和通道组绑定;企业应用表单不展示或提交客户侧 `cmppWindowSize`。 - “短信接口”开关刷新后仍来自真实数据库;关闭后该应用不能通过客户端/API 发送,也不能通过 Gateway bind/login 或 submit。 - - “接口类型”当前只能选择 CMPP2.0;HTTP 接口展示为暂不可选,手工提交 `interfaceType=http` 时后端返回 400。 + - CMPP 协议当前只能选择 CMPP2.0;HTTP 使用独立配置区、总开关和子能力,不与 CMPP `interfaceType` 互斥;手工提交 `interfaceType=http` 时后端仍返回 400。 - `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。 - `cmppEnterpriseCode` 来自应用自身配置,不透传上游通道企业代码;`passwordCipher` 为 16 位,编辑留空不覆盖原密码。 - 填充开关关闭时不渲染填充前缀输入框;开启后前缀才显示并可编辑。前后端只接受数字扩展码/前缀,客户侧接入号为二者拼接且不超过 21 位;关闭填充后前缀清空。 @@ -3378,6 +3378,20 @@ npm run verify:phase8 | TC-CMPP-STATUS-016 | 单连接限速 100,连接数 1 和 2 分别压测。 | 理论能力随在线连接数变化;实际 TPS 不超过限速;不重复发送。 | | TC-CMPP-STATUS-017 | 新建/启用通道后 Gateway 未回写,连接状态停留 connecting 超过 30 秒。 | API 兜底任务将连接标记为 failed,currentConnections=0,lastError 为 `Gateway connection request timed out after 30 seconds`;连接日志包含 connect_timeout;发送路由不可选择该通道。 | +### 17.8.1 运营端与客户端移动端适配 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-RESP-001 | 分别以运营管理员和企业管理员登录,在 390×844 视口打开首页。 | 左侧栏默认不占据业务内容空间;顶部显示菜单、平台标识、通知和用户入口;页面 `scrollWidth` 不大于视口宽度。 | +| TC-RESP-002 | 点击顶部菜单,滚动长菜单,随后点击任一业务菜单;重复打开后点击遮罩、关闭按钮和按 Esc。 | 抽屉覆盖在业务内容上方并带遮罩,菜单内部可滚动;四种关闭方式均有效,路由切换后抽屉自动收起。 | +| TC-RESP-003 | 在 320、360、375、390 和 768px 宽度打开两端登录页。 | 登录面板、账号、密码、验证码和登录按钮完整处于视口内,不出现页面级横向滚动。 | +| TC-RESP-004 | 在小屏打开客户端签名与引流信息、账户账单、短信记录和模板列表。 | 通用表格或业务列表以带字段标签的纵向卡片呈现,长文本可换行,状态和操作无需横向滚动即可查看。 | +| TC-RESP-005 | 在小屏打开运营端发送质量、对账、利润报表、企业查询、审核日志和手机号段库。 | 多列筛选收敛为单列或紧凑按钮组;查询、重置、导出均可操作;页面无固定宽度导致的横向溢出。 | +| TC-RESP-006 | 在小屏打开通道报备详情、通道组新增/编辑、企业签名管理和 HTTP 接口凭据。 | 报备行按卡片重排,通道组表单与报备目标单列显示,凭据和密钥可换行,操作区可换行且不超出视口。 | +| TC-RESP-007 | 在 1280px 及以上桌面视口复核运营端和客户端。 | 保持原桌面侧栏及折叠按钮;筛选和表格仍按桌面布局展示,不因移动端规则产生回归。 | +| TC-RESP-008 | 登录客户端,在桌面侧栏和移动端抽屉中检查全部菜单。 | 不展示“彩信服务”分组,也不展示彩信签名、模板、发送、任务、详情或上行彩信入口;短信和账户等已完成功能菜单正常显示。 | +| TC-RESP-009 | 在桌面和 390px 小屏打开手机号段库,切换“手机号段/运营商区分规则”,执行关键词查询和重置。 | 页面使用系统通用控件;Tab 位于标题与筛选之间;仅展示当前 Tab 的真实总数;表格/移动卡片无横向溢出,查询和重置继续调用真实 API 查询状态。 | + ### 17.9 自动化落地建议 | 层级 | 建议覆盖 | @@ -3476,9 +3490,26 @@ npm run verify:phase8 | 用例编号 | 操作 | 预期结果 | | --- | --- | --- | | TC-HTTP-CONFIG-001 | 运营端编辑企业应用,独立开关 CMPP 与 HTTP,并配置发送/查询/回调子能力、独立 IP 白名单、QPS、投递模式和 Webhook 策略,保存后刷新。 | 配置写入 `SmsApplicationHttpConfig` 和 HTTP 白名单表;CMPP 原配置不丢失;刷新一致;关闭某项能力后对应 OpenAPI 返回稳定 403 业务码。 | +| TC-HTTP-CONFIG-002 | 打开运营端企业应用新增/编辑页,分别开关 CMPP 与 HTTP,并在桌面及 390px 小屏检查两块配置。 | CMPP 与 HTTP 以两个独立区块展示;CMPP 区不出现 HTTP 参数,HTTP 区不出现 CMPP 账号、密码或白名单;关闭某协议后仅收起该协议参数且不影响另一协议区域;小屏无页面级横向滚动。 | | TC-HTTP-AUTH-001 | 使用正确 Access Key/Secret 按原始请求体签名,再分别修改 path、body、timestamp、nonce、来源 IP 和签名。 | 正确请求通过;篡改项返回 `application/problem+json`;过期时间、重复 nonce、白名单外 IP 和错误签名被拒绝;错误签名不得提前占用 nonce。 | | TC-HTTP-AUTH-002 | 同一应用一秒内并发调用超过配置 QPS,再在下一秒继续调用。 | Redis 应用级额度不被多个凭据放大;超额返回 429,下一秒恢复;不依赖单进程内存计数。 | | TC-HTTP-CREDENTIAL-001 | 客户创建第一把凭据、保存 Secret,再创建第二把完成切换并吊销第一把;刷新页面和查看数据库。 | Secret 仅创建当次可见,数据库为 AES-256-GCM 密文;列表只显示末四位;两把凭据轮换期可并存,吊销后旧凭据立即返回 401。 | + +### 17.11 全平台金额四位小数精度 + +| 用例编号 | 操作 | 预期结果 | +| --- | --- | --- | +| TC-MONEY-001 | 运营端编辑企业应用,将客户单价填写为 `0.0325` 并保存,刷新列表后再次进入编辑页。 | 保存调用真实 NestJS API;数据库 `SmsApplication.customerUnitPrice=325`;列表和编辑页均展示 `0.0325`,不被舍入为 `0.03`。 | +| TC-MONEY-002 | 使用单价 `0.0325` 的应用发送 2 个计费条数。 | 预估、冻结及最终计费金额均为 `650` 金额单位,即 `0.0650 元`;返还时按相同精度原额冲回。 | +| TC-MONEY-003 | 分别设置余额、授信、充值、今日消费和今日返还为含 4 位小数的金额,查看运营端企业列表、详情、首页以及客户端首页和账单。 | 所有位置展示同一真实金额且固定为 4 位小数,不使用浮点累计或仅保留到分。 | +| TC-MONEY-004 | 打开短信详单、对账单、利润报表并导出 CSV。 | 消费金额、成本金额和利润均按 4 位小数显示;CSV 表头以“元”为单位,值固定 4 位小数,汇总结果与数据库整数金额单位一致。 | +| TC-MONEY-005 | 在迁移前备份数据库并记录各金额列汇总,执行四位精度迁移后复核字段类型及汇总。 | 金额列升级为 `BIGINT`;迁移后整数汇总等于迁移前的 100 倍,按新除数换算后的人民币金额完全相等。 | +| TC-MONEY-006 | 在单价、授信和充值输入中分别填写超过 4 位小数、非法字符和超出 JavaScript 安全整数范围的值。 | 前后端拒绝无效值并返回可读错误;API 不静默舍入或输出已失真的金额。 | +| TC-IF-PARAM-001 | 运营端分别打开已开通 CMPP、HTTP 的企业应用参数弹窗并一键复制;模拟 Clipboard API 在 HTTP 页面被拒绝。 | CMPP 内容展示平台公网地址和端口而非上游通道地址;HTTP 内容包含应用、能力、QPS、白名单、投递模式和文档地址;降级复制成功且有明确提示。 | +| TC-IF-PARAM-002 | 客户端查看未开通 CMPP 的应用并直接调用该应用 CMPP 参数 API。 | 页面复制按钮禁用;真实 API 返回 403,不泄露账号、密码、接入号等参数。 | +| TC-IF-PARAM-003 | 客户端在已开通 HTTP 的应用“接口对接”页复制 HTTP 参数,再关闭 HTTP 后复测。 | 开通时复制真实 HTTP 配置;关闭时按钮不可用且不生成参数文本。 | +| TC-CMPP-DOWNSTREAM-001 | 将应用最大连接数设为 2,依次建立 3 条 CMPP TCP 连接,断开其中一条后再次连接。 | 前 2 条 bind 成功,第 3 条被拒绝;断开后名额立即释放,新连接可成功;连接记录与真实 TCP 会话一致。 | +| TC-CMPP-DOWNSTREAM-002 | 已建立连接后修改应用 IP/CIDR 白名单为不包含当前来源 IP,或将最大连接数降至当前连接数以下,等待下一次心跳。 | API 拒绝连接事件,Gateway 主动关闭不符合配置的存量连接并回写断开原因;连接数不继续显示为正常。 | | TC-HTTP-SEND-001 | 调用单条发送接口,使用真实已审核签名/模板、余额和通道路由。 | 返回 202 和平台 messageId;真实创建 API 来源批次与短信记录,执行风控、冻结/计费并进入 Redis/BullMQ/Gateway 链路;不得使用静态数组或直接伪造 delivered。 | | TC-HTTP-IDEMPOTENCY-001 | 并发使用相同 `Idempotency-Key` 和相同 body 调用,再用相同 key 改变 body;另重复 clientMessageId。 | 只创建一条真实短信;完成后同内容重放原响应,处理中返回 409 processing;不同 body 返回 409 conflict;应用内重复 clientMessageId 被拒绝。 | | TC-HTTP-QUERY-001 | 用本应用凭据按 messageId/clientMessageId 查询本应用和其他应用短信。 | 只返回当前应用短信状态、提交/回执时间和失败信息;其他应用记录统一 404,不泄露租户数据。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 68575d5..c7bad6e 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -1969,3 +1969,38 @@ git diff --check - 发布包本地与服务器 SHA-256 均为 `b49b51ef1acd2a0bb709ca91fbe0b98c16c80bc86f24b35b5dba141907cc7116`;生产原缺失的 `HTTP_API_MASTER_KEY` 已生成并以 `600` 权限保存,密钥内容未输出。migration `20260716110000_add_http_open_api` 已应用,52 条 migration 全部齐全且 schema 最新;生产运行提交为 `dcb6162dcf93a70e9886c978b07fdccee19dd657`。 - `cmpp-api`、`cmpp-gateway`、MinIO、Nginx、PostgreSQL 和 Redis 正常,`12026/17890/8090/3000/9000` 监听,API/Gateway health、Redis PONG、PostgreSQL readiness 和外部首页、运营端、客户端、客户 Swagger 文档均通过。两个 Redis 通道权威 TPS key 均恢复为真实值 100,`gateway.submit.commands` consumer group 为 `pending=0、lag=0`,数据库连接状态有 3 条 connected,部署后 10 分钟内 API/Gateway 无 error 级日志。 - 客户文档 JSON 仅包含约定的 4 个路径:单条发送、短信状态查询、上行列表和上行详情;未鉴权访问真实 HTTP 接口返回 401。Browser 插件不可用,使用现有 Playwright/Chromium 复核生产运营登录、客户端鉴权跳转和 Swagger 文档:页面非空、标题/表单/4 个接口正常,输入控件交互成功,无框架错误覆盖或 console error/warn;未重置账号、未绕过验证码、未发送真实短信。 + +## 2026-07-16 运营端与客户端 P1/P2 移动端适配(本地未提交) + +- AppShell 在不大于 780px 的视口改为 56px 顶部栏与覆盖式左侧抽屉,抽屉默认关闭,支持遮罩、关闭按钮、Esc 和路由切换后自动收起;运营端长菜单和客户端菜单在抽屉内部独立滚动,不再作为 260px 高的页面顶部区域挤压业务内容。桌面折叠侧栏保持原行为。 +- 修复两端登录面板宽度计算;运营端报表、企业查询和审核筛选、手机号段库、通道组、通道报备详情、企业签名报备目标、客户端 HTTP 凭据等多列/固定宽度区域在小屏下改为单列或可换行布局。 +- 通用 `Table` 为每个单元格输出字段标签,小屏统一转为纵向记录卡片;客户端签名和引流信息列表、通道报备业务列表补专用卡片规则。状态、失败原因、密钥和操作按钮无需依赖横向滚动查看。 +- 应用内浏览器使用本地真实 NestJS API/PostgreSQL 的既有客户端会话在 390×844 视口验证:关闭态 `document.scrollWidth=390`、侧栏完全移出视口;打开态抽屉约占 82% 宽度且菜单独立滚动;点击“签名与引流信息”后路由切换并自动关闭。签名列表由原 1050px 横向内容收敛为 343px 卡片,账户账单表格容器和表格均为 293px 且无内部横向滚动;客户端登录页正文和文档宽度均为 390px,登录面板边界为 16~374px。未注入 mock、未绕过认证、未写业务数据。 +- 前端 TypeScript/Vite 生产构建、API build 和 `git diff --check` 均已通过;前端仅有既有 Vite chunk size warning。桌面规则位于 780px 媒体查询之外,既有侧栏折叠和桌面表格结构保持不变;按要求暂不提交、不 push、不部署。 +- 客户端导航隐藏尚未完成真实后端闭环的整个“彩信服务”分组及六个彩信子菜单;内部占位路由暂时保留,避免把未完成能力暴露给客户。按本轮要求继续保持工作区未提交、未部署。 +- 运营端手机号段库按确认设计重做:复用平台通用 Breadcrumb、Button、Input、Tabs、Table、Tag、Pagination 和 Modal;标题区增加用途说明,Tab 下使用当前数据类型的紧凑真实总数信息带,搜索工具栏与数据表形成单一工作区,手机号段使用等宽强调,运营商使用语义标签,删除改为克制的文本危险操作。未增加批量导入、额外筛选或虚构覆盖统计。 +- 应用内浏览器使用真实本地运营管理员会话和 NestJS API 验收手机号段库:1536×1024 下页面与表格 `scrollWidth=clientWidth=1230`,1280px 下均为 959px,无横向滚动;390×844 下文档、面板、Tab、统计、筛选和表格容器均未超出 390px。已验证 Tab 切换同步“新增号段/新增规则”和当前统计,新增规则 Modal 可打开/关闭,关键词 `138` 查询后输入值保留、重置后清空,console 无 error/warn。设计图中的示例总数、31 省覆盖说明和示例行未照搬,页面只展示本地 PostgreSQL 的真实 0 条数据;分页和标题采用平台通用组件样式。 + +## 2026-07-16 企业应用 CMPP/HTTP 配置区域拆分(本地未提交) + +- 运营端企业应用新增/编辑页将原来混排的“接口配置”拆成独立 CMPP 接入配置和 HTTP 接口配置区块。CMPP 区集中账号、扩展码、接入号、密码、连接数、CMPP 白名单和下游重试;HTTP 区集中能力、HTTP 白名单、QPS、凭据限制、投递方式及 Webhook 安全重试,保存仍沿用现有真实 NestJS API 字段。 +- 两个协议使用独立开关和视觉标识;关闭时只收起本协议参数并展示关闭说明,不改变另一协议的开关或表单状态。HTTP 能力静态定义移出组件渲染,避免每次渲染重复创建配置数组。 +- Browser 插件不可用,使用已有 Playwright/Chromium、真实本地 NestJS API、PostgreSQL、Redis 和临时平台管理员完成 1440×1000 验收:HTTP 从关闭切换为开启后只展开 HTTP 参数;CMPP 关闭后账号等字段消失但 HTTP QPS 保持可见;页面标题、DOM、控制台均正常。390×844 复核 `body.scrollWidth=viewportWidth=390`,无页面级横向滚动。临时管理员、登录操作日志和会话均已清理,未创建企业应用、未发送短信。 +- 使用 Node.js 24 执行前端 TypeScript/Vite 生产构建通过,仅有既有 chunk size warning;默认 Node.js 14 不支持当前 Vite 的 `??=` 语法且会错误返回退出码 0,因此未将旧 Node 结果计为有效构建。按要求暂不提交、不 push、不部署。 + +## 2026-07-16 全平台金额四位小数精度(本地未提交) + +- 根因确认:企业应用编辑页原先按 `Math.round(元 × 100)` 保存,`0.0325 元`只能落为 3 分;PostgreSQL 的余额、流水、单价、计费和利润字段也均为 `Int` 分,无法表达万分之一元。现统一调整为 `1 元 = 10000 金额单位`,字段名中的 `Cents` 仅为兼容既有 API 保留。 +- Prisma 金额列统一升级为 `BigInt`,migration `20260716150000_expand_money_precision_to_four_decimals` 将历史整数分乘以 100。迁移前已备份本地真实 PostgreSQL;抽查 `AccountTransaction、SmsApplication、SmsMessageRecord、TenantAccount` 汇总,迁移后整数值均精确为迁移前 100 倍,按新除数换算后的人民币金额不变。53 条 migration 已全部应用,Prisma schema validate 和 migrate status 均通过。 +- 企业应用客户价、通道成本价、授信和人工充值输入均允许最多 4 位小数并转换为整数金额单位;运营端与客户端的余额、授信、今日消费、今日返还、充值、短信详单、客户价、通道价和利润报表统一固定展示 4 位小数。利润 CSV 改为以“元”为表头并导出 4 位小数。 +- NestJS 对客户价、通道价、充值、授信、计费规则和计费结果增加安全整数校验;Prisma `BigInt` 响应仅在 JavaScript 安全整数范围内序列化为 number,超限直接报错,避免静默精度损失。计费单测新增 `325 × 2 = 650` 金额单位,企业应用更新单测使用 `customerUnitPrice=325`。 +- 使用真实本地 NestJS API、PostgreSQL、Redis 和 Playwright/Chromium 编辑一条已配置通道组的企业应用:页面填写 `0.0325` 后保存,数据库核对 `SmsApplication.customerUnitPrice=325`,再次进入编辑页仍为 `0.0325`,控制台无 error;随后已恢复原单价并清理临时管理员、角色关联和操作日志。 +- API 全量 20 个 Jest 测试套件通过,其中金额与企业应用目标套件 45 条用例通过;API TypeScript build、前端 TypeScript/Vite 生产 build、Gateway `go test ./...`、Prisma validate/status 和 `git diff --check` 均通过。按要求暂不提交、不 push、不部署。 + +## 2026-07-16 企业应用参数复制与下游 CMPP 约束修复(本地未提交) + +- 根因确认:运营端 CMPP 参数 API 原先误取最新上游 `SmsChannel.gatewayHost/gatewayPort`,不是客户接入平台的地址;页面直接调用 `navigator.clipboard.writeText`,在生产 HTTP 非安全上下文可能无提示失败。现改为读取 `CMPP_PUBLIC_HOST/CMPP_PUBLIC_PORT`,并增加 Clipboard API 失败后的 textarea 降级复制和错误提示。 +- 运营端企业应用列表补充 HTTP 参数查看/复制;客户端接口对接页补充 HTTP 参数复制。客户端未开通 CMPP 时按钮禁用,客户端 CMPP 参数 API 同时返回 403,避免仅靠前端隐藏后仍可读取密码等接入参数。 +- Gateway 登录响应接入真实 `cmppMaxConnections`,按应用活动 TCP 会话计数并拒绝超限 bind;底层连接关闭回调会清理会话和回写断开。API 连接事件再次校验应用状态、接口开关、IP/CIDR 白名单和连接数,存量连接在下一次心跳不再符合配置时由 Gateway 主动关闭。 +- 生产只读核查发现应用 `715011` 最大连接数为 1,当前来源 IP 为 `183.194.97.158`,白名单后来改为 `2.2.2.2`;连接建立早于白名单修改,印证旧实现不会主动清理存量连接。核查期间未修改生产配置、数据库或进程。 +- API 目标测试 2 个套件 97 条以及全量 20 个套件 213 条断言通过,API TypeScript build、Gateway `go test ./...`、前端 TypeScript/Vite build 和部署脚本语法检查通过;全量 Jest 完成后仍提示既有异步句柄未关闭,断言结果不受影响,测试进程已单独结束。使用真实本地 NestJS API、PostgreSQL 和 Playwright/Chromium 验证运营端两类参数复制、客户端未开通 CMPP 的页面禁用与 API 403,以及 HTTP 页面复制降级;临时数据已清理。按要求暂不提交、不 push、不部署。 diff --git a/gateway/internal/inbound/server.go b/gateway/internal/inbound/server.go index 723b537..ad55b5a 100644 --- a/gateway/internal/inbound/server.go +++ b/gateway/internal/inbound/server.go @@ -64,6 +64,7 @@ type authResponse struct { TenantID string `json:"tenantId"` Account string `json:"account"` EnterpriseCode string `json:"enterpriseCode"` + MaxConnections int `json:"maxConnections"` } type DownstreamReceipt struct { @@ -179,7 +180,7 @@ func (s Server) ListenAndServe() error { s.logRecoveryCandidates(log.Default()) go s.recoverPendingCandidates(log.Default()) go s.runPendingFlusher(log.Default()) - return cmpp.ListenAndServe(addr, cmpp.V30, 30*time.Second, 3, s.LogWriter, + return cmpp.ListenAndServeWithClose(addr, cmpp.V30, 30*time.Second, 3, s.LogWriter, s.handleConnectionClosed, cmpp.HandlerFunc(s.handleLogin), cmpp.HandlerFunc(s.handleSubmit), cmpp.HandlerFunc(s.handleActivity), @@ -206,9 +207,8 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger setInboundConnectResponse(response.Packer, cmpp.ErrnoConnAuthFailed, req.AuthSrc, "", req.Version) return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnAuthFailed] } - setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version) now := time.Now().UTC() - session := downstreamSession{ + session := &downstreamSession{ account: strings.TrimSpace(defaultString(auth.Account, account)), enterpriseCode: strings.TrimSpace(auth.EnterpriseCode), protocol: cmppVersionName(req.Version), @@ -223,8 +223,13 @@ func (s Server) handleLogin(response *cmpp.Response, packet *cmpp.Packet, logger report: s.reportConnection, deliveryReport: s.reportDownstreamDelivery, } - rememberAccount(session) - go s.reportConnection(&session, "connected", "") + if !rememberAccount(session, auth.MaxConnections) { + logger.Printf("cmpp inbound auth failed account=%s remote=%s err=connection limit exceeded max=%d", account, packet.Conn.Conn.RemoteAddr(), auth.MaxConnections) + setInboundConnectResponse(response.Packer, cmpp.ErrnoConnOthers, req.AuthSrc, "", req.Version) + return false, cmpp.ConnRspStatusErrMap[cmpp.ErrnoConnOthers] + } + setInboundConnectResponse(response.Packer, 0, req.AuthSrc, auth.PasswordCipher, req.Version) + go s.reportConnectionOrDisconnect(session, "connected", "") response.AfterSend = func(sendErr error) { if sendErr == nil { go s.flushPending(defaultString(auth.Account, account), logger) @@ -353,7 +358,7 @@ func (s Server) handleActivity(_ *cmpp.Response, packet *cmpp.Packet, logger *lo switch response := packet.Packer.(type) { case *cmpp.CmppActiveTestReqPkt, *cmpp.CmppActiveTestRspPkt: if session.report != nil { - go session.report(session, "heartbeat", "") + go s.reportConnectionOrDisconnect(session, "heartbeat", "") } case *cmpp.Cmpp2DeliverRspPkt: handleDownstreamAcknowledgement(packet.Conn, response.SeqId, response.MsgId, uint32(response.Result), logger) @@ -364,8 +369,12 @@ func (s Server) handleActivity(_ *cmpp.Response, packet *cmpp.Packet, logger *lo } func (s Server) reportConnection(session *downstreamSession, status string, errorMessage string) { + _ = s.reportConnectionChecked(session, status, errorMessage) +} + +func (s Server) reportConnectionChecked(session *downstreamSession, status string, errorMessage string) error { if session == nil || strings.TrimSpace(session.account) == "" || strings.TrimSpace(session.connectionID) == "" { - return + return nil } event := downstreamConnectionEvent{ Account: session.account, ConnectionID: session.connectionID, Status: status, @@ -375,7 +384,28 @@ func (s Server) reportConnection(session *downstreamSession, status string, erro } if err := s.post(context.Background(), "/gateway/events/inbound/connection", event, nil); err != nil { log.Printf("cmpp inbound connection state callback failed account=%s connection_id=%s status=%s err=%v", session.account, session.connectionID, status, err) + return err } + return nil +} + +func (s Server) reportConnectionOrDisconnect(session *downstreamSession, status string, errorMessage string) { + if err := s.reportConnectionChecked(session, status, errorMessage); err != nil && status != "disconnected" { + _ = s.reportConnectionChecked(session, "disconnected", err.Error()) + forgetDownstream(session) + if session.conn != nil { + session.conn.Close() + } + } +} + +func (s Server) handleConnectionClosed(conn *cmpp.Conn) { + session := findSessionByConn(conn) + if session == nil { + return + } + forgetDownstream(session) + _ = s.reportConnectionChecked(session, "disconnected", "CMPP client connection closed") } func (s Server) reportDownstreamDelivery(event downstreamDeliveryLifecycleEvent) { @@ -683,15 +713,28 @@ func rememberDownstream(session downstreamSession) { downstreamRegistry.Unlock() } -func rememberAccount(session downstreamSession) { - if session.account == "" || session.conn == nil { - return +func rememberAccount(session *downstreamSession, maxConnections int) bool { + if session == nil || session.account == "" || session.conn == nil { + return false + } + if maxConnections <= 0 { + maxConnections = 1 } session.touchPresence("connected", false, false) downstreamRegistry.Lock() - downstreamRegistry.byAccount[session.account] = &session - downstreamRegistry.byConn[session.conn] = &session - downstreamRegistry.Unlock() + defer downstreamRegistry.Unlock() + active := 0 + for _, current := range downstreamRegistry.byConn { + if current != nil && current.account == session.account { + active++ + } + } + if active >= maxConnections { + return false + } + downstreamRegistry.byAccount[session.account] = session + downstreamRegistry.byConn[session.conn] = session + return true } func forgetDownstream(session *downstreamSession) { diff --git a/gateway/internal/inbound/server_test.go b/gateway/internal/inbound/server_test.go index 0e5f079..283f17a 100644 --- a/gateway/internal/inbound/server_test.go +++ b/gateway/internal/inbound/server_test.go @@ -307,9 +307,9 @@ func TestReceiptWithoutOriginalSequenceIsUnrecoverable(t *testing.T) { defer resetDownstreamRegistry() result, err := PushReceiptWithResult(DownstreamReceipt{ - DeliveryID: "delivery-history", - Account: "100001", - MessageID: "MSG-HISTORY", + DeliveryID: "delivery-history", + Account: "100001", + MessageID: "MSG-HISTORY", ReceiptStatus: "undelivered", }) if err != nil { @@ -325,11 +325,11 @@ func TestRecoverableReceiptWaitsForClientConnection(t *testing.T) { defer resetDownstreamRegistry() result, err := PushReceiptWithResult(DownstreamReceipt{ - DeliveryID: "delivery-retry", - Account: "100001", - MessageID: "MSG-RETRY", + DeliveryID: "delivery-retry", + Account: "100001", + MessageID: "MSG-RETRY", SubmitSequenceID: 77, - ReceiptStatus: "delivered", + ReceiptStatus: "delivered", }) if err != nil { t.Fatalf("push receipt: %v", err) @@ -659,7 +659,9 @@ func TestRememberAndForgetAccountUpdatesPresenceStore(t *testing.T) { instanceID: "gateway-a", } - rememberAccount(session) + if !rememberAccount(&session, 1) { + t.Fatal("expected account session to be accepted") + } snapshot, ok := store.snapshots["100001"] if !ok { t.Fatal("expected presence snapshot to be stored") @@ -674,6 +676,25 @@ func TestRememberAndForgetAccountUpdatesPresenceStore(t *testing.T) { } } +func TestRememberAccountEnforcesConfiguredConnectionLimit(t *testing.T) { + resetDownstreamRegistry() + defer resetDownstreamRegistry() + + first := &downstreamSession{account: "100001", connectionID: "conn-1", conn: &cmpp.Conn{}, mu: &sync.Mutex{}} + second := &downstreamSession{account: "100001", connectionID: "conn-2", conn: &cmpp.Conn{}, mu: &sync.Mutex{}} + third := &downstreamSession{account: "100001", connectionID: "conn-3", conn: &cmpp.Conn{}, mu: &sync.Mutex{}} + if !rememberAccount(first, 2) || !rememberAccount(second, 2) { + t.Fatal("expected first two sessions to fit maxConnections=2") + } + if rememberAccount(third, 2) { + t.Fatal("expected third session to be rejected by maxConnections=2") + } + forgetDownstream(first) + if !rememberAccount(third, 2) { + t.Fatal("expected a new session after a previous connection is released") + } +} + func TestDownstreamDeliveryRequiresAcknowledgement(t *testing.T) { resetDownstreamRegistry() defer resetDownstreamRegistry() @@ -702,9 +723,12 @@ func TestReceiptLookupDoesNotFallbackToAccountBeforeSubmitMappingExists(t *testi defer resetDownstreamRegistry() conn := &cmpp.Conn{} - rememberAccount(downstreamSession{ + session := &downstreamSession{ account: "100001", protocol: "cmpp20", conn: conn, mu: &sync.Mutex{}, connectionID: "conn-1", - }) + } + if !rememberAccount(session, 1) { + t.Fatal("expected account session to be accepted") + } if session := findReceiptSession("MSG-NOT-REMEMBERED", "100001"); session != nil { t.Fatalf("receipt unexpectedly fell back to account session: %+v", session) } diff --git a/gateway/third_party/gocmpp/server.go b/gateway/third_party/gocmpp/server.go index aa3d8d3..91fc732 100644 --- a/gateway/third_party/gocmpp/server.go +++ b/gateway/third_party/gocmpp/server.go @@ -79,6 +79,7 @@ type Server struct { // If nil, logging goes to os.Stderr via the log package's // standard logger. ErrorLog *log.Logger + OnClose func(*Conn) } // A conn represents the server side of a Cmpp connection. @@ -393,7 +394,12 @@ func (c *conn) serve() { } }() - defer c.close() + defer func() { + c.close() + if c.server.OnClose != nil { + c.server.OnClose(c.Conn) + } + }() // start a goroutine for sending active test. startActiveTest(c) @@ -468,6 +474,12 @@ func (srv *Server) listenAndServe() error { // ListenAndServe listens on the TCP network address addr // and then calls Serve with handler to handle requests. func ListenAndServe(addr string, typ Type, t time.Duration, n int32, logWriter io.Writer, handlers ...Handler) error { + return ListenAndServeWithClose(addr, typ, t, n, logWriter, nil, handlers...) +} + +// ListenAndServeWithClose behaves like ListenAndServe and invokes onClose once +// after an accepted client connection ends, including abrupt TCP disconnects. +func ListenAndServeWithClose(addr string, typ Type, t time.Duration, n int32, logWriter io.Writer, onClose func(*Conn), handlers ...Handler) error { if addr == "" { return ErrEmptyServerAddr } @@ -492,7 +504,7 @@ func ListenAndServe(addr string, typ Type, t time.Duration, n int32, logWriter i } server := &Server{Addr: addr, Handler: handler, Typ: typ, T: t, N: n, - ErrorLog: log.New(logWriter, "cmppserver: ", log.LstdFlags)} + ErrorLog: log.New(logWriter, "cmppserver: ", log.LstdFlags), OnClose: onClose} return server.listenAndServe() } diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 14ff92a..d730468 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -318,6 +318,7 @@ export type ClientSmsApplication = { sentToday?: number; deliveryRate?: number; cmppStatus?: 'connected' | 'degraded' | 'disconnected' | 'inactive'; + interfaceEnabled?: boolean | null; cmppConnections?: CmppDownstreamConnection[]; httpConfig?: HttpApiConfig | null; }; @@ -919,6 +920,7 @@ export type EnterpriseApplication = { cmppMaxConnections?: number | null; cmppWindowSize?: number | null; ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; + httpConfig?: HttpApiConfig | null; tenant?: TenantOption; sentToday?: number; deliveryRate?: number; diff --git a/src/apps/admin/AdminChannelsPage.tsx b/src/apps/admin/AdminChannelsPage.tsx index 8780fb0..f691178 100644 --- a/src/apps/admin/AdminChannelsPage.tsx +++ b/src/apps/admin/AdminChannelsPage.tsx @@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'; import { adminApi, type AdminChannel, type ChannelConnectionLogResponse, type ChannelTestResponse, type CmppConnectionState } from '@/api/adminApi'; import { Breadcrumb, Button, Input, Modal, Pagination, Select, Tag, Textarea } from '@/components/ui'; import { formatDateTime } from '@/utils/dateTime'; +import { formatCents, isValidMoneyInput, moneyUnitsToYuan, yuanToMoneyUnits } from '@/utils/currency'; type Carrier = 'mobile' | 'unicom' | 'telecom' | 'all'; type ChannelStatus = 'normal' | 'stopped' | 'connecting' | 'failed'; @@ -217,7 +218,8 @@ function ChannelFormModal({ const channel = modal.channel; const [name, setName] = useState(channel?.name ?? ''); const [carrier, setCarrier] = useState(channel?.carrier ?? 'mobile'); - const [unitPrice, setUnitPrice] = useState(channel ? String(channel.unitPrice / 100) : '0.0300'); + const [unitPrice, setUnitPrice] = useState(channel ? moneyUnitsToYuan(channel.unitPrice).toFixed(4) : '0.0300'); + const [unitPriceError, setUnitPriceError] = useState(''); const [region, setRegion] = useState(channel?.sendRegion ?? '全国'); const [protocol, setProtocol] = useState('CMPP'); const [gatewayHost, setGatewayHost] = useState(channel?.gatewayHost ?? ''); @@ -233,12 +235,16 @@ function ChannelFormModal({ const [windowSize, setWindowSize] = useState(String(channel?.windowSize ?? 16)); function submit() { + if (!isValidMoneyInput(unitPrice)) { + setUnitPriceError('单价必须是非负金额,且最多保留小数点后 4 位'); + return; + } onSubmit({ id: channel?.id ?? String(Math.floor(10000 + Math.random() * 80000)), name: name || '新建短信通道', carrier, sendRegion: region, - unitPrice: Number(unitPrice || 0) * 100, + unitPrice: yuanToMoneyUnits(unitPrice), status: channel?.status ?? 'connecting', total: channel?.total ?? 0, successRate: channel?.successRate ?? 0, @@ -288,7 +294,7 @@ function ChannelFormModal({ ))} - setUnitPrice(event.target.value)} value={unitPrice} /> + { setUnitPrice(event.target.value); setUnitPriceError(''); }} step="0.0001" type="number" value={unitPrice} /> - updateRechargeForm('amount', event.target.value)} prefix="¥" required type="number" value={rechargeForm.amount} /> + updateRechargeForm('amount', event.target.value)} prefix="¥" required step="0.0001" type="number" value={rechargeForm.amount} />