fix: correct application cmpp credentials

This commit is contained in:
hectorzhao
2026-07-09 17:18:39 +08:00
parent 57a4c86a79
commit 767f0ca9aa
13 changed files with 149 additions and 24 deletions
@@ -0,0 +1,9 @@
ALTER TABLE "SmsApplication" ADD COLUMN "cmppEnterpriseCode" TEXT;
UPDATE "SmsApplication" AS app
SET "cmppEnterpriseCode" = tenant."code",
"secretHash" = substring(md5(random()::text || clock_timestamp()::text || app."id") from 1 for 16)
FROM "Tenant" AS tenant
WHERE app."tenantId" = tenant."id";
ALTER TABLE "SmsApplication" ALTER COLUMN "cmppEnterpriseCode" SET NOT NULL;
+1
View File
@@ -342,6 +342,7 @@ model SmsApplication {
scene String? scene String?
callbackUrl String? callbackUrl String?
cmppAccount String @unique cmppAccount String @unique
cmppEnterpriseCode String
secretHash String secretHash String
cmppMaxConnections Int @default(1) cmppMaxConnections Int @default(1)
cmppWindowSize Int @default(16) cmppWindowSize Int @default(16)
+33
View File
@@ -143,6 +143,39 @@ describe('BillingService', () => {
}); });
}); });
it('allows negative manual recharge amounts for balance correction', async () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
const order = await service.createManualRecharge({
tenantId: 'tenant-1',
amountCents: -300,
smsUnits: 0,
operatorId: 'admin-1',
remark: '人工冲正',
});
expect(order).toEqual(expect.objectContaining({ amountCents: -300, payMethod: 'manual_topup', status: 'paid' }));
expect(prisma.tenantAccount.update).toHaveBeenCalledWith({
where: { tenantId: 'tenant-1' },
data: { balanceCents: 700, smsUnits: 20 },
});
expect(prisma.accountTransaction.create).toHaveBeenCalledWith({
data: expect.objectContaining({
transactionType: 'recharge',
amountCents: -300,
balanceAfter: 700,
relatedType: 'recharge_order',
}),
});
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
action: 'billing.manual_recharge',
detail: expect.objectContaining({ amountCents: -300, remark: '人工冲正' }),
}),
});
});
it('writes freeze, charge, release, refund, and adjustment transactions', async () => { it('writes freeze, charge, release, refund, and adjustment transactions', async () => {
const prisma = createPrismaMock(); const prisma = createPrismaMock();
const service = new BillingService(prisma as never); const service = new BillingService(prisma as never);
+12 -1
View File
@@ -9,6 +9,7 @@ function createPrismaMock() {
name: '应用A', name: '应用A',
status: 'active', status: 'active',
cmppAccount: '100001', cmppAccount: '100001',
cmppEnterpriseCode: 'APP-EC',
cmppMaxConnections: 2, cmppMaxConnections: 2,
cmppWindowSize: 32, cmppWindowSize: 32,
queuePriority: 'normal', queuePriority: 'normal',
@@ -21,10 +22,11 @@ function createPrismaMock() {
name: '应用A', name: '应用A',
status: 'active', status: 'active',
cmppAccount: '100001', cmppAccount: '100001',
cmppEnterpriseCode: 'APP-EC',
cmppMaxConnections: 2, cmppMaxConnections: 2,
cmppWindowSize: 32, cmppWindowSize: 32,
queuePriority: 'normal', queuePriority: 'normal',
secretHash: 'secret-hash', secretHash: '0123456789abcdef',
tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' },
}), }),
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
@@ -109,6 +111,9 @@ function createPrismaMock() {
operationLog: { operationLog: {
create: jest.fn(), create: jest.fn(),
}, },
tenant: {
findUnique: jest.fn().mockResolvedValue({ code: 'TENANT-A' }),
},
$transaction: jest.fn((callback) => callback({ $transaction: jest.fn((callback) => callback({
smsApplication: { smsApplication: {
update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })),
@@ -171,6 +176,8 @@ describe('SmsConfigService', () => {
applicationId: 'app-1', applicationId: 'app-1',
tenantName: '租户A', tenantName: '租户A',
account: '100001', account: '100001',
enterpriseCode: 'APP-EC',
passwordCipher: '0123456789abcdef',
gatewayHost: '127.0.0.1', gatewayHost: '127.0.0.1',
gatewayPort: 17890, gatewayPort: 17890,
maxConnections: 2, maxConnections: 2,
@@ -187,6 +194,8 @@ describe('SmsConfigService', () => {
tenantId: 'tenant-1', tenantId: 'tenant-1',
name: '优先应用', name: '优先应用',
cmppAccount: '123456', cmppAccount: '123456',
cmppEnterpriseCode: 'CUSTOM-EC',
passwordCipher: '1234567890abcdef',
cmppMaxConnections: 3, cmppMaxConnections: 3,
cmppWindowSize: 32, cmppWindowSize: 32,
queuePriority: 'priority', queuePriority: 'priority',
@@ -198,6 +207,8 @@ describe('SmsConfigService', () => {
tenantId: 'tenant-1', tenantId: 'tenant-1',
name: '优先应用', name: '优先应用',
cmppAccount: '123456', cmppAccount: '123456',
cmppEnterpriseCode: 'CUSTOM-EC',
secretHash: '1234567890abcdef',
cmppMaxConnections: 3, cmppMaxConnections: 3,
cmppWindowSize: 32, cmppWindowSize: 32,
queuePriority: 'priority', queuePriority: 'priority',
+50 -8
View File
@@ -1,6 +1,6 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client'; import { Prisma } from '@prisma/client';
import { randomBytes, randomInt, createHash } from 'node:crypto'; import { randomInt, randomUUID } from 'node:crypto';
import { PrismaService } from '../prisma/prisma.service'; import { PrismaService } from '../prisma/prisma.service';
export interface CreateSmsApplicationDto { export interface CreateSmsApplicationDto {
@@ -9,6 +9,8 @@ export interface CreateSmsApplicationDto {
scene?: string; scene?: string;
callbackUrl?: string; callbackUrl?: string;
cmppAccount?: string; cmppAccount?: string;
cmppEnterpriseCode?: string;
passwordCipher?: string;
cmppMaxConnections?: number; cmppMaxConnections?: number;
cmppWindowSize?: number; cmppWindowSize?: number;
dailyLimit?: number; dailyLimit?: number;
@@ -154,9 +156,10 @@ export class SmsConfigService {
} }
async createApplication(data: CreateSmsApplicationDto) { async createApplication(data: CreateSmsApplicationDto) {
const secret = randomBytes(24).toString('hex'); const secret = normalizeApplicationPassword(data.passwordCipher);
const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); const queuePriority = normalizeApplicationQueuePriority(data.queuePriority);
const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount(); const cmppAccount = data.cmppAccount ? await this.validateAndReserveCmppAccount(data.cmppAccount) : await this.generateCmppAccount();
const cmppEnterpriseCode = await this.resolveCmppEnterpriseCode(data.cmppEnterpriseCode, data.tenantId);
return this.prisma.smsApplication.create({ return this.prisma.smsApplication.create({
data: { data: {
tenantId: data.tenantId, tenantId: data.tenantId,
@@ -164,7 +167,8 @@ export class SmsConfigService {
scene: data.scene, scene: data.scene,
callbackUrl: data.callbackUrl, callbackUrl: data.callbackUrl,
cmppAccount, cmppAccount,
secretHash: hashSecret(secret), cmppEnterpriseCode,
secretHash: secret,
cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
dailyLimit: data.dailyLimit, dailyLimit: data.dailyLimit,
@@ -191,6 +195,12 @@ export class SmsConfigService {
const cmppAccount = data.cmppAccount === undefined const cmppAccount = data.cmppAccount === undefined
? undefined ? undefined
: await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId); : await this.validateAndReserveCmppAccount(data.cmppAccount, applicationId);
const cmppEnterpriseCode = data.cmppEnterpriseCode === undefined
? undefined
: normalizeEnterpriseCode(data.cmppEnterpriseCode);
const secretHash = data.passwordCipher === undefined
? undefined
: normalizeApplicationPassword(data.passwordCipher);
return this.prisma.$transaction(async (tx) => { return this.prisma.$transaction(async (tx) => {
if (data.ipAllowlist) { if (data.ipAllowlist) {
@@ -203,6 +213,8 @@ export class SmsConfigService {
scene: data.scene, scene: data.scene,
callbackUrl: data.callbackUrl, callbackUrl: data.callbackUrl,
cmppAccount, cmppAccount,
cmppEnterpriseCode,
secretHash,
cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'),
cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'),
dailyLimit: data.dailyLimit, dailyLimit: data.dailyLimit,
@@ -286,10 +298,10 @@ export class SmsConfigService {
if (!application) { if (!application) {
throw new NotFoundException('Application not found'); throw new NotFoundException('Application not found');
} }
const secret = randomBytes(24).toString('hex'); const secret = generateApplicationPassword();
const updated = await this.prisma.smsApplication.update({ const updated = await this.prisma.smsApplication.update({
where: { id: applicationId }, where: { id: applicationId },
data: { secretHash: hashSecret(secret) }, data: { secretHash: secret },
}); });
await this.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, { await this.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, {
reason: data.reason, reason: data.reason,
@@ -357,7 +369,7 @@ export class SmsConfigService {
appCode: application.id, appCode: application.id,
gatewayHost: channel?.gatewayHost ?? '', gatewayHost: channel?.gatewayHost ?? '',
gatewayPort: channel?.gatewayPort ?? 0, gatewayPort: channel?.gatewayPort ?? 0,
enterpriseCode: channel?.enterpriseCode ?? application.tenant.code, enterpriseCode: application.cmppEnterpriseCode,
account: application.cmppAccount, account: application.cmppAccount,
passwordCipher: application.secretHash, passwordCipher: application.secretHash,
srcId: channel?.srcId ?? '', srcId: channel?.srcId ?? '',
@@ -390,6 +402,17 @@ export class SmsConfigService {
throw new BadRequestException('Unable to generate unique CMPP account'); throw new BadRequestException('Unable to generate unique CMPP account');
} }
private async resolveCmppEnterpriseCode(cmppEnterpriseCode: string | undefined, tenantId: string) {
if (cmppEnterpriseCode !== undefined) {
return normalizeEnterpriseCode(cmppEnterpriseCode);
}
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, select: { code: true } });
if (!tenant) {
throw new BadRequestException('tenantId does not reference an existing tenant');
}
return normalizeEnterpriseCode(tenant.code);
}
async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) { async disconnectApplicationConnection(applicationId: string, connectionId: string, data: StatusChangeDto = { status: 'disconnected' }) {
const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } });
if (!application) { if (!application) {
@@ -760,8 +783,27 @@ interface TemplateVariableInput {
required?: boolean; required?: boolean;
} }
function hashSecret(secret: string) { function normalizeEnterpriseCode(value: string) {
return createHash('sha256').update(secret).digest('hex'); const enterpriseCode = value.trim();
if (!enterpriseCode) {
throw new BadRequestException('cmppEnterpriseCode is required');
}
if (enterpriseCode.length > 32) {
throw new BadRequestException('cmppEnterpriseCode must be at most 32 characters');
}
return enterpriseCode;
}
function normalizeApplicationPassword(value: string | undefined) {
const password = value?.trim() || generateApplicationPassword();
if (password.length !== 16) {
throw new BadRequestException('passwordCipher must be 16 characters');
}
return password;
}
function generateApplicationPassword() {
return randomUUID().replace(/-/g, '').slice(0, 16);
} }
function estimateBillingUnits(content: string) { function estimateBillingUnits(content: string) {
@@ -106,7 +106,10 @@
6. 发送队列等级属于真实业务配置,必须保存到后端数据库,并在客户端、运营端创建/编辑应用时展示和可修改;不得只作为前端展示字段。 6. 发送队列等级属于真实业务配置,必须保存到后端数据库,并在客户端、运营端创建/编辑应用时展示和可修改;不得只作为前端展示字段。
7. 运营端代企业新增短信应用时,第一步选择企业必须使用项目通用 Select/下拉控件,选项来自真实企业 API,支持加载中、空数据、错误态,不允许写死企业列表。 7. 运营端代企业新增短信应用时,第一步选择企业必须使用项目通用 Select/下拉控件,选项来自真实企业 API,支持加载中、空数据、错误态,不允许写死企业列表。
8. 短信应用必须有独立 6 位数字 CMPP 接入账号 `cmppAccount`;运营端添加/编辑应用时可显式配置,留空时后端自动生成且全局唯一。 8. 短信应用必须有独立 6 位数字 CMPP 接入账号 `cmppAccount`;运营端添加/编辑应用时可显式配置,留空时后端自动生成且全局唯一。
9. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections` 和客户提交窗口 `cmppWindowSize`;这两个字段是平台运行配置,不是 CMPP 协议字段,也不是 gocmpp 库参数 9. 短信应用必须有应用级客户侧企业代码 `cmppEnterpriseCode`,运营端添加/编辑应用时可自定义;不得从上游通道 `SmsChannel.enterpriseCode` 透传
10. 短信应用接口密码 `passwordCipher` 新建时默认随机生成 16 位 UUID 片段,运营端可手工修改;编辑时留空不覆盖原密码。
11. 应用 `AppID` 是平台内部应用标识,用于页面展示、复制参数和工单定位,不作为 CMPP bind/login 认证参数。
12. 短信应用必须可配置客户侧 CMPP 最大连接数 `cmppMaxConnections` 和客户提交窗口 `cmppWindowSize`;这两个字段是平台运行配置,不是 CMPP 协议字段,也不是 gocmpp 库参数。
### 4.3 签名与引流信息 ### 4.3 签名与引流信息
@@ -204,7 +207,7 @@
#### 4.8.2 下游客户 CMPP 接入能力 #### 4.8.2 下游客户 CMPP 接入能力
1. Gateway 必须监听生产 CMPP 端口 `17890`,作为平台侧 CMPP Server 接收企业客户系统连接;该端口不是 HTTP 健康检查或控制接口。 1. Gateway 必须监听生产 CMPP 端口 `17890`,作为平台侧 CMPP Server 接收企业客户系统连接;该端口不是 HTTP 健康检查或控制接口。
2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括企业代码、账号、密码、CMPP 版本、源 IP 白名单、应用状态、企业状态、连接数上限。 2. Gateway 必须按企业应用生成的 CMPP 接入参数校验客户 connect/login,包括客户侧企业代码、账号、密码、CMPP 版本、源 IP 白名单、应用状态、企业状态、连接数上限;客户侧企业代码必须来自 `SmsApplication.cmppEnterpriseCode`
3. 客户端应用的 IP 白名单必须对 CMPP 下游连接生效;未命中白名单、应用停用、企业停用、密码错误、超过连接数上限时必须拒绝连接并记录系统日志。 3. 客户端应用的 IP 白名单必须对 CMPP 下游连接生效;未命中白名单、应用停用、企业停用、密码错误、超过连接数上限时必须拒绝连接并记录系统日志。
4. Gateway 必须维护应用级下游连接状态,回写 applicationId、tenantId、connectionId、currentConnections、desiredConnections、lastHeartbeatAt、lastError,运营端企业应用列表和连接详情必须来自这些真实状态。 4. Gateway 必须维护应用级下游连接状态,回写 applicationId、tenantId、connectionId、currentConnections、desiredConnections、lastHeartbeatAt、lastError,运营端企业应用列表和连接详情必须来自这些真实状态。
5. Gateway 必须实现下游 ActiveTest、Terminate、异常断开处理;断开后连接数和状态必须及时回写。 5. Gateway 必须实现下游 ActiveTest、Terminate、异常断开处理;断开后连接数和状态必须及时回写。
@@ -1283,6 +1286,7 @@
1. 运营端增加企业人工充值入口。 1. 运营端增加企业人工充值入口。
- 运营端可针对指定企业录入人工充值金额、操作人和备注。 - 运营端可针对指定企业录入人工充值金额、操作人和备注。
- 人工充值金额支持负数,用于余额冲正或调减;0 金额不得提交。
- 人工充值必须写入充值订单和账务流水。 - 人工充值必须写入充值订单和账务流水。
- 运营端充值记录需区分人工充值和套餐充值。 - 运营端充值记录需区分人工充值和套餐充值。
2. 客户端概览指标调整。 2. 客户端概览指标调整。
+5 -4
View File
@@ -487,13 +487,14 @@
1. 打开运营端企业应用管理,点击新增短信应用。 1. 打开运营端企业应用管理,点击新增短信应用。
2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。 2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。
3. 选择企业后进入应用参数表单。 3. 选择企业后进入应用参数表单。
4. 配置应用名称、客户单价、IP 白名单、发送队列等级、CMPP 6 位账号、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。
5. 刷新列表并打开编辑页。 5. 刷新列表并打开编辑页。
- 预期结果: - 预期结果:
- 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。 - 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。
- 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。 - 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。
- 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`cmppAccount``cmppMaxConnections``cmppWindowSize` 和通道组绑定。 - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`cmppAccount``cmppEnterpriseCode``passwordCipher``cmppMaxConnections``cmppWindowSize` 和通道组绑定。
- `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。 - `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。
- `cmppEnterpriseCode` 来自应用自身配置,不透传上游通道企业代码;`passwordCipher` 为 16 位,编辑留空不覆盖原密码。
- 后端真实保存应用队列等级和 CMPP 参数,刷新列表、编辑页和 CMPP 参数弹窗后仍显示正确。 - 后端真实保存应用队列等级和 CMPP 参数,刷新列表、编辑页和 CMPP 参数弹窗后仍显示正确。
- 不选择任何通道组或缺少必填字段时不能保存,并显示可读提示。 - 不选择任何通道组或缺少必填字段时不能保存,并显示可读提示。
@@ -990,7 +991,7 @@
### TC-GW-006 下游客户 CMPP 17890 入站 bind/submit ### TC-GW-006 下游客户 CMPP 17890 入站 bind/submit
- 优先级:P0 - 优先级:P0
- 前置条件:企业已认证通过;短信应用 active 且存在独立 6 位 `cmppAccount`;应用 IP 白名单包含测试客户端 IP;应用已有审核和报备通过的签名/模板;通道组、余额和 Gateway 均可用。 - 前置条件:企业已认证通过;短信应用 active 且存在独立 6 位 `cmppAccount`、应用级 `cmppEnterpriseCode` 和 16 位 `passwordCipher`;应用 IP 白名单包含测试客户端 IP;应用已有审核和报备通过的签名/模板;通道组、余额和 Gateway 均可用。
- 步骤: - 步骤:
1. 启动 Go Gateway,确认 `GATEWAY_CMPP_ADDR=0.0.0.0:17890` 1. 启动 Go Gateway,确认 `GATEWAY_CMPP_ADDR=0.0.0.0:17890`
2. 使用 gocmpp 或真实 CMPP 客户端连接 `17890``Source_Addr` 填应用 `cmppAccount`,密码填应用 CMPP 参数 `passwordCipher` 2. 使用 gocmpp 或真实 CMPP 客户端连接 `17890``Source_Addr` 填应用 `cmppAccount`,密码填应用 CMPP 参数 `passwordCipher`
@@ -3023,7 +3024,7 @@ npm run verify:phase8
| 用例 | 细化执行点 | 必查断言 | | 用例 | 细化执行点 | 必查断言 |
| --- | --- | --- | | --- | --- | --- |
| TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topupTenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 | | TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topupTenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 |
| TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不产生 null、NaN 或负数脏数据。 | | TC-BILLING-007 | 分别只填金额、只填短信条数;金额填写负数执行冲正。 | 未填项按 0正负金额和条数字段方向正确;允许有业务含义的负数调整,不产生 null、NaN 或零变更脏数据。 |
| TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | | TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 | | TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | | TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
+3 -2
View File
@@ -757,7 +757,8 @@ npm run verify:phase8
- NestJS `ChannelsService``desiredConnections/windowSize` 增加正整数校验;通道激活后的 `ConnectChannel` 请求和发送链路 `SubmitCommand.upstream` 均复用该真实配置。 - NestJS `ChannelsService``desiredConnections/windowSize` 增加正整数校验;通道激活后的 `ConnectChannel` 请求和发送链路 `SubmitCommand.upstream` 均复用该真实配置。
- Prisma 为 `SmsApplication` 新增 `cmppMaxConnections``cmppWindowSize` 字段;运营端短信应用创建/编辑表单新增 `cmppAccount`、客户最大连接数、客户提交窗口输入。 - Prisma 为 `SmsApplication` 新增 `cmppMaxConnections``cmppWindowSize` 字段;运营端短信应用创建/编辑表单新增 `cmppAccount`、客户最大连接数、客户提交窗口输入。
- 企业应用 `cmppAccount` 现在支持两种真实路径:显式填写 6 位数字账号,或留空由后端自动生成唯一账号;重复账号和非法格式会被后端拒绝。 - 企业应用 `cmppAccount` 现在支持两种真实路径:显式填写 6 位数字账号,或留空由后端自动生成唯一账号;重复账号和非法格式会被后端拒绝。
- 企业应用 CMPP 参数接口改为从应用真实字段返回 `account/maxConnections/windowSize`,不再借用任意通道默认值拼装客户参数。 - 企业应用 CMPP 参数接口改为从应用真实字段返回 `enterpriseCode/account/passwordCipher/maxConnections/windowSize`,不再借用任意通道企业代码或默认值拼装客户参数。
- 应用级 `cmppEnterpriseCode` 新建/编辑可自定义;接口密码新建默认随机 16 位 UUID 片段,编辑留空不覆盖、填写 16 位后更新。`AppID` 仅作为平台应用标识展示,不作为 CMPP 协议认证参数。
### 验证状态 ### 验证状态
@@ -1257,7 +1258,7 @@ git diff --check
### 本轮修复 ### 本轮修复
- 运营端企业管理列表新增“充值”按钮。 - 运营端企业管理列表新增“充值”按钮。
- 点击“充值”打开企业人工充值弹窗,展示企业名称、当前余额,并支持录入充值金额、操作人和备注;企业列表入口不要求填写短信条数。 - 点击“充值”打开企业人工充值弹窗,展示企业名称、当前余额,并支持录入充值金额、操作人和备注;充值金额允许负数冲正,0 金额不允许提交;企业列表入口不要求填写短信条数。
- 提交后调用现有真实接口 `POST /api/admin/billing/manual-recharges`,成功后重新拉取企业管理列表,余额来自真实账户接口聚合结果。 - 提交后调用现有真实接口 `POST /api/admin/billing/manual-recharges`,成功后重新拉取企业管理列表,余额来自真实账户接口聚合结果。
- 该入口不使用前端本地状态模拟充值入账;充值订单、账户余额、账户流水和操作日志仍由后端 `BillingService.createManualRecharge` 负责。 - 该入口不使用前端本地状态模拟充值入账;充值订单、账户余额、账户流水和操作日志仍由后端 `BillingService.createManualRecharge` 负责。
+3 -2
View File
@@ -605,6 +605,7 @@ export type EnterpriseApplication = {
maxPhonesPerTask?: number | null; maxPhonesPerTask?: number | null;
templateMismatchMode?: string | null; templateMismatchMode?: string | null;
cmppAccount?: string | null; cmppAccount?: string | null;
cmppEnterpriseCode?: string | null;
cmppMaxConnections?: number | null; cmppMaxConnections?: number | null;
cmppWindowSize?: number | null; cmppWindowSize?: number | null;
ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>;
@@ -801,9 +802,9 @@ export const adminApi = {
request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)), request<EnterpriseApplication[]>(withQuery('/admin/enterprise-applications', query)),
getEnterpriseApplication: (id: string) => getEnterpriseApplication: (id: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`), request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`),
createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => createEnterpriseApplication: (body: { tenantId: string; name: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }), request<EnterpriseApplication>('/admin/enterprise-applications', { method: 'POST', body: JSON.stringify(body) }),
updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) => updateEnterpriseApplication: (id: string, body: { name?: string; scene?: string; dailyLimit?: number; customerUnitPrice?: number; queuePriority?: 'normal' | 'priority'; maxPhonesPerTask?: number; templateMismatchMode?: string; cmppAccount?: string; cmppEnterpriseCode?: string; passwordCipher?: string; cmppMaxConnections?: number; cmppWindowSize?: number; ipAllowlist?: string[] }) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), request<EnterpriseApplication>(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }),
changeApplicationStatus: (id: string, status: string, reason?: string) => changeApplicationStatus: (id: string, status: string, reason?: string) =>
request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, { request<EnterpriseApplication>(`/admin/enterprise-applications/${id}/status`, {
+2 -2
View File
@@ -127,8 +127,8 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto
async function submitRecharge() { async function submitRecharge() {
if (!rechargeTarget) return; if (!rechargeTarget) return;
const amount = Number(rechargeForm.amount); const amount = Number(rechargeForm.amount);
if (!Number.isFinite(amount) || amount <= 0) { if (!Number.isFinite(amount) || amount === 0) {
setRechargeError('请填写大于 0 的充值金额'); setRechargeError('请填写 0 的充值金额,支持负数冲正');
return; return;
} }
setRecharging(true); setRecharging(true);
@@ -458,7 +458,7 @@ function mapApplication(application: EnterpriseApplication): SmsApp {
deliveryRate: application.deliveryRate ?? 0, deliveryRate: application.deliveryRate ?? 0,
unitPrice: (application.customerUnitPrice ?? 0) / 100, unitPrice: (application.customerUnitPrice ?? 0) / 100,
cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected', cmppStatus: application.cmppStatus === 'connected' ? 'connected' : application.cmppStatus === 'inactive' ? 'inactive' : 'disconnected',
cmppParams: { host: '', port: 0, enterpriseCode: application.tenant?.code ?? application.tenantId, account: application.tenant?.code ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' }, cmppParams: { host: '', port: 0, enterpriseCode: application.cmppEnterpriseCode ?? application.tenant?.code ?? application.tenantId, account: application.cmppAccount ?? application.tenantId, password: '', accessNumber: '', maxConnections: 0, heartbeatSeconds: 30, windowSize: 16, protocolVersion: 'CMPP 3.0' },
cmppConnections: connections, cmppConnections: connections,
}; };
} }
+1 -1
View File
@@ -103,7 +103,7 @@ export function AdminRechargeRecordsPage() {
async function submitManualRecharge() { async function submitManualRecharge() {
const amount = Number(form.amount); const amount = Number(form.amount);
const smsUnits = Number(form.smsUnits || 0); const smsUnits = Number(form.smsUnits || 0);
if (!form.tenantId || !Number.isFinite(amount) || amount <= 0 || !Number.isFinite(smsUnits) || smsUnits < 0) { if (!form.tenantId || !Number.isFinite(amount) || !Number.isFinite(smsUnits) || (amount === 0 && smsUnits === 0)) {
return; return;
} }
await adminApi.createManualRecharge({ await adminApi.createManualRecharge({
+23 -1
View File
@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom'; import { useNavigate, useParams } from 'react-router-dom';
import { ArrowLeft, Info, RadioTower } from 'lucide-react'; import { ArrowLeft, Info, RadioTower, RefreshCw } from 'lucide-react';
import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi'; import { adminApi, type ChannelGroup, type DictionaryItem, type EnterpriseApplication } from '@/api/adminApi';
import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui'; import { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui';
@@ -23,6 +23,8 @@ export function AdminSmsApplicationFormPage() {
const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300'); const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300');
const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal'); const [queuePriority, setQueuePriority] = useState<QueuePriority>('normal');
const [cmppAccount, setCmppAccount] = useState(''); const [cmppAccount, setCmppAccount] = useState('');
const [cmppEnterpriseCode, setCmppEnterpriseCode] = useState('');
const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword());
const [cmppMaxConnections, setCmppMaxConnections] = useState('1'); const [cmppMaxConnections, setCmppMaxConnections] = useState('1');
const [cmppWindowSize, setCmppWindowSize] = useState('16'); const [cmppWindowSize, setCmppWindowSize] = useState('16');
const [phoneDailyLimit, setPhoneDailyLimit] = useState('10'); const [phoneDailyLimit, setPhoneDailyLimit] = useState('10');
@@ -79,6 +81,8 @@ export function AdminSmsApplicationFormPage() {
setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4)); setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4));
setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal'); setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal');
setCmppAccount(application.cmppAccount ?? ''); setCmppAccount(application.cmppAccount ?? '');
setCmppEnterpriseCode(application.cmppEnterpriseCode ?? application.tenant?.code ?? '');
setPasswordCipher('');
setCmppMaxConnections(String(application.cmppMaxConnections ?? 1)); setCmppMaxConnections(String(application.cmppMaxConnections ?? 1));
setCmppWindowSize(String(application.cmppWindowSize ?? 16)); setCmppWindowSize(String(application.cmppWindowSize ?? 16));
setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : ''); setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : '');
@@ -117,6 +121,8 @@ export function AdminSmsApplicationFormPage() {
customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100), customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100),
queuePriority, queuePriority,
cmppAccount: cmppAccount.trim() || undefined, cmppAccount: cmppAccount.trim() || undefined,
cmppEnterpriseCode: cmppEnterpriseCode.trim() || undefined,
passwordCipher: passwordCipher.trim() || undefined,
cmppMaxConnections: Number(cmppMaxConnections) || 1, cmppMaxConnections: Number(cmppMaxConnections) || 1,
cmppWindowSize: Number(cmppWindowSize) || 16, cmppWindowSize: Number(cmppWindowSize) || 16,
maxPhonesPerTask: Number(phoneDailyLimit) || undefined, maxPhonesPerTask: Number(phoneDailyLimit) || undefined,
@@ -177,6 +183,15 @@ export function AdminSmsApplicationFormPage() {
<Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} /> <Input label="日发送数量限制" onChange={(event) => setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} />
<Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} /> <Input label="客户单价(元/条)" onChange={(event) => setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} />
<Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> <Input label="CMPP 6位账号" onChange={(event) => setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} />
<Input label="企业代码" onChange={(event) => setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} />
<Input
hint={isEdit ? '留空则不修改接口密码;填写 16 位字符后覆盖。' : '默认随机生成,可按需修改。'}
label="接口密码"
onChange={(event) => setPasswordCipher(event.target.value)}
placeholder="16 位接口密码"
suffix={<button aria-label="随机生成接口密码" className="icon-button" onClick={() => setPasswordCipher(generateApplicationPassword())} type="button"><RefreshCw size={15} /></button>}
value={passwordCipher}
/>
<Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} /> <Input label="客户最大连接数" onChange={(event) => setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} />
<Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} /> <Input label="客户提交窗口" onChange={(event) => setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
<div className="admin-app-form-row admin-app-form-row--wide"> <div className="admin-app-form-row admin-app-form-row--wide">
@@ -267,3 +282,10 @@ function parseIpAllowlist(value: string) {
.map((item) => item.trim()) .map((item) => item.trim())
.filter(Boolean); .filter(Boolean);
} }
function generateApplicationPassword() {
if (typeof crypto !== 'undefined' && 'randomUUID' in crypto) {
return crypto.randomUUID().replace(/-/g, '').slice(0, 16);
}
return Array.from({ length: 16 }, () => Math.floor(Math.random() * 16).toString(16)).join('');
}