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
141 lines
3.9 KiB
Python
141 lines
3.9 KiB
Python
"""错误码与异常。错误码一旦对外发布就**不许改号**(客户端靠它做分支)。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
from enum import IntEnum
|
||
from typing import Any
|
||
|
||
|
||
class ErrorCode(IntEnum):
|
||
OK = 0
|
||
|
||
# 1xxx 通用
|
||
BAD_REQUEST = 1001
|
||
VALIDATION_FAILED = 1002
|
||
NOT_FOUND = 1004
|
||
CONFLICT = 1009
|
||
PAYLOAD_TOO_LARGE = 1013
|
||
RATE_LIMITED = 1029
|
||
INTERNAL = 1500
|
||
|
||
# 2xxx 认证 / 账号
|
||
UNAUTHORIZED = 2001
|
||
TOKEN_EXPIRED = 2002
|
||
TOKEN_INVALID = 2003
|
||
SMS_CODE_INVALID = 2010
|
||
SMS_TOO_FREQUENT = 2011
|
||
ACCOUNT_DISABLED = 2012
|
||
|
||
# 3xxx 租户 / 权限
|
||
WORKSPACE_NOT_FOUND = 3001
|
||
NOT_A_MEMBER = 3003
|
||
INSUFFICIENT_ROLE = 3004
|
||
ALREADY_A_MEMBER = 3005
|
||
INVITATION_INVALID = 3010
|
||
|
||
# 4xxx 并发
|
||
VERSION_CONFLICT = 4009
|
||
|
||
# 5xxx 任务域
|
||
ITEM_NOT_FOUND = 5001
|
||
ITEM_STATUS_INVALID = 5002
|
||
TAG_NOT_FOUND = 5003
|
||
LINK_NOT_FOUND = 5004
|
||
LINK_CONFLICT = 5005
|
||
|
||
# 6xxx 工单域
|
||
TICKET_NOT_FOUND = 6001
|
||
TICKET_ALREADY_EXISTS = 6002
|
||
TICKET_STATUS_INVALID = 6003
|
||
TICKET_FORBIDDEN = 6004
|
||
NOTIFICATION_NOT_FOUND = 6005
|
||
|
||
# 7xxx 知识域
|
||
KNOWLEDGE_NOT_FOUND = 7001
|
||
KNOWLEDGE_ALREADY_EXISTS = 7002
|
||
KNOWLEDGE_STATUS_INVALID = 7003
|
||
KNOWLEDGE_FORBIDDEN = 7004
|
||
KNOWLEDGE_SOURCE_REQUIRED = 7005
|
||
KNOWLEDGE_CATEGORY_NOT_FOUND = 7006
|
||
KNOWLEDGE_CATEGORY_DEPTH = 7007
|
||
KNOWLEDGE_CATEGORY_CONFLICT = 7008
|
||
|
||
# 8xxx 附件
|
||
ATTACHMENT_NOT_FOUND = 8001
|
||
ATTACHMENT_TOO_LARGE = 8002
|
||
ATTACHMENT_FORBIDDEN = 8003
|
||
CONFIRM_REQUIRED = 8004
|
||
|
||
# 9xxx 客户端发布(OTA)
|
||
APP_RELEASE_NOT_FOUND = 9001
|
||
|
||
|
||
# 默认 HTTP 状态:业务码自己带语义,HTTP 只是传输层
|
||
_HTTP_STATUS: dict[ErrorCode, int] = {
|
||
ErrorCode.OK: 200,
|
||
ErrorCode.BAD_REQUEST: 400,
|
||
ErrorCode.VALIDATION_FAILED: 422,
|
||
ErrorCode.NOT_FOUND: 404,
|
||
ErrorCode.CONFLICT: 409,
|
||
ErrorCode.PAYLOAD_TOO_LARGE: 413,
|
||
ErrorCode.RATE_LIMITED: 429,
|
||
ErrorCode.INTERNAL: 500,
|
||
ErrorCode.UNAUTHORIZED: 401,
|
||
ErrorCode.TOKEN_EXPIRED: 401,
|
||
ErrorCode.TOKEN_INVALID: 401,
|
||
ErrorCode.SMS_CODE_INVALID: 400,
|
||
ErrorCode.SMS_TOO_FREQUENT: 429,
|
||
ErrorCode.ACCOUNT_DISABLED: 403,
|
||
ErrorCode.WORKSPACE_NOT_FOUND: 404,
|
||
ErrorCode.NOT_A_MEMBER: 403,
|
||
ErrorCode.INSUFFICIENT_ROLE: 403,
|
||
ErrorCode.ALREADY_A_MEMBER: 409,
|
||
ErrorCode.INVITATION_INVALID: 400,
|
||
ErrorCode.VERSION_CONFLICT: 409,
|
||
ErrorCode.ITEM_NOT_FOUND: 404,
|
||
ErrorCode.ITEM_STATUS_INVALID: 409,
|
||
ErrorCode.TAG_NOT_FOUND: 404,
|
||
ErrorCode.LINK_NOT_FOUND: 404,
|
||
ErrorCode.LINK_CONFLICT: 409,
|
||
ErrorCode.TICKET_NOT_FOUND: 404,
|
||
ErrorCode.TICKET_ALREADY_EXISTS: 409,
|
||
ErrorCode.TICKET_STATUS_INVALID: 409,
|
||
ErrorCode.TICKET_FORBIDDEN: 403,
|
||
ErrorCode.NOTIFICATION_NOT_FOUND: 404,
|
||
ErrorCode.KNOWLEDGE_NOT_FOUND: 404,
|
||
ErrorCode.KNOWLEDGE_ALREADY_EXISTS: 409,
|
||
ErrorCode.KNOWLEDGE_STATUS_INVALID: 409,
|
||
ErrorCode.KNOWLEDGE_FORBIDDEN: 403,
|
||
ErrorCode.KNOWLEDGE_SOURCE_REQUIRED: 409,
|
||
ErrorCode.KNOWLEDGE_CATEGORY_NOT_FOUND: 404,
|
||
ErrorCode.KNOWLEDGE_CATEGORY_DEPTH: 422,
|
||
ErrorCode.KNOWLEDGE_CATEGORY_CONFLICT: 409,
|
||
ErrorCode.ATTACHMENT_NOT_FOUND: 404,
|
||
ErrorCode.ATTACHMENT_TOO_LARGE: 413,
|
||
ErrorCode.ATTACHMENT_FORBIDDEN: 403,
|
||
ErrorCode.CONFIRM_REQUIRED: 409,
|
||
ErrorCode.APP_RELEASE_NOT_FOUND: 404,
|
||
}
|
||
|
||
|
||
class AppError(Exception):
|
||
"""业务异常。__str__ 只用于日志,对外一律走 code + message。"""
|
||
|
||
def __init__(
|
||
self,
|
||
code: ErrorCode,
|
||
message: str | None = None,
|
||
*,
|
||
http_status: int | None = None,
|
||
details: Any = None,
|
||
) -> None:
|
||
self.code = code
|
||
self.message = message or code.name
|
||
self.http_status = http_status or _HTTP_STATUS.get(code, 400)
|
||
self.details = details
|
||
super().__init__(f"[{int(code)}] {self.message}")
|
||
|
||
|
||
def http_status_for(code: ErrorCode) -> int:
|
||
return _HTTP_STATUS.get(code, 400)
|