189 lines
7.7 KiB
Markdown
189 lines
7.7 KiB
Markdown
## Context
|
||
|
||
用户给出的 Python 实现包含两个核心函数:
|
||
|
||
- `generate_sign(secret, timestamp)`:签名字符串为 `timestamp + "\n" + secret`,把这个完整字符串作为 HMAC key,消息体为空字节数组,算法为 SHA-256,结果做 Base64 编码。
|
||
- `push_markdown(markdown_content, webhook_id, secret)`:构造飞书 interactive markdown 卡片,加上 `timestamp` 和 `sign`,10 秒超时 POST 到飞书 webhook,HTTP 200 且响应 JSON `code == 0` 表示成功。
|
||
|
||
当前 Android 工程是 Java + Android Gradle Plugin 7.2.2,`minSdkVersion=21`、`targetSdkVersion=30`。工程没有 Kotlin、Retrofit、OkHttp 或协程依赖。为了把需求做小、做稳,首版应选择 Java + OkHttp + `org.json`,不引入 Retrofit 接口层。
|
||
|
||
## Goals / Non-Goals
|
||
|
||
**Goals:**
|
||
|
||
- 先形成完整 spec,不直接写代码。
|
||
- Android 侧等价实现 Python 已有飞书 webhook 推送协议。
|
||
- 推送模块独立于短信接收模块,方便单元测试和手动测试。
|
||
- 网络请求后台执行,避免阻塞主线程和短信广播处理。
|
||
- 提供清晰的本地诊断结果,让用户知道是签名、配置、网络、HTTP、JSON 还是飞书业务错误。
|
||
- 推送内容包含短信原文,便于当前调试远端接收结果。
|
||
|
||
**Non-Goals:**
|
||
|
||
- 不改造服务端协议。
|
||
- 不做重试队列、离线持久化队列或 WorkManager 长任务调度。
|
||
- 不把网络发送成功作为短信接收成功的前置条件。
|
||
- 不把 webhook secret 写死到源码或提交到版本库。
|
||
- 不要求本轮编译。
|
||
|
||
## API Strategy
|
||
|
||
### 1. Android 签名算法保持 Python 等价
|
||
|
||
Python 逻辑的关键点不是常见的“secret 作为 key、timestamp 作为 message”,而是:
|
||
|
||
- `string_to_sign = f"{timestamp}\n{secret}"`
|
||
- HMAC key 为 `string_to_sign.encode("utf-8")`
|
||
- HMAC message 为空字节数组
|
||
- digest 为 SHA-256
|
||
- 输出 Base64 字符串
|
||
|
||
Android 侧应实现:
|
||
|
||
- `String stringToSign = timestampSeconds + "\n" + secret;`
|
||
- `Mac mac = Mac.getInstance("HmacSHA256");`
|
||
- `mac.init(new SecretKeySpec(stringToSign.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));`
|
||
- `byte[] signData = mac.doFinal(new byte[0]);`
|
||
- `Base64.encodeToString(signData, Base64.NO_WRAP);`
|
||
|
||
签名单元测试必须固定 `secret` 和 `timestamp`,用 Python 参考算法生成期望值,防止迁移时误用 key/message。
|
||
|
||
### 2. 请求体保持 Python 结构
|
||
|
||
请求 JSON:
|
||
|
||
```json
|
||
{
|
||
"msg_type": "interactive",
|
||
"card": {
|
||
"elements": [
|
||
{
|
||
"tag": "markdown",
|
||
"content": "..."
|
||
}
|
||
]
|
||
},
|
||
"timestamp": "秒级时间戳字符串",
|
||
"sign": "Base64签名"
|
||
}
|
||
```
|
||
|
||
Android 侧用 `JSONObject` / `JSONArray` 构造,避免手写 JSON 字符串。`timestamp` 保持字符串类型,与 Python 实现一致。请求头设置 `Content-Type: application/json; charset=utf-8`。
|
||
|
||
### 3. 网络库选择 OkHttp
|
||
|
||
首版推荐 OkHttp,而不是 Retrofit:
|
||
|
||
- 当前需求只有一个动态 webhook URL 的 POST。
|
||
- Retrofit 更适合多接口、固定 baseUrl、声明式 API;这里会引入额外抽象但收益有限。
|
||
- OkHttp 可以直接设置 10 秒 connect/read/write/call timeout,并明确拿到 HTTP 状态码和响应体。
|
||
- Java 工程接入成本低,测试时可用 `MockWebServer`,也可以先只做纯 Java 请求体和响应解析测试。
|
||
|
||
如果后续服务器接口变多,再引入 Retrofit。
|
||
|
||
### 4. 线程模型
|
||
|
||
`BroadcastReceiver.onReceive` 生命周期短,不能同步执行网络请求。后续实现应采用:
|
||
|
||
- `FeishuWebhookClient.pushMarkdownAsync(...)` 提交到后台 `ExecutorService`。
|
||
- `SmsReceiver` 在验证码解析成功、保存本地结果后,只触发异步推送,不等待网络结果。
|
||
- 推送结果写入 `SharedPreferences` 或轻量状态 store,并通过本地广播刷新 UI。
|
||
- 若未来需要保证进程退出后仍发送,可另起 change 引入 WorkManager;本轮不做。
|
||
|
||
### 5. 配置策略
|
||
|
||
首版本地配置文件:
|
||
|
||
- `webhook_id`
|
||
- `secret`
|
||
- `enabled`
|
||
- `sendFullSmsBodyForDebug`,默认 false
|
||
- 最近一次推送状态、时间、错误类型和错误摘要
|
||
|
||
飞书 webhook 配置保存到 `/sdcard/Android/data/{applicationId}/config/feishu.json`。如果 `feishu.json` 不存在,app 生成默认模板 `/sdcard/Android/data/{applicationId}/config/def_config_feishu.json`,但不把模板当作生效配置。这样调试时可以直接改 `feishu.json`,不需要重新编译 Android 代码。
|
||
|
||
最近一次推送状态仍可保存在 `SharedPreferences`,因为它不是动态调试配置。UI 展示时 secret 必须掩码,不在日志中打印。
|
||
|
||
### 6. 推送内容策略
|
||
|
||
验证码解析成功时,默认 markdown 内容建议包含:
|
||
|
||
- 验证码:解析出的 code。
|
||
- 来源:`system_sms_broadcast` / `sms_inbox_*`。
|
||
- 发送方:掩码后的 sender。
|
||
- 时间:本地格式化时间。
|
||
- 解析策略和置信度:便于诊断。
|
||
|
||
默认包含完整短信原文,便于远端调试确认。仍不建议在 logcat 打印完整正文。
|
||
|
||
### 7. 响应解析和错误分类
|
||
|
||
成功条件:
|
||
|
||
- HTTP status 为 200。
|
||
- 响应体是 JSON。
|
||
- JSON `code` 为数字 0。
|
||
|
||
错误分类:
|
||
|
||
- `disabled`:用户未开启推送。
|
||
- `missing_config`:缺少 `webhook_id` 或 `secret`。
|
||
- `sign_error`:HMAC 算法或 key 初始化失败。
|
||
- `network_error`:DNS、连接失败、TLS、Socket 异常等。
|
||
- `timeout`:请求超过 10 秒。
|
||
- `http_error`:HTTP status 非 200。
|
||
- `invalid_json`:响应不是合法 JSON。
|
||
- `api_error`:飞书返回 `code != 0`,记录 code 和 msg。
|
||
|
||
日志策略:
|
||
|
||
- 可以记录错误类型、HTTP status、飞书 code/msg。
|
||
- 不记录 secret、sign、完整 webhook URL。
|
||
- 不默认记录完整 markdown 内容。
|
||
|
||
## Privacy Boundaries
|
||
|
||
- 默认仅上传验证码和必要诊断摘要。
|
||
- 飞书推送默认包含完整短信正文;开启推送即表示允许验证码短信离开设备。
|
||
- 不上传联系人通讯录、短信历史或设备标识。
|
||
- 本地保存 secret 时使用 `SharedPreferences` 只满足个人调试场景;如需更严格保护,应另起 change 使用 Android Keystore 加密。
|
||
- 推送到飞书意味着验证码会离开设备,UI 应让用户明确知道远端推送已开启。
|
||
|
||
## Test Strategy
|
||
|
||
单元测试:
|
||
|
||
- `generateSign` 固定输入输出测试。
|
||
- 请求 JSON 构造测试,验证字段名、类型和嵌套结构。
|
||
- webhook URL 拼接测试,防止多余斜杠和空 id。
|
||
- 响应解析测试:
|
||
- `{"code":0,"msg":"success"}` 成功。
|
||
- `{"code":19021,"msg":"invalid sign"}` 返回 `api_error`。
|
||
- 空响应、HTML、非法 JSON 返回 `invalid_json`。
|
||
- 配置缺失测试。
|
||
|
||
手动验证:
|
||
|
||
- 在 UI 输入 webhook id 和 secret,点击“测试推送”。
|
||
- 使用真实飞书机器人确认收到 markdown 卡片。
|
||
- 故意填错 secret,确认 UI 显示飞书业务错误而不是短信接收失败。
|
||
- 断网或飞行模式下测试 `network_error`/`timeout`。
|
||
- 收到真实验证码短信后,确认本地结果先保存,远端推送状态独立更新。
|
||
|
||
## Rollout Plan
|
||
|
||
1. 完成本 OpenSpec 并 validate。
|
||
2. 新增 OkHttp 依赖和 `INTERNET` 权限。
|
||
3. 实现签名、请求体构造、响应解析和异步发送模块。
|
||
4. 增加 JSON 配置 store 和最近推送状态。
|
||
5. 在 `MainActivity` 增加配置、测试发送和状态展示。
|
||
6. 将验证码解析成功后的推送接入异步链路。
|
||
7. 增加聚焦单元测试,不要求本轮编译。
|
||
|
||
## Open Questions
|
||
|
||
- webhook id 和 secret 是否只用于本机调试,还是需要后续提供导入/导出配置。
|
||
- 默认推送内容是否只发验证码,还是需要包含发送方掩码和解析策略。
|
||
- 是否需要推送所有收到的字符串,还是只在验证码解析成功时推送。
|
||
- 如果网络失败,是否需要后续补发;本轮建议不做持久化补发队列。
|