新增 doctor 诊断自救功能

This commit is contained in:
jxxghp
2026-06-12 15:55:24 +08:00
parent 10dcb3727e
commit 735a1ebf27
23 changed files with 1635 additions and 56 deletions

62
tests/test_doctor.py Normal file
View File

@@ -0,0 +1,62 @@
from __future__ import annotations
from app.core.config import settings
from app.doctor import run_doctor
from app.doctor.formatters import format_json_report, format_text_report
from app.doctor.models import DoctorFinding, DoctorFindingStatus, DoctorSeverity
def test_doctor_report_has_stable_json_shape(tmp_path, monkeypatch):
"""doctor JSON 报告应包含稳定状态、环境、汇总和发现列表。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
(settings.LOG_PATH).mkdir(parents=True, exist_ok=True)
(settings.ROOT_PATH / "public").mkdir(exist_ok=True)
report = run_doctor()
payload = report.to_dict()
assert payload["schema_version"] == 1
assert payload["status"] in {"healthy", "degraded", "failed"}
assert payload["environment"]["config_path"] == str(tmp_path)
assert isinstance(payload["summary"]["total"], int)
assert isinstance(payload["findings"], list)
assert any(item["id"] == "runtime.paths" for item in payload["findings"])
def test_doctor_formatters_include_status_and_finding(tmp_path, monkeypatch):
"""doctor 文本和 JSON 格式化应展示状态与诊断项。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
report = run_doctor()
report.add_finding(
DoctorFinding(
id="test.demo",
severity=DoctorSeverity.Warn,
status=DoctorFindingStatus.Degraded,
title="测试诊断项",
detail="测试原因",
recommendation="测试建议",
)
)
text = format_text_report(report)
json_text = format_json_report(report)
assert "MoviePilot Doctor" in text
assert "测试诊断项" in text
assert '"schema_version": 1' in json_text
assert '"test.demo"' in json_text
def test_doctor_fix_removes_stale_runtime(tmp_path, monkeypatch):
"""doctor --fix 应清理指向失效进程的 runtime 文件。"""
monkeypatch.setattr(settings, "CONFIG_DIR", str(tmp_path))
settings.TEMP_PATH.mkdir(parents=True, exist_ok=True)
runtime_file = settings.TEMP_PATH / "moviepilot.runtime.json"
runtime_file.write_text('{"pid": 999999, "create_time": 1}', encoding="utf-8")
report = run_doctor(fix=True)
assert not runtime_file.exists()
finding = report.find("runtime.backend_stale")
assert finding is not None
assert finding.fixed

View File

@@ -101,6 +101,21 @@ class FeedbackIssueScriptTestCase(unittest.TestCase):
"original_user_request": "订阅刷新接口返回 500帮我提交上游 Issue",
"found": bool(logs),
"logs": logs,
"doctor": {
"success": True,
"report": {
"status": "degraded",
"summary": {"total": 2, "error": 1, "warn": 1, "fixed": 0},
"environment": {"runtime": "Docker"},
"findings": [
{
"severity": "error",
"title": "后端端口被占用",
"recommendation": "修改 PORT 或停止占用进程",
}
],
},
},
"source_files": [str(settings.LOG_PATH / "moviepilot.log")],
},
)
@@ -225,6 +240,7 @@ class TestCollectFeedbackDiagnosticsScript(FeedbackIssueScriptTestCase):
self.assertIn("TMDB lookup failed", diagnostics["logs"])
self.assertIn("Cookie: <REDACTED>", diagnostics["logs"])
self.assertNotIn("secret", diagnostics["logs"])
self.assertIn("doctor", diagnostics)
class TestPrepareAndSubmitScripts(FeedbackIssueScriptTestCase):
@@ -242,6 +258,8 @@ class TestPrepareAndSubmitScripts(FeedbackIssueScriptTestCase):
self.assertTrue(Path(result["payload_file"]).exists())
preview = Path(result["preview_file"]).read_text(encoding="utf-8")
self.assertIn("请确认是否提交以下问题反馈", preview)
self.assertIn("Doctor 摘要", preview)
self.assertIn("后端端口被占用", preview)
self.assertIn("Cookie: <REDACTED>", preview)
self.assertNotIn("secret", preview)

View File

@@ -1,7 +1,10 @@
import subprocess
import tempfile
from unittest import TestCase
from unittest.mock import patch
from app.helper.system import SystemHelper
from app.core.config import settings
from app.utils.system import SystemUtils
@@ -42,3 +45,32 @@ class SystemUtilsTest(TestCase):
self.assertFalse(success)
self.assertIn("返回码2", message)
self.assertIn("无标准输出或错误输出", message)
class SystemHelperRestartTest(TestCase):
def test_docker_restart_policy_marks_intent_before_sigterm(self):
"""
Docker 内置重启走优雅退出时,应写入意图标记,避免 entrypoint 误进入 doctor 保活。
"""
with tempfile.TemporaryDirectory() as temp_dir:
original_config_dir = settings.CONFIG_DIR
original_intent_file = SystemHelper._SystemHelper__docker_restart_intent_file
settings.CONFIG_DIR = temp_dir
SystemHelper._SystemHelper__docker_restart_intent_file = (
settings.TEMP_PATH / "moviepilot.intentional_restart"
)
try:
with patch("app.helper.system.SystemUtils.is_docker", return_value=True), \
patch.object(SystemHelper, "_check_restart_policy", return_value=True), \
patch.object(SystemHelper, "_start_graceful_shutdown_monitor"), \
patch("app.helper.system.os.kill") as kill_mock:
ret, msg = SystemHelper.restart()
self.assertTrue(ret)
self.assertEqual(msg, "")
self.assertTrue((settings.TEMP_PATH / "moviepilot.intentional_restart").exists())
kill_mock.assert_called_once()
finally:
SystemHelper._SystemHelper__docker_restart_intent_file = original_intent_file
settings.CONFIG_DIR = original_config_dir