diff --git a/api/prisma/migrations/20260709152000_add_application_cmpp_enterprise_code/migration.sql b/api/prisma/migrations/20260709152000_add_application_cmpp_enterprise_code/migration.sql new file mode 100644 index 0000000..735a4c3 --- /dev/null +++ b/api/prisma/migrations/20260709152000_add_application_cmpp_enterprise_code/migration.sql @@ -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; diff --git a/api/prisma/schema.prisma b/api/prisma/schema.prisma index 54c3320..d7d0c52 100644 --- a/api/prisma/schema.prisma +++ b/api/prisma/schema.prisma @@ -342,6 +342,7 @@ model SmsApplication { scene String? callbackUrl String? cmppAccount String @unique + cmppEnterpriseCode String secretHash String cmppMaxConnections Int @default(1) cmppWindowSize Int @default(16) diff --git a/api/src/billing/billing.service.spec.ts b/api/src/billing/billing.service.spec.ts index cc60c94..92a6fb7 100644 --- a/api/src/billing/billing.service.spec.ts +++ b/api/src/billing/billing.service.spec.ts @@ -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 () => { const prisma = createPrismaMock(); const service = new BillingService(prisma as never); diff --git a/api/src/sms-config/sms-config.service.spec.ts b/api/src/sms-config/sms-config.service.spec.ts index b6ba440..1b19474 100644 --- a/api/src/sms-config/sms-config.service.spec.ts +++ b/api/src/sms-config/sms-config.service.spec.ts @@ -9,6 +9,7 @@ function createPrismaMock() { name: '应用A', status: 'active', cmppAccount: '100001', + cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 2, cmppWindowSize: 32, queuePriority: 'normal', @@ -21,10 +22,11 @@ function createPrismaMock() { name: '应用A', status: 'active', cmppAccount: '100001', + cmppEnterpriseCode: 'APP-EC', cmppMaxConnections: 2, cmppWindowSize: 32, queuePriority: 'normal', - secretHash: 'secret-hash', + secretHash: '0123456789abcdef', tenant: { id: 'tenant-1', name: '租户A', code: 'TENANT-A' }, }), update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), @@ -109,6 +111,9 @@ function createPrismaMock() { operationLog: { create: jest.fn(), }, + tenant: { + findUnique: jest.fn().mockResolvedValue({ code: 'TENANT-A' }), + }, $transaction: jest.fn((callback) => callback({ smsApplication: { update: jest.fn().mockImplementation(({ data }) => Promise.resolve({ id: 'app-1', tenantId: 'tenant-1', ...data })), @@ -171,6 +176,8 @@ describe('SmsConfigService', () => { applicationId: 'app-1', tenantName: '租户A', account: '100001', + enterpriseCode: 'APP-EC', + passwordCipher: '0123456789abcdef', gatewayHost: '127.0.0.1', gatewayPort: 17890, maxConnections: 2, @@ -187,6 +194,8 @@ describe('SmsConfigService', () => { tenantId: 'tenant-1', name: '优先应用', cmppAccount: '123456', + cmppEnterpriseCode: 'CUSTOM-EC', + passwordCipher: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, queuePriority: 'priority', @@ -198,6 +207,8 @@ describe('SmsConfigService', () => { tenantId: 'tenant-1', name: '优先应用', cmppAccount: '123456', + cmppEnterpriseCode: 'CUSTOM-EC', + secretHash: '1234567890abcdef', cmppMaxConnections: 3, cmppWindowSize: 32, queuePriority: 'priority', diff --git a/api/src/sms-config/sms-config.service.ts b/api/src/sms-config/sms-config.service.ts index 9506046..40af254 100644 --- a/api/src/sms-config/sms-config.service.ts +++ b/api/src/sms-config/sms-config.service.ts @@ -1,6 +1,6 @@ import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { Prisma } from '@prisma/client'; -import { randomBytes, randomInt, createHash } from 'node:crypto'; +import { randomInt, randomUUID } from 'node:crypto'; import { PrismaService } from '../prisma/prisma.service'; export interface CreateSmsApplicationDto { @@ -9,6 +9,8 @@ export interface CreateSmsApplicationDto { scene?: string; callbackUrl?: string; cmppAccount?: string; + cmppEnterpriseCode?: string; + passwordCipher?: string; cmppMaxConnections?: number; cmppWindowSize?: number; dailyLimit?: number; @@ -154,9 +156,10 @@ export class SmsConfigService { } async createApplication(data: CreateSmsApplicationDto) { - const secret = randomBytes(24).toString('hex'); + const secret = normalizeApplicationPassword(data.passwordCipher); const queuePriority = normalizeApplicationQueuePriority(data.queuePriority); 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({ data: { tenantId: data.tenantId, @@ -164,7 +167,8 @@ export class SmsConfigService { scene: data.scene, callbackUrl: data.callbackUrl, cmppAccount, - secretHash: hashSecret(secret), + cmppEnterpriseCode, + secretHash: secret, cmppMaxConnections: getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppWindowSize: getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), dailyLimit: data.dailyLimit, @@ -191,6 +195,12 @@ export class SmsConfigService { const cmppAccount = data.cmppAccount === undefined ? undefined : 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) => { if (data.ipAllowlist) { @@ -203,6 +213,8 @@ export class SmsConfigService { scene: data.scene, callbackUrl: data.callbackUrl, cmppAccount, + cmppEnterpriseCode, + secretHash, cmppMaxConnections: data.cmppMaxConnections === undefined ? undefined : getPositiveInteger(data.cmppMaxConnections, 1, 'cmppMaxConnections'), cmppWindowSize: data.cmppWindowSize === undefined ? undefined : getPositiveInteger(data.cmppWindowSize, 16, 'cmppWindowSize'), dailyLimit: data.dailyLimit, @@ -286,10 +298,10 @@ export class SmsConfigService { if (!application) { throw new NotFoundException('Application not found'); } - const secret = randomBytes(24).toString('hex'); + const secret = generateApplicationPassword(); const updated = await this.prisma.smsApplication.update({ where: { id: applicationId }, - data: { secretHash: hashSecret(secret) }, + data: { secretHash: secret }, }); await this.writeOperationLog(application.tenantId, data.operatorId, 'sms_application.secret_reset', 'sms_application', applicationId, { reason: data.reason, @@ -357,7 +369,7 @@ export class SmsConfigService { appCode: application.id, gatewayHost: channel?.gatewayHost ?? '', gatewayPort: channel?.gatewayPort ?? 0, - enterpriseCode: channel?.enterpriseCode ?? application.tenant.code, + enterpriseCode: application.cmppEnterpriseCode, account: application.cmppAccount, passwordCipher: application.secretHash, srcId: channel?.srcId ?? '', @@ -390,6 +402,17 @@ export class SmsConfigService { 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' }) { const application = await this.prisma.smsApplication.findUnique({ where: { id: applicationId } }); if (!application) { @@ -760,8 +783,27 @@ interface TemplateVariableInput { required?: boolean; } -function hashSecret(secret: string) { - return createHash('sha256').update(secret).digest('hex'); +function normalizeEnterpriseCode(value: string) { + 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) { diff --git a/docs/first-version-development-requirements.md b/docs/first-version-development-requirements.md index c63251b..465972c 100644 --- a/docs/first-version-development-requirements.md +++ b/docs/first-version-development-requirements.md @@ -106,7 +106,10 @@ 6. 发送队列等级属于真实业务配置,必须保存到后端数据库,并在客户端、运营端创建/编辑应用时展示和可修改;不得只作为前端展示字段。 7. 运营端代企业新增短信应用时,第一步选择企业必须使用项目通用 Select/下拉控件,选项来自真实企业 API,支持加载中、空数据、错误态,不允许写死企业列表。 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 签名与引流信息 @@ -204,7 +207,7 @@ #### 4.8.2 下游客户 CMPP 接入能力 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 下游连接生效;未命中白名单、应用停用、企业停用、密码错误、超过连接数上限时必须拒绝连接并记录系统日志。 4. Gateway 必须维护应用级下游连接状态,回写 applicationId、tenantId、connectionId、currentConnections、desiredConnections、lastHeartbeatAt、lastError,运营端企业应用列表和连接详情必须来自这些真实状态。 5. Gateway 必须实现下游 ActiveTest、Terminate、异常断开处理;断开后连接数和状态必须及时回写。 @@ -1283,6 +1286,7 @@ 1. 运营端增加企业人工充值入口。 - 运营端可针对指定企业录入人工充值金额、操作人和备注。 + - 人工充值金额支持负数,用于余额冲正或调减;0 金额不得提交。 - 人工充值必须写入充值订单和账务流水。 - 运营端充值记录需区分人工充值和套餐充值。 2. 客户端概览指标调整。 diff --git a/docs/system-functional-test-cases.md b/docs/system-functional-test-cases.md index f5949db..5578f32 100644 --- a/docs/system-functional-test-cases.md +++ b/docs/system-functional-test-cases.md @@ -487,13 +487,14 @@ 1. 打开运营端企业应用管理,点击新增短信应用。 2. 在第一步选择企业下拉框中查看企业选项、加载态和空态。 3. 选择企业后进入应用参数表单。 - 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、CMPP 6 位账号、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。 + 4. 配置应用名称、客户单价、IP 白名单、发送队列等级、CMPP 6 位账号、企业代码、16 位接口密码、客户最大连接数、客户提交窗口、移动/联通/电信通道组后保存。 5. 刷新列表并打开编辑页。 - 预期结果: - 企业选择使用项目通用 Select/下拉控件,样式、禁用态、错误态与系统其他下拉一致。 - 企业选项来自真实企业 API,不使用静态数组、mock 或 localStorage。 - - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`cmppAccount`、`cmppMaxConnections`、`cmppWindowSize` 和通道组绑定。 + - 请求体包含 tenantId、queuePriority、客户单价、IP 白名单、`cmppAccount`、`cmppEnterpriseCode`、`passwordCipher`、`cmppMaxConnections`、`cmppWindowSize` 和通道组绑定。 - `cmppAccount` 可显式填写 6 位数字;留空时由后端自动生成唯一账号;重复或非法格式保存失败并提示可读错误。 + - `cmppEnterpriseCode` 来自应用自身配置,不透传上游通道企业代码;`passwordCipher` 为 16 位,编辑留空不覆盖原密码。 - 后端真实保存应用队列等级和 CMPP 参数,刷新列表、编辑页和 CMPP 参数弹窗后仍显示正确。 - 不选择任何通道组或缺少必填字段时不能保存,并显示可读提示。 @@ -990,7 +991,7 @@ ### TC-GW-006 下游客户 CMPP 17890 入站 bind/submit - 优先级: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`。 2. 使用 gocmpp 或真实 CMPP 客户端连接 `17890`,`Source_Addr` 填应用 `cmppAccount`,密码填应用 CMPP 参数 `passwordCipher`。 @@ -3023,7 +3024,7 @@ npm run verify:phase8 | 用例 | 细化执行点 | 必查断言 | | --- | --- | --- | | TC-BILLING-006 | 运营端人工充值金额和短信条数,客户端查看 Dashboard 和账单流水。 | RechargeOrder 状态为 paid/manual_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 | -| TC-BILLING-007 | 分别只填金额、只填短信条数。 | 未填项按 0;金额和条数字段方向正确;不会产生 null、NaN 或负数脏数据。 | +| TC-BILLING-007 | 分别只填金额、只填短信条数;金额填写负数执行冲正。 | 未填项按 0;正负金额和条数字段方向正确;允许有业务含义的负数调整,不产生 null、NaN 或零变更脏数据。 | | TC-BILLING-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 | | TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 | | TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 | diff --git a/docs/testing-progress.md b/docs/testing-progress.md index 00083f9..4c73c54 100644 --- a/docs/testing-progress.md +++ b/docs/testing-progress.md @@ -757,7 +757,8 @@ npm run verify:phase8 - NestJS `ChannelsService` 对 `desiredConnections/windowSize` 增加正整数校验;通道激活后的 `ConnectChannel` 请求和发送链路 `SubmitCommand.upstream` 均复用该真实配置。 - Prisma 为 `SmsApplication` 新增 `cmppMaxConnections`、`cmppWindowSize` 字段;运营端短信应用创建/编辑表单新增 `cmppAccount`、客户最大连接数、客户提交窗口输入。 - 企业应用 `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`,成功后重新拉取企业管理列表,余额来自真实账户接口聚合结果。 - 该入口不使用前端本地状态模拟充值入账;充值订单、账户余额、账户流水和操作日志仍由后端 `BillingService.createManualRecharge` 负责。 diff --git a/src/api/adminApi.ts b/src/api/adminApi.ts index 11543d4..afb3ffd 100644 --- a/src/api/adminApi.ts +++ b/src/api/adminApi.ts @@ -605,6 +605,7 @@ export type EnterpriseApplication = { maxPhonesPerTask?: number | null; templateMismatchMode?: string | null; cmppAccount?: string | null; + cmppEnterpriseCode?: string | null; cmppMaxConnections?: number | null; cmppWindowSize?: number | null; ipAllowlist?: Array<{ id: string; ipCidr: string; remark?: string | null }>; @@ -801,9 +802,9 @@ export const adminApi = { request(withQuery('/admin/enterprise-applications', query)), getEnterpriseApplication: (id: string) => request(`/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('/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(`/admin/enterprise-applications/${id}`, { method: 'PUT', body: JSON.stringify(body) }), changeApplicationStatus: (id: string, status: string, reason?: string) => request(`/admin/enterprise-applications/${id}/status`, { diff --git a/src/apps/admin/AdminCustomersPage.tsx b/src/apps/admin/AdminCustomersPage.tsx index 1671f5a..54349db 100644 --- a/src/apps/admin/AdminCustomersPage.tsx +++ b/src/apps/admin/AdminCustomersPage.tsx @@ -127,8 +127,8 @@ export function AdminCustomersPage({ basePath = '/admin/customers' }: AdminCusto async function submitRecharge() { if (!rechargeTarget) return; const amount = Number(rechargeForm.amount); - if (!Number.isFinite(amount) || amount <= 0) { - setRechargeError('请填写大于 0 的充值金额'); + if (!Number.isFinite(amount) || amount === 0) { + setRechargeError('请填写非 0 的充值金额,支持负数冲正'); return; } setRecharging(true); diff --git a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx index fe7ec4c..b85b442 100644 --- a/src/apps/admin/AdminEnterpriseApplicationsPage.tsx +++ b/src/apps/admin/AdminEnterpriseApplicationsPage.tsx @@ -458,7 +458,7 @@ function mapApplication(application: EnterpriseApplication): SmsApp { deliveryRate: application.deliveryRate ?? 0, unitPrice: (application.customerUnitPrice ?? 0) / 100, 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, }; } diff --git a/src/apps/admin/AdminRechargeRecordsPage.tsx b/src/apps/admin/AdminRechargeRecordsPage.tsx index a558ddc..66e0011 100644 --- a/src/apps/admin/AdminRechargeRecordsPage.tsx +++ b/src/apps/admin/AdminRechargeRecordsPage.tsx @@ -103,7 +103,7 @@ export function AdminRechargeRecordsPage() { async function submitManualRecharge() { const amount = Number(form.amount); 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; } await adminApi.createManualRecharge({ diff --git a/src/apps/admin/AdminSmsApplicationFormPage.tsx b/src/apps/admin/AdminSmsApplicationFormPage.tsx index 6c92e73..501ef3c 100644 --- a/src/apps/admin/AdminSmsApplicationFormPage.tsx +++ b/src/apps/admin/AdminSmsApplicationFormPage.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from 'react'; 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 { Breadcrumb, Button, Input, Select, Tag } from '@/components/ui'; @@ -23,6 +23,8 @@ export function AdminSmsApplicationFormPage() { const [customerUnitPrice, setCustomerUnitPrice] = useState('0.0300'); const [queuePriority, setQueuePriority] = useState('normal'); const [cmppAccount, setCmppAccount] = useState(''); + const [cmppEnterpriseCode, setCmppEnterpriseCode] = useState(''); + const [passwordCipher, setPasswordCipher] = useState(() => generateApplicationPassword()); const [cmppMaxConnections, setCmppMaxConnections] = useState('1'); const [cmppWindowSize, setCmppWindowSize] = useState('16'); const [phoneDailyLimit, setPhoneDailyLimit] = useState('10'); @@ -79,6 +81,8 @@ export function AdminSmsApplicationFormPage() { setCustomerUnitPrice(((application.customerUnitPrice ?? 0) / 100).toFixed(4)); setQueuePriority(application.queuePriority === 'priority' ? 'priority' : 'normal'); setCmppAccount(application.cmppAccount ?? ''); + setCmppEnterpriseCode(application.cmppEnterpriseCode ?? application.tenant?.code ?? ''); + setPasswordCipher(''); setCmppMaxConnections(String(application.cmppMaxConnections ?? 1)); setCmppWindowSize(String(application.cmppWindowSize ?? 16)); setPhoneDailyLimit(application.maxPhonesPerTask ? String(application.maxPhonesPerTask) : ''); @@ -117,6 +121,8 @@ export function AdminSmsApplicationFormPage() { customerUnitPrice: Math.round(Number(customerUnitPrice || 0) * 100), queuePriority, cmppAccount: cmppAccount.trim() || undefined, + cmppEnterpriseCode: cmppEnterpriseCode.trim() || undefined, + passwordCipher: passwordCipher.trim() || undefined, cmppMaxConnections: Number(cmppMaxConnections) || 1, cmppWindowSize: Number(cmppWindowSize) || 16, maxPhonesPerTask: Number(phoneDailyLimit) || undefined, @@ -177,6 +183,15 @@ export function AdminSmsApplicationFormPage() { setDailyLimit(event.target.value)} placeholder="100000" required value={dailyLimit} /> setCustomerUnitPrice(event.target.value)} placeholder="0.0300" required value={customerUnitPrice} /> setCmppAccount(event.target.value)} placeholder="留空自动生成" value={cmppAccount} /> + setCmppEnterpriseCode(event.target.value)} placeholder="请输入客户侧企业代码" value={cmppEnterpriseCode} /> + setPasswordCipher(event.target.value)} + placeholder="16 位接口密码" + suffix={} + value={passwordCipher} + /> setCmppMaxConnections(event.target.value)} placeholder="1" required value={cmppMaxConnections} /> setCmppWindowSize(event.target.value)} placeholder="16" required value={cmppWindowSize} />
@@ -267,3 +282,10 @@ function parseIpAllowlist(value: string) { .map((item) => item.trim()) .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(''); +}