feat(mc-server-status): 服务器列表 Web 管理页 / 数据层抽离 / mcstatus 13 适配

- services/servers.py:校验 + 读写 + 增删改收敛成一份数据层(顶层不 import nonebot,
  可脱离运行时单测),群命令与 Web API 都只经它改数据,校验不再各写一套
- web_hub.py + /hub/mc_server_status 页面:按群列出服务器,抽屉新增/编辑(改名)/
  测试连接,行内删除(删空一台连带删群);每条路由自带 require_admin
- handlers/server.py 改为薄封装,只在需要命令特有提示时自己判断
- services/mc.py 的图标属性名兼容 mcstatus ≥13 的 icon(旧版叫 favicon,
  读错会把整条查询打成"查询失败")
- tests/test_mc_servers.py:数据层归一/改名/删群/坏文件兜底 + 图标解码两版属性名

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-22 14:23:46 +08:00
co-authored by Claude Code
parent 4badcfcf32
commit 814c1994b6
9 changed files with 1150 additions and 46 deletions
@@ -0,0 +1,159 @@
"""MC 服务器信息查询 —— Web API 子应用(挂载到 /api/mc_server_status)。
服务器数据(`hexi/data/mc_status_data.json`:`{群号: {名字: [地址, 类型]}}`)
的唯一 Web 读写入口:列出 / 新增 / 编辑(含改名)/ 删除 / 测试连接。
写操作全部落在 services/servers.py(校验只有那一份),和群里的
`添加服务器`/`删除服务器` 命令改的是同一份内存态 + 同一个文件。
鉴权走 hexi.web_hub.web_auth(OAuth2 + SQLite),与统一管理台 /hub 共用登录态。
**每条路由都要自带 `dependencies=[auth]`** —— mount 层没有兜底,漏一条就是匿名可访问。
前端页面:hexi/web/src/plugins/mc_server_status/index.tsx。
"""
from __future__ import annotations
from fastapi import FastAPI
from fastapi.responses import JSONResponse
from hexi.web_hub.web_auth import require_admin
from .services import mc as mc_service
from .services import servers as store
API = require_admin
def _ok(data=None, msg: str = "ok") -> JSONResponse:
return JSONResponse({"status": 0, "msg": msg, "data": data})
def _fail(msg: str, status: int = 400) -> JSONResponse:
return JSONResponse({"status": status, "msg": msg})
async def _group_names() -> dict[str, str]:
"""群号 → 群名;拿不到 bot(未连接)时返回空表,前端只显示群号。"""
try:
from nonebot import get_adapter
from nonebot.adapters.onebot.v11 import Adapter
bots = get_adapter(Adapter).bots
bot = next(iter(bots.values()), None)
if bot is None:
return {}
return {
str(g["group_id"]): g.get("group_name") or ""
for g in await bot.get_group_list()
}
except Exception: # noqa: BLE001 — 未连接/适配器未加载都按拿不到处理
return {}
def _payload() -> dict:
"""整表视图:群列表(含群名与在线标记)+ 类型选项。"""
groups = store.snapshot()
return {
"groups": [
{
"group_id": str(gid),
"servers": [
{"name": name, "address": value[0], "type": value[1]}
for name, value in sorted(servers.items())
],
}
for gid, servers in sorted(groups.items())
],
"types": store.SERVER_TYPES,
"address_hint": store.ADDRESS_HINT,
}
async def _payload_with_names() -> dict:
"""跑一次 payload 再把群名补上(两条路由共用的收尾)。"""
data = _payload()
names = await _group_names()
for row in data["groups"]:
row["group_name"] = names.get(row["group_id"], "")
row["online"] = row["group_id"] in names
data["bot_online"] = bool(names)
return data
def build_admin_app() -> FastAPI | None:
"""构建 MC 服务器数据管理 API 子应用(挂载到 /api/mc_server_status)。"""
app = FastAPI(title="MC Server Status API")
auth = require_admin
@app.get("/overview", response_class=JSONResponse, dependencies=[auth])
async def overview():
"""一次拿全:所有群 + 服务器列表 + 类型选项。"""
return _ok(await _payload_with_names())
@app.post("/server", response_class=JSONResponse, dependencies=[auth])
async def save_server(data: dict):
"""新增/更新一台服务器;带 old_name 且与 name 不同即为改名。"""
try:
group_id = store.parse_group_id(data.get("group_id"))
name = store.normalize_name(data.get("name"))
created = store.set_server(
group_id,
name,
str(data.get("address") or ""),
str(data.get("type") or ""),
old_name=str(data.get("old_name") or "").strip() or None,
)
except store.ServerError as e:
return _fail(str(e))
return _ok(
await _payload_with_names(),
f"服务器「{name}」已{'添加' if created else '保存'}",
)
@app.delete("/server/{group_id}/{name}", response_class=JSONResponse, dependencies=[auth])
async def remove_server(group_id: str, name: str):
"""删掉某群里的一台服务器(删空了连带删群)。"""
try:
store.delete_server(group_id, name)
except store.ServerError as e:
return _fail(str(e))
return _ok(await _payload_with_names(), f"服务器「{name}」已删除")
@app.delete("/group/{group_id}", response_class=JSONResponse, dependencies=[auth])
async def remove_group(group_id: str):
"""删掉整个群的服务器配置。"""
try:
store.delete_group(group_id)
except store.ServerError as e:
return _fail(str(e))
return _ok(await _payload_with_names(), f"群 {group_id} 的服务器配置已删除")
@app.post("/test", response_class=JSONResponse, dependencies=[auth])
async def test_server(data: dict):
"""测试连接:给了群号+名字就测存量那台,否则测临时填的地址(不落库)。
对不上的地址走 mcstatus 自己的 2 秒超时,返回的是查询失败文案而不是报错。
"""
group_id = str(data.get("group_id") or "").strip()
name = str(data.get("name") or "").strip()
address = str(data.get("address") or "").strip()
server_type = str(data.get("type") or "").strip()
if group_id.isdigit() and name:
found = store.lookup(int(group_id), name)
if found:
address, server_type = found[0], found[1]
if not address:
return _fail("先填服务器地址")
try:
address = store.normalize_address(address)
server_type = store.normalize_type(server_type)
except store.ServerError as e:
return _fail(str(e))
message = await mc_service.check_mc_status(name or "测试", address, server_type)
# check_mc_status 正常返回 str;带图标的 Java 服返回 Message(Web 侧只要纯文本)
text = str(message) if not isinstance(message, str) else message
return _ok({"ok": "查询失败" not in text, "message": text}, "测试完成")
return app