fix phase2 gateway matching and release artifact
This commit is contained in:
@@ -275,6 +275,46 @@ describe('S13 customer gateways API', () => {
|
||||
.expect(403);
|
||||
});
|
||||
|
||||
it('rejects ambiguous source IP and business prefix matches', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
customerId: 'cus_seed',
|
||||
name: 'Duplicated Business Prefix Gateway',
|
||||
authMode: 'IP',
|
||||
sourceIps: ['100.93.185.30'],
|
||||
lineGroupId: 'llg_seed',
|
||||
callerMatchMode: 'ANY',
|
||||
calleeMatchMode: 'BUSINESS_PREFIXES',
|
||||
businessPrefixIds: ['bp_seed']
|
||||
})
|
||||
.expect(409)
|
||||
.expect((response) => {
|
||||
expect(response.body.code).toBe('CUSTOMER_GATEWAY_MATCH_CONFLICT');
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects overlapping caller prefixes on the same source IP', async () => {
|
||||
await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways')
|
||||
.set('Authorization', `Bearer ${tokenFor('usr_ops')}`)
|
||||
.send({
|
||||
customerId: 'cus_seed',
|
||||
name: 'Overlapped Caller Prefix Gateway',
|
||||
authMode: 'IP',
|
||||
sourceIps: ['100.93.185.30'],
|
||||
lineGroupId: 'llg_seed',
|
||||
callerMatchMode: 'PREFIXES',
|
||||
callerPrefixes: ['0211'],
|
||||
calleeMatchMode: 'ANY'
|
||||
})
|
||||
.expect(409)
|
||||
.expect((response) => {
|
||||
expect(response.body.code).toBe('CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP');
|
||||
});
|
||||
});
|
||||
|
||||
it('creates SIP digest gateway, hides the password, and writes audit', async () => {
|
||||
const response = await request(app.getHttpServer())
|
||||
.post('/api/v2/customer-gateways')
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { BadRequestException, Inject, Injectable } from '@nestjs/common';
|
||||
import { BadRequestException, ConflictException, Inject, Injectable } from '@nestjs/common';
|
||||
import crypto from 'node:crypto';
|
||||
import net from 'node:net';
|
||||
import {
|
||||
@@ -48,6 +48,17 @@ interface UpdateCustomerGatewayDto {
|
||||
businessPrefixIds?: unknown;
|
||||
}
|
||||
|
||||
interface GatewayMatchingConfig {
|
||||
id?: string;
|
||||
sourceIps: string[];
|
||||
callerMatchMode: CustomerGatewayCallerMatchMode;
|
||||
callerPrefixes: string[];
|
||||
calleeMatchMode: CustomerGatewayCalleeMatchMode;
|
||||
businessPrefixIds: string[];
|
||||
}
|
||||
|
||||
const EMPTY_BUSINESS_PREFIX_KEY = '__EMPTY_BUSINESS_PREFIX__';
|
||||
|
||||
@Injectable()
|
||||
export class CustomerGatewaysService {
|
||||
constructor(@Inject(CUSTOMER_GATEWAYS_REPOSITORY) private readonly gateways: CustomerGatewaysRepository) {}
|
||||
@@ -61,7 +72,7 @@ export class CustomerGatewaysService {
|
||||
return this.gateways.get(gatewayId);
|
||||
}
|
||||
|
||||
create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
async create(body: CreateCustomerGatewayDto, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
const authMode = this.authMode(body.authMode);
|
||||
const sipIdentity = this.normalizeSipIdentity(authMode, body.sipUsername, body.sipDomain);
|
||||
const sipPassword = this.requiredSipPassword(authMode, body.sipPassword);
|
||||
@@ -88,6 +99,13 @@ export class CustomerGatewaysService {
|
||||
actorId
|
||||
};
|
||||
|
||||
await this.assertMatchingUniqueness(undefined, {
|
||||
sourceIps,
|
||||
callerMatchMode,
|
||||
callerPrefixes,
|
||||
calleeMatchMode,
|
||||
businessPrefixIds
|
||||
});
|
||||
return this.gateways.create(input);
|
||||
}
|
||||
|
||||
@@ -140,10 +158,20 @@ export class CustomerGatewaysService {
|
||||
actorId
|
||||
};
|
||||
|
||||
await this.assertMatchingUniqueness(gatewayId, {
|
||||
id: gatewayId,
|
||||
sourceIps,
|
||||
callerMatchMode,
|
||||
callerPrefixes,
|
||||
calleeMatchMode,
|
||||
businessPrefixIds
|
||||
});
|
||||
return this.gateways.update(gatewayId, input);
|
||||
}
|
||||
|
||||
enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
async enable(gatewayId: string, actorId?: string): Promise<CustomerGatewaySummary> {
|
||||
const current = await this.gateways.get(gatewayId);
|
||||
await this.assertMatchingUniqueness(gatewayId, this.summaryToMatchingConfig(current));
|
||||
return this.gateways.setStatus(gatewayId, 'ENABLED', actorId);
|
||||
}
|
||||
|
||||
@@ -282,6 +310,15 @@ export class CustomerGatewaysService {
|
||||
throw new BadRequestException({ code: 'CALLER_PREFIX_INVALID', message: 'callerPrefixes contains invalid characters.' });
|
||||
}
|
||||
}
|
||||
const overlappedPrefix = prefixes.find((prefix, index) =>
|
||||
prefixes.slice(index + 1).some((otherPrefix) => this.prefixesOverlap(prefix, otherPrefix))
|
||||
);
|
||||
if (overlappedPrefix) {
|
||||
throw new BadRequestException({
|
||||
code: 'CALLER_PREFIX_OVERLAP',
|
||||
message: `Caller prefix "${overlappedPrefix}" overlaps with another caller prefix.`
|
||||
});
|
||||
}
|
||||
return prefixes;
|
||||
}
|
||||
|
||||
@@ -296,6 +333,71 @@ export class CustomerGatewaysService {
|
||||
return ids;
|
||||
}
|
||||
|
||||
private async assertMatchingUniqueness(ignoreGatewayId: string | undefined, proposed: GatewayMatchingConfig): Promise<void> {
|
||||
if (proposed.sourceIps.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proposedIpSet = new Set(proposed.sourceIps);
|
||||
const proposedBusinessKeys = this.businessPrefixKeys(proposed);
|
||||
const proposedCallerPrefixes = proposed.callerMatchMode === 'PREFIXES' ? proposed.callerPrefixes : [];
|
||||
const gateways = await this.gateways.list();
|
||||
|
||||
for (const gateway of gateways) {
|
||||
if (gateway.id === ignoreGatewayId || gateway.status !== 'ENABLED') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sharedIps = gateway.sourceIps.filter((sourceIp) => proposedIpSet.has(sourceIp));
|
||||
if (sharedIps.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const existingBusinessKeys = this.businessPrefixKeys(this.summaryToMatchingConfig(gateway));
|
||||
const duplicatedBusinessKey = proposedBusinessKeys.find((key) => existingBusinessKeys.includes(key));
|
||||
if (duplicatedBusinessKey) {
|
||||
throw new ConflictException({
|
||||
code: 'CUSTOMER_GATEWAY_MATCH_CONFLICT',
|
||||
message:
|
||||
duplicatedBusinessKey === EMPTY_BUSINESS_PREFIX_KEY
|
||||
? 'The same source IP can have only one empty business-prefix fallback gateway.'
|
||||
: 'The same source IP and business prefix can resolve to only one customer gateway.'
|
||||
});
|
||||
}
|
||||
|
||||
if (gateway.callerMatchMode === 'PREFIXES' && proposedCallerPrefixes.length > 0) {
|
||||
const overlappedPrefix = proposedCallerPrefixes.find((prefix) =>
|
||||
gateway.callerPrefixes.some((existingPrefix) => this.prefixesOverlap(prefix, existingPrefix))
|
||||
);
|
||||
if (overlappedPrefix) {
|
||||
throw new ConflictException({
|
||||
code: 'CUSTOMER_GATEWAY_CALLER_PREFIX_OVERLAP',
|
||||
message: `Caller prefix "${overlappedPrefix}" overlaps with another gateway on the same source IP.`
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private summaryToMatchingConfig(gateway: CustomerGatewaySummary): GatewayMatchingConfig {
|
||||
return {
|
||||
id: gateway.id,
|
||||
sourceIps: gateway.sourceIps,
|
||||
callerMatchMode: gateway.callerMatchMode,
|
||||
callerPrefixes: gateway.callerPrefixes,
|
||||
calleeMatchMode: gateway.calleeMatchMode,
|
||||
businessPrefixIds: gateway.businessPrefixes.map((item) => item.id)
|
||||
};
|
||||
}
|
||||
|
||||
private businessPrefixKeys(config: GatewayMatchingConfig): string[] {
|
||||
return config.calleeMatchMode === 'BUSINESS_PREFIXES' ? config.businessPrefixIds : [EMPTY_BUSINESS_PREFIX_KEY];
|
||||
}
|
||||
|
||||
private prefixesOverlap(left: string, right: string): boolean {
|
||||
return left.startsWith(right) || right.startsWith(left);
|
||||
}
|
||||
|
||||
private stringList(value: unknown, field: string, maxItems: number, maxLength: number): string[] {
|
||||
const rawItems = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,,\s]+/) : [];
|
||||
const items = [...new Set(rawItems.map((item) => (typeof item === 'string' ? item.trim() : '')).filter(Boolean))];
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, Simpl
|
||||
import { enStatus } from '../utils/formatters.js';
|
||||
import { api, explainApiError } from '../api.js';
|
||||
|
||||
const emptyBusinessPrefixForm = { prefix: '', name: '', description: '', priority: 100, status: 'ENABLED' };
|
||||
|
||||
export function BusinessPrefixesPage({ can = () => true }) {
|
||||
const [rows, setRows] = useState([]);
|
||||
const [filters, setFilters] = useState({ keyword: '', status: 'all' });
|
||||
|
||||
@@ -363,6 +363,9 @@ export function CustomerGatewaysPage({ gatewayRows: apiGatewayRows, setGatewayRo
|
||||
<Field label="周期内费率">
|
||||
<Input value={gatewayForm.cycleRate} onChange={(event) => setGatewayForm({ ...gatewayForm, cycleRate: event.target.value })} placeholder="0.000000" />
|
||||
</Field>
|
||||
<Alert tone="info" title="匹配规则">
|
||||
业务前缀优先于主叫前缀,精确规则优先于空业务前缀兜底。空业务前缀用于裸被叫号码;同一 IP 下业务前缀组合必须唯一,主叫前缀不能互相覆盖。
|
||||
</Alert>
|
||||
<Field label="主叫匹配">
|
||||
<Select value={gatewayForm.callerMatchMode} onChange={(event) => setGatewayForm({ ...gatewayForm, callerMatchMode: event.target.value, callerPrefixes: '' })}>
|
||||
<option value="ANY">任意号码</option>
|
||||
|
||||
@@ -4,6 +4,8 @@ import { Icon, PageTitle, Panel, ApiNotice, Modal, ConfirmDialog, Drawer, Simple
|
||||
import { permissionGroups } from '../fixtures/devFixtures.js';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
const emptyRoleForm = { name: '', description: '', status: '启用' };
|
||||
|
||||
export function RolesPage({ roleRows, setRoleRows, userRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteRole }) {
|
||||
const [editingRole, setEditingRole] = useState(undefined);
|
||||
const [roleForm, setRoleForm] = useState(emptyRoleForm);
|
||||
|
||||
@@ -3,6 +3,8 @@ import { Alert, Badge, Button, Field, Input, Select } from '../components/ui.jsx
|
||||
import { Icon, PageTitle, Toolbar, Panel, ApiNotice, Modal, ConfirmDialog, SimpleTable } from '../components/layout.jsx';
|
||||
import { explainApiError } from '../api.js';
|
||||
|
||||
const emptyUserForm = { username: '', name: '', phone: '', email: '', roleId: 'R002', status: '启用' };
|
||||
|
||||
export function UsersPage({ userRows, setUserRows, roleRows, apiLoading, apiError, refreshApi, can = () => true, onDeleteUser }) {
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [roleFilter, setRoleFilter] = useState('all');
|
||||
|
||||
Reference in New Issue
Block a user