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
+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 () => {
const prisma = createPrismaMock();
const service = new BillingService(prisma as never);
+12 -1
View File
@@ -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',
+50 -8
View File
@@ -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) {