需求是"改完 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
8.8 KiB
部署(生产 · openclaw-01)
适用于当前唯一的线上环境:
openclaw-01(192.168.8.188),访问入口http://192.168.8.188:8200。 本地开发见DEVELOPMENT.md。部署铁律见仓库根AGENTS.md第 2 节。
1. 生产拓扑
浏览器 ──HTTP──▶ openclaw-01:8200
└ gunicorn(2 × UvicornWorker) ── app.main:app
├ /api/v1/** 业务 API
└ / 前端产物 web/dist(单页应用,404 回退 index.html)
│
┌────────────────────────┼────────────────────────┐
▼ ▼ ▼
fnsvr-01:8432 PG 17.6 fnsvr-01:6379 Redis 本机 backend/var/uploads
库 dadaotask_prod db 6(独立库号) 附件文件
角色 dadao_prod_app(RLS)
角色 dadao_prod_owner(迁移)
为什么是这个形状:
- 生产库和开发库在同一个 PG 实例(fnsvr-01),所以生产用独立的库 + 独立的角色。复用
dadao_app等于拿到开发口令就能连生产。迁移里的GRANT/DEFAULT PRIVILEGES按APP_DB_ROLE/OWNER_DB_ROLE生成,靠backend/app/roles.py注入。 - Redis 用 6 号库隔离:共享实例没有密码,靠库号避免和别的项目撞键(验证码、幂等键)。
- 没有 nginx:openclaw 账号无 sudo 密码,改不了系统 nginx,所以后端单端口同时托管 API 与前端产物(同源,免 CORS)。root 愿意配合时按
deploy/prod/nginx.dadaotask.conf.example加反代,不用改应用。 - 用户级 systemd:openclaw-01 上所有服务(easymemo、portal-site…)都是
systemctl --user起的,本方案沿用,全程不需要 root。
2. 首次部署(一次性)
前置:能 SSH 到 openclaw-01;fnsvr-01 的 root 权限(只在建库这一步需要)。
2.1 建生产库与角色(在 fnsvr-01 上,root)
# 口令自己生成,别用示例里的
OWNER_PW='<dadao_prod_owner 的口令>' APP_PW='<dadao_prod_app 的口令>' \
bash deploy/prod/provision-db.sh
会创建:库 dadaotask_prod(owner dadao_prod_owner)、角色 dadao_prod_owner / dadao_prod_app(NOSUPERUSER NOBYPASSRLS,否则 RLS 会被跳过)、public schema 归 owner、REVOKE CREATE ... FROM PUBLIC。建表与 RLS 策略由 alembic 负责(2.4)。
2.2 代码落地(在 openclaw-01 上,openclaw 身份)
git clone --branch main --single-branch http://gitea.tatta.cn/Tatta/DadaoTask.git ~/app/dadaotask
2.3 配置(不进仓库)
mkdir -p ~/.config/dadaotask && chmod 700 ~/.config/dadaotask
cp ~/app/dadaotask/deploy/prod/env.production.example ~/.config/dadaotask/env.production
chmod 600 ~/.config/dadaotask/env.production
vim ~/.config/dadaotask/env.production # 替换全部 __CHANGE_ME__
必填项:DATABASE_URL / MIGRATION_DATABASE_URL(2.1 的口令)、JWT_SECRET(≥32 位随机串)、REDIS_URL、WEB_DIST_DIR、UPLOAD_DIR。
两个踩过的坑:
CORS_ORIGINS必须写成 JSON 数组('["http://...","http://..."]')。它是 list 字段,逗号分隔的字符串会让 pydantic-settings 当 JSON 解析并抛SettingsError,alembic 这一步就挂。- Redis 有口令:fnsvr-01 的 redis 开了
requirepass(口令看docker inspect redis),写进 URL 时要百分号编码(#→%23),否则连不上、发码/登录直接 500。
当前状态是内测模式:短信通道还没接(非 dev provider 直接 501),所以 SMS_PROVIDER=dev + INTERNAL_TEST_LOGIN=true,固定验证码 123456。启动日志会打 WARNING。只允许内网访问;短信通道接好后删掉 INTERNAL_TEST_LOGIN 并把 SMS_PROVIDER 换成真实供应商。
2.4 装服务单元
mkdir -p ~/.config/systemd/user
cp ~/app/dadaotask/deploy/prod/dadaotask-api.service ~/.config/systemd/user/
systemctl --user daemon-reload
2.5 首次上线
bash ~/app/dadaotask/deploy/prod/deploy.sh
3. 日常发布(每次上线)
# 本地:提交并 push 到 main(生产只跑仓库 HEAD)
# 服务器:
bash ~/app/dadaotask/deploy/prod/deploy.sh
脚本做六件事,任何一步失败即中止:代码对齐 origin/main(git reset --hard,禁止服务器手工改)→ 装 Python 依赖 → alembic upgrade head(用迁移角色)→ 在服务器上 npm ci && npm run build → 写 env.build 并重启 → 校验 /health 与 /version 的 commit 等于 HEAD。
前端产物只在服务器上构建,不允许本地上传 dist(AGENTS.md 铁律 3)。
4. 部署后校验
curl -s http://192.168.8.188:8200/health # {"status":"ok","db":true,...}
curl -s http://192.168.8.188:8200/version # commit 必须等于 git rev-parse --short HEAD
curl -sI http://192.168.8.188:8200/ | head -1 # 200,text/html(前端产物)
浏览器验收:打开 http://192.168.8.188:8200 → 手机号任意 + 验证码 123456 登录 → 任务/工单/知识各点一遍。
5. 安卓安装包发布(OTA)
APK 不进版本库,也不走 deploy.sh:代码发布和"发一个安装包"是两件事,
前者每次上线都做,后者只在安卓端有改动时做。
5.1 出包(本机)
cd android
export JAVA_HOME=<JDK 17> # AGP 8.7 不认 JDK 25
export GRADLE_USER_HOME=<可写的 gradle 目录>
gradle -Dorg.gradle.jvmargs="-Djava.io.tmpdir=<可写 tmp>" assembleRelease
# 产物:android/app/build/outputs/apk/release/app-release.apk
- 版本号在
android/app/build.gradle.kts:versionCode是 OTA 的唯一判据,每次发版必须 +1, 否则客户端查不到更新;versionName只是给人看的。 - 签名证书在
android/keystore.properties(本机文件,.gitignore已排除)指向的.jks。 换证书等于换应用:装了旧包的手机再也装不上新包,只能卸载重装。 dadao.apiBase(android/local.properties)决定包里写死的后端地址。正式包指向http://192.168.8.188:8200,模拟器调试包指向http://10.0.2.2:8100。
5.2 发布清单
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
--version-code 2 --version-name 0.2.0 --notes "新增应用内升级;图标与 Web 一致"
脚本把 APK 复制成 backend/static/apk/dadaotask-<版本>.apk,并写 latest.json
(含 sha256、size、published_at)。接口读的就是这份清单。
5.3 传到服务器
scp backend/static/apk/* openclaw-01:~/app/dadaotask/backend/static/apk/
deploy.sh 的 git reset --hard 不会删未跟踪文件,所以已发布的 APK 在服务器上能留住;
但换了机器或清了目录就要重新传一次。不需要重启服务:接口每次请求都现读清单与文件。
5.4 验证
curl -s "http://192.168.8.188:8200/api/v1/app/release/latest?version_code=1"
# data.version_code 为新版号、has_update=true、download_url 可下载
curl -sI http://192.168.8.188:8200/api/v1/app/release/download/dadaotask-0.2.0.apk | head -1 # 200
手机端:装上新包 → 下次启动自动查一次;也可以在首页底部点「检查更新」。
装完再启动,version_code 已是最新,接口返回 has_update=false,不会再弹。
6. 运维
systemctl --user status dadaotask-api # 状态
systemctl --user restart dadaotask-api # 重启
journalctl --user -u dadaotask-api -f # 日志(gunicorn 输出到 stdout)
- 附件落盘:
backend/var/uploads(按工作区分子目录),删库不删文件,备份时一起打包 - 回滚:
cd ~/app/dadaotask && git reset --hard <旧 commit> && bash deploy/prod/deploy.sh;数据库迁移不回滚,回滚代码前确认旧代码兼容新表结构 - 端口:
8200(systemd 单元与deploy.sh的PORT默认值,两处要一致)
7. 已知限制(当前阶段)
| 限制 | 影响 | 解除方式 |
|---|---|---|
| 没有 HTTPS / 域名 | 只能内网 IP 访问,令牌明文传输 | 域名 + nginx + 证书(需 root) |
| 内测固定验证码 | 谁都能用任意手机号登录 | 接短信通道,关 INTERNAL_TEST_LOGIN |
| 无 pgvector | 语义检索走 real[] 全表扫描,数据量大了会慢 |
上独立 pgvector 实例(deploy/docker-compose.vector.yml) |
| 上传目录在本机磁盘 | 机器坏了附件就没了 | 备份 backend/var/uploads,或将来到对象存储 |
| 共享 PG / Redis | 别人占满连接或内存会波及本站 | 独立实例 |