尧图精选

Android骚扰电话拦截实战:基于CallScreeningService的号码识别与防御方案

🕒 发布时间:2026/9/7 4:38:10 📁 来源:尧图网络
晚上十一点半手机突然响了两声就挂断了。我拿起手机没有短信、没有未接来电提醒的浮窗只有一条陌生号码的记录。凌晨一点多同一个归属地又打进来一个号码相似的数字。连续几次之后我意识到这件系统侧未拦截到的“骚扰电话”已经不只是拉黑两个号码那么简单。“当拨打骚扰电话时”这句话听起来像一部悬疑片的开场但在技术人眼里它更像是一个需要被快速识别的信号号码是不是高频外呼号段有没有被多人标记这一通电话到底该放行、提示还是直接拦截这篇文章不是教大家“如何拨打骚扰电话”而是站在防御侧把完整思路反过来当一通骚扰电话从对方终端播出、拨向用户手机时系统端应当如何在振铃之前完成识别和拦截。我会从骚扰电话的特征分类开始讲一套可落地的号码识别与拦截方案包括后端号码查询服务、Android 端 CallScreeningService 拦截实现、常见踩坑点以及生产环境下的工程建议。代码均以最小 Demo 形式提供新手可以复现有经验的开发者也可以直接参考其中的架构设计。1. 背景与核心概念1.1 骚扰电话到底是什么骚扰电话并不是一个严格的通信技术术语不同平台对它的定义也不完全一致。从用户感受来看凡是“没有经过用户同意、带有营销、诈骗、诱导回拨等目的且高频重复呼入”的电话都可以算作骚扰电话。从拦截系统的视角来看骚扰电话通常分为以下几类类别典型行为主要技术特征广告营销推销房产、贷款、培训课程号码段相对固定呼叫时段集中响一声挂断诱导用户回拨进而产生话费振铃极短接通率低诈骗电话冒充客服、公检法、贷款平台改号较多号码池分散机器人外呼播放录音快速群呼通话时长极短静默检测较弱问卷访谈推销调研干扰正常生活号码来源不明呼叫频次不稳定这些骚扰电话在普通用户眼中只是一个“陌生号码”但在系统层面每一通电话都会携带主叫号码、呼叫时间、振铃时长、接通状态等信息。如果我们能把大量这类信息汇总起来就会形成非常明显的特征比如某个号码在一小时内呼出上百次、某个号段连续多天被大量用户标记为“骚扰”等。1.2 为什么拉黑不是最终答案很多人被骚扰电话惹烦之后第一反应是把号码加入手机黑名单。但这种方式只能解决“单个号码反复呼入”的情况一旦对方换一个号码黑名单就失效了。更麻烦的是骚扰电话的发起方通常会使用自动外呼系统号码池里可能准备了几百甚至上千个号码。系统可以做到每天更换号码、凌晨批量外呼、振铃两三秒就挂断单纯靠人工拉黑的速度永远跟不上骚扰号码更新的速度。再加上改号软件可以伪造主叫号码有些骚扰电话甚至显示为本地某个正常号码这时再做精确匹配就会出现两个问题漏拦截和误拦截。漏拦截会让用户继续被打扰误拦截则可能挡住银行、快递、政务等正常来电。所以生产环境中的反骚扰系统不能只依赖“黑名单”这一种手段而应该把号码库、呼叫行为、用户反馈、时段特征综合起来做判断。1.3 整体防御架构一套基础的骚扰电话识别与拦截系统可以分成三层骚扰号码拨打手机 │ ▼ ┌───────────────────────────────┐ │ 运营商通信层 / IMS 网络 │ │ 可选高频外呼、失信号码识别 │ └───────────────────────────────┘ │ ▼ ┌───────────────────────────────┐ │ Android CallScreeningService │ 本文核心 │ 来电到达用户前的拦截处理 │ └───────────────────────────────┘ │ ▼ ┌───────────────────────────────┐ │ 本地黑白名单 本地号码缓存 │ └───────────────────────────────┘ │ ▼ ┌───────────────────────────────┐ │ 后端号码画像服务FastAPI │ │ 查询 / 上报 / 信誉分计算 │ └───────────────────────────────┘终端侧的拦截负责“快”因为它必须在来电到达用户界面之前完成判断后端服务负责“全”因为它能聚合大量用户的上报数据从而发现某个号码是否被多人标记、是否存在高频呼出行为。两者配合才能达到相对理想的拦截效果。2. 环境准备与版本说明2.1 演示环境本文的 Demo 分为 Android 客户端和 Python 后端两部分。我的演示环境如下Android Studio 最新稳定版示例工程基于 Android 13API 33编译Android 端最低支持版本建议设为 API 29因为CallScreeningService是在 Android 10 才开始提供的系统能力后端开发语言使用 Python 3.10Web 框架使用 FastAPI数据库使用 SQLite方便本地演示不需要额外安装数据库服务真机测试时优先使用“原生 Android”或系统改动较少的手机部分国产 ROM 对第三方来电拦截应用有额外限制这一点会在后面重点说明。版本并不是死标准如果你手里是 Android 14 或 Android 15代码整体思路不变只是部分系统 API 行为有差异。请以你的实际编译环境为准。2.2 依赖准备后端需要安装以下依赖pip install fastapi uvicornAndroid 端不需要额外引入第三方库拦截核心逻辑全部使用系统 API 实现。为了减少工程复杂度示例代码采用 Java 编写即使你主语言是 Kotlin也非常容易对照转换。2.3 权限与设备限制说明在开始编码之前有一个重要的现实问题需要先有心理准备CallScreeningService是一个系统回调服务而不是随意声明就能生效的普通 Service。它需要你在 Manifest 中声明BIND_SCREENING_SERVICE权限并且服务需要被系统识别为“来电筛选服务”。在原生 Android 上系统会在来电到达用户界面之前回调onScreenCall方法但国内不少厂商在 ROM 层做了改动第三方应用不一定能直接阻止来电铃声有些机型要求用户先手动开启“来电拦截”权限有些机型则只允许默认电话应用使用这套机制。因此本文的代码示例以原生 Android 运行环境为基准。如果你的应用需要大规模商用最佳做法是在不同厂商的测试机上提前验证拦截效果并做好“拦截失败时降级为提示”的兜底方案。3. 骚扰电话识别规则设计3.1 从特征到规则拦截系统的第一步不是写代码而是设计规则。一个号码要不要被拦截不应该靠主观感觉而应该基于可量化的特征。这里给出一个比较实用的规则表规则判定逻辑误伤风险缓解方案黑名单精确匹配号码完全命中本地黑名单低定期修正黑名单号段命中号码前缀命中高风险号段中只在高置信度下启用高频外呼短时间内外呼次数超过阈值高结合时间段、接通率综合判断响一声挂断振铃时间极短且未接通中默认提示不默认拦截境外改号号码以 开头且未在白名单中中提供可配置开关多人标记最近24小时被多个用户标记低设置最小标记人数阈值这些规则的共同点是每条规则都无法单独完美识别骚扰电话但把它们叠加使用准确率就会明显提升。3.2 号码信誉分比“一票否决”更温和、更适合生产的方案是给每个号码计算一个信誉分。信誉分可以从 100 分开始每次收到用户标记就扣分出现高频呼出行为再额外扣分如果后来发现有大量用户将号码标记为“正常”也可以逐步加分。这里给出一个简化的计算思路score 基准分 60 分 被标记一次 - score - 5 一小时内呼叫 20 次 - score - 15 通话时长 30 秒 - score 3 被用户恢复为正常 - score 10当分数低于 30 时系统可以自动拦截分数在 30 到 60 之间时不对用户做完全拦截只显示“该号码被多人标记”的提示。这一设计就是为了避免把“疑似骚扰”和“确认骚扰”混在一起处理。3.3 白名单与双阶段处理任何识别系统都存在误判可能因此必须引入白名单机制。银行客服、快递官方号码、政务热线等号码应该进入白名单即使它们在某些维度上看起来像高频外呼也不能被系统拦截。我比较推荐“双阶段处理”策略第一阶段先把号码标记为“疑似骚扰”仅做界面提示只有达到更强条件的号码才进入第二阶段直接拦截。这样的好处是系统有误判时用户至少还来得及看到来电提醒不会在不知情的情况下漏接重要电话。4. 后端号码查询服务实践4.1 数据库表设计后端部分我用 FastAPI 写两个核心接口号码查询接口和号码上报接口。数据库设计得很简单先用一张phone_number_label表保存号码的标签信息和信誉分再用一张user_report_log表记录每一次用户上报行为。-- 文件路径backend/schema.sql CREATE TABLE IF NOT EXISTS phone_number_label ( id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT UNIQUE NOT NULL, label_type TEXT NOT NULL, tag_count INTEGER DEFAULT 0, call_total INTEGER DEFAULT 0, call_1min_freq INTEGER DEFAULT 0, score INTEGER DEFAULT 60, block_mode INTEGER DEFAULT 0, update_time INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS user_report_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, reporter TEXT, number TEXT NOT NULL, report_type TEXT NOT NULL, create_time INTEGER NOT NULL );number字段存储完整手机号或固话号码。label_type用来区分骚扰、诈骗、广告营销、正常等类型。block_mode等于 0 表示仅提示等于 1 表示拦截。真实项目中这张表通常还应该包含号码归属地、首次出现时间、最后活跃时间等字段方便做进一步分析。4.2 编写 FastAPI 查询与上报接口# 文件路径backend/app/main.py import sqlite3 import time from fastapi import FastAPI from pydantic import BaseModel app FastAPI() DB_PATH spam_demo.db class ReportBody(BaseModel): number: str report_type: str spam def get_conn(): conn sqlite3.connect(DB_PATH) conn.row_factory sqlite3.Row return conn app.get(/api/v1/query) def query_number(number: str): conn get_conn() row conn.execute( SELECT number, label_type, tag_count, score, block_mode FROM phone_number_label WHERE number ?, (number,) ).fetchone() conn.close() if not row: return { number: number, exists: False, label: , score: 60, action: pass, } action block if row[block_mode] 1 else hint return { number: row[number], exists: True, label: row[label_type], tag_count: row[tag_count], score: row[score], action: action, } app.post(/api/v1/report) def report_number(body: ReportBody): conn get_conn() now int(time.time()) conn.execute( INSERT INTO user_report_log(reporter, number, report_type, create_time) VALUES (?, ?, ?, ?), (unknown, body.number, body.report_type, now) ) row conn.execute( SELECT id FROM phone_number_label WHERE number ?, (body.number,) ).fetchone() if row: conn.execute( UPDATE phone_number_label SET tag_count tag_count 1, score MAX(score - 5, 0), update_time ? WHERE number ?, (now, body.number) ) else: conn.execute( INSERT INTO phone_number_label(number, label_type, tag_count, score, update_time) VALUES (?, ?, 1, 50, ?), (body.number, body.report_type, now) ) conn.commit() conn.close() return {msg: ok}这个示例里GET /api/v1/query用于查询号码状态POST /api/v1/report用于用户上报号码。查询接口的返回值中带有action字段Android 端拿到这个字段后可以决定是放行、提示还是拦截。为了演示方便我没有拆分 Service 层也没有做异步任务队列。真实项目中上报操作不应该在请求线程里同步修改数据库因为高并发场景下会产生锁竞争建议引入消息队列逐步消化。4.3 初始化测试数据和运行接口先创建数据库并写入几条测试数据# 文件路径backend/init_data.py import sqlite3 import time conn sqlite3.connect(spam_demo.db) now int(time.time()) conn.execute( CREATE TABLE IF NOT EXISTS phone_number_label ( id INTEGER PRIMARY KEY AUTOINCREMENT, number TEXT UNIQUE NOT NULL, label_type TEXT NOT NULL, tag_count INTEGER DEFAULT 0, call_total INTEGER DEFAULT 0, call_1min_freq INTEGER DEFAULT 0, score INTEGER DEFAULT 60, block_mode INTEGER DEFAULT 0, update_time INTEGER NOT NULL ) ) conn.execute( INSERT OR IGNORE INTO phone_number_label (number, label_type, tag_count, score, block_mode, update_time) VALUES (01012345678, spam, 18, 15, 1, ?), (now,) ) conn.commit() conn.close() print(init done)运行初始化脚本后启动 FastAPIpython init_data.py uvicorn app.main:app --host 0.0.0.0 --port 8000然后可以用curl验证接口curl http://127.0.0.1:8000/api/v1/query?number01012345678 curl -X POST http://127.0.0.1:8000/api/v1/report \ -H Content-Type: application/json \ -d {number: 02166668888, report_type: spam}第一条查出来的action应该是block第二条上报之后再查询02166668888分数会变成 50tag_count会变成 1。4.4 接口上线前必须注意的问题上面的 Demo 代码只能用于本地学习如果真的要部署到生产环境有几点必须补上第一接口要做鉴权。手机端调用查询接口时要增加签名参数防止别人直接遍历你的接口获取号码库数据。第二接口要做频率限制。同一个 IP 或者同一个设备在短时间内的调用次数要有上限。第三号码数据属于敏感个人信息存储时必须做好权限控制无关人员不能直接查询库表更不能把号码库用于任何商业出售行为。第四上报数据要校验格式避免有人恶意刷量把正常号码批量上报成骚扰号码。5. Android 端来电拦截实现5.1 工程结构与 Manifest 配置Android 端我命名为SpamGuardDemo整体结构如下app/src/main/ ├── AndroidManifest.xml └── java/com/example/spamguard/ ├── service/ │ ├── SpamCallScreeningService.java │ ├── LocalRuleCenter.java │ └── RemoteSpamApi.java └── MainActivity.javaManifest 中的关键配置是声明CallScreeningService!-- 文件路径app/src/main/AndroidManifest.xml -- manifest xmlns:androidhttp://schemas.android.com/apk/res/android application android:labelstring/app_name android:themestyle/Theme.AppCompat.Light activity android:name.MainActivity intent-filter action android:nameandroid.intent.action.MAIN / category android:nameandroid.intent.category.LAUNCHER / /intent-filter /activity service android:name.service.SpamCallScreeningService android:permissionandroid.permission.BIND_SCREENING_SERVICE android:exportedtrue intent-filter action android:nameandroid.app.CallScreeningService / /intent-filter /service /application /manifestandroid:permissionandroid.permission.BIND_SCREENING_SERVICE的意思是只有持有系统来电筛选权限的组件才能绑定这个服务。exported设置为true才能被系统回调。5.2 核心拦截服务实现接下来编写核心服务类// 文件路径app/src/main/java/com/example/spamguard/service/SpamCallScreeningService.java package com.example.spamguard.service; import android.net.Uri; import android.os.Handler; import android.os.Looper; import android.telecom.Call; import android.telecom.CallResponse; import android.telecom.CallScreeningService; import android.util.Log; public class SpamCallScreeningService extends CallScreeningService { private static final String TAG SpamCallScreening; Override public void onScreenCall(Call.Details callDetails) { Uri handle callDetails.getHandle(); if (handle null) { respondToCall(callDetails, new CallResponse.Builder().build()); return; } String number handle.getSchemeSpecificPart(); Log.d(TAG, onScreenCall number number); // 第一步本地规则优先判断 if (LocalRuleCenter.shouldBlock(number)) { respondWithBlock(callDetails, number); return; } // 第二步异步查询远端号码库 new Thread(() - { boolean remoteBlock RemoteSpamApi.isSpam(number); new Handler(Looper.getMainLooper()).post(() - { if (remoteBlock) { respondWithBlock(callDetails, number); } else { respondToCall(callDetails, new CallResponse.Builder().build()); } }); }).start(); } private void respondWithBlock(Call.Details callDetails, String number) { CallResponse.Builder builder new CallResponse.Builder() .setDisallowCall(true) .setSkipCallLog(true) .setSkipNotification(true); // 不同 Android 版本对 setRejectCall 的支持略有差异 // 生产环境中要按目标版本做兼容处理。 // builder.setRejectCall(true); respondToCall(callDetails, builder.build()); Log.i(TAG, blocked number number); } }这里有个非常重要的设计respondToCall必须在回调中及时执行而且不能重复调用。如果查询远端耗时太长用户可能会先听到一两声铃声然后才被拦截。所以真实项目中应该先在本地缓存中做一次快速判断再异步查询远端如果远端查询超时也要有一个默认决策通常建议“查询失败时放行”避免因为服务不稳定误伤正常电话。5.3 本地规则中心本地规则中心的职责是快速处理高频命中的号码减少网络请求// 文件路径app/src/main/java/com/example/spamguard/service/LocalRuleCenter.java package com.example.spamguard.service; import java.util.Arrays; import java.util.HashSet; import java.util.Set; public class LocalRuleCenter { private static final SetString BLOCK_SET new HashSet(Arrays.asList( 01012345678, 02166668888 )); private static final SetString WHITE_SET new HashSet(Arrays.asList( 95588, 95533, 12345 )); public static boolean shouldBlock(String number) { if (number null || number.isEmpty()) { return false; } // 白名单优先避免误拦银行和政务电话 if (WHITE_SET.contains(number)) { return false; } // 黑名单精确匹配 if (BLOCK_SET.contains(number)) { return true; } // 演示规则号段匹配仅作为示例 if (number.startsWith(010) number.length() 11) { return false; } return false; } }WHITE_SET的优先级必须高于BLOCK_SET否则银行客服号码一旦被误加入黑名单就会造成大量用户投诉。真实项目中白名单应该由后台统一维护并且支持热更新不能写死到代码里。5.4 远端查询工具类远端查询工具类使用HttpURLConnection直接请求后端接口。这里为了避免引入第三方网络库保持 Demo 简洁// 文件路径app/src/main/java/com/example/spamguard/service/RemoteSpamApi.java package com.example.spamguard.service; import java.net.HttpURLConnection; import java.net.URL; import java.util.Scanner; public class RemoteSpamApi { private static final String QUERY_URL http://10.0.2.2:8000/api/v1/query?number; public static boolean isSpam(String number) { try { URL url new URL(QUERY_URL number); HttpURLConnection conn (HttpURLConnection) url.openConnection(); conn.setConnectTimeout(1500); conn.setReadTimeout(1500); int code conn.getResponseCode(); if (code ! 200) { return false; } Scanner scanner new Scanner(conn.getInputStream()).useDelimiter(\\A); String body scanner.hasNext() ? scanner.next() : ; return body.contains(\action\:\block\); } catch (Exception e) { // 网络异常时返回 false默认放行 return false; } } }注意10.0.2.2指的是 Android 模拟器访问宿主机时使用的地址。如果使用真机调试这里要改成电脑在局域网中的 IP比如http://192.168.1.100:8000。此外Android 9 及以上系统默认禁止明文 HTTP 请求你需要在 Manifest 的application节点中临时允许明文流量或者让后端使用 HTTPS。明文流量仅用于本地 Demo生产环境必须使用 HTTPS。application android:usesCleartextTraffictrue ...5.5 运行与验证把工程安装到模拟器或支持原生 Android 的真机后在另一个设备上给测试手机拨号。如果测试号码命中了本地或远端黑名单在 Logcat 中可以看到如下日志SpamCallScreening: onScreenCall number01012345678 SpamCallScreening: blocked number01012345678如果号码没有命中任何规则系统会正常响铃日志中只打印onScreenCall一行。整个流程的核心是“来电先进入筛选流程正常放行或拦截由决策结果决定”。6. 常见问题与排查思路问题现象常见原因解决思路服务从未被回调设备不是原生 Android或 ROM 限制了第三方拦截换用原生模拟器测试或让应用成为默认电话应用拦截后依旧响铃系统没有完全采纳setDisallowCall检查目标版本 API 差异增加提示兜底多个正常号码被误拦规则设置得太宽或白名单不完整收紧评分阈值启用双阶段策略查询接口无数据后端数据库未初始化或者端口不对先登录后端直接curl查询测试来电延迟明显远端查询超时时间太长增加本地缓存减少同步等待上报功能报错数据库路径或表结构不正确检查DB_PATH和schema.sql是否完整6.1 为什么有些手机上拦截无效这是最典型的问题根本原因通常是CallScreeningService的生效条件与厂商 ROM 相关。部分国产系统要求第三方应用必须被用户手动设置为“默认拨号应用”或“通话管理应用”否则系统不会把普通运营商的语音来电回调给第三方服务。遇到这种情况可以引导用户前往设置页开启相应权限。如果某个机型实在无法支持就应该在应用中降级为“来电提示”模式在来电界面上弹出一个浮窗提示“该号码被多人标记”而不是完全放手不管。保证用户至少能获得提示信息也是一种可用方案。6.2 异步回调导致的崩溃如果直接在子线程里调用respondToCall部分版本上会抛出异常因为系统要求回调在主线程执行。建议在子线程拿到结果后通过Handler切回主线程再执行respondToCall同时利用标志位保证每个来电只响应一次避免重复回调导致崩溃。7. 最佳实践与工程建议7.1 规则与数据分层需求中的反骚扰系统应当把数据量分为三层热数据、温数据、冷数据。本机最近 7 天命中过的号码属于热数据可以缓存到内存或本地数据库最近 30 天未被访问的号码属于温数据可以放在服务端 Redis 中超过 90 天没有访问的号码属于冷数据可以归档到大数据平台。不要把所有号码都塞进同一张表否则查询效率和数据维护成本都会失控。7.2 用户反馈闭环拦截系统的价值不仅在于拦截还在于反馈。用户把某个号码标记为“骚扰”后这个行为应该进入到信誉分模型中影响后续其他用户的查询结果反过来当用户把某个被拦截的号码恢复为“正常”时也应该有相应机制把它从黑名单中释放出来。一个只允许用户上报、不允许用户撤回的系统是很容易积累误报数据的。7.3 合规与隐私保护号码数据属于典型的个人信息开发者在处理时必须有明确的法律意识。个人开发者不要擅自爬取网络上的大量手机号码更不能买卖、传播号码库。即使用户授权后采集号码信息也需要做好脱敏、加密、日志审计和访问控制。相关服务上线前建议咨询法务或合规人员确保产品符合数据安全相关的法律法规要求。7.4 拦截策略要可以配置骚扰电话的形态会随着时间变化写死规则的系统很快会失效。比较好的做法是把拦截规则参数放到后台配置中心让运营人员能够动态调整阈值。例如“一小时内高频呼出次数超过 20 次”这个阈值可以根据不同地区、不同时段灵活调整。在灰度发布时可以先只让 5% 的用户启用“自动拦截”策略观察误拦截率后再逐步放量。7.5 日志与监控每一通被拦截或提示的来电都应该产生一条结构化日志记录号码、命中规则、决策结果、耗时等字段。上线后要关心几个核心指标拦截总量、误拦率、用户主动放行量、接口成功率。没有监控的拦截规则就是盲盒出了问题只能等用户投诉。8. 后续可以深入的方向到这里一套基本的骚扰电话识别与拦截系统就搭建完成了。你可以在本地跑通整个流程从后端查询接口、号码信誉分到 Android 端 CallScreeningService 回调形成一条完整的防御链路。接下来值得深入的方向有三个。第一把单一号码信誉模型升级为“号码 行为 时间”的联合模型利用简单的机器学习算法判断来电风险。第二引入通话录音转写在接通后通过关键词识别诈骗话术这需要处理好隐私合规问题。第三尝试对接运营商或第三方号码认证服务让号码画像数据更完整。不要指望一套代码永久解决骚扰电话问题骚扰外呼系统也在不断更新。真正可靠的防御系统一定是在用户反馈、规则迭代和数据校准中不断进化的。希望这篇实战记录能帮你少走一些弯路也欢迎在实际调通后再回来调整属于自己的拦截策略。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →