#!/usr/bin/env python3 # -*- coding: utf-8 -*- from __future__ import annotations import os import time import uuid from flask import jsonify, request, session from routes.admin_api import admin_api_bp from routes.decorators import admin_required from services.time_utils import get_beijing_now from services.update_files import ( ensure_update_dirs, get_update_job_log_path, get_update_request_path, get_update_result_path, get_update_status_path, load_json_file, sanitize_job_id, tail_text_file, write_json_atomic, ) def _request_ip() -> str: try: return request.headers.get("X-Forwarded-For", "").split(",")[0].strip() or request.remote_addr or "" except Exception: return "" def _make_job_id(prefix: str = "upd") -> str: now_str = get_beijing_now().strftime("%Y%m%d_%H%M%S") rand = uuid.uuid4().hex[:8] return f"{prefix}_{now_str}_{rand}" def _has_pending_request() -> bool: try: return os.path.exists(get_update_request_path()) except Exception: return False def _parse_bool_field(data: dict, key: str) -> bool | None: if not isinstance(data, dict) or key not in data: return None value = data.get(key) if isinstance(value, bool): return value if isinstance(value, int): if value in (0, 1): return bool(value) raise ValueError(f"{key} 必须是 0/1 或 true/false") if isinstance(value, str): text = value.strip().lower() if text in ("1", "true", "yes", "y", "on"): return True if text in ("0", "false", "no", "n", "off", ""): return False raise ValueError(f"{key} 必须是 0/1 或 true/false") if value is None: return None raise ValueError(f"{key} 必须是 0/1 或 true/false") def _admin_reauth_required() -> bool: try: return time.time() > float(session.get("admin_reauth_until", 0) or 0) except Exception: return True @admin_api_bp.route("/update/status", methods=["GET"]) @admin_required def get_update_status_api(): """读取宿主机 Update-Agent 写入的 update/status.json。""" ensure_update_dirs() status_path = get_update_status_path() data, err = load_json_file(status_path) if err: return jsonify({"ok": False, "error": f"读取 status 失败: {err}", "data": data}), 200 if not data: return jsonify({"ok": False, "error": "未发现更新状态(Update-Agent 可能未运行)"}), 200 data.setdefault("update_available", False) return jsonify({"ok": True, "data": data}), 200 @admin_api_bp.route("/update/result", methods=["GET"]) @admin_required def get_update_result_api(): """读取 update/result.json(最近一次更新执行结果)。""" ensure_update_dirs() result_path = get_update_result_path() data, err = load_json_file(result_path) if err: return jsonify({"ok": False, "error": f"读取 result 失败: {err}", "data": data}), 200 if not data: return jsonify({"ok": True, "data": None}), 200 return jsonify({"ok": True, "data": data}), 200 @admin_api_bp.route("/update/log", methods=["GET"]) @admin_required def get_update_log_api(): """读取 update/jobs/.log 的末尾内容(用于后台展示进度)。""" ensure_update_dirs() job_id = sanitize_job_id(request.args.get("job_id")) if not job_id: # 若未指定,则尝试用 result.json 的 job_id result_data, _ = load_json_file(get_update_result_path()) job_id = sanitize_job_id(result_data.get("job_id") if isinstance(result_data, dict) else None) if not job_id: return jsonify({"ok": True, "job_id": None, "log": "", "truncated": False}), 200 max_bytes = request.args.get("max_bytes", "200000") try: max_bytes_i = int(max_bytes) except Exception: max_bytes_i = 200_000 max_bytes_i = max(10_000, min(2_000_000, max_bytes_i)) log_path = get_update_job_log_path(job_id) text, truncated = tail_text_file(log_path, max_bytes=max_bytes_i) return jsonify({"ok": True, "job_id": job_id, "log": text, "truncated": truncated}), 200 @admin_api_bp.route("/update/check", methods=["POST"]) @admin_required def request_update_check_api(): """请求宿主机 Update-Agent 立刻执行一次检查更新。""" ensure_update_dirs() if _has_pending_request(): return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409 job_id = _make_job_id(prefix="chk") payload = { "job_id": job_id, "action": "check", "requested_at": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"), "requested_by": session.get("admin_username") or "", "requested_ip": _request_ip(), } write_json_atomic(get_update_request_path(), payload) return jsonify({"success": True, "job_id": job_id}), 200 @admin_api_bp.route("/update/run", methods=["POST"]) @admin_required def request_update_run_api(): """请求宿主机 Update-Agent 执行一键更新并重启服务。""" ensure_update_dirs() if _admin_reauth_required(): return jsonify({"error": "需要二次确认", "code": "reauth_required"}), 401 if _has_pending_request(): return jsonify({"error": "已有更新请求正在处理中,请稍后再试"}), 409 data = request.json or {} try: build_no_cache = _parse_bool_field(data, "build_no_cache") if build_no_cache is None: build_no_cache = _parse_bool_field(data, "no_cache") build_pull = _parse_bool_field(data, "build_pull") if build_pull is None: build_pull = _parse_bool_field(data, "pull") except ValueError as e: return jsonify({"error": str(e)}), 400 job_id = _make_job_id(prefix="upd") payload = { "job_id": job_id, "action": "update", "requested_at": get_beijing_now().strftime("%Y-%m-%d %H:%M:%S"), "requested_by": session.get("admin_username") or "", "requested_ip": _request_ip(), "build_no_cache": bool(build_no_cache) if build_no_cache is not None else False, "build_pull": bool(build_pull) if build_pull is not None else False, } write_json_atomic(get_update_request_path(), payload) return jsonify( { "success": True, "job_id": job_id, "message": "已提交更新请求,服务将重启(页面可能短暂不可用),请等待1-2分钟后刷新", } ), 200