feat: improve operations diagnostics and channel management
This commit is contained in:
@@ -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'],
|
||||
['括号区号和分机', '致电(010)8888-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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user