你有没有过这种经历:手机锁在抽屉里,或者人不在工位,短信验证码偏偏在这时候到了,错过一次验证,整个流程又得从头再来。更麻烦的是,如果验证码不是一条两条,而是要对接进自己写的脚本、管理系统或者自动化流程里,靠人肉复制根本没法谈自动化。SmsForwarder是我用了好几年的安卓开源短信转发工具,Flask则是Python里把接口快速立起来最顺手的选择,这俩组合在一起,就能做一个只需要5步的验证码自动接收服务:手机收到短信,SmsForwarder把它 POST 到一个自己写的 Flask API,API 负责解析验证码、入库、再对外开放一个查询接口。今天这篇就直接把整套搭建过程、代码、部署和踩坑记录都放出来,适合自己写小工具的开发者,也适合想让验证码自动流转的运维和自动化爱好者。
1. 项目拆解:SmsForwarder + Flask 这套方案的思路
1.1 为什么选这套组合
短信自动化这件事,方案其实不少,但我实际试过一圈后,最后还是选了 SmsForwarder 加 Flask 的组合。先说手机端,除了 SmsForwarder,还有 Tasker、Auto.js 这类自动化脚本工具也能监听短信,Tasker 功能确实强大,但它的脚本可读性差,改起来麻烦;Auto.js 以前挺好用,但后续维护情况一般,而且为了读一条短信写一大段无障碍脚本,性价比太低了。SmsForwarder 的优势在于它是专门为短信转发设计的开源工具,界面清楚,权限接管成熟,自带 Webhook 发送通道,我只需要在服务端准备一个 HTTP 接口就行。
服务端选 Flask 而不是 FastAPI、Django,理由也很直接。这个场景本质上就是“接收一个 POST 请求,做点解析,落库,再给一个查询接口”,Flask 写下来整个应用不到 100 行,没有额外的心智负担。FastAPI 虽然自带请求校验和自动文档,但引入的异步、类型标注概念对只想要一个短信接收接口的人来说是多余的;Django 就更不用说了,为一个接口起全家桶,没必要。SmsForwarder 负责把短信从手机里“搬运”出来,Flask 负责把短信内容变成结构化数据,这套组合刚好卡在简单和够用之间。
1.2 数据链路与部署形态
在设计整个方案之前,先把数据链路画清楚,后面每一步才知道自己在调什么。我实际跑通的链路是这样的:运营商把短信送到手机系统短信收件箱,SmsForwarder 通过短信读取权限监听新短信,按照事先配置的转发规则做过滤,命中的短信交给 Webhook 发送通道,由 SmsForwarder 向 Flask API 发起一个 HTTP POST 请求,Flask 拿到手机号、短信内容和时间信息后,用正则把验证码抠出来,写入 SQLite 数据库,最后外部系统通过一个查询接口把验证码取走。
部署形态上,有几种做法。刚开始调试时,手机和电脑连同一个 WiFi,直接用局域网 IP 就能联调,这是最快的方式。如果手机在外网环境,比如把旧手机插着卡固定放在家里,或者人就在外面但手机随时可能切换网络,那就必须有一个公网可达的地址。我一般会优先把 API 部署到云服务器,省事,稳定;如果只是想拿家里那台常开的电脑做后端,就用 frp 内网穿透把本地端口暴露出去;路由器端口映射也能做,但运营商经常封 80/443 端口,而且自己改路由器配置暴露内网服务风险更大,我基本不推荐。
1.3 五步搭建法整体看板
为了心里有数,先把五步对应的内容和产出放在一张表里,后面每一步展开讲。
| 步骤 | 核心工作 | 关键产出 |
|---|---|---|
| 步骤1 | Android 端 SmsForwarder 安装、权限与 Webhook 通道配置 | 手机能监听短信并发出 HTTP 请求 |
| 步骤2 | Flask API 服务端代码编写 | 可接收、解析、存储、查询验证码的接口 |
| 步骤3 | API 部署到公网或局域网可访问地址 | 手机能把请求送达服务端 |
| 步骤4 | SmsForwarder 对接 API,验证整条链路 | 验证码自动入库并可查询 |
| 步骤5 | 接口安全加固、进程保活与数据清理 | 服务长期稳定运行 |
2. 步骤1:Android 端 SmsForwarder 的安装与配置
2.1 安装、权限与保活设置
SmsForwarder 我实际用的是 v6.4.0,你直接从 GitHub Releases 页面下载对应 APK,或者在国内一些应用市场也能搜到。安装完打开,第一件事不是去配转发规则,而是先把权限给足。短信读取权限是核心,没有它什么都监听不到;部分 ROM 还会要求单独授权“通知使用权”或者“后台弹出界面”,这些最好一次性全部允许。
这里要特别提醒一句,国内 ROM 的后台管理比原生 Android 激进很多。我遇到过最典型的情况是:权限全给了,转发规则也配好了,但手机锁屏一段时间后,SmsForwarder 进程被系统杀掉,短信到了却没触发任何转发。解决的办法是把 SmsForwarder 加入电池优化的“不限制”白名单,同时允许它自启动,再把通知栏常驻打开。部分手机上还要在“最近任务”里把 App 锁定,防止被一键清理。手机重启后也建议手动打开一次 App,有些系统不会在开机后自动拉起所有广播接收器,这一步如果不做,重启就等于服务停了。
2.2 新建 Webhook 发送通道
SmsForwarder 的“发送通道”就是负责把短信内容发出去的出口,进去之后点右下角添加,选“Webhook”。我一般这样配置:请求方式选 POST,URL 先随便填一个占位地址,等到后面 Flask 接口部署好再回来改;请求头里加一行Content-Type: application/json;Body 用 JSON 模板,常见的变量是%s代表短信正文,%ss代表发送方号码,所以 Body 填:
{"phone":"%ss","content":"%s"}不同小版本对变量的写法可能有差异,如果界面上有“参数说明”入口,以软件提示为准。我遇到过有人直接在 Body 里写死字符串,导致所有短信进来都是同一条,检查了大半天才发现是变量没生效。配置完成后,SmsForwarder 一般自带一个“测试发送”按钮,点一下如果发送通道能收到请求,说明通道本身是通的,后面再联调真实短信。
2.3 配置转发规则只过滤验证码
转发规则的逻辑是:满足条件的短信才走发送通道。默认可以全量转发,但实际运行起来你会发现垃圾短信、通知短信全进来了,数据一多,后面提取和查询都变难处理。所以我会在规则里加上关键词过滤,常见的就是“验证码”“校验码”“动态码”,满足其中任意一个就转发。
针对不同 SIM 卡,SmsForwarder 也支持在规则里指定卡槽,这个在多卡手机上很有用。比如工作卡和生活卡分开,我只想把工作卡收到的验证码进自动化流程,那就单独建一条规则,选择卡1,再加上关键词条件。另外,如果短信来源是固定的服务号码,也可以把发件人号码直接加进规则,减少拦截误判。配置完先在“转发记录”里看有没有日志,不要直接拿真实的银行验证码测试,先用一个自己发的普通短信验证链路更稳妥。
3. 步骤2:Flask API 服务端代码实现
3.1 项目结构与依赖安装
服务端代码我不喜欢拆得太零碎,这个场景就一个文件就能讲清楚。完整目录结构如下:
sms-forwarder-api/ ├── app.py └── requirements.txtrequirements.txt里只需要 Flask,我本地用的是 Flask 3.0 以上的版本,Python 用 3.10 或更高,代码里用到的sqlite3是标准库,不需要额外安装。
flask>=3.0建议在服务器上用虚拟环境安装,避免污染系统 Python 环境,后面的 systemd 配置也会用到这个路径。
3.2 核心接口代码
下面的代码就是我实际在用的简化版本,包含了初始化数据库、短信接收接口、验证码提取和最新记录查询接口。你可以直接复制保存为app.py。
import os import re import sqlite3 import time from flask import Flask, request, jsonify DATABASE = "sms.db" ACCESS_TOKEN = os.getenv("SMS_API_TOKEN", "please-change-me") app = Flask(__name__) def get_db(): return sqlite3.connect(DATABASE) def init_db(): conn = get_db() try: conn.execute( """ CREATE TABLE IF NOT EXISTS sms_message ( id INTEGER PRIMARY KEY AUTOINCREMENT, phone TEXT NOT NULL, content TEXT NOT NULL, code TEXT DEFAULT '', sim TEXT DEFAULT '', received_at INTEGER NOT NULL ) """ ) conn.commit() finally: conn.close() def save_sms(phone, content, code, sim=""): conn = get_db() try: conn.execute( "INSERT INTO sms_message (phone, content, code, sim, received_at) VALUES (?, ?, ?, ?, ?)", (phone, content, code, sim, int(time.time())), ) conn.commit() finally: conn.close() def extract_code(content): # 优先匹配带关键词的验证码文案 patterns = [ r"验证码[::\s]*([0-9]{4,8})", r"动态码[::\s]*([0-9]{4,8})", r"校验码[::\s]*([0-9]{4,8})", ] for pattern in patterns: match = re.search(pattern, content, re.IGNORECASE) if match: return match.group(1) # 兜底:取连续 6 位数字 match = re.search(r"\b(\d{6})\b", content) if match: return match.group(1) return "" @app.route("/sms", methods=["POST"]) def receive_sms(): # 鉴权:优先从 Header 取,再兼容 query 参数 token = request.headers.get("X-Api-Token") or request.args.get("token") if token != ACCESS_TOKEN: return jsonify({"code": 403, "message": "forbidden"}), 403 data = request.get_json(silent=True) if not data: data = request.form.to_dict() if not data: return jsonify({"code": 400, "message": "invalid json body"}), 400 phone = str(data.get("phone", "")).strip() content = str(data.get("content", "")).strip() sim = str(data.get("sim", "")).strip() if not content: return jsonify({"code": 400, "message": "content is empty"}), 400 code = extract_code(content) save_sms(phone, content, code, sim) print(f"[sms] phone={phone} code={code} content_len={len(content)}") return jsonify({"code": 0, "message": "ok", "data": {"code": code}}) @app.route("/sms/latest", methods=["GET"]) def latest_sms(): token = request.headers.get("X-Api-Token") or request.args.get("token") if token != ACCESS_TOKEN: return jsonify({"code": 403, "message": "forbidden"}), 403 conn = get_db() try: row = conn.execute( "SELECT phone, content, code, sim, received_at FROM sms_message ORDER BY id DESC LIMIT 1" ).fetchone() finally: conn.close() if not row: return jsonify({"code": 404, "message": "no sms yet"}), 404 return jsonify({ "code": 0, "data": { "phone": row[0], "content": row[1], "code": row[2], "sim": row[3], "received_at": row[4], }, }) @app.route("/healthz", methods=["GET"]) def healthz(): return jsonify({"code": 0, "message": "ok"}) if __name__ == "__main__": init_db() app.run(host="0.0.0.0", port=5000, debug=False)3.3 关键逻辑说明
Token 这块我重点说一下。代码里SMS_API_TOKEN从环境变量读取,避免把密钥硬编码到代码里提交到 Git。这是很多小项目最容易忽略的点,以为本地代码不公开就无所谓,等代码传到仓库或者发给同事之后就收不住了。
request.get_json(silent=True)的作用是,如果请求体不是合法 JSON,不会直接抛异常,而是返回None,程序再尝试从表单数据里取值。之所以要兼容表单,是因为 SmsForwarder 不同版本或者不同人配置的发送通道,可能用的是application/x-www-form-urlencoded,接口兼容两种格式,后面联调时能少踩很多坑。
extract_code的正则我做了两层:第一层匹配“验证码”“动态码”“校验码”这类关键词后面跟 4 到 8 位数字,第二层是兜底逻辑,当短信文案里没有这些关键词时,直接提取整段文本里连续的 6 位数字。实际跑下来,这个方法能覆盖国内绝大多数验证码短信格式。返回结构统一用{"code": ..., "message": ..., "data": ...},对接方拿到响应先看code是不是 0,逻辑清楚,排查问题也方便。
3.4 接口参数说明与扩展思路
| 接口 | 方法 | 说明 |
|---|---|---|
/sms | POST | SmsForwarder 调用的短信上报接口 |
/sms/latest | GET | 外部系统轮询获取最新一条验证码 |
/healthz | GET | 健康检查接口,用于进程守护 |
基础版本能跑了之后,可以根据自己的需求扩展。比如要接多个业务方,可以在/sms的 Body 里加一个source字段;要把验证码实时推送到企业微信群或者钉钉群,在save_sms之后调用对应的 Webhook 就行;如果不想用轮询方式取验证码,也可以在/sms接口里直接调用下游业务接口,把验证码送到自己的登录脚本里。
4. 步骤3:把 API 暴露到公网,让手机能回调
4.1 本地先跑通,不要让手机直接上公网
我强烈建议第一步先在本机把接口验证清楚,再考虑公网部署。本地运行命令很简单:
export SMS_API_TOKEN=your-secret-token python app.py然后另开一个终端,用 curl 模拟 SmsForwarder 的请求:
curl -X POST 'http://127.0.0.1:5000/sms?token=your-secret-token' \ -H 'Content-Type: application/json' \ -d '{"phone":"13800138000","content":"【测试】验证码:123456,5分钟内有效。"}'正常的话会返回{"code":0,"message":"ok","data":{"code":"123456"}}。再访问查询接口:
curl 'http://127.0.0.1:5000/sms/latest?token=your-secret-token'能看到刚才入库的那条记录。这个流程通了,说明 Flask 代码本身没问题,后面出问题都是网络层和配置层的事,排查范围一下子就缩小了。
4.2 云服务器部署:systemd 托管加 Nginx 反代
如果手头有云服务器,我推荐直接部署到云上,最省心。把代码上传到服务器后,在/etc/systemd/system/sms-forwarder-api.service里写一个服务文件:
[Unit] Description=Sms Forwarder Flask API After=network.target [Service] Type=simple User=root WorkingDirectory=/opt/sms-forwarder-api Environment=SMS_API_TOKEN=your-secret-token ExecStart=/usr/bin/python3 /opt/sms-forwarder-api/app.py Restart=always RestartSec=3 [Install] WantedBy=multi-user.target然后执行:
systemctl daemon-reload systemctl enable --now sms-forwarder-api这里有一个很多人会漏掉的细节:Environment里写的 token 就是 Flask 读的SMS_API_TOKEN,部署的时候不要复制我这里的占位符,务必改成一个足够长的随机字符串。进程崩溃后会自动重启,我现在线上服务跑了大半年,基本没有因为进程退出导致服务不可用的情况。
如果域名已经备案并且能解析到服务器,我再建议加一层 Nginx 反向代理,把 80 端口转给本机 5000 端口,顺便用 certbot 签一个免费 HTTPS 证书。Nginx 配置核心就一段:
server { listen 80; server_name sms.example.com; location / { proxy_pass http://127.0.0.1:5000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }为什么要加 HTTPS?因为验证码短信本身就是敏感信息,如果走明文 HTTP,在公网链路上等于裸奔,任何一个中间节点都能看到内容。证书用 Let's Encrypt 免费签就行,三分钟的事,别省。
4.3 frp 内网穿透:把家里电脑变成后端
如果不想买云服务器,或者后端服务本来就在家里电脑上,FRP 内网穿透是成本最低的选择。需要一台有公网 IP 的服务器做 frps 服务端,然后在家里的电脑上跑 frpc 客户端。
frps 服务端配置,以较新版本的 TOML 格式为例:
bindPort = 7000 auth.method = "token" auth.token = "your-frps-token"frpc 客户端配置:
serverAddr = "1.2.3.4" serverPort = 7000 auth.method = "token" auth.token = "your-frps-token" [[proxies]] name = "smsapi" type = "tcp" localIP = "127.0.0.1" localPort = 5000 remotePort = 25000配置完启动 frpc,公网访问地址就变成http://服务器IP:25000/sms。仔细看我的示例,remotePort 用的是 25000,不是常见的 5000 或者 8080。不用默认端口是为了减少被扫描器乱撞的概率,虽然 API 本身有 token 保护,但少暴露一点是一点。frp 版本之间的配置格式差异比较大,老版本是 INI 风格,新版本是 TOML 风格,如果启动报错,先确认一下版本和配置格式是否匹配。
4.4 连不通时先查什么
这里分享一个我自己的排查顺序,根据出现概率从高到低排:先看服务器安全组或者防火墙有没有放行对应端口,云服务器经常在系统防火墙之外还有一层安全组,最容易漏;再确认进程有没有监听在0.0.0.0上,如果 Flask 启动时写成127.0.0.1,外网必然连不上;接着看 Nginx 和 frpc 的日志,Nginx 的 access log 能看到请求到底进没进来;最后才是看代码逻辑。
5. 步骤4:SmsForwarder 对接 API 与验证码自动提取
5.1 回调地址与 Header 配置
Flask 接口部署好之后,回到 SmsForwarder 的 Webhook 通道,把刚才的占位 URL 改成真实的回调地址。比如云服务器部署就填:
http://sms.example.com/sms如果用了 frp,就填:
http://服务器IP:25000/smsToken 我建议放在请求头里,不要放到 URL 上。因为 URL 会出现在 Nginx access log、frp 日志、SmsForwarder 的转发记录里,任何一方日志泄露,token 就跟着泄露了。放在 Header 里虽然 Nginx 默认也可能记录一部分请求头,但至少减少了一个泄露面。所以我在 SmsForwarder 的 Header 里加上:
X-Api-Token: your-secret-tokenBody 模板保持之前写的格式不变:
{"phone":"%ss","content":"%s"}5.2 验证码提取规则怎么写才稳
从实际收到的短信来看,验证码文案五花八门。最常见的是“验证码:123456”,这种我的extract_code先用带关键词的正则就能匹配到。也有短信写“您的动态码是123456,请勿泄露”的,“动态码”关键词也能命中。最麻烦的是“本次操作为123456,若非本人操作请忽略”这类没有验证码关键词的文案,所以代码里加了一个兜底正则,提取整条短信里第一组连续的 6 位数字。
为什么用 6 位而不是 4 位?国内银行和主流互联网平台的验证码绝大多数是 6 位数字,用 4 位容易把手机号里的片段误认为是验证码,误提率太高。不过如果你主要接收的是某些老系统的 4 位验证码,可以把兜底正则可选范围扩大成\d{4,8},但准确性需要自己观察一段时间来判断。还有一点,短信里偶尔会有全角冒号或者空格,所以正则里写了[::\s]*做兼容,这个细节在实际解析时帮了我不少。
5.3 实测验证整条链路
配置完 SmsForwarder 后,不要再走 SmsForwarder 自带的测试按钮,那个只能验证发送通道通不通,验证不了真实的短信读取和规则匹配。我的做法是,直接用自己的另一个手机号给这台手机发一条真实短信,内容就写“【测试】验证码:123456”。
短信到达后,稍等一两秒,看 Flask 这边的日志,正常会输出类似这样的内容:
[sms] phone=13800138000 code=123456 content_len=22再请求一次/sms/latest,能看到那条短信的所有字段已经入库。到这里,整条链路就通了。后面如果要对接自己的自动化系统,外部脚本只需要定时请求/sms/latest,拿到新的验证码之后填入自己的登录或注册流程就行。
5.4 多张 SIM 卡怎么区分
多卡用户经常问,怎么知道验证码是哪张卡收到的?SmsForwarder 支持按卡槽绑定不同的发送通道,所以我的做法是建两个 Webhook 通道:通道 A 的 Body 里加一个固定字段"sim":"card1",在规则里指定卡槽1使用通道 A;通道 B 的 Body 里写"sim":"card2",规则指定卡槽2使用通道 B。这样 Flask 入库时,sim字段就能区分来源。查询的时候也可以加一个按sim过滤的接口参数,需要的话自己扩展两行代码就行。
6. 步骤5:安全加固与长期稳定运行
6.1 接口鉴权与防刷
很多从零开始做这个小工具的人会忽略一个问题:/sms接口一旦公开到公网,理论上任何人都可以往你的服务器上灌数据。单靠一个固定 token 其实是不够的,因为 token 一旦泄露,防刷就形同虚设。我自己的做法是在 Flask 里再加一层简单的 IP 白名单判断,把 SmsForwarder 所在手机的公网出口 IP 或者云服务器内网地址加入白名单,其他 IP 一律拒绝。如果手机经常换网络,这个策略就需要权衡,至少要保证 token 的强度足够。
Token 的生成不要用生日、手机号这类可猜测的字符串,用openssl rand -hex 32生成长随机串,定期更换。更换 token 的时候,SmsForwarder 里的 Header 和服务器上的环境变量要同步改,建议先在服务器更新完环境变量并重启服务,再改 SmsForwarder 配置,减少中间态的时间窗口。
6.2 数据与日志安全
验证码短信属于敏感个人信息,存得越多,风险越大。我的线上服务每 30 天清一次历史短信,用一条 SQL 就能完成:
DELETE FROM sms_message WHERE received_at < strftime('%s', 'now', '-30 days');用 cron 定时跑这条命令就行。日志方面,Flask 代码里我故意只打印验证码和内容长度,不打印完整短信原文,这样即使日志被翻到,泄露的信息也有限。SQLite 数据库文件权限也要收一下,chmod 600 sms.db是基本操作。如果部署在云服务器上,建议把数据库目录和代码目录分开,备份时只备份数据库文件,不给别人看源代码的机会。
6.3 进程守护与手机保活
服务端有 systemd 的Restart=always兜底,但重启之后如果接口起不来,还是要有一个主动检测手段。我在 crontab 里加了一个定时任务,每分钟检查一次健康接口:
* * * * * curl -fsS http://127.0.0.1:5000/healthz || systemctl restart sms-forwarder-api这样即使进程假死、端口被占或者其他异常情况,最多一分钟服务就自动恢复了。手机端那一边,SmsForwarder 的保活是更大的难点。我现在的做法是找一台不插卡的旧手机,插着充电线固定放在办公室或者家里,WiFi 连接稳定,专门跑 SmsForwarder,这样不依赖手机作为日常通讯工具时的各种锁屏限制,也没有电池续航焦虑。这台设备加电后,SmsForwarder 配上开机自启和后台白名单,基本能实现几个月不用碰的稳定运行。
7. 常见问题与排查技巧实录
7.1 SmsForwarder 收不到短信或者不触发转发
遇到这个问题,先打开 SmsForwarder 的转发记录看一眼。如果记录里根本没有这条短信,说明短信读取环节出了问题,重点查系统短信权限有没有真正生效,以及手机有没有把 SmsForwarder 进程杀掉。如果记录里有短信但显示“发送失败”,那就是发送通道配置有问题,回去检查 URL、Header 和 Body 模板。
国内 ROM 还有一类比较隐蔽的问题:双卡手机上,卡槽2 的短信被系统自动归档到“骚扰拦截”或“垃圾短信”文件夹,SmsForwarder 默认只监听收件箱的短信,这两类短信可能触发不了广播。遇到这种情况,把相关号码加入系统联系人,或者关闭系统的骚扰拦截功能,基本就能解决。
7.2 API 返回 400 和 403 的处理
400绝大多数情况下是请求体不是合法 JSON,或者 JSON 里的字段名和代码不一致。SmsForwarder 端显示收到 400 时,先手动用 curl 模拟请求确认接口本身是好的,再检查 Body 模板里的变量有没有被正确替换。有些版本的 SmsForwarder 在 Body 里直接用字符串拼接,如果短信内容里包含引号或者特殊字符,JSON 就可能被破坏,导致服务端解析失败。这时建议把变量换成系统支持的其他占位符,或者检查通道配置里有没有专门处理转义的选项。
这里特别提醒,网上搜索“API 400 错误”时经常会看到类似于api error: 400 invalid schema for function 'artifact'这样的报错,那是大模型 API 调用时 function schema 不合法导致的,和我们这个 Flask 接口的 400 完全是两回事。遇到 Flask 报 400,不要套用那类排查思路,老老实实看请求体格式和字段名。
403就是 token 不对或者 IP 不在白名单里,先确认 SmsForwarder 请求头里的 token 和服务器环境变量是否一致,再看是不是换了网络导致出口 IP 变化。
7.3 手机显示发送成功但 Flask 没收到
SmsForwarder 的“发送成功”只代表请求从手机发出去了,不代表服务端真的接收并处理了。先看 Flask 进程日志,如果没有任何输出,说明请求根本没到应用层。按这个顺序排查:在服务器上用tcpdump或者 Nginx access log 确认请求有没有进来;确认防火墙和安全组放行了对应端口;如果用了 frp,看 frpc 客户端日志有没有建立连接;还有一步很容易漏,服务器上 Flask 绑定的地址如果是默认127.0.0.1,外网请求永远进不来,检查启动配置里有没有写host="0.0.0.0"。
7.4 验证码提取为空或者提取错误
这是最让人头疼的问题,因为网络都通了,就是拿到的验证码不对。先用/sms/latest接口看看content字段里存的原始短信内容是什么,是不是 SmsForwarder 没把短信正文完整传过来。如果内容完整但验证码为空,就是正则没匹配上,根据原始文案调整extract_code里的正则。有一种比较常见的情况是短信里验证码和数字混合在一起,比如“验证码:A1B2C3”,这种纯数字正则是匹配不到的,需要自己补一个字母数字混合的正则。
还有一种容易被忽略的情况:系统自带的垃圾短信拦截在短信到达收件箱之前就拦掉了,SmsForwarder 自然监听不到。如果某些平台的短信一直收不到,先把系统的智能拦截功能关闭再测试一次。
7.5 同一条验证码被多次处理
SmsForwarder 在弱网环境或者发送通道响应慢的时候,会自动重试,加上手机信号切换等不可控因素,同一短信被重复投递是真实存在的情况。我的解决办法是服务端加一个时间窗口去重,同一个手机号、同一条内容在 60 秒内重复上报就忽略。SQL 层面写起来不复杂,在save_sms之前先查一下最近 60 秒内有没有相同phone和content的记录,有就直接返回成功但不落库。最终效果是,即使手机重试三次,业务层只会收到一条验证码,不会因为重复消费导致下游流程执行多次。
7.6 服务重启后数据遇到丢失
如果是正常用 SQLite 落库,重启不会丢数据。如果发现重启后/sms/latest查不到记录,大概率是你改代码的时候用了内存列表存储,也就是把短信存在了 Python 进程的全局变量里。这种方案进程一重启就全没了,虽然代码简单,但生产环境不能用。确认代码里是 SQLite 读写逻辑,同时定期备份sms.db文件,云服务器上做快照或者直接scp拉到本地都行。
最后聊几句实操心得
整个方案跑了大半年之后,我最大的体会是:手机端保活比服务端难搞得多。服务端只要写了 systemd 文件,进程崩了自动拉起来,基本不用管;但手机端不同,国内各种 ROM 的后台策略五花八门,同一个版本SmsForwarder在 A 手机上正常,在 B 手机上就可能被系统杀掉。最省心的做法就是找一台旧手机专机专用,不插日常使用的卡,不装乱七八糟的 App,固定插电放家里跑这个服务,它能稳定运行很久。另外一个建议是 token 能放 Header 就不要放 URL,因为 URL 会出现在 Nginx 日志、frp 日志、SmsForwarder 的转发记录里,泄露面实在太大。还有一个小工具思路可以顺手加上:在/sms接口里解析完验证码后,直接调一次企业微信或者钉钉的群机器人 Webhook,验证码实时推送到群里,这样即使你不在电脑前,手机上也能第一时间看到验证码内容。整体来说这套组合代码量不大,关键是把每一环的日志和验证做扎实,一次性把链路跑通之后,后面基本不需要再动它。