feat(app): 安卓应用内升级,安装包由后端发布
CI / backend (push) Canceled after 0s

需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。

后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
  还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
  内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
  backend/static/apk/ 不进版本库

安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
  校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
  是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
  同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
  没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease

文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
  发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑

验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
  version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
This commit is contained in:
Steven
2026-10-31 03:15:00 +08:00
parent 35fe4d92cb
commit 7668799040
34 changed files with 937 additions and 12 deletions
+61 -3
View File
@@ -544,8 +544,6 @@ Query:
### 6.3 附件错误码
### 6.3 附件错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
@@ -562,7 +560,67 @@ Query:
---
## 7. 健康与版本
## 7. 安卓发布与升级 `/app`
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
`latest.json` 清单。接口只读这份清单,**没有清单就等于没发布过**(9001)。
### 7.1 `GET /app/release/latest`
| 参数 | 说明 |
|---|---|
| `version_code` | 可选,客户端当前版本号;给了才会算 `has_update` |
免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。
```json
{
"code": 0,
"data": {
"platform": "android",
"version_code": 2,
"version_name": "0.2.0",
"notes": "新增应用内升级;图标换成和 Web 一样",
"file": "dadaotask-0.2.0.apk",
"size": 8123456,
"sha256": "…",
"min_sdk": 26,
"published_at": "2026-10-31T03:20:00Z",
"download_url": "/api/v1/app/release/download/dadaotask-0.2.0.apk",
"has_update": true
}
}
```
`download_url` 是**相对路径**:同一份清单要被内网、公网、模拟器三个入口共用,
写死域名只会让其中一个坏掉;客户端用自己配置的 API 地址拼。
`has_update` 的判据只有 `version_code`,不看版本名字符串——字符串比较会把
`0.10.0` 判成比 `0.9.0` 旧。
### 7.2 `GET /app/release/download/{file}`
只认清单里登记过的文件名,其它一律 9001(含 `..%2F` 这类路径穿越尝试)。
返回 `application/vnd.android.package-archive`。同样免登录:下载地址本身就是
清单里给出的,能拿到清单就说明已经过了一道。
### 7.3 发布包的约定
- APK 必须用**同一把证书**签名,否则新包装不上去(系统只会报"应用未安装")。
证书放在发布者本机(`android/keystore.properties` 指向它),不进仓库。
- 上传清单带 `sha256`,客户端下完先校验再交给系统安装器:内网代理缓存旧包时,
这是唯一能挡住"装了个旧版本"的地方。
- 「允许安装未知应用」是系统开关,App 只能把用户送到设置页,不能代点。
### 7.4 错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 9001 | 404 | 还没发布过安装包,或下载的文件不在清单里 |
---
## 8. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|