Docker打包

This commit is contained in:
2026-06-11 23:06:29 +08:00
parent ddcc7262b6
commit 8b54706ae7
15 changed files with 637 additions and 45 deletions
+160 -1
View File
@@ -4,13 +4,18 @@
启动后与主守护进程共享 asyncio 事件循环,不阻塞 Redis 消费。
"""
import asyncio
import os
import signal
import sys
from datetime import datetime
from pathlib import Path
import yaml
from aiohttp import web
from loguru import logger
from src.config.accounts import MultiAccountConfig
from src.config.settings import get_settings
# 默认只展示最近 N 天的截图
@@ -117,6 +122,76 @@ async def handle_index(request: web.Request) -> web.Response:
return web.Response(text=_INDEX_HTML, content_type="text/html")
async def handle_accounts_get(request: web.Request) -> web.Response:
"""GET /api/accounts — 返回 accounts.yaml 原始文本。"""
settings = get_settings()
config_path = Path(settings.accounts_config_path)
if not config_path.exists():
return web.Response(text="", content_type="text/plain", charset="utf-8")
return web.Response(
text=config_path.read_text(encoding="utf-8"),
content_type="text/plain",
charset="utf-8",
)
async def handle_accounts_put(request: web.Request) -> web.Response:
"""PUT /api/accounts — 校验并保存 accounts.yaml(Pydantic 结构校验通过才写入)。"""
raw_text = await request.text()
try:
raw_data = yaml.safe_load(raw_text) or {}
config = MultiAccountConfig.model_validate(raw_data)
except Exception as e:
return web.json_response({"ok": False, "error": f"配置校验失败: {e}"}, status=400)
settings = get_settings()
config_path = Path(settings.accounts_config_path)
config_path.parent.mkdir(parents=True, exist_ok=True)
# 先写临时文件再原子替换,避免写入中途崩溃损坏配置
tmp_path = config_path.with_name(config_path.name + ".tmp")
tmp_path.write_text(raw_text, encoding="utf-8")
os.replace(tmp_path, config_path)
account_count = len(config.sites.surugaya)
logger.warning(f"accounts.yaml 已通过 API 更新(骏河屋账号 {account_count} 个),重启进程后生效。")
return web.json_response({
"ok": True,
"message": f"配置已保存(骏河屋账号 {account_count} 个),需重启进程后生效。",
})
async def handle_restart(request: web.Request) -> web.Response:
"""
POST /api/restart — 触发进程优雅重启。
向自身发送 SIGTERM,复用 main.py 的优雅停机流程(停监听、关浏览器);
进程退出后由 supervisord(容器内 autorestart=true)拉起新进程,
重新加载 accounts.yaml 并预热浏览器。
"""
if sys.platform == "win32":
return web.json_response(
{"ok": False, "error": "Windows 环境不支持 API 重启,请手动重启进程。"},
status=501,
)
logger.warning("收到 API 重启请求,0.5 秒后向自身发送 SIGTERM 触发优雅重启...")
loop = asyncio.get_running_loop()
loop.call_later(0.5, os.kill, os.getpid(), signal.SIGTERM)
return web.json_response({
"ok": True,
"message": "进程即将优雅退出,由 supervisord 自动重启并加载最新配置。",
})
async def handle_accounts_page(request: web.Request) -> web.Response:
"""GET /accounts — accounts.yaml 在线编辑 HTML 页面。"""
return web.Response(text=_ACCOUNTS_HTML, content_type="text/html")
# 简易 HTML 页面,运维人员可直接在浏览器中查看
_INDEX_HTML = """<!DOCTYPE html>
<html lang="zh-CN">
@@ -147,7 +222,7 @@ _INDEX_HTML = """<!DOCTYPE html>
</style>
</head>
<body>
<h1>截图查看器</h1>
<h1>截图查看器 <a href="/accounts" style="font-size:0.6em;font-weight:normal;margin-left:12px;">账号配置 →</a></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>
@@ -196,12 +271,96 @@ load();
</html>"""
# accounts.yaml 在线编辑页面,供运维人员修改账号配置后触发优雅重启
_ACCOUNTS_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; }
textarea { width: 100%; height: 60vh; font-family: Consolas, Menlo, monospace; font-size: 13px;
padding: 10px; border: 1px solid #ccc; border-radius: 4px; box-sizing: border-box;
background: #fff; white-space: pre; }
.actions { margin: 12px 0; display: flex; gap: 10px; align-items: center; }
.actions button { padding: 8px 20px; border: none; border-radius: 4px; cursor: pointer; color: #fff; }
#btnSave { background: #4a90d9; }
#btnSave:hover { background: #357abd; }
#btnSaveRestart { background: #d9534f; }
#btnSaveRestart:hover { background: #c9302c; }
.actions button:disabled { background: #aaa; cursor: not-allowed; }
#msg { font-size: 0.9em; }
#msg.ok { color: #2a7a2a; }
#msg.err { color: #c00; white-space: pre-wrap; }
.tip { font-size: 0.85em; color: #888; margin-bottom: 8px; }
</style>
</head>
<body>
<h1>账号配置 (accounts.yaml) <a href="/" style="font-size:0.6em;font-weight:normal;margin-left:12px;">← 截图查看器</a></h1>
<div class="tip">保存时会做 YAML 结构校验;修改仅在进程重启后生效。"保存并重启"会优雅停机后由 supervisord 自动拉起进程并按新配置重新预热浏览器。</div>
<textarea id="editor" spellcheck="false"></textarea>
<div class="actions">
<button id="btnSave" onclick="save(false)">保存</button>
<button id="btnSaveRestart" onclick="save(true)">保存并重启</button>
<span id="msg"></span>
</div>
<script>
function setMsg(text, isErr) {
const el = document.getElementById('msg');
el.textContent = text;
el.className = isErr ? 'err' : 'ok';
}
function setBusy(busy) {
document.getElementById('btnSave').disabled = busy;
document.getElementById('btnSaveRestart').disabled = busy;
}
async function load() {
const resp = await fetch('/api/accounts');
document.getElementById('editor').value = await resp.text();
}
async function save(restart) {
if (restart && !confirm('确认保存并重启进程?进行中的任务会被优雅停机打断。')) return;
setBusy(true);
setMsg('保存中...', false);
try {
const resp = await fetch('/api/accounts', {
method: 'PUT',
headers: { 'Content-Type': 'text/plain; charset=utf-8' },
body: document.getElementById('editor').value,
});
const data = await resp.json();
if (!data.ok) { setMsg(data.error, true); return; }
if (!restart) { setMsg(data.message, false); return; }
setMsg('已保存,正在触发重启...', false);
const r = await fetch('/api/restart', { method: 'POST' });
const rd = await r.json();
if (!rd.ok) { setMsg(rd.error, true); return; }
setMsg('重启已触发,进程将在优雅停机后自动拉起(约数十秒),期间 API 短暂不可用。', false);
} catch (e) {
setMsg('请求失败: ' + e, true);
} finally {
setBusy(false);
}
}
load();
</script>
</body>
</html>"""
def create_app() -> web.Application:
"""创建 aiohttp Application 实例。"""
app = web.Application()
app.router.add_get("/", handle_index)
app.router.add_get("/accounts", handle_accounts_page)
app.router.add_get("/api/screenshots", handle_screenshots)
app.router.add_get("/api/screenshots/{date}/{topic}/{filename}", handle_screenshot_image)
app.router.add_get("/api/accounts", handle_accounts_get)
app.router.add_put("/api/accounts", handle_accounts_put)
app.router.add_post("/api/restart", handle_restart)
return app