feat: improve operations diagnostics and channel management

This commit is contained in:
hectorzhao
2026-08-09 14:27:19 +08:00
parent 44352aeb2f
commit 4724b9db6a
65 changed files with 1211 additions and 293 deletions
@@ -11,7 +11,6 @@ describe('drainage content detection', () => {
['裸域名', '访问 t.cn/a1 查看详情', 'url'],
['IP 链接', '入口 192.168.1.10:8080/path。', 'url'],
['中文句号拆分域名', '请访问 example。com 领取', 'url'],
['空格拆分域名', '请访问 ex ample . com 领取', 'url'],
['+86 和空格手机号', '电话 +86 138 0013 8000', 'mobile'],
['短横线手机号', '电话 138-0013-8000', 'mobile'],
['括号区号和分机', '致电(0108888-8888 转 123', 'landline'],
@@ -26,6 +25,23 @@ describe('drainage content detection', () => {
expect(detectDrainageContentWithRules('邮箱 13800138000 @ example . com', rules).hasDrainageContent).toBe(false);
});
it.each([' ', '\t', '\n', '\u3000'])('stops a URL match at whitespace %p', (separator) => {
const url = 'https://example.com/path';
const suffix = '后续字符不属于链接';
const content = `详情 ${url}${separator}${suffix}`;
const result = detectDrainageContentWithRules(content, rules);
const urlMatches = (result.drainageDetection as { matches: Array<{ category: string; text: string; normalizedText: string }> })
.matches.filter((item) => item.category === 'url');
expect(urlMatches).toHaveLength(1);
expect(urlMatches[0]).toMatchObject({ text: url, normalizedText: url });
});
it('does not join a domain split by spaces into one URL', () => {
const result = detectDrainageContentWithRules('请访问 ex ample . com 领取', rules);
expect(result.hasDrainageContent).toBe(false);
});
it('keeps original offsets for record-page highlighting', () => {
const content = '📨详情请看 example。com/path,谢谢';
const result = detectDrainageContentWithRules(content, rules);
@@ -83,7 +83,10 @@ function normalizeContent(content: string, category: DrainageDetectionCategory):
.replace(/[()]/g, (char) => char === '' ? '(' : ')')
.replace(/[]/g, '+');
if (category === 'url') {
// 链接常被空格或中文句号拆开;句末中文句号也安全地成为正则边界。
// Whitespace is a URL boundary: removing it would incorrectly join the suffix into the link.
normalized = normalized.replace(/。/g, '.');
} else if (category === 'email') {
// Email exclusion keeps its broader normalization so spaced emails cannot leak into phone/URL matches.
normalized = normalized.replace(/\s+/gu, '').replace(//g, '.');
} else if (category === 'mobile' || category === 'landline') {
// 电话号码仅在检测副本中去除常见规避分隔符,绝不改写实际发送内容。
@@ -131,7 +134,7 @@ export function detectDrainageContentWithRules(
): DrainageDetectionResult {
const matches: DrainageDetectionMatch[] = [];
const normalizedByCategory = new Map<string, NormalizedContent>();
const emailNormalized = normalizeContent(content, 'url');
const emailNormalized = normalizeContent(content, 'email');
const originalEmailRanges = emailRanges(emailNormalized).map((range) => sourceRange(emailNormalized, range.start, range.end));
for (const rule of [...rules].sort((a, b) => a.priority - b.priority || a.code.localeCompare(b.code))) {
validateDrainageDetectionPattern(rule.pattern, rule.flags);
@@ -25,6 +25,8 @@ export interface GatewayInboundAuthDto {
authSource?: string;
timestamp?: number;
remoteIp?: string;
version?: string;
requestedVersion?: number;
}
export interface GatewayInboundSubmitDto {
+51 -2
View File
@@ -983,19 +983,34 @@ describe('SendChainService', () => {
expect(prisma.smsBatchTask.create).not.toHaveBeenCalled();
});
it('returns the application enterprise code after Gateway authentication', async () => {
const { service } = createService();
it('returns the application enterprise code and audits the inbound parameters after Gateway authentication', async () => {
const { service, prisma } = createService();
await expect(service.authenticateInboundApplication({
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
version: 'cmpp30',
requestedVersion: 48,
})).resolves.toEqual(expect.objectContaining({
account: '100001',
enterpriseCode: 'SP0001',
maxConnections: 2,
status: 'authenticated',
}));
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: 'app-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({
result: 'authenticated',
request: expect.objectContaining({ account: '100001', password: 'secret-hash', version: 'cmpp30', requestedVersion: 48 }),
}),
}),
});
});
it('rejects Gateway authentication when application interface is disabled', async () => {
@@ -1015,6 +1030,38 @@ describe('SendChainService', () => {
password: 'secret-hash',
remoteIp: '127.0.0.1',
})).rejects.toThrow('CMPP interface is disabled for this application');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: 'tenant-1',
ipAddress: '127.0.0.1',
detail: expect.objectContaining({ result: 'failed', error: 'CMPP interface is disabled for this application' }),
}),
});
});
it('audits an unknown Gateway authentication account with its source IP', async () => {
const { service, prisma } = createService();
prisma.smsApplication.findFirst.mockResolvedValue(null);
await expect(service.authenticateInboundApplication({
account: 'ATTACKER',
authSource: 'invalid-auth-source',
timestamp: 120000000,
remoteIp: '203.0.113.9',
version: 'cmpp30',
requestedVersion: 48,
})).rejects.toThrow('CMPP account is invalid or disabled');
expect(prisma.operationLog.create).toHaveBeenCalledWith({
data: expect.objectContaining({
tenantId: undefined,
resourceId: 'ATTACKER',
ipAddress: '203.0.113.9',
detail: expect.objectContaining({
result: 'failed',
request: expect.objectContaining({ account: 'ATTACKER', authSource: 'invalid-auth-source' }),
}),
}),
});
});
it('rejects new submissions synchronously when the application interface was disabled after bind', async () => {
@@ -3308,6 +3355,8 @@ describe('SendChainService', () => {
account: '100001',
password: 'secret-hash',
remoteIp: '127.0.0.1',
version: 'cmpp30',
requestedVersion: 48,
})).resolves.toEqual(expect.objectContaining({ status: 'authenticated' }));
await expect(service.submitInboundMessage({
account: '100001',
@@ -59,31 +59,77 @@ export class SendInboundEntryService {
async authenticateInboundApplication(data: GatewayInboundAuthDto) {
const application = await this.facade.findInboundApplication(data.account);
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
let tenantId: string | undefined;
let applicationId: string | undefined;
try {
const application = await this.facade.findInboundApplication(data.account);
tenantId = application?.tenantId;
applicationId = application?.id;
if (!application || !['active', 'disabling'].includes(application.status) || application.tenant.status !== 'active') {
throw new BadRequestException('CMPP account is invalid or disabled');
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
await this.recordInboundConnectRequest(data, { tenantId, applicationId, result: 'authenticated' });
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
} catch (error) {
await this.recordInboundConnectRequest(data, {
tenantId,
applicationId,
result: 'failed',
error: error instanceof Error ? error.message : 'unknown error',
});
throw error;
}
if (!application.interfaceEnabled) {
throw new BadRequestException('CMPP interface is disabled for this application');
}
if (application.tenant.certificationStatus !== 'approved') {
throw new BadRequestException('Enterprise certification is not approved');
}
if (!matchesApplicationSecret(data, application.secretHash)) {
throw new BadRequestException('CMPP account or password is invalid');
}
if (data.remoteIp && !isIpAllowed(data.remoteIp, application.ipAllowlist.map((item) => item.ipCidr))) {
throw new BadRequestException('CMPP source IP is not in application allowlist');
}
return {
applicationId: application.id,
tenantId: application.tenantId,
account: application.cmppAccount,
enterpriseCode: application.cmppEnterpriseCode,
passwordCipher: application.secretHash,
maxConnections: application.cmppMaxConnections,
status: 'authenticated',
};
}
private recordInboundConnectRequest(
data: GatewayInboundAuthDto,
outcome: { tenantId?: string; applicationId?: string; result: 'authenticated' | 'failed'; error?: string },
) {
return this.prisma.operationLog.create({
data: {
tenantId: outcome.tenantId,
action: 'cmpp_connection.connect_requested',
resource: 'cmpp_downstream_connection',
resourceId: outcome.applicationId ?? data.account,
ipAddress: data.remoteIp?.trim() || undefined,
detail: {
direction: 'client_to_platform',
result: outcome.result,
applicationId: outcome.applicationId ?? null,
request: {
remoteIp: data.remoteIp?.trim() || null,
account: data.account,
// Standard CMPP sends AuthenticatorSource rather than a plaintext password; keep both fields truthful.
password: data.password ?? null,
authSource: data.authSource ?? null,
timestamp: data.timestamp ?? null,
version: data.version ?? null,
requestedVersion: data.requestedVersion ?? null,
},
error: outcome.error ?? null,
} as Prisma.InputJsonValue,
},
});
}
async submitInboundMessage(data: GatewayInboundSubmitDto) {