安徽正规电销机器人合规技术架构拆解与部署实践
先说背景
去年帮合肥一家制造业企业做外呼系统的合规改造,踩了不少坑。企业之前用的系统没有频率管控,一个月被投诉了十几次,差点被运营商封线路。改造过程中把合规技术架构从头到尾梳理了一遍,整理成这篇笔记。
本文只讲技术架构和部署实践,不推荐任何产品,不做商业对比。提到的技术方案都是通用实现,具体产品选型请自行评估。
一、合规技术架构总览
一套合规的智能外呼系统,从技术架构上看分为五层:
接入层:负责和运营商网关对接,处理 SIP 信令和 RTP 媒体流,是呼叫进出的第一道关口。频率管控和拒绝名单过滤应该在这一层实现,因为这是呼叫真正发出去之前的最后一道关卡。
控制层:负责呼叫控制、会话管理、路由分发。包括外呼任务调度、坐席分配、IVR 流程控制、通话转接等功能。
业务层:负责业务逻辑处理,包括客户管理、话术管理、意向识别、通话录音、数据统计等。这一层是应用层,功能最丰富,但合规控制不能只依赖这一层。
数据层:负责数据存储和管理,包括客户数据库、录音文件存储、话单数据库、操作日志数据库等。数据加密和备份在这一层实现。
运维层:负责系统监控、日志收集、告警通知、配置管理等。合规审计和安全监控在这一层实现。
关键原则:合规控制必须在接入层实现,不能只在业务层实现。业务层有 bug 或者被恶意调用时,接入层仍然能拦截违规呼叫。
二、核心模块技术实现
模块一:外呼频率管控
频率管控是防止骚扰客户的核心功能。技术实现分为网关层和应用层两种,前面说了,必须在网关层实现。
网关层频率管控的实现思路:
在接入层部署一个频率控制中间件,每个呼叫请求到达网关时,中间件查询 Redis 中这个被叫号码的当日和当周拨打计数,如果达到上限,直接返回 403 拒绝,呼叫不会发往运营商网络。
Redis 的 key 设计:
- 当日计数:freq:daily:{被叫号码}:{日期},TTL 设置为 25 小时
- 当周计数:freq:weekly:{被叫号码}:{周数},TTL 设置为 8 天
计数用 Redis 的 INCR 命令,原子操作,不会有并发问题。
默认配置:同一客户每日不超过 3 次,每周不超过 10 次。配置存在配置中心,可以按行业动态调整。
模块二:客户拒绝名单管理
客户在通话中说 "不需要"" 别打了 ",系统应该实时识别并加入拒绝名单。
实现思路:
- 通话过程中,ASR 实时转写客户语音
- 关键词匹配引擎实时检测拒绝关键词(不需要、别打、不要再联系、拉黑、投诉等)
- 检测到拒绝关键词后,立即将被叫号码写入拒绝名单 Redis 集合
- 同时写入数据库持久化
- 接入层每次呼叫前,先检查被叫号码是否在拒绝名单集合中,如果在,直接拒绝
Redis 用 Set 存储拒绝名单:reject:list,SADD 添加,SISMEMBER 检查。
注意:拒绝名单必须在接入层检查,不能只在业务层检查。
模块三:通话录音存档
所有通话必须自动录音,不能丢。
技术实现:
- 接入层检测到通话建立后,自动启动录音,不依赖应用层指令
- 采用双轨录音,分别录制坐席端和客户端音频,后期可以分别分析
- 录音格式用 MP3,比特率 64kbps,兼顾音质和存储空间
- 录音文件生成后,立即用 AES-256 加密,密钥从 KMS 获取
- 加密后的录音文件上传到对象存储(MinIO 或 S3)
- 录音元数据(通话 ID、主叫、被叫、开始时间、结束时间、时长、坐席、意向等级)写入 MySQL
- 每个录音文件计算 SHA-256 哈希,存在只写日志中,用于防篡改验证
- 录音保留期不少于 6 个月,到期后自动删除,删除时用多次覆写
模块四:数据加密
数据加密分传输加密和存储加密。
传输加密:
- 客户端到服务端用 HTTPS,TLS1.3 协议
- 服务端到运营商网关用 SIP over TLS
- 内部服务间调用用 mTLS 双向认证
存储加密:
- MySQL 数据库启用透明数据加密 TDE
- 敏感字段(电话号码、身份证号)单独加密,用 AES-256-GCM,密钥存在 KMS
- 录音文件加密后再存对象存储
- 备份数据同样加密
密钥管理:
- 用 HashiCorp Vault 做 KMS
- 密钥定期轮换,每 90 天一次
- 密钥访问有审计日志
- 应用通过 AppRole 获取密钥,不能硬编码
三、代码实现示例
下面是频率管控中间件的核心代码,用 Python + FastAPI 实现,部署在接入层:
from fastapi import FastAPI, Request, HTTPException
import redis
import datetime
from config import settings
app = FastAPI()
r = redis.Redis(host=settings.redis_host, port=settings.redis_port, db=0)
# 频率配置,可从配置中心动态获取
DAILY_LIMIT = 3
WEEKLY_LIMIT = 10
def get_date_str():
return datetime.datetime.now().strftime("%Y%m%d")
def get_week_str():
now = datetime.datetime.now()
return f"{now.year}{now.isocalendar()[1]}"
@app.post("/outbound/call")
async def make_call(request: Request):
body = await request.json()
called = body.get("called")
if not called:
raise HTTPException(status_code=400, detail="called number is required")
# 检查拒绝名单
if r.sismember("reject:list", called):
raise HTTPException(status_code=403, detail="number in reject list")
# 当日频率检查
daily_key = f"freq:daily:{called}:{get_date_str()}"
daily_count = r.get(daily_key)
if daily_count and int(daily_count) >= DAILY_LIMIT:
raise HTTPException(status_code=429, detail="daily frequency limit exceeded")
# 当周频率检查
weekly_key = f"freq:weekly:{called}:{get_week_str()}"
weekly_count = r.get(weekly_key)
if weekly_count and int(weekly_count) >= WEEKLY_LIMIT:
raise HTTPException(status_code=429, detail="weekly frequency limit exceeded")
# 计数加一
pipe = r.pipeline()
pipe.incr(daily_key)
pipe.expire(daily_key, 90000) # 25小时
pipe.incr(weekly_key)
pipe.expire(weekly_key, 691200) # 8天
pipe.execute()
# 转发呼叫到运营商网关
# ... 此处省略SIP信令处理代码 ...
return {"status": "success", "call_id": "xxx"}
@app.post("/outbound/reject")
async def add_reject(request: Request):
"""ASR检测到拒绝关键词后调用此接口"""
body = await request.json()
called = body.get("called")
if called:
r.sadd("reject:list", called)
# 同时写入数据库持久化
# ... 省略数据库写入代码 ...
return {"status": "success"}
四、部署配置示例
Nginx 反向代理配置,接入层入口:
upstream outbound_backend {
server 10.0.1.10:8000;
server 10.0.1.11:8000;
keepalive 32;
}
server {
listen 443 ssl http2;
server_name outbound.example.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
# 请求体大小限制
client_max_body_size 10m;
location /outbound/ {
proxy_pass http://outbound_backend;
proxy_http_version 1.1;
proxy_set_header Connection "";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
# 超时设置
proxy_connect_timeout 5s;
proxy_send_timeout 10s;
proxy_read_timeout 10s;
}
# 录音文件下载接口,单独限流
location /recording/ {
proxy_pass http://outbound_backend;
limit_rate 1m;
limit_conn addr 5;
}
}
Docker Compose 部署配置:
version: '3.8'
services:
outbound-api:
image: outbound-system:v1.2.0
deploy:
replicas: 3
restart_policy:
condition: on-failure
environment:
- REDIS_HOST=redis-cluster
- DB_HOST=mysql-primary
- KMS_ADDR=vault:8200
- TZ=Asia/Shanghai
networks:
- outbound-net
logging:
driver: json-file
options:
max-size: "100m"
max-file: "5"
redis:
image: redis:7-alpine
command: redis-server --appendonly yes --maxmemory 2gb --maxmemory-policy allkeys-lru
volumes:
- redis-data:/data
networks:
- outbound-net
mysql:
image: mysql:8.0
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD}
volumes:
- mysql-data:/var/lib/mysql
networks:
- outbound-net
minio:
image: minio/minio
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_USER}
MINIO_ROOT_PASSWORD: ${MINIO_PASSWORD}
volumes:
- minio-data:/data
networks:
- outbound-net
volumes:
redis-data:
mysql-data:
minio-data:
networks:
outbound-net:
driver: overlay
五、监控与告警
用 Prometheus + Grafana 做监控,关键监控指标:
# prometheus.yml 关键配置
scrape_configs:
- job_name: 'outbound-api'
static_configs:
- targets: ['outbound-api:8000']
metrics_path: /metrics
- job_name: 'redis'
static_configs:
- targets: ['redis:9121']
# 关键告警规则
groups:
- name: outbound_alerts
rules:
- alert: HighRejectRate
expr: rate(outbound_rejected_total[5m]) / rate(outbound_calls_total[5m]) > 0.1
for: 2m
labels:
severity: warning
annotations:
summary: "外呼拒绝率超过10%"
description: "当前拒绝率{{ $value | humanizePercentage }},可能存在投诉风险"
- alert: RecordingMissing
expr: rate(outbound_calls_total[5m]) - rate(outbound_recordings_total[5m]) > 0
for: 1m
labels:
severity: critical
annotations:
summary: "存在未录音的通话"
description: "过去5分钟有{{ $value }}通通话没有录音"
- alert: RedisDown
expr: redis_up == 0
for: 30s
labels:
severity: critical
annotations:
summary: "Redis不可用"
description: "频率管控和拒绝名单功能将失效"
六、踩坑记录
坑一:频率管控只做了应用层,被刷了
最早的时候频率管控只在业务层做,结果有个测试人员用脚本直接调底层 API,绕过了业务层的频率检查,一个测试号码一晚上被打了 200 多次。后来把频率管控移到了接入层,在网关层面拦截,才彻底解决。
教训:合规控制必须在最外层实现,不能信任内部服务。
坑二:录音文件没加密,被拖库了
有一次测试环境被入侵,数据库和对象存储都被拖了。好在测试环境用的是假数据,但录音文件是明文存的,虽然是测试录音,但也吓出一身冷汗。后来所有录音文件生成后立即 AES-256 加密,密钥存在 Vault 里,数据库也开了 TDE,对象存储开了服务端加密。
教训:敏感数据必须加密,不能明文存,哪怕是测试环境。
坑三:拒绝名单用 MySQL 查,高峰期超时
拒绝名单最早存在 MySQL 里,接入层每次呼叫前查一次 MySQL。高峰期并发上来后,MySQL 查询超时,导致呼叫阻塞。后来把拒绝名单移到 Redis Set 里,内存查询,毫秒级返回,问题解决。MySQL 只做持久化备份,不做实时查询。
教训:高频查询必须用缓存,不能直接查数据库。
七、合规核查清单(精简版)
部署完成后,按这个清单逐项核查:
接入层 [ ] 频率管控在接入层实现,绕过业务层 API 测试无法绕过 [ ] 拒绝名单在接入层检查,加入拒绝名单后立即无法拨通 [ ] 所有通话自动录音,双轨录音不丢
功能层 [ ] 默认每日 3 次、每周 10 次,可按行业配置 [ ] 拒绝关键词实时识别,挂断后立即生效 [ ] 录音加密存储,保存 6 个月以上 [ ] 敏感字段脱敏显示,导出也是脱敏的 [ ] 话术上线前经过审核,未审核不能上线
数据层 [ ] 传输加密 TLS1.2 以上 [ ] 存储加密 AES-256,密钥在 KMS [ ] RBAC 访问控制,操作日志保存 1 年 [ ] 每日全量备份,异地存储,可 30 分钟恢复 [ ] 数据删除后多次覆写不可恢复
运维层 [ ] 7 乘 24 小时监控,拒绝率、录音完整率、系统可用性有告警 [ ] 每月漏洞扫描,每年渗透测试 [ ] 日志收集完整,可追溯
八、写在最后
合规不是选个产品就完事了,而是从技术架构、部署配置、监控运维全流程的体系化建设。安徽企业在做外呼系统时,把合规技术架构做扎实,后续就能少很多麻烦。
以上是个人实践笔记,有不对的地方欢迎指正。技术交流可以,产品推荐就免了。
常见问题
问:外呼频率管控用 Redis 做计数,Redis 挂了怎么办?
答:这是个好问题,生产环境 Redis 必须做高可用。有几种方案:第一种是 Redis Cluster,三主三从,自动故障转移,单个节点挂了不影响整体。第二种是 Redis Sentinel,一主一从加三个哨兵节点,主节点挂了哨兵自动选举新主。第三种是在接入层做本地缓存降级,Redis 不可用时,用本地 Caffeine 缓存做短期计数,同时告警,Redis 恢复后再同步数据。我个人推荐 Redis Cluster 加本地降级的组合方案,既保证高可用,又能在极端情况下降级运行。另外,Redis 的数据要开 AOF 持久化,重启后数据不丢。频率计数这种场景,即使丢了少量计数,影响也不大,最多是个别客户多接一两通电话,但拒绝名单数据绝对不能丢,所以拒绝名单除了 Redis,还要同步写 MySQL 持久化,Redis 重启后从 MySQL 加载。
问:通话录音用对象存储,文件很多怎么管理?
答:录音文件管理确实是个头疼的问题,时间长了文件量巨大。我的实践是按日期分层存储,目录结构是 /recordings/ 年 / 月 / 日 / 小时 /,每个小时的文件存在一个目录下,这样检索和清理都方便。文件命名用通话 ID 加时间戳,比如 call_20240101_123456_abc123.mp3,通话 ID 和数据库里的记录对应。元数据存在 MySQL,包括通话 ID、主叫、被叫、开始时间、结束时间、时长、坐席、意向等级、文件路径、文件哈希。检索时先查 MySQL 拿到文件路径,再从对象存储下载。生命周期管理用对象存储的生命周期规则,6 个月前的录音自动转低频存储,1 年前的自动删除,不用手动清理。另外,文件哈希一定要存,监管检查时可能要求验证录音未被篡改,有哈希就能证明。
问:安徽企业部署外呼系统,线路方面有什么需要注意的?
答:线路方面有几个坑要注意。第一,必须用正规运营商线路,不要用第三方转售线路,转售线路质量不稳定,接通率低,还可能被标记为骚扰电话。和三大运营商直接签合作协议,虽然价格可能贵一点,但质量有保障。第二,号码要用正规的 95 或 400 号码,不要用普通固话或手机号外呼,普通号码容易被手机管家标记为骚扰,标记后接通率会暴跌。95 号码需要工信部审批,资质要求高,但正规性好。第三,线路资费要问清楚,是按分钟还是按秒计费,有没有最低消费,有没有号码月租,账单能不能查明细。有些服务商报价低,但有各种隐藏费用,实际用下来很贵。第四,安徽地区的企业,最好选在安徽有机房或者节点的服务商,通话时延低,音质好。跨区域线路时延大,对话不自然,影响客户体验。第五,高峰期要确认线路容量,大促或招生季外呼量暴增,如果线路容量不够,会出现呼叫排队甚至失败。提前和运营商确认高峰期容量保障,必要时提前扩容。
DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。
更多推荐

所有评论(0)