1. 从一通陌生来电说起:Android 电话模糊查询姓名到底难在哪
手机响起来,屏幕上只显示一串号码,没有名字。你盯着这串数字想半天,隐约记得尾号是 8899,但通讯录里存的是带 +86 的格式,或者中间有空格、有横杠。这时候如果 App 能根据号码片段反查出姓名,体验会好很多。这就是Android 电话模糊查询姓名要解决的问题:给一段号码,从联系人数据里匹配出对应的人。
它适合谁?做通话记录增强、来电弹窗、客服工单自动填单、企业通讯录同步的 Android 开发者。核心检索词就三个:android、电话模糊查询、姓名。听起来简单,真做起来坑不少。
最直觉的做法是查ContactsContract,用Phone.NUMBER LIKE 'xxx%'。但系统通讯录里号码格式五花八门:138 0013 8000、+8613800138000、138-0013-8000都可能是同一个号。直接 LIKE 匹配,命中率惨不忍睹。而且系统联系人查询有权限门槛,多来源数据(本地 SQLite、云端 CRM、企业微信导出的表)格式还不统一。
所以真实的工程路径通常分两层:本地用 Room/SQLite 做归一化后的模糊匹配,云端用统一 Key 走检索接口补充。本地负责快和离线,云端负责全和跨设备。这篇就按这个思路,把 Room 模糊查询配置、号码归一化、TaoToken 统一 Key 接入、验证请求、报错排查一条龙写清楚,你可以直接照着改。
先说结论性的判断:模糊查询的成败,80% 取决于号码归一化做得好不好,20% 才是 SQL 和接口写得对不对。很多人一上来就调 API,结果本地数据没洗干净,云端返回的又对不上,最后怪检索不准。顺序反了。
2. 前置准备:TaoToken 统一 Key 与本地检索链路怎么搭
在动手写代码前,先把两条链路的"地基"打好。本地这条链路是 Room + 归一化字段,云端这条链路是 TaoToken 统一 Key。为什么要用统一 Key?因为多来源联系人数据往往来自不同模型或不同检索服务,如果每个服务一套鉴权,配置会爆炸。TaoToken 把模型对话、检索、编码类能力收敛到一个 Key 下,Android 端只需要维护一份凭证。
先看本地。Room 是 Android 官方推荐的 SQLite 封装,做模糊查询比裸ContentResolver可控得多。关键设计是:存号码时同时存一份归一化号码(normalized_number),查询时把输入也归一化,然后对归一化字段做 LIKE。这样138 0013 8000和+8613800138000都能归一到13800138000,匹配就稳了。
归一化规则我建议这样定:去掉所有非数字字符;去掉国际区号前缀(+86、86、0086);保留 11 位手机号或固话区号+号码。写成一个纯函数,存和查都调它,保证一致。
object PhoneNormalizer { fun normalize(raw: String?): String { if (raw.isNullOrBlank()) return "" // 只保留数字 var digits = raw.filter { it.isDigit() } // 去掉常见国际区号前缀 val prefixes = listOf("0086", "86") for (p in prefixes) { if (digits.startsWith(p) && digits.length > p.length + 6) { digits = digits.substring(p.length) break } } return digits } }云端这条链路,先去 TaoToken 控制台拿 Key。地址是 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite ,登录后在 API Keys 页面创建一个,复制出来形如sk-xxxx。这个 Key 就是后面所有云端检索请求的凭证。注意别把它硬编码进 APK,放local.properties或后端代理。
模型和检索能力的选择,可以在模型对话页先试跑: https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。如果你要做的是长期编码类、Agent 类任务,比如自动补全联系人、批量清洗,可以看 Coding Plan: https://taotoken.net/coding-plan?utm_source=taotoken_aicg_blog_end&utm_content=coding_plan&utm_campaign=rewrite 。接入文档在 https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite ,Base URL 统一用https://taotoken.net/api。
前置清单列一下,照着核对:
| 项目 | 本地链路 | 云端链路 |
|---|---|---|
| 数据源 | Room 数据库 | TaoToken 检索接口 |
| 鉴权 | 无 | 统一 Key(sk-xxx) |
| 关键字段 | normalized_number | 号码片段 + 归一化 |
| 网络 | 不需要 | 需要 INTERNET 权限 |
| 失败兜底 | 直接返回本地结果 | 超时降级到本地 |
权限方面,Android 6.0 以上读联系人要动态申请READ_CONTACTS,联网要INTERNET。这两个别漏,否则后面验证请求会直接报权限错。
3. 可复制配置:Room 模糊查询 + TaoToken 统一 Key 接入
这一节是全文最该抄的部分。先给 Room 的实体、DAO 和数据库配置,再给云端请求的 JSON 和 Kotlin 调用。
实体设计,重点是normalizedNumber加索引,模糊查询才快:
@Entity(tableName = "contacts", indices = [Index(value = ["normalizedNumber"])]) data class ContactEntity( @PrimaryKey(autoGenerate = true) val id: Long = 0, val displayName: String, val rawNumber: String, val normalizedNumber: String, val source: String // local / cloud / crm )DAO 里写模糊查询,用LIKE :keyword || '%'做前缀匹配,也可以%keyword%做包含匹配。前缀匹配能走索引,包含匹配走不了,数据量大时优先前缀:
@Dao interface ContactDao { @Query("SELECT * FROM contacts WHERE normalizedNumber LIKE :keyword || '%' ORDER BY length(normalizedNumber) ASC LIMIT 20") suspend fun fuzzyByName(keyword: String): List<ContactEntity> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insertAll(list: List<ContactEntity>) }数据库构建:
@Database(entities = [ContactEntity::class], version = 1, exportSchema = false) abstract class AppDatabase : RoomDatabase() { abstract fun contactDao(): ContactDao companion object { @Volatile private var INSTANCE: AppDatabase? = null fun get(context: Context): AppDatabase = INSTANCE ?: synchronized(this) { INSTANCE ?: Room.databaseBuilder( context.applicationContext, AppDatabase::class.java, "contact.db" ).build().also { INSTANCE = it } } } }云端接入的配置,我建议放一个taotoken.properties,路径和原文一致,别散落各处:
# app/src/main/assets/taotoken.properties base_url=https://taotoken.net/api api_key=sk-你的统一Key model_id=你的检索或对话模型ID读取后构造请求。下面是 Kotlin 用 OkHttp 发一次检索请求的完整片段,注意 Base URL、Key、Model ID 三件套齐全:
data class SearchRequest(val query: String, val model: String) fun searchCloud(keyword: String, cfg: Config): String { val client = OkHttpClient.Builder() .connectTimeout(8, TimeUnit.SECONDS) .readTimeout(15, TimeUnit.SECONDS) .build() val json = JSONObject().apply { put("model", cfg.modelId) put("messages", JSONArray().put(JSONObject().apply { put("role", "user") put("content", "根据号码片段 $keyword 匹配联系人姓名,只返回姓名") })) } val body = json.toString().toRequestBody("application/json".toMediaType()) val req = Request.Builder() .url("${cfg.baseUrl}/v1/chat/completions") .addHeader("Authorization", "Bearer ${cfg.apiKey}") .addHeader("Content-Type", "application/json") .post(body) .build() client.newCall(req).execute().use { resp -> if (!resp.isSuccessful) throw IOException("HTTP ${resp.code}") return resp.body?.string() ?: "" } }如果你用的是 Claude Code 这类编码工具做批量清洗,配置走settings.json,Base URL 填https://taotoken.net/api,Key 填统一 Key,Model ID 填你选的模型。三件套缺一不可,缺 Key 报 401,缺 Model ID 报模型不存在。
本地和云端的合并策略:先查本地,命中且唯一就直接返回;本地为空或命中多条,再调云端补充,云端结果写回本地缓存。这样既快又全。
4. 验证请求:号码归一化与模糊命中怎么确认成功
配置写完,别急着上真机,先用单元测试验证归一化和查询逻辑。归一化是地基,地基歪了后面全歪。
@Test fun testNormalize() { assertEquals("13800138000", PhoneNormalizer.normalize("138 0013 8000")) assertEquals("13800138000", PhoneNormalizer.normalize("+8613800138000")) assertEquals("13800138000", PhoneNormalizer.normalize("138-0013-8000")) assertEquals("01088886666", PhoneNormalizer.normalize("010-8888-6666")) }跑通这四条,说明归一化规则覆盖了常见格式。接着验证 Room 查询,插入几条数据后按片段查:
@Test fun testFuzzyQuery() = runBlocking { val dao = db.contactDao() dao.insertAll(listOf( ContactEntity(displayName = "张三", rawNumber = "138 0013 8000", normalizedNumber = "13800138000", source = "local"), ContactEntity(displayName = "李四", rawNumber = "+8613900139000", normalizedNumber = "13900139000", source = "crm") )) val r1 = dao.fuzzyByName("1380013") assertEquals(1, r1.size) assertEquals("张三", r1[0].displayName) val r2 = dao.fuzzyByName("139") assertEquals("李四", r2[0].displayName) }云端验证用 curl 先打通,确认 Key 和 Base URL 没问题:
curl -X POST https://taotoken.net/api/v1/chat/completions \ -H "Authorization: Bearer sk-你的Key" \ -H "Content-Type: application/json" \ -d '{ "model": "你的模型ID", "messages": [{"role":"user","content":"号码片段 1380013 匹配姓名"}] }'返回里能看到choices数组,说明链路通了。如果返回 401,是 Key 问题;返回 404,是 Base URL 或路径问题;返回模型不存在,是 Model ID 问题。这三个错误对应三件套,一一排查。
真机验证时,我习惯在来电弹窗里打日志:输入号码、归一化结果、本地命中数、云端命中数、最终显示姓名。这样任何一环出问题都能定位。实测下来,归一化 + 前缀匹配的组合,在几千条联系人里响应基本在几十毫秒内。
成功结果长这样:输入138 0013,本地归一化后1380013,命中张三,弹窗显示"张三",背景高亮。如果本地没命中,云端返回"张三",写回本地,下次离线也能查到。
5. 常见报错排查:401、local proxy failed、reading choices、OAuth
这一节按真实报错来,遇到哪个查哪个。
401 Unauthorized。最常见,Key 没填对或没带上。检查Authorization: Bearer sk-xxx头是否完整,Key 前后有没有空格,是不是把控制台里的 Key ID 当成了 Key。去 https://taotoken.net/api-keys?utm_source=taotoken_aicg_blog_end&utm_content=api_keys&utm_campaign=rewrite 重新复制一次。另外 Key 泄露要立刻轮换。
local proxy failed / connection refused。这类通常是本地网络配置或代理设置问题。检查设备网络是否正常,OkHttp 有没有误设代理,base_url是不是写成了https://taotoken.net/api/多带了斜杠导致路径拼接错误。正确写法是https://taotoken.net/api,请求路径再拼/v1/chat/completions。
reading choices 报错 / JSON 解析失败。返回体里没有choices字段,多半是请求体格式不对,比如messages不是数组,或者model字段为空。用 curl 先验证,再对比 Kotlin 构造的 JSON。解析时用optJSONArray("choices")做空判断,别直接getJSONArray,否则一个异常就崩。
OAuth / 鉴权失败。如果你用的是 Claude Code 或 Codex 这类工具,配置里可能残留了旧的 OAuth 流程。改用统一 Key 的 Bearer 鉴权,把auth.json或settings.json里的鉴权方式改成 API Key。Codex 的auth.json里确认OPENAI_API_KEY或对应字段填的是 TaoToken 的 Key,Base URL 指向https://taotoken.net/api。
查询命中多条或零条。这不是报错但更烦。零条先查归一化是否一致,存的时候归一化了没;多条说明片段太短,加长关键词或按length(normalizedNumber)排序取最匹配的。原文里cursor.getCount() != 1就清空显示,这个逻辑在多来源场景下太粗暴,建议改成取第一条并标注来源。
权限被拒。READ_CONTACTS没申请或用户拒绝,查询直接返回空。加运行时权限申请,拒绝后引导去设置页。
排查顺序建议:先 curl 验证云端,再单元测试验证本地,最后真机联调。三层分开,问题不会混在一起。
6. 把两条链路收进一个 Key:后续怎么扩展
走到这里,本地 Room 模糊查询和云端 TaoToken 检索已经能协同工作了。统一 Key 的价值在于,后面你要加语音转写、加智能补全、加批量清洗,都不用再折腾一套鉴权。Base URL 固定https://taotoken.net/api,Key 一份,Model ID 按场景换。
想继续深入,几个方向:把归一化规则扩展到座机分机号、把云端结果做本地缓存失效策略、用 Coding Plan 跑批量联系人清洗任务。模型对话页可以先试不同模型对号码片段的匹配效果: https://taotoken.net/models?utm_source=taotoken_aicg_blog_end&utm_content=models&utm_campaign=rewrite 。接入细节看文档: https://taotoken.net/doc?utm_source=taotoken_aicg_blog_end&utm_content=doc&utm_campaign=rewrite 。控制台管理 Key: https://taotoken.net/console?utm_source=taotoken_aicg_blog_end&utm_content=console&utm_campaign=rewrite 。
最后一个实用技巧:归一化函数一定要有单元测试,而且要和云端检索用同一份规则。我踩过的坑就是本地存的是去区号的,云端传的是带区号的,两边对不上,查半天以为是接口问题。规则统一,问题少一半。