fix: correct application cmpp credentials
This commit is contained in:
+9
@@ -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;
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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. 客户端概览指标调整。
|
||||||
|
|||||||
@@ -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_topup;TenantAccount 同步增加;AccountTransaction 类型 recharge;运营日志和客户端流水均可追溯。 |
|
| 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-008 | 对已充值记录执行撤销/冲正,分别覆盖未消费和已部分消费。 | 未消费可全额回退;已消费按规则拒绝或生成人工调整;原订单状态和反向流水清晰;日志记录原因。 |
|
||||||
| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
|
| TC-BILLING-009 | 无权限用户、审核员、管理员分别执行充值;大额人工充值不走审批。 | 权限不足被拒绝并写失败日志;有权限用户确认后立即入账;不产生 pending 审批态;充值订单、账户余额、流水和日志同步完成。 |
|
||||||
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
|
| TC-BILLING-010 | 余额不足发送失败,人工充值后重试发送并模拟 delivered。 | 充值前不扣费;充值后发送成功;冻结、扣费、短信计费记录完整;reconciliation diff 为 0。 |
|
||||||
|
|||||||
@@ -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
@@ -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`, {
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user