
简介这套基于Java实现的微信手机号批量检测系统采用SpringBootLayUIMySQL主流组合解决运营人员或开发者批量导入手机号并判断其是否开通微信的重复性工作可显著减少人工逐条核验的时间成本适合有一定Java基础、希望快速上手或二次改造的技术用户。压缩包共530个文件约110.33MB其中38个java源码及对应class字节码构成后端业务逻辑js、css、png、gif等前端资源支撑LayUI界面交互bath_phone.sql提供数据库表结构与初始测试数据properties文件用于配置运行环境另附日志和说明文档rar格式便于整体存放与离线使用。目前已有1571人学习下载。项目按Controller、Service、Entity分层后台覆盖文件上传、Apache POI解析、手机号检测、结果入库与导出等完整处理链路前端基于LayUI渲染列表并标记开通状态可在IDEA中直接启动调试。通过阅读源码既能掌握SpringBoot整合MySQL及Redis的常见写法也能积累批量数据处理与接口调用的实战经验是一份实用价值较高的参考项目。1. 手机号批量导入并检测微信状态这个 Java 系统到底在解决什么营销或私域运营里最重复的一步就是把 Excel 里几千个手机号一个个手动加微信好友。加之前还得先判断哪些号已经开通微信没开通的加了也白加。这套“基于 Java 的手机批量导入微信手机号系统检测是否开通”的项目做的就是把“导入号码 → 清洗去重 → 批量检测 → 回写状态”这一整条链路自动化。标题里挂着的源码和数据库文件意味着它不是套壳工具而是一套可以自己改、自己部署的 Java 工程骨架。适合做企业内部工具开发的 Java 工程师也适合手里有稳定号码源、想用程序替代人工筛选的运营团队。要提醒的是微信官方并没有开放“批量查手机号是否注册”这种接口所以这类系统的核心难点不在增删改查而在判定逻辑的容错和风控的应对——这两点会在下文重点展开。2. “开通微信”的判定逻辑与数据模型先想清楚再写代码2.1 判定逻辑为什么没有官方批量接口以及常见的替代路径先解决一个根本问题微信开放平台到底能不能查不能。开放平台的手机号验证能力是让“已登录用户”主动授权手机号给第三方小程序或公众号再由微信服务端返回该手机号对应的 openid 等会话信息。这是一个“用户主动发起”的流程每次只针对一个手机号而且必须本人登录操作用来做批量后台扫描完全不适用。所以市面上这套方向的系统落地路径基本都是“模拟客户端行为”用一个可控的微信账号登录态按照手机号逐个去执行搜索然后观察结果是被找到、未找到还是被风控拦截。我一般把这个逻辑封装成一个独立的 WechatDetectClient 组件上层业务不关心协议细节只接收一个判定结果对象。这么做的原因是检测响应很不稳定网络超时、登录失效、操作频繁都可能混在一起必须把“不确定”单独拎出来否则批量跑完你会发现一半号码被误判成未开通。另外还有一个隐藏坑即使用户真的开通了微信也可能在隐私设置里关闭了“通过手机号搜索到我”。这种情况下搜索会返回“未找到”但实际人家微信在用。所以业务上要把“未开通”和“被隐私隐藏”区分看待尤其在营销场景前一种是永久无价值后一种只是暂时不可触达。很多第一次做的人在这里翻车把大量有效号码洗掉了。下面是典型的返回结果映射表不同协议阶段的返回码大同小异但必须由自己实测确认不能照抄。返回表现真实含义建议业务状态找到用户可查看资料该手机号绑定微信且允许被搜索已开通明确提示用户不存在未绑定微信或隐私关闭搜索未开通需结合隐私策略提示操作频繁 / 验证码触发频控非真实结果不确定稍后重试登录态失效账号被顶号或过期异常暂停任务请求超时网络或服务不稳定不确定2.2 数据模型三张表把任务和结果分开标题既然明确带了数据库文件说明实现方案里有完整的建表语句。我按自己的经验推荐三张表import_task导入任务、phone_item号码明细、detect_result检测结果快照。为什么不给 phone_item 加一个 batch 字段就完事因为导入和检测是两段异步流程任务需要单独记录文件、总数、进度号码需要记录清洗状态和重试次数最终检测的原始返回文本还要留存做排障。三张表的职责边界非常清楚。-- 导入任务表一个 Excel/CSV 文件对应一个任务 CREATE TABLE import_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_no VARCHAR(32) NOT NULL COMMENT 业务任务号, file_name VARCHAR(255) COMMENT 原始文件名, total_count INT DEFAULT 0 COMMENT 解析出的总条数, success_count INT DEFAULT 0 COMMENT 完成检测的条数, fail_count INT DEFAULT 0 COMMENT 异常条数, status TINYINT DEFAULT 0 COMMENT 0待导入 1导入中 2待检测 3检测中 4完成 5失败, create_time DATETIME, update_time DATETIME, UNIQUE KEY uk_task_no (task_no) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 号码明细表导入后的号码和检测状态 CREATE TABLE phone_item ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_id BIGINT NOT NULL COMMENT 所属任务, phone VARCHAR(20) NOT NULL COMMENT 清洗后的号码, raw_phone VARCHAR(64) COMMENT 原始文本保留现场, normalized TINYINT DEFAULT 0 COMMENT 是否清洗成功 1是 0否, status TINYINT DEFAULT 0 COMMENT 0待检测 1检测中 2已开通 3未开通 4不确定 5异常, retry_count INT DEFAULT 0 COMMENT 已重试次数, create_time DATETIME, KEY idx_task (task_id), KEY idx_status (status) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4; -- 检测结果快照每次检测的原始返回都落库方便复盘 CREATE TABLE detect_result ( id BIGINT PRIMARY KEY AUTO_INCREMENT, item_id BIGINT NOT NULL, phone VARCHAR(20), code VARCHAR(32) COMMENT 协议返回码, raw_message TEXT COMMENT 原始返回内容, detect_time DATETIME, KEY idx_item (item_id) ) ENGINE InnoDB DEFAULT CHARSET utf8mb4;这里有两个设计细节值得照着抄第一raw_phone 一定要保留清洗规则改错了还可以用原始字段重新跑一遍标准化这是后悔药第二detect_result 是独立快照表不只是冗余它直接决定了后续排查效率。没这张表时遇到大面积异常只能看业务日志有了它按 item_id 一查当时的返回码和原文都在能快速判断是号码问题、账号问题还是频率问题。2.3 状态机让导入和检测解耦支持暂停续跑任务状态和号码状态需要分别管理否则代码里到处散落着对整数的判断。我习惯用枚举类收敛状态流转public enum PhoneStatus { PENDING(0, 待检测), DETECTING(1, 检测中), REGISTERED(2, 已开通), NOT_REGISTERED(3, 未开通), UNKNOWN(4, 不确定), ABNORMAL(5, 异常); public final int code; public final String desc; PhoneStatus(int code, String desc) { this.code code; this.desc desc; } }状态机里最容易被忽略的是 UNKNOWN。很多初版实现只有“已开通 / 未开通”两个终态出问题就全算未开通结果就是前面说的误杀大量号码。我的原则是拿不准的宁可标记为 UNKNOWN也不要给运营一个错误的“未开通”结果毕竟业务后续要基于这个状态做加好友动作的。任务侧的状态流转则是 待导入 → 导入中 → 待检测 → 检测中 → 完成/失败。核心好处是导入和检测是两个独立线程执行互不阻塞导入跑完只是把任务置为待检测检测调度器轮询到待检测任务后启动消费线程。这样即使检测过程崩溃重启后任务会从“待检测”状态继续而不是从头再来。3. 环境搭建与数据库初始化照着做就能跑通的准备步骤3.1 项目结构与依赖Spring Boot 最小配置项目的常规形态是 Spring Boot 工程配合 MyBatis-Plus 做数据访问层因为这类工具系统 CRUD 多、条件查询杂用 MP 的 LambdaQueryWrapper 能省大量样板代码。我建议用的组合是 Spring Boot 2.7 MyBatis-Plus 3.5 EasyExcel 3.1 MySQL 8.0。Java 版本选 8 或 11 都行JDK 17 也能跑但要注意 Lombok 版本兼容。目录结构建议按功能分包而不是按技术分包controller、service、mapper、domain、client其中 client 目录专门放微信检测客户端的封装。很多人喜欢把检测逻辑直接塞在 service 里后面想换协议实现或加多账号轮询时改动成本会非常高。application.yml 里的重点配置是数据库连接池和检测线程池参数spring: datasource: url: jdbc:mysql://localhost:3306/wechat_check?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: root password: change_me hikari: maximum-pool-size: 10 connection-timeout: 3000 task: pool: core-size: 5 max-size: 10 queue-capacity: 200 detect: # 两个连续请求之间的基础间隔单位毫秒 interval-base: 800 # 触发风控后的等待秒数 ban-wait-seconds: 300 # 单个号码最大重试次数 max-retry: 3参数说明pool 这里的 core-size 指检测消费线程数不是 Web 容器线程数。很多人上来就配 20 个线程觉得跑得快结果半小时后账号全被限制整批任务白做。interval-base 为 800 毫秒时单线程每分钟约 75 个号码5 线程每分钟约 375 个一天下来足够处理 20 万以上的量——绝大多数业务根本到不了这个规模。慢就是快这个场景里尤其真。3.2 初始化 SQL 文件怎么放有数据库文件配套的项目通常初始化方案分两派一派把 init.sql 扔在 resources 下由 Spring 自动执行另一派用 Flyway 管版本。开发环境用前者没问题配置如下spring: sql: init: mode: always schema-locations: classpath:db/init.sql但 production 环境我强烈建议关掉自动初始化改用 Flyway。原因很简单表结构是一定会变的比如给 phone_item 加一个 area_code 字段如果靠手改 init.sql 再让 Spring 执行已存在的表根本不会变化容易产生“开发环境是新结构、测试环境是老结构”的混乱。Flyway 用 V1__init.sql、V2__add_area_code.sql 这种版本化文件管理变更任何环境执行到最新版本都会停在同一套表结构上。数据库文件本身只是初始快照真正的长期维护要走迁移脚本。建表时还有一个很多人不看重的点排序规则。MySQL 8 默认字符集 utf8mb4排序规则用 utf8mb4_general_ci 还是 utf8mb4_0900_ai_ci差别主要体现在中文排序和特殊字符处理上。手机号这种纯数字字段其实无所谓但 raw_phone 里可能混入中文备注如果出现乱码先查连接串有没有加 characterEncodingutf8再查表的 charset。3.3 EasyExcel 还是 POI5 万行数据的取舍手机号文件最常见的格式是 Excel 和 CSV。Excel 解析上POI 是底层库EasyExcel 是对 POI 的 SAX 封装。有人说“EasyExcel 慢”这个说法不准确——EasyExcel 用流式读单文件解析速度不一定比 POI 快但内存占用只有 POI 的零头。5 万行、纯手机号POI 的 XSSFWorkbook 全量加载可能要几百 MB 堆内存EasyExcel 几十 MB 就能扛下来。对比项EasyExcelPOIXSSFWorkbook读取模型SAX 流式DOM 全量加载5 万行内存占用较低高易 GC 频繁合并单元格处理需要额外 API原生支持但易漏复杂格式样式弱强适用场景大批量简单读小批量表格操作如果只是导入号码无脑选 EasyExcel。读出每一行后转成字符串交给后面的清洗器。CSV 则要注意编码和 BOM 头Windows 下导出的 CSV 常带 UTF-8 BOM直接用 Java 的 FileReader 读会把 BOM 字符带进来导致第一行手机号前面多出一个不可见字符清洗时被误杀。4. 核心代码实现导入、清洗、检测、回写一条链路4.1 手机号清洗与去重先过 PhoneNormalizer 再过 Redis手机号来源五花八门有人把“客户1138xxxx”整段备注写进表格有人手机号被 Excel 自动转成科学计数法变成 1.38123E10也有人用全角数字录入。清洗规则按优先级做四件事剔除不可见字符和备注、去掉 86/0086 前缀、校验 11 位号和号段、失败的不淘汰而是标记异常待人工复核。public class PhoneNormalizer { // 只保留数字和可能的 号 private static final Pattern KEEP Pattern.compile([^\\d]); public static String normalize(String raw) { if (raw null || raw.isBlank()) { return null; } String s KEEP.matcher(raw).replaceAll(); // 处理 86 和 0086 前缀 if (s.startsWith(86)) { s s.substring(3); } else if (s.startsWith(0086)) { s s.substring(4); } else if (s.startsWith(86) s.length() 11) { s s.substring(2); } return isValid(s) ? s : null; } public static boolean isValid(String phone) { if (phone null || phone.length() ! 11) { return false; } // 主流号段校验虚拟运营商 170/171 不在此列 return phone.matches(^1[3-9]\\d{9}$); } }逻辑说明正则把非数字字符全部剔除所以“138-1234-5678”、“138 1234 5678”都会被还原成标准 11 位号。国家码处理顺序有讲究先看 86再看 0086最后才看普通 86避免把 136 开头的号码截断。最后一步86 length 11是保底逻辑防止误伤国内号。清洗成功后还要去重。同一份文件里可能有重复号码不同批次的导入之间也可能重复。去重我用 Redis 的 setIfAbsent以 phone 为 key业务上还可以顺带控制“同一个号码多久内不重复检测”。这里有个细节数据库层面要给 phone_item 表建唯一索引吗我建议不建因为同一个手机号可以出现在两个不同任务里业务上各自独立。去重只发生在“同一个任务内部”用内存 Set 或者 Redis 都可以跨任务去重是运营策略问题不该由数据库硬约束。4.2 导入落库与任务状态推进分批插入而不是一条条写导入线程读取到号码后按批次写入 phone_item。批量插入一次 500 条事务粒度按整个文件控制而不是每条提交一次。代码如下Transactional(rollbackFor Exception.class) public void importPhones(Long taskId, ListString rawPhones) { ListPhoneItem items new ArrayList(rawPhones.size()); for (String raw : rawPhones) { String phone PhoneNormalizer.normalize(raw); PhoneItem item new PhoneItem(); item.setTaskId(taskId); item.setRawPhone(raw); // 清洗失败也保留现场标记为异常待人工复核 item.setPhone(phone ! null ? phone : raw); item.setNormalized(phone ! null ? 1 : 0); item.setStatus(phone ! null ? PhoneStatus.PENDING.code : PhoneStatus.ABNORMAL.code); items.add(item); } // 分批插入避免一次塞几万条导致 undo log 膨胀 for (int i 0; i items.size(); i 500) { int end Math.min(i 500, items.size()); phoneItemMapper.insertBatch(items.subList(i, end)); } // 更新任务总数 importTaskMapper.updateTotalCount(taskId, items.size()); }逻辑说明清洗失败的号码也入库但 raw_phone 和 phone 字段是同一个值并且 normalized 标记为 0。很多人图省事把清洗失败的直接丢弃问题是用户不会告诉你“我这 Excel 里哪几行格式有问题”保留现场才能导出异常清单给上游。insertBatch 是 MyBatis-Plus 的批量插入能力底层生成一条多 VALUES 的 Insert SQL比单条插入快一个数量级。事务粒度说明这个方法是加在 service 类上的一个任务一个事务。5 万条号码走 500 批量插入事务内会有 100 次 insert耗时主要在 SQL 执行事务本身没问题。真正的问题是把检测请求放进事务——那会长时间持有数据库连接是后面避坑章节要讲的经典翻车场景。4.3 检测调度生产者消费者模式与并发参数检测模块架构上是生产者-消费者模式。生产从数据库按状态分页捞待检测 ID塞进 BlockingQueue消费者线程从队列取出 ID执行检测并回写结果。用队列的原因数据库查询快网络检测慢两者速度不匹配队列做缓冲可以防止数据库压力过大。Component public class DetectScheduler { private final ExecutorService detectPool; private final BlockingQueueLong queue new LinkedBlockingQueue(500); public DetectScheduler(Value(${task.pool.core-size:5}) int poolSize) { this.detectPool Executors.newFixedThreadPool(poolSize, r - { Thread t new Thread(r, wechat-detect-worker); t.setDaemon(true); return t; }); } public void startTask(Long taskId) { // 生产阶段分批加载该任务下所有待检测号码 ID CompletableFuture.runAsync(() - produce(taskId)); // 消费阶段固定线程数避免并发失控 for (int i 0; i 5; i) { detectPool.submit(() - consume(taskId)); } } private void produce(Long taskId) { long lastId 0L; while (true) { ListPhoneItem page phoneItemMapper.selectPending( taskId, lastId, 200); if (page.isEmpty()) { break; } for (PhoneItem item : page) { try { queue.put(item.getId()); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } lastId item.getId(); } } // 哨兵对象标记生产结束 queue.offer(Long.MIN_VALUE); } private void consume(Long taskId) { while (true) { Long itemId; try { itemId queue.poll(30, TimeUnit.SECONDS); } catch (InterruptedException e) { Thread.currentThread().interrupt(); return; } if (itemId null || itemId Long.MIN_VALUE) { return; } handleOne(itemId); } } }逻辑说明分页查询用id lastId而不是 OFFSET因为 OFFSET 越深越慢游标方式在大表下稳定。哨兵值 Long.MIN_VALUE 是生产结束的信号5 个消费线程里谁拿到谁退出其他人继续把队列里的任务清完再退出。队列容量 500 是经验值生产者快、消费者慢时队列会把生产者阻塞住形成天然背压防止内存里积压太多 ID。4.4 检测执行与状态回写风控优先处理消费线程拿到 ID 后要做的第一件事不是直接检测而是回查数据库确认状态还是 PENDING。因为任务可以暂停续跑可能出现“同一个 ID 被两个线程同时取出”的情况这时要通过乐观更新抢占private void handleOne(Long itemId) { // 乐观更新只有当前状态是 0 待检测 才能更新为 1 检测中 int updated phoneItemMapper.casStatus(itemId, PhoneStatus.PENDING.code, PhoneStatus.DETECTING.code); if (updated 0) { // 已经被其他线程处理过直接跳过 return; } PhoneItem item phoneItemMapper.selectById(itemId); try { DetectResult result wechatDetectClient.checkPhone(item.getPhone()); if (result.isOk()) { int status REGISTERED.equals(result.getCode()) ? PhoneStatus.REGISTERED.code : PhoneStatus.NOT_REGISTERED.code; phoneItemMapper.updateStatus(itemId, status); } else if (result.isRateLimited()) { // 触发频控等待后重新入队 retryLater(item, banWaitSeconds); } else { retryLater(item, 5); } } catch (Exception e) { retryLater(item, 5); } } private void retryLater(PhoneItem item, int waitSeconds) { if (item.getRetryCount() maxRetry) { phoneItemMapper.updateStatus(item.getId(), PhoneStatus.ABNORMAL.code); return; } phoneItemMapper.incrementRetry(item.getId()); scheduleRequeue(item.getId(), waitSeconds); }逻辑说明casStatus 是关键中的关键它在数据库层面执行update phone_item set status1 where id? and status0返回值是受影响行数。0 说明被别人抢占了1 说明本线程拿下了这个号码。这样即使任务暂停续跑、队列重复投递同一个号码也只会被真正检测一次。风控处理上我把“触发频率限制”和“其他异常”分开。触发频率限制直接等待 ban-wait-seconds 后重新入队其他异常等 5 秒快速重试。如果重试超过 max-retry就不继续耗资源置为 ABNORMAL 并保留 detect_result 里的原始错误供运营人工处理。检测客户端内部也要做线程隔离的限流常见做法是每个工作线程一个 RateLimiter// 每线程独立限流保证单线程请求间隔不低于配置值 private final RateLimiter rateLimiter RateLimiter.create(1.25); // 800ms 一次 public DetectResult checkPhone(String phone) { rateLimiter.acquire(); // 协议请求逻辑在这里返回统一封装的对象 return doCheck(phone); }参数说明RateLimiter.create(1.25) 表示每秒最多放行 1.25 个请求即平均间隔 800 毫秒。如果你把间隔改成 3000 毫秒这里就是1.0 / (3000 / 1000.0) 0.333。为什么每线程单独限流而不是全局限流因为全局限流会造成线程互相等待一个线程触发等待时其他线程也空转整体吞吐反而更差。4.5 计数回写不要每个号码都更新任务表每个号码处理完都去 update import_task 的 success_count在小数据量时没问题但几万条任务会出现明显的行锁竞争——所有线程都在更新同一行事务排队数据库 CPU 飙升。更好的做法是消费线程本地累计每处理 50 条批量更新一次任务表最后任务结束时再 count 汇总兜底。我的实现里给 consume 线程加了一个局部变量 count在 handleOne 成功返回后自增攒够 50 就执行一次计数更新。这样任务表的更新频率降为原来的五十分之一。如果业务上任务列表的实时进度不是刚需甚至可以只在任务结束时更新最终计数中间的展示让运营看 phone_item 的统计就行。5. 避坑指南微信批量检测的五个高频问题与排查路径5.1 现象号码全部判为“未开通”一个“已开通”都没有刚开始跑批量检测1000 个号码全都落到状态 3但自己拿手机微信手动搜索这些号码是能搜到的。原因绝大多数情况是风控拦截导致协议侧返回了统一错误而不是真实“未找到”。也可能是检测账号本身没有实名认证或信任度低从源头就被限制。还有一种少见但存在的情况本地网络触发了异常检测所有请求被降级。解决第一步用确定已开通的测试号码做单条检测看返回码正不正常。单条正常、批量全挂基本就是频率问题——把 interval-base 从 800 调到 3000并发从 5 降到 2再跑 100 条验证。如果单条都失败优先换检测账号换完依然失败抓 detect_result 的 raw_message 看具体错误文案别瞎猜。5.2 现象任务跑一半登录态失效全部请求返回需要重新扫码任务执行 10 分钟左右检测请求开始报登录态无效后台扫码后顶掉了正在跑的会话。原因批量操作必然提升账号风险等级加上并发稍高被判定为异常登录。另一个常见原因是多个环境同时复用同一个登录态比如你本地调试开了一个服务器又跑着一个后者会把前者顶下线。解决检测任务里前置登录态健康检查每 5 分钟发一个轻量请求确认会话有效失效立即暂停任务并告警不要持续失败占用资源。生产环境用专门的小号池一个账号跑任务时不要在其他设备登录。同一批账号就不要频繁切换 IP 登录风控模型会记录登录环境变化。5.3 现象Excel 导入后行数变少几百个号码凭空消失文件用 WPS 或 Excel 打开有 2000 行导入完成后库里只有 1800 行少掉的部分也不是格式错误。原因八成是合并单元格。EasyExcel 流式读合并区域时只有合并区域第一行有值其余行读到的是 null这些 null 被清洗器跳过就表现为“行数变少”。另一个原因是 Excel 末尾存在全空行虽然从数据上看不到但 POI 解析时会产生空 cell 对象。解决解析后的每一行先判断是否整行为空是则跳过同时读取 Excel 的合并区域配置把合并单元格的值向下填充到整个区域。用 EasyExcel 的 merge 返回元数据接口去拿合并范围然后把后续行的值设为首个非空值。写单测时一定要构造一个带合并单元格的文件验证不然上线后用户一传文件就翻车。5.4 现象数据库连接池爆掉任务卡死不报错跑了几千条后日志开始出现HikariPool-1 - Connection is not available, request timed out整个服务像死了一样既没有异常堆栈也不往下走。原因检测请求被写在了事务方法里。这是典型的“远程调用放事务”问题。检测一个号码可能要等好几秒如果事务在等待期间一直持有一个数据库连接连接池十来个连接很快就全被占满后面的查询全部排队形成连锁故障。解决定下铁律——远程检测不能出现在事务里。导入阶段可以开事务检测阶段的所有数据库操作都是单条 update不需要事务。如果确实要保证状态回写的原子性把 update 语句本身做成单条自包含的 SQL数据库默认就是原子提交比包一层大事务更高效。另外把 Hikari 的 maximum-pool-size 从 20 调回 10真正常用的连接就那么几个设计上给后台任务留出独立连接额度更稳妥。5.5 现象清洗后的手机号出现 10 位或 12 位导入后检查 phone_item发现 phone 字段有的变成 138123456710 位有的变成 861381234567812 位而且这些号码全被判为异常。原因国家码截断逻辑写错了。原始号码是 86-13812345678 时前面用replaceAll([^\\d], )会把横杠去掉得到 8613812345678如果截断条件写的是“以 86 开头就截”那号码会被正确处理但如果原始号码是 8613812345678 且确实是个异常数据截断后变成 13812345678 又误判成合法。另一个源头是 Excel 科学计数法13812345678 被显示成 1.38123E10读出来已经是 1.38123E10清洗后完全变样。解决截断逻辑加长度条件只有“86 开头且总长度 13 位”才截截完必须再走一遍 isValid。科学计数法在读取阶段就要处理EasyExcel 对单元格指定 cellType 为 STRING或者读成 BigDecimal 后调 toPlainString()不能用 toString。数据清洗是最不值得省时间的环节清洗器写完后把 10 位、12 位、全角、科学计数法、备注混排这些样本都喂一遍再放行。6. 进阶从“能跑”到“值得投入”的验证方法系统能跑通只是第一步真正决定值不值得投入的是一套可重复的验证流程。我自己习惯在上线前跑三轮。第一轮是小样本验证。取 200 个真实号码其中包含 50 个确定已开通的、50 个确定未开通的、100 个随机号跑完整链路。看已开通的召回率能不能到 95% 以上未开通的准确率能不能到 98% 以上。如果召回率低优先调间隔和重试次数不要加并发——风控场景下并发加到一定程度是负收益账号被限制后整个任务停摆损失远大于节省的时间。第二轮是人工抽检。从检测结果随机抽 30 个号码用自己手机微信手动搜索核对。这一步其实是在校准协议返回码的业务映射不同客户端版本对“未找到”和“异常”的返回可能不一样宁可把模棱两可的判成“不确定”也不要判“未开通”因为后者往往是不可逆的业务决定。把抽检结果整理成表格每批次抽检完记录准确率连续三批都达标再扩大规模。第三轮是灰度任务验证。先放一个 1000 号的小任务跑完后统计 detect_result 中的返回码分布。如果 UNKNOWN/ABNORMAL 占比超过 5%说明当前账号状态或频率参数不适合大批量运行先停下来排查不要硬着头皮继续投量。另外我会把排队逻辑做成人机协同任务可以暂停人工处理异常号码后再续跑这样即使少数号码触发风控也不会把整批任务拖垮。最后是数据闭环。检测完成后我用一条 SQL 把已开通号码导出成 CSV交给运营系统做加好友动作。这里还有一条自己的教训加好友动作和检测动作不要同时跑加好友频率控制比检测更严格它是在真实地骚扰用户后台检测只是搜索展示两者风险等级完全不同。先让检测跑了稳定一周再考虑把加好友接进来而且加好友热度也要设置独立的速率参数。做这种工具最深的体感是协议侧的稳定性永远是黑匣子能控制的是自己的数据质量、频率策略和状态机设计。数据清洗没做好后面的检测全是白跑风控没敬畏心再好的并发设计也早晚翻车。这套系统值不值得做可以先用 5000 个号码手动筛一遍估算时间如果手动耗时超过半天而周期是每周一次那就值得投入去做。方向本身没问题关键是先想好退路希望这些经验能帮到你少走点冤枉路。本文还有配套的精品资源点击获取