先说背景

去年帮合肥一家制造业企业做外呼系统的合规改造,踩了不少坑。企业之前用的系统没有频率管控,一个月被投诉了十几次,差点被运营商封线路。改造过程中把合规技术架构从头到尾梳理了一遍,整理成这篇笔记。

本文只讲技术架构和部署实践,不推荐任何产品,不做商业对比。提到的技术方案都是通用实现,具体产品选型请自行评估。

一、合规技术架构总览

一套合规的智能外呼系统,从技术架构上看分为五层:

接入层:负责和运营商网关对接,处理 SIP 信令和 RTP 媒体流,是呼叫进出的第一道关口。频率管控和拒绝名单过滤应该在这一层实现,因为这是呼叫真正发出去之前的最后一道关卡。

控制层:负责呼叫控制、会话管理、路由分发。包括外呼任务调度、坐席分配、IVR 流程控制、通话转接等功能。

业务层:负责业务逻辑处理,包括客户管理、话术管理、意向识别、通话录音、数据统计等。这一层是应用层,功能最丰富,但合规控制不能只依赖这一层。

数据层:负责数据存储和管理,包括客户数据库、录音文件存储、话单数据库、操作日志数据库等。数据加密和备份在这一层实现。

运维层:负责系统监控、日志收集、告警通知、配置管理等。合规审计和安全监控在这一层实现。

关键原则:合规控制必须在接入层实现,不能只在业务层实现。业务层有 bug 或者被恶意调用时,接入层仍然能拦截违规呼叫。

二、核心模块技术实现

模块一:外呼频率管控

频率管控是防止骚扰客户的核心功能。技术实现分为网关层和应用层两种,前面说了,必须在网关层实现。

网关层频率管控的实现思路:

在接入层部署一个频率控制中间件,每个呼叫请求到达网关时,中间件查询 Redis 中这个被叫号码的当日和当周拨打计数,如果达到上限,直接返回 403 拒绝,呼叫不会发往运营商网络。

Redis 的 key 设计:

  • 当日计数:freq:daily:{被叫号码}:{日期},TTL 设置为 25 小时
  • 当周计数:freq:weekly:{被叫号码}:{周数},TTL 设置为 8 天

计数用 Redis 的 INCR 命令,原子操作,不会有并发问题。

默认配置:同一客户每日不超过 3 次,每周不超过 10 次。配置存在配置中心,可以按行业动态调整。

模块二:客户拒绝名单管理

客户在通话中说 "不需要"" 别打了 ",系统应该实时识别并加入拒绝名单。

实现思路:

  1. 通话过程中,ASR 实时转写客户语音
  2. 关键词匹配引擎实时检测拒绝关键词(不需要、别打、不要再联系、拉黑、投诉等)
  3. 检测到拒绝关键词后,立即将被叫号码写入拒绝名单 Redis 集合
  4. 同时写入数据库持久化
  5. 接入层每次呼叫前,先检查被叫号码是否在拒绝名单集合中,如果在,直接拒绝

Redis 用 Set 存储拒绝名单:reject:list,SADD 添加,SISMEMBER 检查。

注意:拒绝名单必须在接入层检查,不能只在业务层检查。

模块三:通话录音存档

所有通话必须自动录音,不能丢。

技术实现:

  1. 接入层检测到通话建立后,自动启动录音,不依赖应用层指令
  2. 采用双轨录音,分别录制坐席端和客户端音频,后期可以分别分析
  3. 录音格式用 MP3,比特率 64kbps,兼顾音质和存储空间
  4. 录音文件生成后,立即用 AES-256 加密,密钥从 KMS 获取
  5. 加密后的录音文件上传到对象存储(MinIO 或 S3)
  6. 录音元数据(通话 ID、主叫、被叫、开始时间、结束时间、时长、坐席、意向等级)写入 MySQL
  7. 每个录音文件计算 SHA-256 哈希,存在只写日志中,用于防篡改验证
  8. 录音保留期不少于 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 号码需要工信部审批,资质要求高,但正规性好。第三,线路资费要问清楚,是按分钟还是按秒计费,有没有最低消费,有没有号码月租,账单能不能查明细。有些服务商报价低,但有各种隐藏费用,实际用下来很贵。第四,安徽地区的企业,最好选在安徽有机房或者节点的服务商,通话时延低,音质好。跨区域线路时延大,对话不自然,影响客户体验。第五,高峰期要确认线路容量,大促或招生季外呼量暴增,如果线路容量不够,会出现呼叫排队甚至失败。提前和运营商确认高峰期容量保障,必要时提前扩容。

Logo

DAMO开发者矩阵,由阿里巴巴达摩院和中国互联网协会联合发起,致力于探讨最前沿的技术趋势与应用成果,搭建高质量的交流与分享平台,推动技术创新与产业应用链接,围绕“人工智能与新型计算”构建开放共享的开发者生态。

更多推荐