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
+93
View File
@@ -0,0 +1,93 @@
"""安卓 OTA:最新版本清单 + APK 下载。
为什么不用一张表:一个安装包就是一条"发布记录",而 APK 是构建产物、不能进仓库。
所以发布目录里放「latest.json + 同名 apk」,换版本只换文件,运维不需要动数据库。
清单由 `backend/scripts/publish_apk.py` 生成(算好大小与 sha256),本模块只读。
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from fastapi import APIRouter
from fastapi.responses import FileResponse
from ..config import get_settings
from ..errors import AppError, ErrorCode
from ..logging_setup import get_logger
from ..schemas import ok
router = APIRouter(prefix="/app", tags=["版本升级"])
settings = get_settings()
logger = get_logger("app.release")
MANIFEST_NAME = "latest.json"
def apk_dir() -> Path:
"""发布目录:优先用配置里的 APK_DIR,默认在 backend/static/apk(部署时保留、不入库)。"""
if settings.apk_dir:
return Path(settings.apk_dir)
return Path(__file__).resolve().parents[2] / "static" / "apk"
def _manifest() -> dict[str, Any]:
path = apk_dir() / MANIFEST_NAME
if not path.is_file():
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "还没有发布过安装包")
try:
data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc: # noqa: BLE001 - 清单坏了也要说人话
logger.exception("读取 OTA 清单失败")
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单损坏,请重新发布") from exc
if not isinstance(data, dict) or not data.get("file"):
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包清单缺少文件名")
return data
def _payload(data: dict[str, Any], base: str, current: int | None) -> dict[str, Any]:
version_code = int(data.get("version_code") or 0)
return {
"platform": data.get("platform", "android"),
"version_code": version_code,
"version_name": data.get("version_name", ""),
"notes": data.get("notes", ""),
"file": data["file"],
"size": int(data.get("size") or 0),
"sha256": data.get("sha256", ""),
"min_sdk": int(data.get("min_sdk") or 26),
"published_at": data.get("published_at"),
# 客户端拿相对路径拼自己的 API 前缀,换域名不用改包
"download_url": f"{base}/app/release/download/{data['file']}",
# 带了当前版本号就顺手算好要不要升级,客户端不用再比一次
"has_update": current is not None and version_code > current,
}
@router.get("/release/latest", summary="安卓最新版本")
async def latest_release(version_code: int | None = None) -> dict[str, Any]:
"""公开接口:装完 App 之前就要能查,不该要登录。
`version_code` 传客户端当前版本(`BuildConfig.VERSION_CODE`),返回里的
`has_update` 直接告诉它要不要提示升级。
"""
data = _manifest()
return ok(_payload(data, settings.api_prefix, version_code))
@router.api_route("/release/download/{name}", methods=["GET", "HEAD"], summary="下载安装包")
async def download_release(name: str) -> FileResponse:
# 只认清单里那一个文件:不做目录列举,也不接受 ../ 之类
data = _manifest()
if name != data["file"]:
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "没有这个安装包")
path = apk_dir() / name
if not path.is_file():
raise AppError(ErrorCode.APP_RELEASE_NOT_FOUND, "安装包文件不见了,请重新发布")
return FileResponse(
path,
media_type="application/vnd.android.package-archive",
filename=name,
)
+5
View File
@@ -108,6 +108,11 @@ class Settings(BaseSettings):
# 指向 web/dist 的绝对路径;留空则只提供 API。
web_dist_dir: str | None = None
# ── 安卓安装包(OTA)──────────────────────────────────
# 放 latest.json + 对应 apk 的目录;留空则用 backend/static/apk。
# 发布目录是构建产物,不入库,部署脚本也不清理(git reset 不动未跟踪文件)。
apk_dir: str | None = None
# ── 构建标记(部署校验用)──────────────────────────────
build_commit: str = "unknown"
build_time: str = "unknown"
+4
View File
@@ -66,6 +66,9 @@ class ErrorCode(IntEnum):
ATTACHMENT_FORBIDDEN = 8003
CONFIRM_REQUIRED = 8004
# 9xxx 客户端发布(OTA)
APP_RELEASE_NOT_FOUND = 9001
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
_HTTP_STATUS: dict[ErrorCode, int] = {
@@ -111,6 +114,7 @@ _HTTP_STATUS: dict[ErrorCode, int] = {
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
ErrorCode.CONFIRM_REQUIRED: 409,
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
}
+2 -1
View File
@@ -16,7 +16,7 @@ from starlette.responses import Response
from starlette.staticfiles import StaticFiles
from starlette.types import Scope
from .api import attachments, auth, items, knowledge, tickets, workspaces
from .api import app_release, attachments, auth, items, knowledge, tickets, workspaces
from .config import get_settings
from .db import SessionFactory, current_app_role, engine
from .errors import AppError, ErrorCode, http_status_for
@@ -115,6 +115,7 @@ app.include_router(tickets.router, prefix=settings.api_prefix)
app.include_router(tickets.notify_router, prefix=settings.api_prefix)
app.include_router(knowledge.router, prefix=settings.api_prefix)
app.include_router(attachments.router, prefix=settings.api_prefix)
app.include_router(app_release.router, prefix=settings.api_prefix)
@app.get("/health", tags=["运维"], summary="存活探针")
+86
View File
@@ -0,0 +1,86 @@
"""把构建好的 APK 发布成 OTA 版本:算 sha256、写 latest.json。
用法(在仓库根目录):
python backend/scripts/publish_apk.py android/app/build/outputs/apk/release/app-release.apk \
--version-code 2 --version-name 0.2.0 --notes "第一版安装包:知识库支持分类与附件"
发布目录默认 `backend/static/apk/`,可用 `--dir` 或环境变量 `APK_DIR` 改。
这个目录是产物、不入库;部署到服务器时把 `latest.json` 和 apk 一起 scp 过去即可。
"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import shutil
import sys
from datetime import datetime, timezone
from pathlib import Path
REPO = Path(__file__).resolve().parents[2]
DEFAULT_DIR = REPO / "backend" / "static" / "apk"
def sha256_of(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="发布安卓安装包(OTA)")
parser.add_argument("apk", type=Path, help="构建产物路径,如 app-release.apk")
parser.add_argument(
"--version-code", type=int, required=True, help="与 build.gradle 的 versionCode 一致"
)
parser.add_argument("--version-name", required=True, help="展示用版本号,如 0.2.0")
parser.add_argument("--notes", default="", help="更新说明,App 里会原样显示")
parser.add_argument("--min-sdk", type=int, default=26)
parser.add_argument(
"--dir",
type=Path,
default=Path(os.environ.get("APK_DIR") or DEFAULT_DIR),
help="发布目录(默认 backend/static/apk)",
)
args = parser.parse_args(argv)
if not args.apk.is_file():
print(f"APK 不存在:{args.apk}", file=sys.stderr)
return 1
target_dir = args.dir
target_dir.mkdir(parents=True, exist_ok=True)
name = f"dadaotask-{args.version_name}.apk"
target = target_dir / name
shutil.copyfile(args.apk, target)
manifest = {
"platform": "android",
"version_code": args.version_code,
"version_name": args.version_name,
"notes": args.notes.strip(),
"file": name,
"size": target.stat().st_size,
"sha256": sha256_of(target),
"min_sdk": args.min_sdk,
"published_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
}
(target_dir / "latest.json").write_text(
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
)
print(f"已发布 {name}")
print(f" versionCode={manifest['version_code']} versionName={manifest['version_name']}")
print(f" size={manifest['size'] / 1024 / 1024:.1f}MB sha256={manifest['sha256'][:16]}…")
print(f" 清单:{target_dir / 'latest.json'}")
print("提示:服务器上把这两个文件放到 <repo>/backend/static/apk/ 即可,无需重启服务。")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+101
View File
@@ -0,0 +1,101 @@
"""OTA:最新版本清单与安装包下载。
不依赖数据库,也不需要登录——App 还没登录就可能在检查更新。
发布目录用 tmp_path 顶替,绝不去碰真实产物。
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
import pytest
from httpx import AsyncClient
from app.api import app_release
@pytest.fixture()
def release_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
monkeypatch.setattr(app_release.settings, "apk_dir", str(tmp_path))
return tmp_path
def _publish(directory: Path, *, version_code: int = 2, version_name: str = "0.2.0") -> bytes:
payload = b"PK\x03\x04 fake apk bytes"
name = f"dadaotask-{version_name}.apk"
(directory / name).write_bytes(payload)
manifest = {
"platform": "android",
"version_code": version_code,
"version_name": version_name,
"notes": "修了分类侧栏的层级",
"file": name,
"size": len(payload),
"sha256": hashlib.sha256(payload).hexdigest(),
"min_sdk": 26,
"published_at": "2026-10-31T02:00:00+00:00",
}
(directory / "latest.json").write_text(json.dumps(manifest), encoding="utf-8")
return payload
async def test_latest_release_is_public_and_reports_update(
client: AsyncClient, release_dir: Path
) -> None:
# 没发布过:明确告诉客户端没有安装包,而不是 500
resp = await client.get("/api/v1/app/release/latest")
body = resp.json()
assert resp.status_code == 404
assert body["code"] == 9001
async def test_latest_release_payload(client: AsyncClient, release_dir: Path) -> None:
_publish(release_dir)
resp = await client.get("/api/v1/app/release/latest")
assert resp.status_code == 200
data = resp.json()["data"]
assert data["version_code"] == 2
assert data["version_name"] == "0.2.0"
assert data["notes"] == "修了分类侧栏的层级"
assert data["download_url"] == "/api/v1/app/release/download/dadaotask-0.2.0.apk"
assert data["has_update"] is False # 没带当前版本就不猜
async def test_latest_release_tells_client_whether_to_upgrade(
client: AsyncClient, release_dir: Path
) -> None:
_publish(release_dir, version_code=2)
older = await client.get("/api/v1/app/release/latest?version_code=1")
assert older.json()["data"]["has_update"] is True
same = await client.get("/api/v1/app/release/latest?version_code=2")
assert same.json()["data"]["has_update"] is False
newer = await client.get("/api/v1/app/release/latest?version_code=9")
assert newer.json()["data"]["has_update"] is False
async def test_download_returns_the_apk_bytes(client: AsyncClient, release_dir: Path) -> None:
payload = _publish(release_dir)
resp = await client.get("/api/v1/app/release/download/dadaotask-0.2.0.apk")
assert resp.status_code == 200
assert resp.headers["content-type"] == "application/vnd.android.package-archive"
assert resp.content == payload
async def test_download_refuses_files_outside_the_manifest(
client: AsyncClient, release_dir: Path
) -> None:
_publish(release_dir)
resp = await client.get("/api/v1/app/release/download/..%2Fsecret.txt")
assert resp.status_code == 404
missing = await client.get("/api/v1/app/release/download/dadaotask-9.9.9.apk")
assert missing.status_code == 404
assert missing.json()["code"] == 9001