feat: 用户权限系统(权限位/管理员全权限/前端隐藏/最后管理员保护)

This commit is contained in:
2026-08-11 08:27:33 +08:00
parent a0d1a533c6
commit 06a9df111a
4 changed files with 192 additions and 35 deletions
+103 -22
View File
@@ -93,6 +93,77 @@ def api_csrf():
return jsonify({"ok": True, "token": _csrf_token()})
# ================== 权限控制 ==================
# 业务权限位(is_admin 管理员拥有全部权限,不受 perms 限制):
# tasks — 任务管理:任务/自定义动作/分组的创建、修改、删除、启停、执行
# devices — 设备控制:停止设备、释放占用、清除异常、前台扫描、元素抓取
# apks — 应用管理:APK 上传、安装、删除
# logs — 日志查看
# 查看类接口(GET 状态/列表)所有登录用户可用;用户管理仅管理员可用(admin_required)。
PERM_TASKS = "tasks"
PERM_DEVICES = "devices"
PERM_APKS = "apks"
PERM_LOGS = "logs"
ALL_PERMS = (PERM_TASKS, PERM_DEVICES, PERM_APKS, PERM_LOGS)
_PERM_LABELS = {PERM_TASKS: "任务管理", PERM_DEVICES: "设备控制",
PERM_APKS: "应用管理", PERM_LOGS: "日志查看"}
def _has_perm(perm):
"""当前用户是否拥有指定权限。管理员恒为 True。"""
u = current_user
return bool(u and (u.is_admin or u.has_perm(perm)))
def _validate_perms(raw):
"""校验权限位列表:只保留合法值、去重。返回合法列表。"""
valid = set(ALL_PERMS)
out = []
for p in raw or []:
if p in valid and p not in out:
out.append(p)
return out
def perm_required(perm):
"""路由装饰器:要求登录且拥有指定业务权限,否则 403。"""
def deco(fn):
@functools.wraps(fn)
@login_required
def wrapper(*args, **kwargs):
if not _has_perm(perm):
return jsonify({"ok": False,
"error": f"无权限执行此操作(需要权限: {_PERM_LABELS.get(perm, perm)})"}), 403
return fn(*args, **kwargs)
return wrapper
return deco
def admin_required(fn):
"""路由装饰器:仅管理员可用(用户管理类接口),否则 403。"""
@functools.wraps(fn)
@login_required
def wrapper(*args, **kwargs):
if not current_user.is_admin:
return jsonify({"ok": False, "error": "仅管理员可执行此操作"}), 403
return fn(*args, **kwargs)
return wrapper
@app.route("/api/me")
@login_required
def api_me():
"""当前登录用户信息(含权限位),前端据此隐藏无权限的功能入口。"""
u = current_user
return jsonify({"ok": True, "user": {
"id": u.id,
"username": u.username,
"is_admin": bool(u.is_admin),
# 管理员返回全部权限位,前端统一用 perms 判断
"perms": list(ALL_PERMS) if u.is_admin else u.get_perms(),
}})
# ================== 页面路由 ==================
@app.route("/")
@login_required
@@ -209,7 +280,7 @@ def api_status():
@app.route("/api/scan_foreground", methods=["POST"])
@login_required
@perm_required(PERM_DEVICES)
def api_scan_foreground():
"""手动触发前台 App 扫描(不打扰设备)。"""
started = mgr._fg_scanner.scan_once()
@@ -315,13 +386,13 @@ def api_jobs_delete(job_id):
@app.route("/api/jobs/<job_id>/run", methods=["POST"])
@login_required
@perm_required(PERM_TASKS)
def api_jobs_run(job_id):
return jsonify(mgr.run_job_now(job_id))
@app.route("/api/jobs/<job_id>/toggle", methods=["POST"])
@login_required
@perm_required(PERM_TASKS)
def api_jobs_toggle(job_id):
enabled = (request.json or {}).get("enabled", True)
job = mgr.toggle_job(job_id, enabled)
@@ -338,7 +409,7 @@ def api_groups_list():
@app.route("/api/groups", methods=["POST"])
@login_required
@perm_required(PERM_TASKS)
def api_groups_create():
data = request.json or {}
name = (data.get("name") or "").strip()
@@ -351,7 +422,7 @@ def api_groups_create():
@app.route("/api/groups/<name>", methods=["PUT"])
@login_required
@perm_required(PERM_TASKS)
def api_groups_update(name):
if name not in mgr.groups:
return jsonify({"ok": False, "error": "分组不存在"}), 404
@@ -372,14 +443,15 @@ def api_groups_delete(name):
# ================== API:用户管理 CRUD ==================
@app.route("/api/users")
@login_required
@admin_required
def api_users_list():
users = [{"id": u.id, "username": u.username, "is_admin": u.is_admin} for u in User.query.all()]
users = [{"id": u.id, "username": u.username,
"is_admin": u.is_admin, "perms": u.get_perms()} for u in User.query.all()]
return jsonify({"ok": True, "users": users})
@app.route("/api/users", methods=["POST"])
@login_required
@admin_required
def api_users_create():
data = request.json or {}
username = (data.get("username") or "").strip()
@@ -388,16 +460,18 @@ def api_users_create():
return jsonify({"ok": False, "error": "用户名和密码不能为空"}), 400
if User.query.filter_by(username=username).first():
return jsonify({"ok": False, "error": "用户名已存在"}), 400
u = User(username=username, is_admin=data.get("is_admin", True))
# 默认普通用户(最小权限);管理员由 is_admin 决定,权限位照常保存(降级后生效)
u = User(username=username, is_admin=bool(data.get("is_admin", False)))
u.set_perms(_validate_perms(data.get("perms") or []))
u.set_password(password)
db.session.add(u)
db.session.commit()
_log.info(f"创建用户 {username}")
_log.info(f"创建用户 {username} (admin={u.is_admin}, perms={u.get_perms()})")
return jsonify({"ok": True, "msg": "用户已创建"})
@app.route("/api/users/<int:uid>", methods=["PUT"])
@login_required
@admin_required
def api_users_update(uid):
u = User.query.get(uid)
if not u:
@@ -406,14 +480,19 @@ def api_users_update(uid):
if "password" in data and data["password"]:
u.set_password(data["password"])
if "is_admin" in data:
u.is_admin = bool(data["is_admin"])
new_admin = bool(data["is_admin"])
if u.is_admin and not new_admin and User.query.filter_by(is_admin=True).count() <= 1:
return jsonify({"ok": False, "error": "不能取消最后一个管理员"}), 400
u.is_admin = new_admin
if "perms" in data:
u.set_perms(_validate_perms(data["perms"]))
db.session.commit()
_log.info(f"更新用户 {u.username}")
_log.info(f"更新用户 {u.username} (admin={u.is_admin}, perms={u.get_perms()})")
return jsonify({"ok": True, "msg": "用户已更新"})
@app.route("/api/users/<int:uid>", methods=["DELETE"])
@login_required
@admin_required
def api_users_delete(uid):
u = User.query.get(uid)
if not u:
@@ -422,6 +501,8 @@ def api_users_delete(uid):
return jsonify({"ok": False, "error": "不能删除默认管理员"}), 400
if u.id == current_user.id:
return jsonify({"ok": False, "error": "不能删除当前登录用户"}), 400
if u.is_admin and User.query.filter_by(is_admin=True).count() <= 1:
return jsonify({"ok": False, "error": "不能删除最后一个管理员"}), 400
db.session.delete(u)
db.session.commit()
_log.info(f"删除用户 {u.username}")
@@ -430,7 +511,7 @@ def api_users_delete(uid):
# ================== API:日志查看 ==================
@app.route("/api/logs")
@login_required
@perm_required(PERM_LOGS)
def api_logs():
files = list(_MODULE_FILES.values())
current = request.args.get("file", "core.log")
@@ -448,7 +529,7 @@ def api_logs():
# ================== API:运行控制 ==================
@app.route("/api/stop_device", methods=["POST"])
@login_required
@perm_required(PERM_DEVICES)
def api_stop_device():
serial = (request.json or {}).get("serial", "")
if mgr.stop_device(serial):
@@ -535,7 +616,7 @@ def api_custom_actions_list():
@app.route("/api/custom_actions", methods=["POST"])
@login_required
@perm_required(PERM_TASKS)
def api_custom_actions_create():
data = request.json or {}
name = (data.get("name") or "").strip()
@@ -554,7 +635,7 @@ def api_custom_actions_create():
@app.route("/api/custom_actions/<action_id>", methods=["PUT"])
@login_required
@perm_required(PERM_TASKS)
def api_custom_actions_update(action_id):
row = CustomAction.query.get(action_id)
if not row:
@@ -576,7 +657,7 @@ def api_custom_actions_update(action_id):
@app.route("/api/custom_actions/<action_id>", methods=["DELETE"])
@login_required
@perm_required(PERM_TASKS)
def api_custom_actions_delete(action_id):
row = CustomAction.query.get(action_id)
if not row:
@@ -587,7 +668,7 @@ def api_custom_actions_delete(action_id):
@app.route("/api/uiauto/elements")
@login_required
@perm_required(PERM_DEVICES)
def api_uiauto_elements():
"""获取设备当前 UI 元素树(供步骤编辑器"抓取元素"用)。
@@ -705,7 +786,7 @@ def api_apks_list():
@app.route("/api/apks/upload", methods=["POST"])
@login_required
@perm_required(PERM_APKS)
def api_apks_upload():
"""上传 APK 文件(multipart/form-data,字段名 file)。"""
file = request.files.get("file")
@@ -719,7 +800,7 @@ def api_apks_upload():
@app.route("/api/apks/<apk_id>", methods=["DELETE"])
@login_required
@perm_required(PERM_APKS)
def api_apks_delete(apk_id):
"""删除 APK 文件和记录。"""
ok, msg = apk_mgr.delete(apk_id)