Files
auto_control/web/device_agent_api.py
T
butubb 25e4fbb053 feat: 应用列表按包名合并 + 剪贴板/安装/版本管理显示设备名
两件事都是"列表看不清楚"的问题:

1) **同一个应用传多个版本会并排显示多行**(v1.2 / v1.3 各占一行),看着像重复。
   现在按 **package_name 合并成一个应用一行**,显示最新版:
   - `apk_manager.list_latest()`:版本按 version_code 排序取最新,其余版本放进
     `versions` 字段;解析不出包名的 APK 各自独立成行(不硬合并)
   - 管理页版本列显示 `1.3(另有 1 个旧版本)`,多一个「清理旧版本」按钮
   - 「删除」语义改为**删掉这个应用的所有版本**(行=应用),另给
     `?scope=older` 只清理旧版本;老的单条删除行为保留(不带 scope)
   - **设备端商店也走同一个列表** —— 手机上不会再看到同一个 App 的 v1.2/v1.3 两行

2) **设备只显示地址**(`192.168.20.100:5555`),同型号多台时分不清是哪一台。
   三处补齐设备名(平台给设备起的名字,如 A08),统一「名称 · 地址」:
   - 剪贴板注入的设备勾选列表(`/api/adb/devices` 经 `_merged_device_list` 带 name)
   - 应用管理的安装弹窗(`/api/apks/install/devices` 带 name)
   - 应用版本管理表格(`/api/tools/appver` 结果里带 name)
   - 顺带:安装进度表里的「设备」列原来显示**型号**(而且是 STF 时代残留的空 dict),
     改为显示设备名
   没名字的设备(不在池里)自动回退只显示地址,不显示空占位

验证:上传同包名 v1.1/v1.2/v1.3 三个版本 → 列表只出现一行(v1.3,标注共 4 个版本)、
设备端 bootstrap 也只剩 2 个应用;`scope=older` 只留最新、`scope=package` 整包删除;
三个面板在浏览器里渲染无 JS 报错,devText 有名字/没名字两种形态都正确。
2026-09-13 16:29:38 +08:00

