
未获用户同意的电话营销外呼正在成为越来越多市场的监管重点。法国宣布对未征得用户同意的电话营销外呼进行更严格的限制表面看是一则合规政策新闻落到工程侧它其实给每一家依赖外呼做运营的技术团队提出了同一个问题你能不能从数据上证明每一次外呼都有用户同意作为依据如果不能外呼系统就必须在现有自动拨号能力之上叠加一套“外呼合规校验层”。这篇文章从工程实现角度出发围绕外呼合规系统的最小闭环讲清楚四件事外呼合规在技术上到底要解决哪些问题数据模型怎么设计Java 服务怎么实现同意校验、黑名单过滤、频次限制和时段限制以及上线后怎么验证和排查。适合正在做呼叫中心、客户运营、外呼触达系统的后端开发也适合需要对接营销合规要求的产品和技术负责人参考。1. 先想清楚外呼合规限制的到底是什么1.1 一次合规外呼要满足的四个条件很多人谈到“禁止未经同意的电话营销”第一反应是维护一个退订名单。实际上完整的合规校验比退订名单复杂得多。把监管要求翻译成工程需求一次被允许的外呼至少需要同时满足四个条件号码来源合法用户主动留下号码或通过活动、App 注册、线下渠道明确授权使用而不是从第三方批量购买的数据。用户同意状态有效用户曾经同意接收营销电话且该同意没有被撤回。用户退订之后同意状态立即失效。不在禁呼名单中包括用户主动退订名单、监管禁呼名单、运营商投诉名单、企业内部黑名单。呼叫频次和时段合规同一号码在指定周期内的外呼次数不能超过上限且只能在允许的时间窗口内呼出。这四个条件缺一不可。只做退订名单拦截忽略同意状态仍然会在用户从未同意的情况下外呼只记录同意状态忽略频次和时段又会在凌晨重复骚扰同一个用户。1.2 为什么必须做成系统而不是人工管理外呼量小的时候靠客服手动标记“这个客户不接电话”“这个客户投诉过”看起来可行。但外呼系统通常是批量任务驱动的一个任务可能同时覆盖几万甚至几十万个号码人工判断根本来不及也做不到留痕。更关键的是审计要求。当监管或用户投诉要求解释“为什么给这个号码打过电话”时企业需要拿出的是用户同意记录、退订时间、外呼时间、频次计数这些可追溯的数据。只有把校验逻辑沉淀到系统里并在每次外呼前后写入审计日志才能回答这类问题。1.3 外呼请求要经过的检查链从工程视角看可以把外呼合规校验设计成一条责任链。每一条外呼请求按顺序经过以下检查点号码格式校验过滤无效号码。禁呼名单检查命中直接拒绝。同意状态检查区分已同意、未同意、已退订。频次检查统计最近 N 天内的呼叫次数。时段检查当前时间是否在外呼窗口内。任何一个检查点不通过外呼请求都应该在真正拨号之前被拦截而不是等电话已经呼出之后再做补偿。拦截动作本身和拦截原因也要记录方便后续分析哪些号码为什么被过滤。2. 合规校验服务的技术选型与数据模型2.1 组件选型和环境要求这里按常见的后端技术栈设计不绑定特定厂商。核心组件如下组件用途说明JDK 17运行环境也可以使用 JDK 11示例代码使用的语法需要向下兼容Spring Boot 3.x应用框架提供 Web 接口、定时任务、配置绑定MySQL 8.x主数据存储保存同意记录、禁呼名单、外呼记录、规则配置Redis 6.x缓存与计数缓存禁呼名单、做滑动窗口频次计数MyBatis-Plus 或 Spring Data JPAORM示例使用 Mapper 接口不强制选型学习环境里Redis 和 MySQL 都可以用 Docker 快速启动。生产环境则要考虑主从、持久化、备份和监控不能直接用本地单实例配置。2.2 项目目录结构一个最小可运行的合规校验服务可以按下面的目录组织call-compliance-service ├── pom.xml ├── src/main/java/com/example/callcompliance │ ├── CallComplianceApplication.java │ ├── controller │ │ └── ComplianceCheckController.java │ ├── service │ │ ├── ComplianceCheckService.java │ │ └── BatchPrecheckService.java │ ├── mapper │ │ ├── ConsentRecordMapper.java │ │ ├── DoNotCallMapper.java │ │ ├── CallRecordMapper.java │ │ └── RuleConfigMapper.java │ ├── model │ │ ├── ConsentStatus.java │ │ ├── CheckResult.java │ │ └── RejectItem.java │ └── util │ └── PhoneValidator.java └── src/main/resources ├── application.yml └── db/schema.sql目录本身不复杂核心在于把“校验规则”和“外呼执行”解耦。校验服务只负责判断不负责真正拨号这样后续接入任意呼叫平台都可以复用同一套合规逻辑。2.3 四张核心表的 DDL 设计第一张表保存用户同意和退订记录。这里的核心设计是“一号码一行”同一个号码的同意状态被最新记录覆盖便于快速查询。CREATE TABLE consent_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL COMMENT 手机号或座机号, consent_status TINYINT NOT NULL COMMENT 0未知 1已同意 2已退订, source VARCHAR(64) NOT NULL COMMENT 同意来源渠道app/website/offline/activity, consent_time DATETIME NULL COMMENT 同意时间, opt_out_time DATETIME NULL COMMENT 退订时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) COMMENT 用户同意与退订记录;第二张表保存禁呼名单。它和 consent_record 分开的原因在于来源不同consent_record 是用户本人的同意状态而禁呼名单可能来自监管名单、运营商投诉、企业内部风控等多个渠道且可能带有效期。CREATE TABLE do_not_call_list ( id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL COMMENT 禁呼号码, source VARCHAR(64) NOT NULL COMMENT 名单来源complaint/regulator/internal, expire_time DATETIME NULL COMMENT 名单过期时间NULL 表示长期有效, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_phone (phone) ) COMMENT 禁止外呼名单;第三张表记录每一次外呼。它有两个作用一是用于频次统计二是用于审计回溯。频次统计可以走 Redis但最终落库记录不能省。CREATE TABLE call_record ( id BIGINT AUTO_INCREMENT PRIMARY KEY, phone VARCHAR(20) NOT NULL, task_id VARCHAR(64) NULL COMMENT 外呼任务 ID, status VARCHAR(20) NOT NULL COMMENT ALLOWED/REJECTED/CALLED, reject_reason VARCHAR(64) NULL COMMENT 拒绝原因编码, call_time DATETIME NOT NULL COMMENT 外呼时间, create_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY idx_phone_time (phone, call_time) ) COMMENT 外呼记录;第四张表保存规则配置。时段、频次这类参数如果写死在代码里每次调整都要发版抽成配置表后运维人员可以通过接口或后台动态修改并保留修改时间。CREATE TABLE rule_config ( rule_key VARCHAR(64) PRIMARY KEY, rule_value VARCHAR(255) NOT NULL, description VARCHAR(255) NULL, update_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP ) COMMENT 合规规则配置;初始化数据示例如下INSERT INTO rule_config (rule_key, rule_value, description) VALUES (call_window_start, 09:00, 允许外呼开始时间), (call_window_end, 20:00, 允许外呼结束时间), (max_calls_per_day, 1, 同一号码每天最大外呼次数), (max_calls_per_week, 3, 同一号码每周最大外呼次数), (consent_required, true, 是否要求必须有用户同意);注意call_record表中的reject_reason应该使用固定编码而不是自然语言。这样监控报表和告警规则可以按编码聚合。3. 用 Java 实现最小合规外呼校验服务3.1 检查入口一次外呼要做哪些判断先定义校验结果对象和同意状态枚举它们是整个服务的基础类型。public enum ConsentStatus { UNKNOWN(0), CONSENTED(1), OPTED_OUT(2); private final int code; ConsentStatus(int code) { this.code code; } public int getCode() { return code; } public static ConsentStatus fromCode(int code) { for (ConsentStatus status : values()) { if (status.code code) { return status; } } return UNKNOWN; } }public class CheckResult { private final boolean pass; private final String reason; private CheckResult(boolean pass, String reason) { this.pass pass; this.reason reason; } public static CheckResult pass() { return new CheckResult(true, null); } public static CheckResult reject(String reason) { return new CheckResult(false, reason); } public boolean isPass() { return pass; } public String getReason() { return reason; } }核心检查服务按责任链顺序执行。注意这里刻意把每个检查点拆成独立方法目的是让规则可以单独测试和调整。Service public class ComplianceCheckService { private final StringRedisTemplate redisTemplate; private final ConsentRecordMapper consentMapper; private final DoNotCallMapper doNotCallMapper; private final RuleConfigMapper ruleConfigMapper; public ComplianceCheckService(StringRedisTemplate redisTemplate, ConsentRecordMapper consentMapper, DoNotCallMapper doNotCallMapper, RuleConfigMapper ruleConfigMapper) { this.redisTemplate redisTemplate; this.consentMapper consentMapper; this.doNotCallMapper doNotCallMapper; this.ruleConfigMapper ruleConfigMapper; } public CheckResult check(String phone, String campaignType) { // 1. 号码格式检查 if (!PhoneValidator.isValid(phone)) { return CheckResult.reject(INVALID_PHONE); } // 2. 禁呼名单检查 if (isInDoNotCallList(phone)) { return CheckResult.reject(IN_DO_NOT_CALL_LIST); } // 3. 同意状态检查 ConsentStatus status getConsentStatus(phone); if (status ConsentStatus.OPTED_OUT) { return CheckResult.reject(OPTED_OUT); } if (status ConsentStatus.UNKNOWN consentRequired()) { return CheckResult.reject(NO_CONSENT); } // 4. 频次检查 if (!withinFrequencyLimit(phone)) { return CheckResult.reject(FREQUENCY_LIMIT_EXCEEDED); } // 5. 时段检查 if (!withinTimeWindow()) { return CheckResult.reject(OUTSIDE_TIME_WINDOW); } return CheckResult.pass(); } private boolean isInDoNotCallList(String phone) { String cacheKey dnc: phone; Boolean exists redisTemplate.hasKey(cacheKey); if (Boolean.TRUE.equals(exists)) { return true; } DoNotCall record doNotCallMapper.findByPhone(phone); if (record ! null) { redisTemplate.opsForValue().set(cacheKey, 1, Duration.ofMinutes(30)); return true; } return false; } private ConsentStatus getConsentStatus(String phone) { ConsentRecord record consentMapper.findByPhone(phone); if (record null) { return ConsentStatus.UNKNOWN; } return ConsentStatus.fromCode(record.getConsentStatus()); } private boolean consentRequired() { String value ruleConfigMapper.getValue(consent_required, true); return Boolean.parseBoolean(value); } private boolean withinTimeWindow() { LocalTime now LocalTime.now(); LocalTime start LocalTime.parse(ruleConfigMapper.getValue(call_window_start, 09:00)); LocalTime end LocalTime.parse(ruleConfigMapper.getValue(call_window_end, 20:00)); return !now.isBefore(start) now.isBefore(end); } }这里的核心思路是“先查询后判断再记录”。禁呼名单先查 Redis未命中再查数据库命中后回填缓存并设置较短过期时间避免名单变更后缓存长期失效。3.2 用 Redis 滑动窗口做频次限制频次限制最容易做错的地方是把计数放在应用内存里。应用一旦多实例部署每个实例各计各的总频次必然超限。正确做法是用 Redis 保存统计窗口内的呼叫记录并通过 Lua 脚本保证“检查 写入”是原子操作。public boolean withinFrequencyLimit(String phone) { int maxPerDay Integer.parseInt(ruleConfigMapper.getValue(max_calls_per_day, 1)); int maxPerWeek Integer.parseInt(ruleConfigMapper.getValue(max_calls_per_week, 3)); long now System.currentTimeMillis(); long dayWindowStart now - TimeUnit.DAYS.toMillis(1); long weekWindowStart now - TimeUnit.DAYS.toMillis(7); if (!atomicIncrCheck(call:freq:day: phone, dayWindowStart, maxPerDay, now)) { return false; } return atomicIncrCheck(call:freq:week: phone, weekWindowStart, maxPerWeek, now); } private boolean atomicIncrCheck(String key, long windowStart, int maxCount, long now) { String luaScript redis.call(ZREMRANGEBYSCORE, KEYS[1], 0, ARGV[1]) local count redis.call(ZCARD, KEYS[1]) if count tonumber(ARGV[2]) then redis.call(ZADD, KEYS[1], ARGV[3], ARGV[4]) redis.call(PEXPIRE, KEYS[1], ARGV[5]) return 1 else return 0 end; DefaultRedisScriptLong script new DefaultRedisScript(luaScript, Long.class); ListString keys Collections.singletonList(key); Long result redisTemplate.execute( script, keys, String.valueOf(windowStart), String.valueOf(maxCount), String.valueOf(now), UUID.randomUUID().toString(), String.valueOf(TimeUnit.DAYS.toMillis(8)) ); return result ! null result 1L; }这里使用 Redis 有序集合保存每次外呼的时间戳。每次检查时先删除窗口之外的旧记录再统计集合大小。如果没超过上限就把当前时间戳写入集合并设置过期时间。整个过程在 Lua 脚本里完成多线程并发下也不会出现“同时检查通过然后一起写入”的超频问题。滑动窗口比固定时间窗口更适合外呼场景。固定窗口按自然天或自然周计数用户可能在周一零点刚过就连续收到多次外呼滑动窗口则严格限制“任意连续 24 小时”或“任意连续 7 天”内的呼叫次数更符合监管对用户感受的判断。3.3 时段规则与配置中心时段规则在示例里直接从rule_config表读取。实际项目中如果公司已经有配置中心或 Apollo、Nacos 这类组件建议把时段、频次、开关类配置放到配置中心管理数据库rule_config可以作为兜底或本地缓存。需要注意时区问题。如果外呼业务同时面向多个时区的用户时段规则不能只看服务器本地时间而应该根据被叫号码归属地换算用户当地时间。否则服务器在东八区给欧洲用户外呼时本地 14 点可能就是对方凌晨直接构成骚扰。public boolean withinTimeWindow(String phone) { ZoneId userZone PhoneZoneResolver.resolve(phone); LocalTime now LocalTime.now(userZone); LocalTime start LocalTime.parse(ruleConfigMapper.getValue(call_window_start, 09:00)); LocalTime end LocalTime.parse(ruleConfigMapper.getValue(call_window_end, 20:00)); return !now.isBefore(start) now.isBefore(end); }PhoneZoneResolver在真实项目中可以基于号码前缀映射时区示例里略去具体实现但要记住同一时刻不同国家的“允许外呼时间”是完全不同的判断。3.4 批量外呼前的预检逻辑外呼任务通常是批量的不能等呼叫平台已经拉起电话线程再逐条校验。批量预检的目的是在任务下发前过滤掉不合格号码并把过滤结果落库。Service public class BatchPrecheckService { private final ComplianceCheckService checkService; private final CallRecordMapper callRecordMapper; public BatchPrecheckService(ComplianceCheckService checkService, CallRecordMapper callRecordMapper) { this.checkService checkService; this.callRecordMapper callRecordMapper; } public BatchResult precheck(String taskId, ListString phones, String campaignType) { ListString allowed new ArrayList(); ListRejectItem rejected new ArrayList(); for (String phone : phones) { CheckResult result checkService.check(phone, campaignType); if (result.isPass()) { allowed.add(phone); callRecordMapper.insert(phone, taskId, ALLOWED, null); } else { rejected.add(new RejectItem(phone, result.getReason())); callRecordMapper.insert(phone, taskId, REJECTED, result.getReason()); } } // 汇总统计供任务调度系统使用 return new BatchResult(taskId, allowed, rejected); } }批量预检要注意内存占用。几万个号码直接放到内存 List 里应用会感受到明显压力。生产环境建议分批读取每批 1000 或 2000 条处理完一批落一批记录避免把整个文件一次性加载。4. 关键参数说明与配置示例4.1 application.yml 配置以下配置以一个学习环境为基准可以直接本地启动。server: port: 8080 spring: application: name: call-compliance-service datasource: url: jdbc:mysql://127.0.0.1:3306/call_compliance?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: call_app password: change-me hikari: maximum-pool-size: 10 data: redis: host: 127.0.0.1 port: 6379 database: 0 mybatis-plus: mapper-locations: classpath:/mapper/*.xml configuration: map-underscore-to-camel-case: true logging: level: com.example.callcompliance: info生产环境必须调整的点包括数据库连接池按业务峰值设置、开启 Redis 持久化和密码认证、日志接入集中采集、配置项外置化。不要把数据库密码和 Redis 密码写在项目的 yml 里直接提交到代码仓库。4.2 规则参数速查表下面这些参数会直接影响外呼是否放行理解每个参数调大或调小的影响比抄配置更重要。参数含义默认值调小影响调大影响call_window_start允许外呼开始时间09:00提前开始可能打扰用户休息时段缩短有效外呼时长任务积压call_window_end允许外呼结束时间20:00提前停止减少触达机会延迟到夜间投诉风险上升max_calls_per_day单号码每日最大外呼次数1漏唤次数多接通率下降用户被重复骚扰max_calls_per_week单号码每周最大外呼次数3触达不足运营效果下降违反频次约束易被投诉consent_required是否必须存在用户同意true无同意也可外呼合规风险高要求更严可呼号码池变小从合规角度看这些参数宁愿设置得保守一些。一次外呼触达失败造成的业务损失远小于一次投诉或监管处罚带来的风险。5. 运行验证与监控指标5.1 单号码验证启动服务后可以用 curl 模拟一次外呼前的检查请求。示例接口只用于演示生产环境建议加鉴权和限流。curl -X POST http://127.0.0.1:8080/api/compliance/check \ -H Content-Type: application/json \ -d {phone:13800138000,campaignType:SALES}如果号码已经在禁呼名单中预期返回{ pass: false, reason: IN_DO_NOT_CALL_LIST }如果号码没有同意记录且consent_requiredtrue预期返回{ pass: false, reason: NO_CONSENT }如果号码一切正常且未超过频次预期返回{ pass: true, reason: null }验证频次限制时连续调用同一个号码的检查接口第 1 次应该返回passtrue第 2 次返回FREQUENCY_LIMIT_EXCEEDED。此时可以登录 Redis 查看对应的 keyredis-cli ZCARD call:freq:day:13800138000 redis-cli ZCARD call:freq:week:138001380005.2 批量预检验证批量场景可以准备一个文本文件每行一个号码然后调用批量预检接口。跑完后查询call_record表确认每条号码都有ALLOWED或REJECTED状态且REJECTED记录带上了原因编码。SELECT status, reject_reason, COUNT(*) FROM call_record WHERE task_id TASK_20250101001 GROUP BY status, reject_reason;正常结果应该是拒绝原因分布清晰例如NO_CONSENT占比最高、FREQUENCY_LIMIT_EXCEEDED次之IN_DO_NOT_CALL_LIST最少。如果拒绝原因集中在某个固定值说明对应名单或规则可能配置错误。5.3 上线后要盯的指标合规校验服务上线后重点盯四类指标校验通过率通过号码数 / 待校验号码总数。通过率突然升高可能是禁呼名单加载失败突然降低可能是规则配置变化。拒绝原因分布观察NO_CONSENT、OPTED_OUT、FREQUENCY_LIMIT_EXCEEDED的占比变化判断哪些数据源需要清洗。校验服务耗时单号码校验要控制在毫秒级。如果每次校验都穿透到数据库性能会明显恶化。Redis 命中率禁呼名单缓存的命中率越高对数据库压力越小。命中率下降说明缓存失效策略有问题。开发环境可以只看接口返回测试环境要验证规则边界生产环境则必须有监控和告警任何一项指标异常都要能追溯到具体规则变更。6. 常见问题排查6.1 典型问题排查表问题现象可能原因检查方式处理建议黑名单号码仍被外呼缓存与数据库不同步或名单加载任务未执行检查 Redis 中dnc:{phone}是否存在数据库是否有该号码重新触发名单同步任务清掉错误缓存频次限制不生效Redis 滑动窗口脚本有误或多实例未共享 Redis手动执行ZCARD call:freq:day:{phone}查看计数核对 Lua 脚本参数确认所有实例连接同一 Redis用户退订后仍收到电话退订数据未实时同步到校验服务查consent_record.opt_out_time与外呼任务生成时间外呼任务生成前强制校验主库或使用实时消息同步时段规则显示生效但没有拦截服务器时区与被叫用户时区不一致检查 JVM 默认时区和号码归属地映射按用户所在地时区计算LocalTime.now()批量任务运行缓慢每个号码单独查数据库没有走缓存或批量查询查看数据库慢查询日志和 Redis 命中率预检改为分批处理名单加载进本地缓存6.2 三个最容易踩的坑第一个坑是只做退订名单检查不做同意状态检查。很多团队把“用户没退订”误认为“用户可以呼”实际上用户从未同意也属于不合规外呼。正确做法是把同意状态作为独立检查点UNKNOWN状态在需要同意时直接拒绝。第二个坑是频次计数放在应用本地内存。单实例测试时一切正常上线多实例后每个实例各自计数用户收到的电话次数翻倍。坑的根源在于它只在扩容时暴露测试环境难以提前发现。应当尽早把计数放到 Redis 或 MySQL 这类共享存储中并保证检查与写入的原子性。第三个坑是缓存没有失效策略。禁呼名单写入 Redis 后如果不设 TTL用户退订后即使数据库已经更新缓存里的老记录仍然会拦截或放行一段时间。建议对名单缓存设置 30 到 60 分钟 TTL同时提供主动刷新接口在名单同步完成后立即清理相关缓存。7. 生产环境最佳实践与扩展方向7.1 发布前检查清单合规系统和普通业务系统不一样上线前要额外确认一系列问题。下面清单可以直接用于发布评审[ ] 最新退订名单和禁呼名单是否已同步同步任务是否可追溯。[ ] 测试号码是否已从名单和外呼记录中清理。[ ] 时段、频次、同意开关是否按目标市场配置时区是否统一。[ ] 每次外呼是否记录ALLOWED或REJECTED状态及原因编码。[ ] Redis 是否开启持久化名单缓存是否有 TTL 和主动刷新机制。[ ] 数据库主从、备份、慢查询监控是否就绪。[ ] 批量预检是否分批执行内存是否受控。[ ] 监控指标和告警规则是否覆盖通过率、拒绝分布、耗时、Redis 命中率。[ ] 是否有回滚方案规则配置变更是否可一键回退。[ ] 是否确认不需要任何绕过合规限制的能力所有校验逻辑对外呼线程强制生效。7.2 下一步扩展方向这个最小闭环跑通之后按业务复杂度有四个扩展方向。第一把校验规则做成可配置的多租户模式。不同业务线、不同品牌可能有不同的同意要求和频次策略规则配置表可以增加tenant_id维度。第二接入外部监管名单和运营商投诉名单。部分市场会提供统一的禁呼名单库合规服务需要定时拉取并合并到本地do_not_call_list同时保留来源字段方便溯源。第三引入消息队列异步化预检。当号码规模达到百万级时直接在 HTTP 请求里同步预检会让调用方长时间等待。可以把待检号码写入消息队列由消费端分批预检结果回写状态表外呼调度系统轮询完成状态。第四完善审计与报表。合规不只是拦截还要能证明。建议把每次规则命中、名单变更、规则修改都写入审计日志定期输出合规报表供业务和法务团队使用。回到最初的问题当监管开始收紧未经同意的电话营销外呼时技术团队真正要交付的并不是一个简单的“退订名单开关”而是一条完整、可审计、可验证的校验链路。先把同意状态、禁呼名单、频次限制、时段限制这四个检查点做扎实再逐步接入外部名单和异步化处理这套系统就能从“能跑”进化到“合规可解释”。对于正在做外呼系统或呼叫中心的同学建议先用今天示例里的四张表和两个服务接口搭出最小闭环再结合自己的号码量级和业务渠道逐步完善。