From 487b5282a66ec11957a3edf4e9e93cc70cd76342 Mon Sep 17 00:00:00 2001 From: hectorzhao Date: Fri, 21 Aug 2026 10:25:40 +0800 Subject: [PATCH] perf: batch paid CMPP accounting and weighted routes --- api/src/billing/billing.service.spec.ts | 31 +++++++- api/src/billing/billing.service.ts | 47 +++++++++++- api/src/send-chain/send-accounting.service.ts | 23 ++---- api/src/send-chain/send-chain.helpers.spec.ts | 14 ++++ api/src/send-chain/send-chain.helpers.ts | 27 ++++++- api/src/send-chain/send-chain.service.spec.ts | 7 +- .../send-chain/send-gateway-submit.service.ts | 1 + .../send-chain/send-inbound-entry.service.ts | 75 +++++++++++++++---- docs/codebase-modularization-roadmap.md | 7 ++ .../first-version-development-requirements.md | 7 ++ docs/system-functional-test-cases.md | 13 ++++ docs/testing-progress.md | 7 ++ 12 files changed, 219 insertions(+), 40 deletions(-) diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index 17702f5..e082d79 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -13,10 +13,12 @@ function createPrismaMock() { tenantAccount: { findMany: jest.fn(), findUnique: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), + findUniqueOrThrow: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), create: jest.fn(), upsert: jest.fn().mockImplementation(() => Promise.resolve({ ...accountState })), updateMany: jest.fn().mockImplementation(({ data }) => { if (data.balanceCents?.increment !== undefined) accountState.balanceCents += data.balanceCents.increment; + else if (data.balanceCents?.decrement !== undefined) accountState.balanceCents -= data.balanceCents.decrement; accountState.updatedAt = new Date(accountState.updatedAt.getTime() + 1); return Promise.resolve({ count: 1 }); }), @@ -31,7 +33,9 @@ function createPrismaMock() { findMany: jest.fn(), findFirst: jest.fn(), findUnique: jest.fn().mockResolvedValue(null), + findUniqueOrThrow: jest.fn().mockImplementation(({ where }) => Promise.resolve({ id: 'tx-charged', idempotencyKey: where.idempotencyKey })), create: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: `tx-${data.transactionType}`, ...data })), + createMany: jest.fn().mockResolvedValue({ count: 2 }), }, rechargeOrder: { findMany: jest.fn(), @@ -94,7 +98,7 @@ describe('BillingService', () => { ); }); - it('allows sending only when cash balance plus credit is greater than zero', async () => { + it('allows sending only when cash balance plus credit covers the required amount', async () => { const prisma = createPrismaMock(); const service = new BillingService(prisma as never); @@ -102,7 +106,7 @@ describe('BillingService', () => { expect.objectContaining({ availableAmount: 1000, canSend: true }), ); await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1001 })).resolves.toEqual( - expect.objectContaining({ availableAmount: 1000, canSend: true }), + expect.objectContaining({ availableAmount: 1000, canSend: false }), ); await service.updateCreditLimit('tenant-1', { creditCents: -1000, operatorId: 'admin-1' }); await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 1 })).resolves.toEqual( @@ -110,7 +114,7 @@ describe('BillingService', () => { ); await service.updateCreditLimit('tenant-1', { creditCents: 500, operatorId: 'admin-1' }); await expect(service.checkAccount({ tenantId: 'tenant-1', amountCents: 999999 })).resolves.toEqual( - expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: true }), + expect.objectContaining({ availableAmount: 1500, creditCents: 500, canSend: false }), ); await expect(service.updateCreditLimit('tenant-1', { creditCents: 1.5 })).rejects.toThrow('授信额度最多支持人民币小数点后 4 位'); expect(prisma.operationLog.create).toHaveBeenCalledWith({ @@ -312,6 +316,27 @@ describe('BillingService', () => { expect(prisma.accountState).toEqual(expect.objectContaining({ balanceCents: 890 })); }); + it('settles a frozen SMS charge with one account lock and an idempotent ledger pair', async () => { + const prisma = createPrismaMock(); + const rows = new Map>(); + prisma.accountTransaction.findUnique.mockImplementation(({ where }) => Promise.resolve(rows.get(where.idempotencyKey) ?? null)); + prisma.accountTransaction.findUniqueOrThrow.mockImplementation(({ where }) => Promise.resolve(rows.get(where.idempotencyKey))); + prisma.accountTransaction.createMany.mockImplementation(({ data }) => { + data.forEach((row: Record) => rows.set(String(row.idempotencyKey), { id: `tx-${row.transactionType}`, ...row })); + return Promise.resolve({ count: data.length }); + }); + const service = new BillingService(prisma as never); + const input = { tenantId: 'tenant-1', amountCents: 325, messageId: 'msg-paid-1', taskId: 'task-paid-1' }; + + const first = await service.settleFrozenCharge(input); + const replay = await service.settleFrozenCharge(input); + + expect(first).toEqual(expect.objectContaining({ transactionType: 'charged', amountCents: -325 })); + expect(replay).toEqual(first); + expect(prisma.accountTransaction.createMany).toHaveBeenCalledTimes(1); + expect(prisma.tenantAccount.update).not.toHaveBeenCalled(); + }); + it('serializes and replays concurrent refunds with one balance mutation', async () => { const prisma = createPrismaMock(); let transactionChain = Promise.resolve(undefined); diff --git a/api/src/billing/billing.service.ts b/api/src/billing/billing.service.ts index df3cf62..732c928 100644 --- a/api/src/billing/billing.service.ts +++ b/api/src/billing/billing.service.ts @@ -416,7 +416,7 @@ export class BillingService { availableAmount, balanceCents, creditCents, - canSend: availableAmount > 0, + canSend: availableAmount >= requiredAmount, }; } @@ -436,6 +436,51 @@ export class BillingService { }); } + async settleFrozenCharge(data: { tenantId: string; amountCents: number; messageId: string; taskId: string; remark?: string }) { + const amountCents = data.amountCents ?? 0; + if (amountCents <= 0) return null; + const releaseKey = `sms-charge-release:${data.messageId}`; + const chargeKey = `sms-charge:${data.messageId}`; + return this.prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + data.tenantId}, 0))`; + const [release, charge] = await Promise.all([ + tx.accountTransaction.findUnique({ where: { idempotencyKey: releaseKey } }), + tx.accountTransaction.findUnique({ where: { idempotencyKey: chargeKey } }), + ]); + if (charge) return charge; + const account = await tx.tenantAccount.findUniqueOrThrow({ where: { tenantId: data.tenantId } }); + const balance = moneyToNumber(account.balanceCents); + if (release) { + const updated = await tx.tenantAccount.update({ + where: { tenantId: data.tenantId }, + data: { balanceCents: { decrement: amountCents } }, + }); + return tx.accountTransaction.create({ + data: { + tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey, + amountCents: -amountCents, balanceAfter: updated.balanceCents, + relatedType: 'sms_message_record', relatedId: data.messageId, remark: '提交成功扣费', + }, + }); + } + await tx.accountTransaction.createMany({ + data: [ + { + tenantId: data.tenantId, transactionType: 'released', idempotencyKey: releaseKey, + amountCents, balanceAfter: balance + amountCents, + relatedType: 'sms_batch_task', relatedId: data.taskId, remark: data.remark, + }, + { + tenantId: data.tenantId, transactionType: 'charged', idempotencyKey: chargeKey, + amountCents: -amountCents, balanceAfter: balance, + relatedType: 'sms_message_record', relatedId: data.messageId, remark: '提交成功扣费', + }, + ], + }); + return tx.accountTransaction.findUniqueOrThrow({ where: { idempotencyKey: chargeKey } }); + }, { isolationLevel: Prisma.TransactionIsolationLevel.ReadCommitted }); + } + release(data: BillingActionDto) { return this.applyAccountDelta({ ...data, diff --git a/api/src/send-chain/send-accounting.service.ts b/api/src/send-chain/send-accounting.service.ts index 7a55bf4..b71ad9a 100644 --- a/api/src/send-chain/send-accounting.service.ts +++ b/api/src/send-chain/send-accounting.service.ts @@ -44,24 +44,13 @@ export class SendAccountingService { if (exists?.billingStatus === 'charged') { return; } - if (amountCents > 0) { - await this.billing.release({ - tenantId: message.tenantId, - amountCents, - idempotencyKey: `sms-charge-release:${message.messageId}`, - relatedType: 'sms_batch_task', - relatedId: message.batchTaskId, - remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, - }); - } - const transaction = await this.billing.charge({ + const transaction = amountCents > 0 ? await this.billing.settleFrozenCharge({ tenantId: message.tenantId, amountCents, - idempotencyKey: `sms-charge:${message.messageId}`, - relatedType: 'sms_message_record', - relatedId: message.messageId, - remark: '提交成功扣费', - }); + taskId: message.batchTaskId, + messageId: message.messageId, + remark: `短信 ${message.messageId} 提交成功释放冻结并转扣费`, + }) : null; const data = { tenantId: message.tenantId, applicationId: message.applicationId ?? undefined, @@ -73,7 +62,7 @@ export class SendAccountingService { unitPrice, amountCents, billingStatus: 'charged', - transactionId: transaction.id, + transactionId: transaction?.id, }; if (exists) { await this.prisma.smsBillingRecord.update({ where: { id: exists.id }, data }); diff --git a/api/src/send-chain/send-chain.helpers.spec.ts b/api/src/send-chain/send-chain.helpers.spec.ts index b8eb1b3..2120785 100644 --- a/api/src/send-chain/send-chain.helpers.spec.ts +++ b/api/src/send-chain/send-chain.helpers.spec.ts @@ -49,6 +49,20 @@ describe('send-chain pure policies', () => { })).toBeUndefined(); }); + it('uses a stable weighted choice among equal-priority online primary channels', () => { + const items = [ + { channelId: 'primary-a', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } }, + { channelId: 'primary-b', carrier: 'mobile', province: null, priority: 1, weight: 1, isBackup: false, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } }, + { channelId: 'backup', carrier: 'mobile', province: null, priority: 2, weight: 100, isBackup: true, channel: { carrier: 'mobile', sendRegion: '全国', status: 'active', connectionStates: connected } }, + ]; + const selected = new Set(Array.from({ length: 100 }, (_, index) => selectChannelCandidate(items, { + carrier: 'mobile', routingKey: `message-${index}`, + excludedChannelIds: new Set(), approvedChannelIds: new Set(items.map((item) => item.channelId)), + })?.channelId)); + + expect(selected).toEqual(new Set(['primary-a', 'primary-b'])); + }); + it('keeps a segmented message non-terminal until all receipts arrive', () => { const result = aggregateReceiptSegmentState( [{ segmentTotal: 2, receiptStatus: 'delivered', deliveredAt: new Date('2026-07-31T00:00:00Z') }], diff --git a/api/src/send-chain/send-chain.helpers.ts b/api/src/send-chain/send-chain.helpers.ts index a55b278..08fdd91 100644 --- a/api/src/send-chain/send-chain.helpers.ts +++ b/api/src/send-chain/send-chain.helpers.ts @@ -447,6 +447,9 @@ export type ChannelCandidate = { channelId: string; carrier?: string | null; province?: string | null; + priority?: number | null; + weight?: number | null; + isBackup?: boolean | null; channel: { carrier?: string | null; carriers?: string[] | null; @@ -477,6 +480,7 @@ export function selectChannelCandidate( forceNational?: boolean; excludedChannelIds: ReadonlySet; approvedChannelIds: ReadonlySet; + routingKey?: string; }, ) { const eligible = items.filter((item) => @@ -489,7 +493,28 @@ export function selectChannelCandidate( ? [] : eligible.filter((item) => isProvinceChannel(item, options.province)); const nationalCandidates = eligible.filter((item) => isNationalChannel(item)); - return [...provinceCandidates, ...nationalCandidates].find((item) => isChannelSendAvailable(item.channel)); + const scope = provinceCandidates.some((item) => isChannelSendAvailable(item.channel)) + ? provinceCandidates + : nationalCandidates; + const available = scope.filter((item) => isChannelSendAvailable(item.channel)); + if (available.length === 0) return undefined; + const priority = Math.min(...available.map((item) => item.priority ?? 100)); + const priorityPool = available.filter((item) => (item.priority ?? 100) === priority); + const primaryPool = priorityPool.filter((item) => !item.isBackup); + const pool = primaryPool.length > 0 ? primaryPool : priorityPool; + if (pool.length === 1 || !options.routingKey) return pool[0]; + const totalWeight = pool.reduce((sum, item) => sum + Math.max(1, item.weight ?? 1), 0); + let hash = 2166136261; + for (const character of options.routingKey) { + hash ^= character.charCodeAt(0); + hash = Math.imul(hash, 16777619) >>> 0; + } + let slot = hash % totalWeight; + for (const item of pool) { + slot -= Math.max(1, item.weight ?? 1); + if (slot < 0) return item; + } + return pool[0]; } export type ReceiptSegmentAudit = { diff --git a/api/src/send-chain/send-chain.service.spec.ts b/api/src/send-chain/send-chain.service.spec.ts index ea5dfad..b37de80 100644 --- a/api/src/send-chain/send-chain.service.spec.ts +++ b/api/src/send-chain/send-chain.service.spec.ts @@ -387,6 +387,7 @@ function createService( freeze: jest.fn().mockResolvedValue({ id: 'tx-freeze' }), release: jest.fn().mockResolvedValue({ id: 'tx-release' }), charge: jest.fn().mockResolvedValue({ id: 'tx-charge' }), + settleFrozenCharge: jest.fn().mockResolvedValue({ id: 'tx-charge' }), refund: jest.fn().mockResolvedValue({ id: 'tx-refund' }), } as unknown as BillingService; const riskReview = { @@ -2373,8 +2374,7 @@ describe('SendChainService', () => { where: { id: 'record-1', status: { notIn: ['delivered', 'failed', 'unknown'] } }, data: expect.objectContaining({ gatewayMessageId: 'GW-1', status: 'submitted', submitStatus: 'accepted' }), }); - expect(billing.release).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'task-1' })); - expect(billing.charge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, relatedId: 'MSG-1' })); + expect(billing.settleFrozenCharge).toHaveBeenCalledWith(expect.objectContaining({ amountCents: 3, taskId: 'task-1', messageId: 'MSG-1' })); expect(prisma.smsBillingRecord.create).toHaveBeenCalledWith({ data: expect.objectContaining({ messageId: 'MSG-1', amountCents: 3, billingStatus: 'charged', transactionId: 'tx-charge' }), }); @@ -2466,8 +2466,7 @@ describe('SendChainService', () => { await service.handleSubmitResult(event); await service.handleSubmitResult(event); - expect(billing.charge).toHaveBeenCalledTimes(1); - expect(billing.release).toHaveBeenCalledTimes(1); + expect(billing.settleFrozenCharge).toHaveBeenCalledTimes(1); expect(prisma.smsSubmitRecord.updateMany).toHaveBeenCalledWith({ where: { id: 'submit-1', diff --git a/api/src/send-chain/send-gateway-submit.service.ts b/api/src/send-chain/send-gateway-submit.service.ts index 15acdf4..8f61918 100644 --- a/api/src/send-chain/send-gateway-submit.service.ts +++ b/api/src/send-chain/send-gateway-submit.service.ts @@ -349,6 +349,7 @@ async selectChannelForMessage( forceNational: options.forceNational, excludedChannelIds: excluded, approvedChannelIds, + routingKey: message.id, }); if (!selected) { throw new NotFoundException('无已报备通过且在线的可用通道'); diff --git a/api/src/send-chain/send-inbound-entry.service.ts b/api/src/send-chain/send-inbound-entry.service.ts index ec86441..3931ac9 100644 --- a/api/src/send-chain/send-inbound-entry.service.ts +++ b/api/src/send-chain/send-inbound-entry.service.ts @@ -1,4 +1,4 @@ -import { BadRequestException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; +import { BadRequestException, ConflictException, HttpException, HttpStatus, Logger, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; import { Queue, Worker } from 'bullmq'; import IORedis from 'ioredis'; @@ -1401,11 +1401,7 @@ startInboundWorkflowWorker() { || application.tenant.certificationStatus !== 'approved' || !/^1\d{10}$/.test(phoneNumber) || globalRejected.has(phoneNumber) - || enterpriseRejected.has(`${application.tenantId}:${application.id}:${phoneNumber}`) - // Paid messages retain the existing per-message account lock until the - // dedicated batch-ledger migration is introduced; never weaken billing - // correctness merely to increase the benchmark number. - || moneyToNumber(application.customerUnitPrice) !== 0) continue; + || enterpriseRejected.has(`${application.tenantId}:${application.id}:${phoneNumber}`)) continue; try { if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((allowlist) => allowlist.ipCidr))) continue; const clientSrcId = validateInboundApplicationSrcId(data.srcId, application); @@ -1536,9 +1532,63 @@ startInboundWorkflowWorker() { const messageRecordId = randomUUID(); const content = candidate.payload.data.content; const drainageDetection = await detectDrainageContent(this.prisma, content); - return { candidate, workflowDigest, taskId, messageRecordId, content, drainageDetection }; + const billing = this.billing.estimateSmsCost({ + tenantId: candidate.application.tenantId, + applicationId: candidate.application.id, + content, + phoneCount: 1, + unitPrice: moneyToNumber(candidate.application.customerUnitPrice), + }); + return { candidate, workflowDigest, taskId, messageRecordId, content, drainageDetection, billing }; })); await this.measureInboundStage('message_persist', () => this.prisma.$transaction(async (tx) => { + // Lock accounts in a stable order and reserve the whole tenant subtotal once. + // Per-message idempotency rows remain separate so retries, releases and charges + // keep the original accounting contract without one account update per Inbox row. + const tenantIds = [...new Set(prepared.map(({ candidate }) => candidate.application.tenantId))].sort(); + for (const tenantId of tenantIds) { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${'tenant-account:' + tenantId}, 0))`; + await tx.tenantAccount.upsert({ + where: { tenantId }, update: {}, + create: { tenantId, balanceCents: 0, creditCents: 0, status: 'active' }, + }); + const account = await tx.tenantAccount.findUniqueOrThrow({ where: { tenantId } }); + const paid = prepared.filter(({ candidate, billing }) => candidate.application.tenantId === tenantId && billing.amountCents > 0); + const totalAmount = paid.reduce((sum, entry) => sum + entry.billing.amountCents, 0); + if (totalAmount === 0) continue; + const available = moneyToNumber(account.balanceCents) + moneyToNumber(account.creditCents); + if (account.status !== 'active' || available < totalAmount) { + throw new BadRequestException('企业账户余额不足'); + } + const existing = await tx.accountTransaction.findMany({ + where: { idempotencyKey: { in: paid.map(({ candidate }) => `${candidate.item.requestKey}:freeze`) } }, + select: { idempotencyKey: true }, + }); + if (existing.length > 0) { + throw new ConflictException('批量计费幂等流水已存在,转入逐条恢复'); + } + const balanceBefore = moneyToNumber(account.balanceCents); + let reserved = 0; + await tx.accountTransaction.createMany({ + data: paid.map(({ candidate, taskId, billing }) => { + reserved += billing.amountCents; + return { + tenantId, + transactionType: 'frozen', + idempotencyKey: `${candidate.item.requestKey}:freeze`, + amountCents: -billing.amountCents, + balanceAfter: balanceBefore - reserved, + relatedType: 'sms_batch_task', + relatedId: taskId, + remark: 'CMPP 入站短信批量冻结', + }; + }), + }); + await tx.tenantAccount.update({ + where: { tenantId }, + data: { balanceCents: { decrement: totalAmount } }, + }); + } await tx.smsBatchTask.createMany({ data: prepared.map(({ candidate, workflowDigest, taskId, content }) => ({ id: taskId, @@ -1563,7 +1613,7 @@ startInboundWorkflowWorker() { })), }); await tx.smsMessageRecord.createMany({ - data: prepared.map(({ candidate, taskId, messageRecordId, content, drainageDetection }) => ({ + data: prepared.map(({ candidate, taskId, messageRecordId, content, drainageDetection, billing }) => ({ id: messageRecordId, tenantId: candidate.application.tenantId, batchTaskId: taskId, @@ -1575,12 +1625,9 @@ startInboundWorkflowWorker() { phoneNumber: candidate.phoneNumber, content, ...drainageDetection, - billingUnits: this.billing.estimateSmsCost({ - tenantId: candidate.application.tenantId, applicationId: candidate.application.id, - content, phoneCount: 1, unitPrice: 0, - }).billingUnitsPerMessage, - unitPrice: 0, - amountCents: 0, + billingUnits: billing.billingUnitsPerMessage, + unitPrice: billing.unitPrice, + amountCents: billing.amountCents, queuePriority: normalizeQueuePriority(candidate.application.queuePriority), cmppSubmitSequenceId: candidate.payload.data.sequenceId == null ? null : String(candidate.payload.data.sequenceId), cmppSubmitGroupMessageId: candidate.payload.submitGroupMessageId, diff --git a/docs/codebase-modularization-roadmap.md b/docs/codebase-modularization-roadmap.md index 3c08e77..c634ff5 100644 --- a/docs/codebase-modularization-roadmap.md +++ b/docs/codebase-modularization-roadmap.md @@ -1164,3 +1164,10 @@ global,不能错误归入client。`client-signature-*`、发送页、企业认 - 500条/秒第三阶段继续留在`send-inbound-entry`编排边界,但将批次只读风控快照下沉到`RiskReviewService.evaluateTasksBatch`,将频控原子批量预留下沉到`PhoneFrequencyService.reserveBatch`。Inbox编排层只负责批次分组、日限额锁顺序、正常三表批量持久化、幂等入队与逐条租约结算;付费及异常状态机仍委托原单条路径,避免形成第二套计费/审核领域模型。 - 批次锁顺序固定为:领取事务只锁Inbox后立即提交;日限额事务按applicationId排序锁`SmsApplicationDailyUsage`;频控按tenant/application分组且规则优先级顺序更新号码状态;三表事务不持有前两类锁,Redis/BullMQ发布永不位于数据库事务内。该顺序用于限制死锁面并允许单批失败后按每条稳定幂等键恢复。 - 容量参数继续归进程组装层:Inbox业务槽、BullMQ发送槽、Gateway供应商槽和结果Outbox槽分别有界、分别观测。代码模块不得假定测试环境参数就是生产默认值;生产调优必须重新基于PostgreSQL连接预算、六通道TPS/窗口和回调承载证据。 + +## 2026-08-21 第四阶段模块边界 + +- `send-inbound-entry`负责编排正价批次短事务;账户固定锁序、总额覆盖、逐短信冻结幂等键留在PostgreSQL一致性边界,不迁入Redis。 +- `billing.service`集中维护账户锁和流水,`settleFrozenCharge`把释放/扣费合并为一个事务;`send-accounting`只编排消息计费状态。 +- `send-chain.helpers`提供无副作用的优先级、主备和稳定weight选择;提交服务只提供消息稳定键与真实在线/报备候选,不保存进程内轮询状态。 +- Gateway既有Submit池与结果Outbox继续独立有界;先以正价六通道实测定位容量,只有证据证明单条回调仍触发停止线时才扩展批量回调协议。 diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index 346edfc..a0b5b31 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -2129,3 +2129,10 @@ - 正常单号码、零计费短短信允许走批量快路径:日限额按应用锁定并为每个Inbox请求写独立幂等预留,风控只共享只读输入,号码频控在同一事务批量更新状态并逐请求保存决定,任务/API请求/消息三表在一个短事务批量创建,BullMQ使用消息ID作为幂等Job ID批量入队。 - 付费短信、重复号码、黑名单/格式拒绝、模板人工审核、引流匹配歧义、既有消息恢复及其他异常分支必须回到原逐条状态机;不得为压测降低计费、风控、频控、模板、签名或失败回执规则。批量路径在数据库提交后、队列发布前崩溃时,逐条恢复必须利用稳定任务号、请求号、MessageId和预留键补齐,不得重复计量或创建消息。 - 批量Worker指标增加`worker_claim/reference_preload/daily_quota`固定低基数阶段,并继续记录`risk_frequency/message_persist/queue_publish`;配置必须显式启用批处理并使用正整数批次大小。验收仍以真实PostgreSQL、Redis、BullMQ和隔离供应商证据为准,不能用零计费压测结果外推付费链路吞吐。 + +## 完整处理500条/秒第四阶段:正价计费与供应商并行(2026-08-21) + +- 普通CMPP短短信批量路径必须支持正单价:按企业固定锁序一次校验批次总金额、一次更新余额,并为每短信写独立幂等冻结流水;任务、请求、消息与冻结流水同属一个短事务。余额加授信必须覆盖所需金额,不能只判断大于零。 +- 供应商接受后,释放冻结与正式扣费在一个账户锁事务内保留两条审计流水;零金额不创建AccountTransaction。拒绝、超时、重投和最终失败继续沿用逐短信幂等释放、扣费和退款。 +- 同通道组内在线、已报备、同地域、同最低优先级且非备用的通道按weight和稳定消息键分流;备用、离线及更低优先级不抢占。只有显式配置为同优先级非备用的通道才参与主动双活。 +- 压测使用隔离测试企业、真实PostgreSQL账务、单价`0.0325元/计费条`、三运营商混合号码和六个模拟供应商账号;逐档核对消息、冻结/释放/扣费/退款、SmsBillingRecord、通道分布、队列和数据库。临时单价与主动双活配置须先快照、测试后恢复。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index a0080a4..91bd650 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -4772,3 +4772,16 @@ npm run verify:phase8 | TC-CMPP-500-P2-006 | 100→200→500完整链阶梯 | 使用全新测试号段、隔离六通道模拟器,逐档注入并等待全部队列排空 | 每档报告入口实际速率/分位、Inbox完成、各队列峰值与排空、供应商阶段和数据库对账;500档只有入口及完整链均达到500条/秒且零丢重才通过 | 执行记录(2026-08-20,测试环境):P2-001至004由121项SendChain专项、真实PostgreSQL修复后低负载9/9及100条/秒2998/2998受理覆盖;首次真实执行发现并修复`jsonb_build_object`参数类型错误,该无效轮未写Inbox。P2-005使用API/Worker池48/32及96/96/128/32四类有界业务槽,数据库压后无idle-in-transaction。P2-006在100条/秒入口通过但完整链失败:Inbox约57.5条/秒、双Stream完整排空约22.0条/秒,且后台API回调超时;按停止线未继续200/500,因此第二阶段仍不通过完整500条/秒目标。 + +## TC-CMPP-500-P4 正价计费与六通道并行 + +| 用例 | 场景 | 预期 | +| --- | --- | --- | +| TC-CMPP-500-P4-001 | 同企业正价短短信批量入站并重放 | 一次账户余额更新;每短信唯一`:freeze`流水;任务/请求/消息金额正确;重放不重复冻结 | +| TC-CMPP-500-P4-002 | 余额加授信小于批次或单条金额 | 不得透支;批次安全回退且只发送余额覆盖的短信 | +| TC-CMPP-500-P4-003 | 正价短信Submit接受及Outbox重放 | 一个账户锁事务生成released/charged;余额净值不重复变化;SmsBillingRecord唯一charged | +| TC-CMPP-500-P4-004 | 零价短信Submit接受 | 保留业务结果但无0金额AccountTransaction | +| TC-CMPP-500-P4-005 | 两个同优先级非备用通道与一个备用通道 | 稳定weighted分流只覆盖两个主动通道;排除已尝试通道后可安全切换;备用不抢占 | +| TC-CMPP-500-P4-006 | 三运营商、六通道、325金额单位阶梯压测 | 每档入口/Inbox/供应商/回执/账务对账一致,双Stream和数据库最终稳定排空;失败即停止升档 | +| TC-CMPP-500-P4-007 | priority与normal并发积压 | priority保持明确服务能力且normal最终不饿死 | +| TC-CMPP-500-P4-008 | 测试配置治理 | 单价、账户与组项目先快照;临时主动双活和单价测试后恢复;预生产和凭据不变 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index fa3d0f1..a020a3f 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -3833,3 +3833,10 @@ git diff --check - 100档注入结束时命令Stream`pending=128/lag=922`、结果Stream`pending=32/lag=31`,数据库瞬时`idle in transaction=3/waiting active=12/not-granted locks=7`;约25秒后数据库恢复0/0/0、双Stream恢复0/0。自开始至最终排空约87秒,按2998条折算完整下游约34.5条/秒,低于100条/秒;最终消息delivered2770、failed64、submitted164,隔离供应商本档累计提交尝试3281、accepted3226、rejected55、无回执69,错误0。 - 因100档下游队列在负载期持续增长且完整链不足100条/秒,严格按停止线没有执行200/300/500档。第三阶段结论:入口100条/秒和业务Inbox接近实时排空通过、相较第二阶段约57.5条/秒显著改善,但业务Worker500条/秒与完整链500条/秒均未证明;第四阶段仍需供应商六通道真实并行、发送链/回调独立工作池后再升档。测试应用单价为0,本结果不得外推正单价批量计费吞吐;正单价继续走已回归的逐条原子账务路径。 - 全程仅使用`100.93.204.60`测试环境和`100.91.249.119:17900`隔离供应商模拟器,没有发送、补发或重投真实短信,没有修改通道账号、密码、启停状态、企业余额或客户连接。预生产只读标记保持`433b2ee5...+gateway-v2.cd7bb8d05e7b`,未发布、未回退、未压测;受保护的`tsbuildinfo`、`outputs/`、`pnpm-lock.yaml`和空文件`=`继续排除提交、不删除、不归因。 + +## 2026-08-21 完整处理500条/秒第四阶段(实施中) + +- 接管复核:本地`HEAD=0176aa6952f1e71a50a8f49eeaa64e307da02370`、`origin/main=c4f36fc50d7906dfb2f97c881e9ea43c6a64c370`;测试环境仍为`52028b9b...+workspace.p3.7c14cd5f197a`,预生产只读标记仍为`433b2ee5...+gateway-v2.cd7bb8d05e7b`。测试机六连接在线、双Stream 0/0、数据库无等待锁和idle in transaction。 +- 10个隔离应用仍为单价0、同企业余额1/授信0;移动主备各200条/秒,联通/电信主备各150条/秒,窗口32。组项目为主10/备用20,默认只能三主主动承载,六连接在线不等于六通道并行。 +- 已实现正价Worker批次、覆盖本次金额的余额判断、单事务释放/扣费、零价免账户流水,以及同最低优先级非备用通道的稳定weighted分流;默认主备语义不变。API正式编译和Billing/纯策略/SendChain三套143项通过。 +- 部署、恢复资产、测试充值、临时六通道主动双活、smoke及阶梯结果待补记;所有临时配置只允许在测试环境先快照后变更并在测试后恢复。