Files
DadaoTask/backend/tests/test_app_release.py
T
Steven 7668799040
CI / backend (push) Canceled after 0s
feat(app): 安卓应用内升级,安装包由后端发布
需求是"改完 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
2026-10-31 03:15:00 +08:00

102 lines
3.4 KiB
Python

"""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