307 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""设备端 Agent 专用接口(设备主动来取,**不走登录会话**,靠设备令牌鉴权)。
用途:设备上装的聚合 Agent(剪贴板注入 / 身份显示 / 应用商店)用这组接口
自报身份、拉可安装的应用清单、下载 APK、回报安装结果。
为什么另开一组接口而不是复用管理端 API:
* 设备登录不了后台会话,也不该拿 admin 的凭据;
* 平台 → 设备的**指令**仍然走 adb(`am start`),这组接口只解决
「设备主动来取东西」这一半,边界清楚。
安全设计(这组接口是平台唯一对设备开放的面):
1. **默认关闭**:`app_meta.agent_store_enabled != "1"` 时统一返回 404,
不暴露接口是否存在;
2. 必须带 `X-Device-Token`,与 `app_meta.agent_device_token` **常量时间**比对;
3. 只暴露 APK 元数据与文件本身 —— 不返回任何配置、密钥、设备凭据;
4. 令牌可一键重置(重置后旧令牌立即失效)。
管理端接口在文件后半段(`/api/agent-store/*`,走正常登录 + 权限)。
"""
import hmac
import os
import secrets
import time
from flask import Blueprint, jsonify, request, send_file
from config import APK_DIR
from core import device_pool
from core.db_config import meta_get, meta_set
from core.logger import get_logger
from web.auth import PERM_APKS, perm_required
bp = Blueprint("device_agent", __name__)
_log = get_logger("web.devagent")
# app_meta 键
_K_ENABLED = "agent_store_enabled" # "1" 开启;默认关
_K_TOKEN = "agent_device_token" # 设备令牌(首次开启时生成)
_K_CREATED = "agent_device_token_at" # 令牌生成/重置时间
_API_VERSION = 1 # 本组接口的版本(契约里写死给 agent 对照)
_MAX_LOG = 500 # 记录表最多保留条数(超了删最旧)
# ================== 公共:开关与令牌 ==================
def store_enabled():
return (meta_get(_K_ENABLED) or "0") == "1"
def get_token():
return meta_get(_K_TOKEN) or ""
def ensure_token(regenerate=False):
"""取设备令牌,没有(或要求重置)就生成一个。"""
tok = get_token()
if regenerate or not tok:
tok = secrets.token_urlsafe(24)
meta_set(_K_TOKEN, tok)
meta_set(_K_CREATED, time.strftime("%Y-%m-%d %H:%M:%S"))
_log.info("设备端商店令牌已%s", "重置" if regenerate else "生成")
return tok
def _deny():
"""统一拒绝:功能关着就 404(不暴露存在性),令牌不对 401。"""
if not store_enabled():
return jsonify({"ok": False, "error": "not found"}), 404
tok = request.headers.get("X-Device-Token", "") or ""
real = get_token()
if not real or not tok or not hmac.compare_digest(tok, real):
_log.warning("设备端接口鉴权失败(来源 %s)", request.remote_addr)
return jsonify({"ok": False, "error": "unauthorized"}), 401
return None
def _identify():
"""从请求头识别是哪台设备:指纹优先(换过 IP 也认得出),其次序列号。"""
fp = (request.headers.get("X-Device-Fingerprint", "") or "").strip()
serial = (request.headers.get("X-Device-Serial", "") or "").strip()
name = ""
try:
if fp:
# 注意:find_by_fingerprint / list_configured 返回的都是 dict(不是 ORM 对象)
d = device_pool.find_by_fingerprint(fp)
if d:
return fp, d.get("serial", serial), d.get("name") or "", True
if serial:
for d in device_pool.list_configured(enabled_only=False):
if d.get("serial") == serial:
return fp, serial, d.get("name") or "", True
except Exception as e:
_log.warning("识别设备失败: %s", e)
return fp, serial, name, False
# ================== 设备侧接口 ==================
@bp.route("/api/device/agent/bootstrap")
def api_dev_agent_bootstrap():
"""设备开机/定时来拉:我是谁 + 有哪些应用可以装。
请求头:`X-Device-Token`(必带)、`X-Device-Fingerprint`、`X-Device-Serial`
响应:设备身份(平台分配的名称)+ 应用清单(含下载地址)
"""
deny = _deny()
if deny:
return deny
fp, serial, name, known = _identify()
from web import context
apks = []
try:
# 用「按包名合并」的列表:同一个应用只出现一行(最新版),
# 设备上不会看到同一个 App 的 v1.2 / v1.3 两行
for a in context.apk_mgr.list_latest():
apks.append({
"id": a.get("id", ""),
"display_name": a.get("display_name") or a.get("filename") or "",
"package_name": a.get("package_name") or "",
"version_name": a.get("version_name") or "",
"version_code": a.get("version_code") or 0,
"size": a.get("size") or 0,
"upload_time": a.get("upload_time") or "",
"download_path": "/api/device/agent/apk/{}".format(a.get("id", "")),
})
except Exception as e:
_log.error("设备端拉取应用清单失败: %s", e)
return jsonify({"ok": False, "error": "清单读取失败"}), 500
_log.info("设备端拉清单: %s(%s) 应用 %d 个", name or "未登记", serial or fp, len(apks))
return jsonify({
"ok": True,
"agent_api_version": _API_VERSION,
"server_time": time.strftime("%Y-%m-%d %H:%M:%S"),
"platform": {
# 用请求里的 Host 回填,设备按自己实际能连通的地址访问
"url": request.host_url.rstrip("/"),
},
"device": {"fingerprint": fp, "serial": serial,
"name": name, "known": known},
"apks": apks,
})
@bp.route("/api/device/agent/apk/<apk_id>")
def api_dev_agent_apk(apk_id):
"""下载 APK 文件(设备自己拉,比 adb push 快且可并行)。"""
deny = _deny()
if deny:
return deny
from core.models import ApkFile
row = ApkFile.query.get(apk_id)
if not row:
return jsonify({"ok": False, "error": "apk not found"}), 404
path = os.path.join(APK_DIR, row.filename or (apk_id + ".apk"))
fname = row.display_name or row.filename or apk_id
if not os.path.exists(path):
return jsonify({"ok": False, "error": "apk file missing"}), 404
_log.info("设备端下载 APK: %s", apk_id)
return send_file(path, as_attachment=True,
download_name="{}.apk".format(fname),
mimetype="application/vnd.android.package-archive")
@bp.route("/api/device/agent/install", methods=["POST"])
def api_dev_agent_install():
"""设备请求**平台**帮它装这个应用(平台走 adb,静默,没有系统安装框)。
为什么这么设计:普通 App 自己调系统安装器,MIUI 上要过「继续 → 勾选未检测
→ 继续更新」甚至 ICP 备案检查好几步;而 `adb install` 在同一台设备上实测是
**静默**的(升级 2.7s、全新安装 7.9s,零弹框)。
所以设备端商店的「安装/更新」按钮走这里:**用户还是在手机上选**(应用商店体验),
真正干活的是平台,几秒钟装完。万一 adb 被 MIUI 拦了,返回 ok=false,
设备端自己退回本地安装(有弹框,但至少能装)。
"""
deny = _deny()
if deny:
return deny
data = request.json or {}
apk_id = str(data.get("apk_id", ""))
if not apk_id:
return jsonify({"ok": False, "error": "缺少 apk_id"}), 400
fp, serial, name, known = _identify()
if not serial:
return jsonify({"ok": False, "error": "无法识别设备"}), 400
if not known:
return jsonify({"ok": False, "error": "这台设备还没登记进平台设备池"}), 409
from core.models import ApkFile
if not ApkFile.query.get(apk_id):
return jsonify({"ok": False, "error": "apk not found"}), 404
from web import context
try:
ok, msg = context.apk_mgr.install(apk_id, [serial])
except Exception as e:
_log.error("设备端请求安装失败: %s", e)
return jsonify({"ok": False, "error": "平台安装任务启动失败"}), 500
_log.info("设备端请求安装: %s(%s) apk=%s -> %s", name or "未登记", serial, apk_id, msg)
if not ok:
return jsonify({"ok": False, "error": msg}), 400
return jsonify({"ok": True, "msg": msg, "serial": serial})
@bp.route("/api/device/agent/report", methods=["POST"])
def api_dev_agent_report():
"""设备上报下载/安装结果,平台侧留下记录(应用管理页可见)。
请求体:{apk_id, action: download|install_ok|install_fail,
package_name?, version_name?, message?, serial?}
"""
deny = _deny()
if deny:
return deny
data = request.json or {}
action = str(data.get("action", ""))[:20]
if action not in ("download", "install_ok", "install_fail"):
return jsonify({"ok": False, "error": "action 非法"}), 400
fp, serial, name, _known = _identify()
if data.get("serial"):
serial = str(data["serial"])[:120]
from core.models import DeviceInstallLog, db
try:
db.session.add(DeviceInstallLog(
fingerprint=fp, serial=serial, device_name=name,
apk_id=str(data.get("apk_id", ""))[:32],
package_name=str(data.get("package_name", ""))[:200],
version_name=str(data.get("version_name", ""))[:50],
action=action,
message=str(data.get("message", ""))[:500],
created_at=time.strftime("%Y-%m-%d %H:%M:%S")))
db.session.commit()
_prune_logs()
except Exception as e:
db.session.rollback()
_log.error("设备端安装记录写入失败: %s", e)
return jsonify({"ok": False, "error": "记录失败"}), 500
_log.info("设备端上报: %s(%s) %s %s %s", name or "未登记", serial or fp,
action, data.get("apk_id", ""), data.get("message", ""))
return jsonify({"ok": True})
def _prune_logs(keep=_MAX_LOG):
"""记录表只留最近 N 条(失败忽略,不影响上报)。"""
try:
from core.models import DeviceInstallLog, db
n = db.session.query(DeviceInstallLog).count()
if n <= keep:
return
old = (db.session.query(DeviceInstallLog.id)
.order_by(DeviceInstallLog.id).limit(n - keep).all())
if old:
db.session.query(DeviceInstallLog).filter(
DeviceInstallLog.id.in_([r[0] for r in old])).delete(
synchronize_session=False)
db.session.commit()
except Exception as e:
_log.warning("清理设备端安装记录失败: %s", e)
# ================== 管理端接口(登录 + 权限)==================
@bp.route("/api/agent-store/config", methods=["GET", "POST"])
@perm_required(PERM_APKS)
def api_agent_store_config():
"""查看/修改设备端商店配置(应用管理页)。GET 也要权限:令牌属于敏感信息。"""
if request.method == "GET":
return jsonify({
"ok": True,
"enabled": store_enabled(),
"token": get_token(),
"token_created_at": meta_get(_K_CREATED) or "",
"api_version": _API_VERSION,
"base_url_hint": request.host_url.rstrip("/"),
})
data = request.json or {}
if "enabled" in data:
meta_set(_K_ENABLED, "1" if data["enabled"] else "0")
if data["enabled"]:
ensure_token()
if data.get("regenerate_token"):
ensure_token(regenerate=True)
_log.info("设备端商店配置更新: enabled=%s", store_enabled())
return jsonify({"ok": True, "enabled": store_enabled(), "token": get_token()})
@bp.route("/api/agent-store/logs")
@perm_required(PERM_APKS)
def api_agent_store_logs():
"""设备端安装记录(最近 N 条)。"""
limit = min(int(request.args.get("limit", 50) or 50), 200)
from core.models import DeviceInstallLog
rows = (DeviceInstallLog.query
.order_by(DeviceInstallLog.id.desc()).limit(limit).all())
return jsonify({"ok": True, "logs": [{
"id": r.id, "device_name": r.device_name or "", "serial": r.serial or "",
"fingerprint": r.fingerprint or "", "apk_id": r.apk_id or "",
"package_name": r.package_name or "", "version_name": r.version_name or "",
"action": r.action or "", "message": r.message or "",
"created_at": r.created_at or "",
} for r in rows]})