Compare commits

..

2 Commits

Author SHA1 Message Date
q792602257 ddcc7262b6 Merge remote-tracking branch 'origin/master' 2026-06-11 23:05:34 +08:00
q792602257 687c28e8fc 提供 API 方便排查问题 2026-06-11 22:28:26 +08:00
5 changed files with 1117 additions and 398 deletions
+1
View File
@@ -22,6 +22,7 @@ dependencies = [
"pyscreeze>=1.0.1",
"pillow>=12.2.0",
"httpx>=0.27.0",
"aiohttp>=3.9.0",
]
[build-system]
View File
+216
View File
@@ -0,0 +1,216 @@
"""
轻量级 HTTP API 服务,为运维人员提供截图查看接口。
启动后与主守护进程共享 asyncio 事件循环,不阻塞 Redis 消费。
"""
import os
from datetime import datetime
from pathlib import Path
from aiohttp import web
from loguru import logger
from src.config.settings import get_settings
# 默认只展示最近 N 天的截图
_DEFAULT_DAYS = 7
def _scan_screenshots(archives_root: str, days: int = _DEFAULT_DAYS) -> list[dict]:
"""
扫描 archives 目录,返回最近 `days` 天的截图信息列表。
按修改时间倒序排列(最新在前)。
"""
root = Path(archives_root)
if not root.exists():
return []
results: list[dict] = []
# 筛选最近的日期目录
today = datetime.now()
valid_date_dirs: set[str] = set()
for i in range(days):
d = today - __import__("datetime").timedelta(days=i)
valid_date_dirs.add(d.strftime("%Y%m%d"))
for date_dir in sorted(root.iterdir(), reverse=True):
if not date_dir.is_dir():
continue
if date_dir.name not in valid_date_dirs:
continue
for topic_dir in date_dir.iterdir():
if not topic_dir.is_dir():
continue
for img_file in topic_dir.iterdir():
if not img_file.is_file():
continue
if img_file.suffix.lower() not in (".png", ".jpg", ".jpeg"):
continue
# 解析文件名:{task_id}_{scene}_{timestamp_ms}.png
name_parts = img_file.stem.split("_")
scene = name_parts[1] if len(name_parts) >= 2 else "unknown"
stat = img_file.stat()
results.append({
"filename": img_file.name,
"date": date_dir.name,
"topic": topic_dir.name,
"scene": scene,
"relative_path": f"{date_dir.name}/{topic_dir.name}/{img_file.name}",
"size_kb": round(stat.st_size / 1024, 1),
"modified_at": datetime.fromtimestamp(stat.st_mtime).strftime("%Y-%m-%d %H:%M:%S"),
})
# 按修改时间倒序
results.sort(key=lambda x: x["modified_at"], reverse=True)
return results
async def handle_screenshots(request: web.Request) -> web.Response:
"""GET /api/screenshots — 返回截图列表 JSON。"""
days = int(request.query.get("days", _DEFAULT_DAYS))
scene_filter = request.query.get("scene", "").strip()
topic_filter = request.query.get("topic", "").strip()
settings = get_settings()
screenshots = _scan_screenshots(settings.screenshot_dir, days=days)
# 可选过滤
if scene_filter:
screenshots = [s for s in screenshots if scene_filter in s["scene"]]
if topic_filter:
screenshots = [s for s in screenshots if topic_filter in s["topic"]]
return web.json_response({
"total": len(screenshots),
"days": days,
"screenshots": screenshots,
})
async def handle_screenshot_image(request: web.Request) -> web.Response:
"""GET /api/screenshots/{date}/{topic}/{filename} — 返回截图图片文件。"""
date = request.match_info["date"]
topic = request.match_info["topic"]
filename = request.match_info["filename"]
# 安全校验:防止路径遍历
if ".." in date or ".." in topic or ".." in filename:
return web.json_response({"error": "非法路径"}, status=400)
settings = get_settings()
filepath = Path(settings.screenshot_dir) / date / topic / filename
if not filepath.exists() or not filepath.is_file():
return web.json_response({"error": "文件不存在"}, status=404)
return web.FileResponse(filepath)
async def handle_index(request: web.Request) -> web.Response:
"""GET / — 简单的截图浏览 HTML 页面。"""
return web.Response(text=_INDEX_HTML, content_type="text/html")
# 简易 HTML 页面,运维人员可直接在浏览器中查看
_INDEX_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>截图查看器 — JP Purchase Bot</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; margin: 20px; background: #f5f5f5; }
h1 { color: #333; font-size: 1.4em; }
.filters { margin: 12px 0; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
.filters label { font-size: 0.9em; color: #666; }
.filters input, .filters select { padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; }
.filters button { padding: 6px 16px; background: #4a90d9; color: #fff; border: none; border-radius: 4px; cursor: pointer; }
.filters button:hover { background: #357abd; }
table { width: 100%; border-collapse: collapse; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
th, td { padding: 8px 12px; text-align: left; border-bottom: 1px solid #eee; font-size: 0.85em; }
th { background: #fafafa; color: #555; font-weight: 600; }
tr:hover { background: #f0f7ff; }
.scene-tag { display: inline-block; padding: 2px 8px; border-radius: 3px; font-size: 0.8em; }
.scene-error { background: #ffe0e0; color: #c00; }
.scene-other { background: #e0f0ff; color: #369; }
.thumb { max-width: 60px; max-height: 40px; cursor: pointer; border: 1px solid #ddd; }
.thumb:hover { border-color: #4a90d9; }
#overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.85); z-index: 999; cursor: pointer; }
#overlay img { max-width: 95%; max-height: 95%; position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); }
.stats { font-size: 0.85em; color: #888; margin-bottom: 8px; }
</style>
</head>
<body>
<h1>截图查看器</h1>
<div class="filters">
<label>最近 <input type="number" id="days" value="3" min="1" max="30" style="width:50px"> 天</label>
<label>场景 <input type="text" id="scene" placeholder="如:任务执行出错"></label>
<label>主题 <input type="text" id="topic" placeholder="如:surugaya"></label>
<button onclick="load()">查询</button>
</div>
<div class="stats" id="stats"></div>
<table>
<thead><tr><th>时间</th><th>主题</th><th>场景</th><th>文件</th><th>大小</th><th>预览</th></tr></thead>
<tbody id="tbody"></tbody>
</table>
<div id="overlay" onclick="this.style.display='none'"><img id="bigImg" src=""></div>
<script>
async function load() {
const days = document.getElementById('days').value;
const scene = document.getElementById('scene').value;
const topic = document.getElementById('topic').value;
let url = '/api/screenshots?days=' + days;
if (scene) url += '&scene=' + encodeURIComponent(scene);
if (topic) url += '&topic=' + encodeURIComponent(topic);
const resp = await fetch(url);
const data = await resp.json();
document.getElementById('stats').textContent = '' + data.total + ' 张截图(最近 ' + data.days + ' 天)';
const tbody = document.getElementById('tbody');
tbody.innerHTML = '';
for (const s of data.screenshots) {
const cls = s.scene.includes('出错') || s.scene.includes('失败') || s.scene.includes('异常') ? 'scene-error' : 'scene-other';
const imgUrl = '/api/screenshots/' + s.relative_path;
tbody.innerHTML += '<tr>'
+ '<td>' + s.modified_at + '</td>'
+ '<td>' + s.topic + '</td>'
+ '<td><span class="scene-tag ' + cls + '">' + s.scene + '</span></td>'
+ '<td>' + s.filename + '</td>'
+ '<td>' + s.size_kb + ' KB</td>'
+ '<td><img class="thumb" src="' + imgUrl + '" onclick="showBig(\\'' + imgUrl + '\\')"></td>'
+ '</tr>';
}
}
function showBig(url) {
document.getElementById('bigImg').src = url;
document.getElementById('overlay').style.display = 'block';
}
load();
</script>
</body>
</html>"""
def create_app() -> web.Application:
"""创建 aiohttp Application 实例。"""
app = web.Application()
app.router.add_get("/", handle_index)
app.router.add_get("/api/screenshots", handle_screenshots)
app.router.add_get("/api/screenshots/{date}/{topic}/{filename}", handle_screenshot_image)
return app
async def start_api_server(host: str = "0.0.0.0", port: int = 8080) -> web.AppRunner:
"""启动 API 服务,返回 AppRunner 以便后续优雅关闭。"""
app = create_app()
runner = web.AppRunner(app)
await runner.setup()
site = web.TCPSite(runner, host, port)
await site.start()
logger.info(f"截图查看 API 已启动 → http://{host}:{port}/")
return runner
+5
View File
@@ -21,6 +21,7 @@ from src.task_queue.delay_zset_listener import RedisZSetDelayListener
from src.task_queue.listener import RedisStreamListener
from src.task_queue.trade_fetch_listener import TradeFetchStreamListener
from src.task_queue.redis_manager import get_redis_manager
from src.api.server import start_api_server
async def main() -> None:
"""
@@ -56,6 +57,9 @@ async def main() -> None:
delay_listener = RedisZSetDelayListener(dispatcher)
trade_fetch_listener = TradeFetchStreamListener(dispatcher)
# 4.1 启动截图查看 API 服务
api_runner = await start_api_server()
# 5. 优雅停机信号处理 (跨平台支持)
loop = asyncio.get_running_loop()
@@ -95,6 +99,7 @@ async def main() -> None:
trade_fetch_listener.stop(),
get_browser_manager().close_all(),
get_redis_manager().close(),
api_runner.cleanup(),
return_exceptions=True,
)
logger.success("日本采购自动化守护进程已安全优雅退出。")
Generated
+895 -398
View File
File diff suppressed because it is too large Load Diff