1229 lines
55 KiB
Python
1229 lines
55 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""企微客服助手的内置配置后台。
|
|
|
|
仅依赖 Python 标准库,提供:
|
|
- 用户名/密码登录和强制首次改密
|
|
- admin / operator / viewer 三种角色
|
|
- AI 与 MCP 配置的网页管理
|
|
- 供桌面端认证并拉取配置的 JSON API
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import base64
|
|
import getpass
|
|
import hashlib
|
|
import hmac
|
|
import html
|
|
import ipaddress
|
|
import json
|
|
import os
|
|
import secrets
|
|
import sqlite3
|
|
import sys
|
|
import threading
|
|
import time
|
|
import urllib.parse
|
|
from collections import defaultdict, deque
|
|
from datetime import datetime
|
|
from http import HTTPStatus
|
|
from http.cookies import SimpleCookie
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
SCRIPT_DIR = Path(__file__).resolve().parent
|
|
DEFAULT_DB = SCRIPT_DIR / "backend.db"
|
|
DEFAULT_RUNTIME_FILE = SCRIPT_DIR / "backend_runtime.json"
|
|
DEFAULT_HOST = "127.0.0.1"
|
|
DEFAULT_PORT = 8765
|
|
DEFAULT_PORT_ATTEMPTS = 100
|
|
DEFAULT_ADMIN_PASSWORD = "Admin@123456"
|
|
ROLES = ("admin", "operator", "viewer")
|
|
ROLE_LABELS = {"admin": "管理员", "operator": "配置员", "viewer": "只读用户"}
|
|
PBKDF2_ITERATIONS = 310_000
|
|
CONFIG_KEYS = (
|
|
"AI_ENABLED",
|
|
"AI_API_BASE",
|
|
"AI_API_KEY",
|
|
"AI_MODEL",
|
|
"AI_USE_VISION",
|
|
"AI_CONTEXT_ENABLED",
|
|
"AI_CONTEXT_MAX_ROUNDS",
|
|
"AI_COUNTER_INSULT_ENABLED",
|
|
"AI_AGENT_NAME",
|
|
"AI_HOSPITAL_NAME",
|
|
"AI_MAX_TOKENS",
|
|
"AI_TEMPERATURE",
|
|
"AI_TIMEOUT",
|
|
"AI_MCP_ENABLED",
|
|
"AI_MCP_MAX_ROUNDS",
|
|
"AI_MCP_SERVERS",
|
|
)
|
|
BOOL_KEYS = {
|
|
"AI_ENABLED",
|
|
"AI_USE_VISION",
|
|
"AI_CONTEXT_ENABLED",
|
|
"AI_COUNTER_INSULT_ENABLED",
|
|
"AI_MCP_ENABLED",
|
|
}
|
|
|
|
|
|
def now_text() -> str:
|
|
return datetime.now().astimezone().isoformat(timespec="seconds")
|
|
|
|
|
|
def password_hash(password: str, salt: bytes | None = None) -> tuple[str, str]:
|
|
salt = salt or secrets.token_bytes(16)
|
|
digest = hashlib.pbkdf2_hmac(
|
|
"sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS
|
|
)
|
|
return base64.b64encode(salt).decode("ascii"), base64.b64encode(digest).decode("ascii")
|
|
|
|
|
|
def verify_password(password: str, salt_text: str, digest_text: str) -> bool:
|
|
try:
|
|
salt = base64.b64decode(salt_text)
|
|
expected = base64.b64decode(digest_text)
|
|
except Exception:
|
|
return False
|
|
actual = hashlib.pbkdf2_hmac(
|
|
"sha256", password.encode("utf-8"), salt, PBKDF2_ITERATIONS
|
|
)
|
|
return hmac.compare_digest(actual, expected)
|
|
|
|
|
|
def token_hash(token: str) -> str:
|
|
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
|
|
|
|
|
def load_initial_config() -> dict[str, Any]:
|
|
path = SCRIPT_DIR / "ai_settings.json"
|
|
try:
|
|
saved = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, TypeError):
|
|
saved = {}
|
|
defaults: dict[str, Any] = {
|
|
"AI_ENABLED": True,
|
|
"AI_API_BASE": "",
|
|
"AI_API_KEY": "",
|
|
"AI_MODEL": "",
|
|
"AI_USE_VISION": False,
|
|
"AI_CONTEXT_ENABLED": True,
|
|
"AI_CONTEXT_MAX_ROUNDS": 5,
|
|
"AI_COUNTER_INSULT_ENABLED": False,
|
|
"AI_AGENT_NAME": "客服",
|
|
"AI_HOSPITAL_NAME": "",
|
|
"AI_MAX_TOKENS": 500,
|
|
"AI_TEMPERATURE": 0.35,
|
|
"AI_TIMEOUT": 120,
|
|
"AI_MCP_ENABLED": False,
|
|
"AI_MCP_MAX_ROUNDS": 5,
|
|
"AI_MCP_SERVERS": [],
|
|
}
|
|
if isinstance(saved, dict):
|
|
defaults.update({key: saved[key] for key in CONFIG_KEYS if key in saved})
|
|
return defaults
|
|
|
|
|
|
class ClosingConnection(sqlite3.Connection):
|
|
"""提交/回滚后立即关闭,避免 Windows 上数据库文件长期被占用。"""
|
|
|
|
def __exit__(self, exc_type, exc_value, traceback):
|
|
try:
|
|
return super().__exit__(exc_type, exc_value, traceback)
|
|
finally:
|
|
self.close()
|
|
|
|
|
|
class Database:
|
|
def __init__(self, path: Path):
|
|
self.path = path
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
|
def connect(self) -> sqlite3.Connection:
|
|
connection = sqlite3.connect(
|
|
self.path, timeout=10, factory=ClosingConnection
|
|
)
|
|
connection.row_factory = sqlite3.Row
|
|
connection.execute("PRAGMA foreign_keys = ON")
|
|
return connection
|
|
|
|
def initialize(self, initial_password: str) -> bool:
|
|
with self.connect() as db:
|
|
db.executescript(
|
|
"""
|
|
PRAGMA journal_mode = WAL;
|
|
CREATE TABLE IF NOT EXISTS users (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
|
password_salt TEXT NOT NULL,
|
|
password_digest TEXT NOT NULL,
|
|
role TEXT NOT NULL CHECK(role IN ('admin','operator','viewer')),
|
|
active INTEGER NOT NULL DEFAULT 1,
|
|
must_change_password INTEGER NOT NULL DEFAULT 1,
|
|
created_at TEXT NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS auth_tokens (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
|
token_digest TEXT NOT NULL UNIQUE,
|
|
kind TEXT NOT NULL,
|
|
csrf_token TEXT NOT NULL,
|
|
device_name TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL,
|
|
expires_at INTEGER NOT NULL,
|
|
last_used_at TEXT NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS model_config (
|
|
id INTEGER PRIMARY KEY CHECK(id = 1),
|
|
config_json TEXT NOT NULL,
|
|
version INTEGER NOT NULL DEFAULT 1,
|
|
updated_at TEXT NOT NULL,
|
|
updated_by INTEGER REFERENCES users(id)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS audit_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
user_id INTEGER REFERENCES users(id),
|
|
action TEXT NOT NULL,
|
|
detail TEXT NOT NULL DEFAULT '',
|
|
ip_address TEXT NOT NULL DEFAULT '',
|
|
created_at TEXT NOT NULL
|
|
);
|
|
CREATE INDEX IF NOT EXISTS idx_tokens_digest ON auth_tokens(token_digest);
|
|
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
|
|
"""
|
|
)
|
|
created = db.execute("SELECT COUNT(*) FROM users").fetchone()[0] == 0
|
|
if created:
|
|
salt, digest = password_hash(initial_password)
|
|
stamp = now_text()
|
|
db.execute(
|
|
"""INSERT INTO users
|
|
(username,password_salt,password_digest,role,active,must_change_password,created_at,updated_at)
|
|
VALUES (?,?,?,?,1,1,?,?)""",
|
|
("admin", salt, digest, "admin", stamp, stamp),
|
|
)
|
|
if db.execute("SELECT COUNT(*) FROM model_config").fetchone()[0] == 0:
|
|
admin = db.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
|
db.execute(
|
|
"""INSERT INTO model_config
|
|
(id,config_json,version,updated_at,updated_by) VALUES (1,?,1,?,?)""",
|
|
(
|
|
json.dumps(load_initial_config(), ensure_ascii=False),
|
|
now_text(),
|
|
admin["id"] if admin else None,
|
|
),
|
|
)
|
|
db.commit()
|
|
return created
|
|
|
|
def authenticate(self, username: str, password: str) -> sqlite3.Row | None:
|
|
with self.connect() as db:
|
|
user = db.execute(
|
|
"SELECT * FROM users WHERE username = ? AND active = 1", (username,)
|
|
).fetchone()
|
|
if user and verify_password(password, user["password_salt"], user["password_digest"]):
|
|
return user
|
|
return None
|
|
|
|
def create_token(
|
|
self, user_id: int, kind: str, device_name: str, lifetime: int
|
|
) -> tuple[str, str]:
|
|
token = secrets.token_urlsafe(36)
|
|
csrf = secrets.token_urlsafe(24)
|
|
stamp = now_text()
|
|
with self.connect() as db:
|
|
db.execute(
|
|
"""INSERT INTO auth_tokens
|
|
(user_id,token_digest,kind,csrf_token,device_name,created_at,expires_at,last_used_at)
|
|
VALUES (?,?,?,?,?,?,?,?)""",
|
|
(
|
|
user_id,
|
|
token_hash(token),
|
|
kind,
|
|
csrf,
|
|
str(device_name or "")[:120],
|
|
stamp,
|
|
int(time.time()) + lifetime,
|
|
stamp,
|
|
),
|
|
)
|
|
db.execute("DELETE FROM auth_tokens WHERE expires_at < ?", (int(time.time()),))
|
|
db.commit()
|
|
return token, csrf
|
|
|
|
def session(self, token: str) -> sqlite3.Row | None:
|
|
if not token:
|
|
return None
|
|
with self.connect() as db:
|
|
row = db.execute(
|
|
"""SELECT u.*, t.id AS token_id, t.kind AS token_kind,
|
|
t.csrf_token, t.expires_at
|
|
FROM auth_tokens t JOIN users u ON u.id=t.user_id
|
|
WHERE t.token_digest=? AND t.expires_at>=? AND u.active=1""",
|
|
(token_hash(token), int(time.time())),
|
|
).fetchone()
|
|
if row:
|
|
db.execute(
|
|
"UPDATE auth_tokens SET last_used_at=? WHERE id=?",
|
|
(now_text(), row["token_id"]),
|
|
)
|
|
db.commit()
|
|
return row
|
|
|
|
def revoke(self, token: str) -> None:
|
|
if not token:
|
|
return
|
|
with self.connect() as db:
|
|
db.execute("DELETE FROM auth_tokens WHERE token_digest=?", (token_hash(token),))
|
|
db.commit()
|
|
|
|
def config(self) -> sqlite3.Row:
|
|
with self.connect() as db:
|
|
return db.execute(
|
|
"""SELECT c.*, u.username AS updated_by_name
|
|
FROM model_config c LEFT JOIN users u ON u.id=c.updated_by WHERE c.id=1"""
|
|
).fetchone()
|
|
|
|
def save_config(self, config: dict[str, Any], user_id: int, ip: str) -> int:
|
|
with self.connect() as db:
|
|
db.execute("BEGIN IMMEDIATE")
|
|
current = db.execute("SELECT version FROM model_config WHERE id=1").fetchone()
|
|
version = int(current["version"]) + 1
|
|
db.execute(
|
|
"""UPDATE model_config SET config_json=?,version=?,updated_at=?,updated_by=?
|
|
WHERE id=1""",
|
|
(json.dumps(config, ensure_ascii=False), version, now_text(), user_id),
|
|
)
|
|
self._audit(db, user_id, "config.update", f"version={version}", ip)
|
|
db.commit()
|
|
return version
|
|
|
|
@staticmethod
|
|
def _audit(
|
|
db: sqlite3.Connection, user_id: int | None, action: str, detail: str, ip: str
|
|
) -> None:
|
|
db.execute(
|
|
"INSERT INTO audit_log(user_id,action,detail,ip_address,created_at) VALUES(?,?,?,?,?)",
|
|
(user_id, action, detail[:500], ip[:80], now_text()),
|
|
)
|
|
|
|
def audit(self, user_id: int | None, action: str, detail: str, ip: str) -> None:
|
|
with self.connect() as db:
|
|
self._audit(db, user_id, action, detail, ip)
|
|
db.commit()
|
|
|
|
def list_users(self) -> list[sqlite3.Row]:
|
|
with self.connect() as db:
|
|
return list(db.execute("SELECT * FROM users ORDER BY id"))
|
|
|
|
def recent_audit(self, limit: int = 12) -> list[sqlite3.Row]:
|
|
with self.connect() as db:
|
|
return list(
|
|
db.execute(
|
|
"""SELECT a.*, u.username FROM audit_log a
|
|
LEFT JOIN users u ON u.id=a.user_id ORDER BY a.id DESC LIMIT ?""",
|
|
(limit,),
|
|
)
|
|
)
|
|
|
|
def create_user(
|
|
self, username: str, password: str, role: str, actor_id: int, ip: str
|
|
) -> None:
|
|
salt, digest = password_hash(password)
|
|
stamp = now_text()
|
|
with self.connect() as db:
|
|
db.execute(
|
|
"""INSERT INTO users
|
|
(username,password_salt,password_digest,role,active,must_change_password,created_at,updated_at)
|
|
VALUES(?,?,?,?,1,1,?,?)""",
|
|
(username, salt, digest, role, stamp, stamp),
|
|
)
|
|
self._audit(db, actor_id, "user.create", f"username={username}, role={role}", ip)
|
|
db.commit()
|
|
|
|
def update_user(
|
|
self,
|
|
user_id: int,
|
|
role: str,
|
|
active: bool,
|
|
new_password: str,
|
|
actor_id: int,
|
|
ip: str,
|
|
) -> None:
|
|
with self.connect() as db:
|
|
target = db.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
|
if not target:
|
|
raise ValueError("用户不存在")
|
|
if user_id == actor_id and not active:
|
|
raise ValueError("不能停用当前登录账号")
|
|
if target["role"] == "admin" and (role != "admin" or not active):
|
|
count = db.execute(
|
|
"SELECT COUNT(*) FROM users WHERE role='admin' AND active=1"
|
|
).fetchone()[0]
|
|
if count <= 1:
|
|
raise ValueError("系统至少需要保留一个启用的管理员")
|
|
values: list[Any] = [role, int(active), now_text()]
|
|
sql = "UPDATE users SET role=?,active=?,updated_at=?"
|
|
if new_password:
|
|
salt, digest = password_hash(new_password)
|
|
sql += ",password_salt=?,password_digest=?,must_change_password=1"
|
|
values.extend([salt, digest])
|
|
sql += " WHERE id=?"
|
|
values.append(user_id)
|
|
db.execute(sql, values)
|
|
if not active or new_password:
|
|
db.execute("DELETE FROM auth_tokens WHERE user_id=?", (user_id,))
|
|
self._audit(
|
|
db,
|
|
actor_id,
|
|
"user.update",
|
|
f"username={target['username']}, role={role}, active={int(active)}, password_reset={bool(new_password)}",
|
|
ip,
|
|
)
|
|
db.commit()
|
|
|
|
def change_password(self, user_id: int, current: str, new_password: str, ip: str) -> None:
|
|
with self.connect() as db:
|
|
user = db.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone()
|
|
if not user or not verify_password(
|
|
current, user["password_salt"], user["password_digest"]
|
|
):
|
|
raise ValueError("当前密码不正确")
|
|
salt, digest = password_hash(new_password)
|
|
db.execute(
|
|
"""UPDATE users SET password_salt=?,password_digest=?,must_change_password=0,
|
|
updated_at=? WHERE id=?""",
|
|
(salt, digest, now_text(), user_id),
|
|
)
|
|
self._audit(db, user_id, "password.change", "", ip)
|
|
db.commit()
|
|
|
|
def reset_admin_password(self, password: str) -> None:
|
|
salt, digest = password_hash(password)
|
|
with self.connect() as db:
|
|
result = db.execute(
|
|
"""UPDATE users SET password_salt=?,password_digest=?,active=1,
|
|
must_change_password=1,updated_at=? WHERE username='admin'""",
|
|
(salt, digest, now_text()),
|
|
)
|
|
if result.rowcount == 0:
|
|
stamp = now_text()
|
|
db.execute(
|
|
"""INSERT INTO users
|
|
(username,password_salt,password_digest,role,active,must_change_password,created_at,updated_at)
|
|
VALUES('admin',?,?,'admin',1,1,?,?)""",
|
|
(salt, digest, stamp, stamp),
|
|
)
|
|
db.execute(
|
|
"DELETE FROM auth_tokens WHERE user_id=(SELECT id FROM users WHERE username='admin')"
|
|
)
|
|
db.commit()
|
|
|
|
|
|
class LoginLimiter:
|
|
def __init__(self):
|
|
self._attempts: dict[str, deque[float]] = defaultdict(deque)
|
|
self._lock = threading.Lock()
|
|
|
|
def allowed(self, key: str) -> bool:
|
|
cutoff = time.time() - 300
|
|
with self._lock:
|
|
items = self._attempts[key]
|
|
while items and items[0] < cutoff:
|
|
items.popleft()
|
|
return len(items) < 8
|
|
|
|
def failure(self, key: str) -> None:
|
|
with self._lock:
|
|
self._attempts[key].append(time.time())
|
|
|
|
def success(self, key: str) -> None:
|
|
with self._lock:
|
|
self._attempts.pop(key, None)
|
|
|
|
|
|
LOGIN_LIMITER = LoginLimiter()
|
|
|
|
|
|
BASE_CSS = """
|
|
:root{--bg:#f3f7f5;--surface:#fff;--ink:#17251f;--muted:#67786f;--line:#dce7e1;
|
|
--accent:#0d9871;--deep:#102b21;--danger:#c94352;--soft:#e1f4ec}
|
|
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--ink);font-family:"Microsoft YaHei UI","Segoe UI",sans-serif}
|
|
a{color:var(--accent);text-decoration:none}.shell{min-height:100vh;display:grid;grid-template-columns:250px 1fr}
|
|
aside{background:var(--deep);color:white;padding:30px 24px;position:sticky;top:0;height:100vh}.brand{font-size:22px;font-weight:800;letter-spacing:.04em}
|
|
.brand small{display:block;margin-top:8px;color:#9fc0b3;font-size:12px;font-weight:500}.userbox{margin-top:44px;padding:16px;background:#17392c;border:1px solid #2b5143;border-radius:14px}
|
|
.role{display:inline-block;margin-top:8px;padding:4px 9px;background:#275643;border-radius:999px;color:#cce9de;font-size:12px}
|
|
nav{margin-top:30px;display:grid;gap:8px}nav a{color:#c6d8d0;padding:10px 12px;border-radius:9px}nav a:hover{background:#1c4435;color:white}
|
|
main{padding:34px;max-width:1450px;width:100%;margin:0 auto}.top{display:flex;justify-content:space-between;align-items:end;margin-bottom:24px}.top h1{font-size:30px;margin:0 0 7px}.muted{color:var(--muted);font-size:13px}
|
|
.grid{display:grid;grid-template-columns:1.35fr .9fr;gap:20px;align-items:start}.card{background:var(--surface);border:1px solid var(--line);border-radius:17px;padding:22px;margin-bottom:20px;box-shadow:0 10px 30px rgba(17,51,38,.035)}
|
|
.card h2{font-size:18px;margin:0 0 5px}.cardhead{display:flex;justify-content:space-between;gap:15px;align-items:start;margin-bottom:18px}.version{font:600 12px Consolas,monospace;color:var(--accent);background:var(--soft);padding:6px 9px;border-radius:8px}
|
|
.formgrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.full{grid-column:1/-1}label{display:block;color:#53675d;font-size:13px;margin-bottom:6px}input,select,textarea{width:100%;border:1px solid #ceddd5;background:#fbfcfb;border-radius:9px;padding:10px 12px;color:var(--ink);font:inherit;outline:none}
|
|
input:focus,select:focus,textarea:focus{border-color:var(--accent);box-shadow:0 0 0 3px #dff3eb}textarea{min-height:150px;font:13px Consolas,monospace;resize:vertical}.switches{display:grid;grid-template-columns:1fr 1fr;gap:10px 18px}.check{display:flex;align-items:center;gap:9px;color:var(--ink)}.check input{width:18px;height:18px;accent-color:var(--accent)}
|
|
.actions{display:flex;justify-content:flex-end;margin-top:18px;gap:10px}button,.button{border:0;border-radius:9px;padding:10px 17px;font:600 14px inherit;cursor:pointer;background:var(--accent);color:white}button:hover{filter:brightness(.93)}button.secondary{background:#edf3f0;color:#28483b}.danger{color:var(--danger)}
|
|
.flash{padding:12px 15px;border-radius:10px;margin-bottom:18px;background:var(--soft);color:#087656;border:1px solid #c9eadc}.flash.error{background:#fbeaec;color:var(--danger);border-color:#f2cbd1}
|
|
.user{display:grid;grid-template-columns:1.1fr .8fr .6fr 1.1fr auto;gap:8px;align-items:end;padding:12px 0;border-top:1px solid #edf2ef}.user:first-of-type{border-top:0}.username{font-weight:700;padding:11px 0}.user input,.user select{padding:8px 9px}.tiny{font-size:11px;color:#829189}
|
|
.audit{display:grid;gap:12px}.event{border-left:3px solid #b8ddce;padding-left:11px}.event b{font-size:13px}.event div{font-size:11px;color:#829189;margin-top:3px}
|
|
.loginwrap{min-height:100vh;display:grid;place-items:center;padding:24px}.login{width:min(430px,100%);background:white;border:1px solid var(--line);border-radius:22px;padding:34px;box-shadow:0 25px 70px rgba(12,48,34,.12)}.login h1{margin:0 0 8px;font-size:27px}.login form{display:grid;gap:15px;margin-top:26px}.login button{margin-top:5px;padding:12px}.notice{background:#fff7e8;border:1px solid #f0d6a5;color:#8a5a0a;padding:13px;border-radius:10px;font-size:13px;margin-bottom:18px}
|
|
@media(max-width:980px){.shell{grid-template-columns:1fr}aside{height:auto;position:static}.grid{grid-template-columns:1fr}.user{grid-template-columns:1fr 1fr}.user .actions{grid-column:1/-1}.formgrid{grid-template-columns:1fr}.full{grid-column:auto}main{padding:20px}}
|
|
"""
|
|
|
|
|
|
def page(title: str, body: str) -> str:
|
|
return (
|
|
"<!doctype html><html lang='zh-CN'><head><meta charset='utf-8'>"
|
|
"<meta name='viewport' content='width=device-width,initial-scale=1'>"
|
|
f"<title>{html.escape(title)}</title><style>{BASE_CSS}</style></head><body>{body}</body></html>"
|
|
)
|
|
|
|
|
|
class AdminServer(ThreadingHTTPServer):
|
|
daemon_threads = True
|
|
|
|
def __init__(self, address: tuple[str, int], database: Database):
|
|
self.local_sync_token = secrets.token_urlsafe(36)
|
|
super().__init__(address, AdminHandler)
|
|
self.database = database
|
|
|
|
|
|
def _address_in_use(exc: OSError) -> bool:
|
|
"""兼容 Windows、Linux 和 macOS 的端口占用错误码。"""
|
|
# Windows 在独占地址被另一个进程监听时,可能返回 10013 而不是 10048。
|
|
return exc.errno in (13, 48, 98, 10013, 10048) or getattr(
|
|
exc, "winerror", None
|
|
) in (10013, 10048)
|
|
|
|
|
|
def create_server(
|
|
host: str,
|
|
preferred_port: int,
|
|
database: Database,
|
|
*,
|
|
max_attempts: int = DEFAULT_PORT_ATTEMPTS,
|
|
) -> tuple[AdminServer, int]:
|
|
"""创建服务;首选端口被占用时自动向后尝试可用端口。"""
|
|
if not 0 <= preferred_port <= 65535:
|
|
raise ValueError("端口必须在 0-65535 之间")
|
|
if max_attempts < 1:
|
|
raise ValueError("端口尝试次数必须大于 0")
|
|
if preferred_port == 0:
|
|
server = AdminServer((host, 0), database)
|
|
return server, int(server.server_address[1])
|
|
|
|
last_error: OSError | None = None
|
|
for port in range(preferred_port, min(65535, preferred_port + max_attempts - 1) + 1):
|
|
try:
|
|
return AdminServer((host, port), database), port
|
|
except OSError as exc:
|
|
if not _address_in_use(exc):
|
|
raise
|
|
last_error = exc
|
|
raise OSError(
|
|
f"端口 {preferred_port}-{min(65535, preferred_port + max_attempts - 1)} 均被占用"
|
|
) from last_error
|
|
|
|
|
|
def public_server_url(host: str, port: int) -> str:
|
|
display_host = "127.0.0.1" if host in ("0.0.0.0", "::", "") else host
|
|
if ":" in display_host and not display_host.startswith("["):
|
|
display_host = f"[{display_host}]"
|
|
return f"http://{display_host}:{port}"
|
|
|
|
|
|
def write_runtime_info(
|
|
path: Path,
|
|
host: str,
|
|
port: int,
|
|
*,
|
|
local_sync_token: str = "",
|
|
) -> dict[str, Any]:
|
|
"""发布实际监听端口,供同项目桌面端自动发现。"""
|
|
info = {
|
|
"pid": os.getpid(),
|
|
"host": host,
|
|
"port": int(port),
|
|
"server_url": public_server_url(host, port),
|
|
"local_sync_token": local_sync_token,
|
|
"started_at": now_text(),
|
|
}
|
|
path = path.resolve()
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary = path.with_suffix(path.suffix + ".tmp")
|
|
temporary.write_text(json.dumps(info, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
os.replace(temporary, path)
|
|
return info
|
|
|
|
|
|
def clear_runtime_info(path: Path, port: int) -> None:
|
|
"""只清理由当前进程写入的发现文件,避免影响另一个后台实例。"""
|
|
path = path.resolve()
|
|
try:
|
|
info = json.loads(path.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError, TypeError):
|
|
return
|
|
if info.get("pid") == os.getpid() and info.get("port") == int(port):
|
|
try:
|
|
path.unlink()
|
|
except FileNotFoundError:
|
|
pass
|
|
|
|
|
|
class AdminHandler(BaseHTTPRequestHandler):
|
|
server_version = "WeComConfig/1.0"
|
|
|
|
@property
|
|
def db(self) -> Database:
|
|
return self.server.database # type: ignore[attr-defined]
|
|
|
|
@property
|
|
def client_ip(self) -> str:
|
|
forwarded = self.headers.get("X-Forwarded-For", "").split(",", 1)[0].strip()
|
|
return forwarded or self.client_address[0]
|
|
|
|
def log_message(self, format_string: str, *args: Any) -> None:
|
|
sys.stdout.write(
|
|
f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {self.client_ip} {format_string % args}\n"
|
|
)
|
|
|
|
def _security_headers(self) -> None:
|
|
self.send_header("X-Content-Type-Options", "nosniff")
|
|
self.send_header("X-Frame-Options", "DENY")
|
|
self.send_header("Referrer-Policy", "no-referrer")
|
|
self.send_header("Cache-Control", "no-store")
|
|
self.send_header(
|
|
"Content-Security-Policy",
|
|
"default-src 'none'; style-src 'unsafe-inline'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'",
|
|
)
|
|
|
|
def _send(self, status: int, content: str, content_type: str) -> None:
|
|
data = content.encode("utf-8")
|
|
self.send_response(status)
|
|
self._security_headers()
|
|
self.send_header("Content-Type", content_type)
|
|
self.send_header("Content-Length", str(len(data)))
|
|
self.end_headers()
|
|
self.wfile.write(data)
|
|
|
|
def json_response(self, status: int, data: dict[str, Any]) -> None:
|
|
self._send(
|
|
status,
|
|
json.dumps(data, ensure_ascii=False),
|
|
"application/json; charset=utf-8",
|
|
)
|
|
|
|
def html_response(self, status: int, content: str) -> None:
|
|
self._send(status, content, "text/html; charset=utf-8")
|
|
|
|
def redirect(self, location: str, *, cookie: str = "") -> None:
|
|
self.send_response(HTTPStatus.SEE_OTHER)
|
|
self._security_headers()
|
|
self.send_header("Location", location)
|
|
if cookie:
|
|
self.send_header("Set-Cookie", cookie)
|
|
self.send_header("Content-Length", "0")
|
|
self.end_headers()
|
|
|
|
def body_bytes(self, limit: int = 512_000) -> bytes:
|
|
try:
|
|
length = int(self.headers.get("Content-Length", "0"))
|
|
except ValueError:
|
|
raise ValueError("Content-Length 无效")
|
|
if length < 0 or length > limit:
|
|
raise ValueError("请求内容过大")
|
|
return self.rfile.read(length)
|
|
|
|
def json_body(self) -> dict[str, Any]:
|
|
try:
|
|
data = json.loads(self.body_bytes().decode("utf-8") or "{}")
|
|
except (UnicodeDecodeError, ValueError) as exc:
|
|
raise ValueError("请求 JSON 无效") from exc
|
|
if not isinstance(data, dict):
|
|
raise ValueError("请求 JSON 必须是对象")
|
|
return data
|
|
|
|
def form_body(self) -> dict[str, str]:
|
|
try:
|
|
parsed = urllib.parse.parse_qs(
|
|
self.body_bytes().decode("utf-8"), keep_blank_values=True
|
|
)
|
|
except UnicodeDecodeError as exc:
|
|
raise ValueError("表单编码无效") from exc
|
|
return {key: values[-1] for key, values in parsed.items()}
|
|
|
|
def bearer_token(self) -> str:
|
|
authorization = self.headers.get("Authorization", "")
|
|
if authorization.startswith("Bearer "):
|
|
return authorization[7:].strip()
|
|
return ""
|
|
|
|
def cookie_token(self) -> str:
|
|
cookie = SimpleCookie()
|
|
try:
|
|
cookie.load(self.headers.get("Cookie", ""))
|
|
except Exception:
|
|
return ""
|
|
return cookie.get("session_token").value if cookie.get("session_token") else ""
|
|
|
|
def auth(self, api: bool = False) -> tuple[sqlite3.Row | None, str]:
|
|
token = self.bearer_token() if api else self.cookie_token()
|
|
return self.db.session(token), token
|
|
|
|
@staticmethod
|
|
def valid_password(value: str) -> bool:
|
|
return len(value) >= 10 and any(c.isalpha() for c in value) and any(
|
|
c.isdigit() for c in value
|
|
)
|
|
|
|
def require_web_auth(
|
|
self, *, roles: tuple[str, ...] | None = None, allow_password_change: bool = False
|
|
) -> tuple[sqlite3.Row, str] | None:
|
|
user, token = self.auth(False)
|
|
if not user:
|
|
self.redirect("/login")
|
|
return None
|
|
if user["must_change_password"] and not allow_password_change:
|
|
self.redirect("/?error=" + urllib.parse.quote("首次登录请先修改密码"))
|
|
return None
|
|
if roles and user["role"] not in roles:
|
|
self.redirect("/?error=" + urllib.parse.quote("当前角色没有执行此操作的权限"))
|
|
return None
|
|
return user, token
|
|
|
|
def require_api_auth(self) -> sqlite3.Row | None:
|
|
user, _ = self.auth(True)
|
|
if not user:
|
|
self.json_response(HTTPStatus.UNAUTHORIZED, {"error": "登录已失效,请重新登录"})
|
|
return None
|
|
if user["must_change_password"]:
|
|
self.json_response(
|
|
HTTPStatus.FORBIDDEN, {"error": "请先在后台网页修改初始密码"}
|
|
)
|
|
return None
|
|
return user
|
|
|
|
def local_sync_authorized(self) -> bool:
|
|
"""允许同机桌面端使用运行时随机凭证只读模型配置。"""
|
|
supplied = self.headers.get("X-Desktop-Sync-Token", "")
|
|
expected = getattr(self.server, "local_sync_token", "")
|
|
if not supplied or not expected:
|
|
return False
|
|
try:
|
|
is_loopback = ipaddress.ip_address(self.client_address[0]).is_loopback
|
|
except ValueError:
|
|
return False
|
|
return is_loopback and hmac.compare_digest(supplied, expected)
|
|
|
|
@staticmethod
|
|
def csrf_ok(user: sqlite3.Row, form: dict[str, str]) -> bool:
|
|
return hmac.compare_digest(str(user["csrf_token"]), str(form.get("csrf", "")))
|
|
|
|
def do_GET(self) -> None:
|
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
if path == "/health":
|
|
self.json_response(HTTPStatus.OK, {"status": "ok", "time": now_text()})
|
|
elif path == "/login":
|
|
user, _ = self.auth(False)
|
|
if user:
|
|
self.redirect("/")
|
|
else:
|
|
self.render_login()
|
|
elif path == "/":
|
|
auth = self.require_web_auth(allow_password_change=True)
|
|
if auth:
|
|
self.render_dashboard(auth[0])
|
|
elif path == "/api/v1/me":
|
|
user = self.require_api_auth()
|
|
if user:
|
|
self.json_response(HTTPStatus.OK, {"user": self.public_user(user)})
|
|
elif path == "/api/v1/config":
|
|
local_sync = self.local_sync_authorized()
|
|
user = None if local_sync else self.require_api_auth()
|
|
if local_sync or user:
|
|
row = self.db.config()
|
|
self.json_response(
|
|
HTTPStatus.OK,
|
|
{
|
|
"version": row["version"],
|
|
"updated_at": row["updated_at"],
|
|
"updated_by": row["updated_by_name"] or "system",
|
|
"config": json.loads(row["config_json"]),
|
|
},
|
|
)
|
|
else:
|
|
self.json_response(HTTPStatus.NOT_FOUND, {"error": "页面不存在"})
|
|
|
|
def do_POST(self) -> None:
|
|
path = urllib.parse.urlparse(self.path).path.rstrip("/") or "/"
|
|
try:
|
|
if path == "/login":
|
|
self.web_login()
|
|
elif path == "/logout":
|
|
self.web_logout()
|
|
elif path == "/admin/config":
|
|
self.web_save_config()
|
|
elif path == "/admin/users/create":
|
|
self.web_create_user()
|
|
elif path == "/admin/users/update":
|
|
self.web_update_user()
|
|
elif path == "/admin/password":
|
|
self.web_change_password()
|
|
elif path == "/api/v1/auth/login":
|
|
self.api_login()
|
|
elif path == "/api/v1/auth/logout":
|
|
self.api_logout()
|
|
else:
|
|
self.json_response(HTTPStatus.NOT_FOUND, {"error": "接口不存在"})
|
|
except ValueError as exc:
|
|
if path.startswith("/api/"):
|
|
self.json_response(HTTPStatus.BAD_REQUEST, {"error": str(exc)})
|
|
else:
|
|
self.redirect("/?error=" + urllib.parse.quote(str(exc)))
|
|
except sqlite3.IntegrityError:
|
|
if path.startswith("/api/"):
|
|
self.json_response(HTTPStatus.CONFLICT, {"error": "用户名已存在"})
|
|
else:
|
|
self.redirect("/?error=" + urllib.parse.quote("用户名已存在"))
|
|
|
|
@staticmethod
|
|
def public_user(user: sqlite3.Row) -> dict[str, Any]:
|
|
return {
|
|
"id": user["id"],
|
|
"username": user["username"],
|
|
"role": user["role"],
|
|
"role_name": ROLE_LABELS[user["role"]],
|
|
}
|
|
|
|
def render_login(self, error: str = "") -> None:
|
|
message = (
|
|
f"<div class='flash error'>{html.escape(error)}</div>" if error else ""
|
|
)
|
|
body = f"""
|
|
<div class='loginwrap'><section class='login'>
|
|
<div class='brand' style='color:var(--accent)'>ZHEN AI ADMIN</div>
|
|
<h1>配置管理后台</h1><div class='muted'>统一管理桌面端账号、角色和 AI 模型参数。</div>
|
|
{message}
|
|
<form method='post' action='/login'>
|
|
<div><label>用户名</label><input name='username' autocomplete='username' required autofocus></div>
|
|
<div><label>密码</label><input name='password' type='password' autocomplete='current-password' required></div>
|
|
<button type='submit'>登录后台</button>
|
|
</form>
|
|
</section></div>"""
|
|
self.html_response(HTTPStatus.OK, page("登录 · 配置管理后台", body))
|
|
|
|
def web_login(self) -> None:
|
|
form = self.form_body()
|
|
username = form.get("username", "").strip()
|
|
key = f"{self.client_ip}:{username.lower()}"
|
|
if not LOGIN_LIMITER.allowed(key):
|
|
self.render_login("登录失败次数过多,请 5 分钟后再试")
|
|
return
|
|
user = self.db.authenticate(username, form.get("password", ""))
|
|
if not user:
|
|
LOGIN_LIMITER.failure(key)
|
|
self.db.audit(None, "login.failed", f"username={username}", self.client_ip)
|
|
self.render_login("用户名或密码不正确")
|
|
return
|
|
LOGIN_LIMITER.success(key)
|
|
token, _ = self.db.create_token(user["id"], "web", "browser", 12 * 3600)
|
|
self.db.audit(user["id"], "login.web", "", self.client_ip)
|
|
cookie = f"session_token={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=43200"
|
|
self.redirect("/", cookie=cookie)
|
|
|
|
def web_logout(self) -> None:
|
|
user, token = self.auth(False)
|
|
if user:
|
|
try:
|
|
form = self.form_body()
|
|
except ValueError:
|
|
form = {}
|
|
if self.csrf_ok(user, form):
|
|
self.db.revoke(token)
|
|
self.redirect(
|
|
"/login", cookie="session_token=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"
|
|
)
|
|
|
|
def api_login(self) -> None:
|
|
data = self.json_body()
|
|
username = str(data.get("username") or "").strip()
|
|
key = f"{self.client_ip}:{username.lower()}"
|
|
if not LOGIN_LIMITER.allowed(key):
|
|
self.json_response(
|
|
HTTPStatus.TOO_MANY_REQUESTS, {"error": "登录失败次数过多,请稍后再试"}
|
|
)
|
|
return
|
|
user = self.db.authenticate(username, str(data.get("password") or ""))
|
|
if not user:
|
|
LOGIN_LIMITER.failure(key)
|
|
self.db.audit(None, "login.failed", f"username={username}", self.client_ip)
|
|
self.json_response(HTTPStatus.UNAUTHORIZED, {"error": "用户名或密码不正确"})
|
|
return
|
|
if user["must_change_password"]:
|
|
self.json_response(
|
|
HTTPStatus.FORBIDDEN, {"error": "首次登录请先在后台网页修改密码"}
|
|
)
|
|
return
|
|
LOGIN_LIMITER.success(key)
|
|
token, _ = self.db.create_token(
|
|
user["id"], "api", str(data.get("device_name") or "desktop"), 30 * 86400
|
|
)
|
|
self.db.audit(user["id"], "login.api", str(data.get("device_name") or ""), self.client_ip)
|
|
self.json_response(
|
|
HTTPStatus.OK,
|
|
{"access_token": token, "expires_in": 30 * 86400, "user": self.public_user(user)},
|
|
)
|
|
|
|
def api_logout(self) -> None:
|
|
user, token = self.auth(True)
|
|
if user:
|
|
self.db.revoke(token)
|
|
self.db.audit(user["id"], "logout.api", "", self.client_ip)
|
|
self.json_response(HTTPStatus.OK, {"ok": True})
|
|
|
|
def web_save_config(self) -> None:
|
|
auth = self.require_web_auth(roles=("admin", "operator"))
|
|
if not auth:
|
|
return
|
|
user, _ = auth
|
|
form = self.form_body()
|
|
if not self.csrf_ok(user, form):
|
|
raise ValueError("页面已过期,请刷新后重试")
|
|
current = json.loads(self.db.config()["config_json"])
|
|
config = validate_config_form(form, current)
|
|
version = self.db.save_config(config, user["id"], self.client_ip)
|
|
self.redirect("/?message=" + urllib.parse.quote(f"配置已发布为 v{version}"))
|
|
|
|
def web_create_user(self) -> None:
|
|
auth = self.require_web_auth(roles=("admin",))
|
|
if not auth:
|
|
return
|
|
user, _ = auth
|
|
form = self.form_body()
|
|
if not self.csrf_ok(user, form):
|
|
raise ValueError("页面已过期,请刷新后重试")
|
|
username = form.get("username", "").strip()
|
|
password = form.get("password", "")
|
|
role = form.get("role", "viewer")
|
|
if not (3 <= len(username) <= 40) or not all(
|
|
char.isalnum() or char in "_.-" for char in username
|
|
):
|
|
raise ValueError("用户名需为 3-40 位字母、数字、点、下划线或短横线")
|
|
if role not in ROLES:
|
|
raise ValueError("角色无效")
|
|
if not self.valid_password(password):
|
|
raise ValueError("初始密码至少 10 位,并同时包含字母和数字")
|
|
self.db.create_user(username, password, role, user["id"], self.client_ip)
|
|
self.redirect("/?message=" + urllib.parse.quote(f"用户 {username} 已创建"))
|
|
|
|
def web_update_user(self) -> None:
|
|
auth = self.require_web_auth(roles=("admin",))
|
|
if not auth:
|
|
return
|
|
user, _ = auth
|
|
form = self.form_body()
|
|
if not self.csrf_ok(user, form):
|
|
raise ValueError("页面已过期,请刷新后重试")
|
|
try:
|
|
user_id = int(form.get("user_id", "0"))
|
|
except ValueError as exc:
|
|
raise ValueError("用户编号无效") from exc
|
|
role = form.get("role", "viewer")
|
|
password = form.get("new_password", "")
|
|
if role not in ROLES:
|
|
raise ValueError("角色无效")
|
|
if password and not self.valid_password(password):
|
|
raise ValueError("重置密码至少 10 位,并同时包含字母和数字")
|
|
self.db.update_user(
|
|
user_id,
|
|
role,
|
|
form.get("active") == "1",
|
|
password,
|
|
user["id"],
|
|
self.client_ip,
|
|
)
|
|
self.redirect("/?message=" + urllib.parse.quote("用户资料已更新"))
|
|
|
|
def web_change_password(self) -> None:
|
|
auth = self.require_web_auth(allow_password_change=True)
|
|
if not auth:
|
|
return
|
|
user, _ = auth
|
|
form = self.form_body()
|
|
if not self.csrf_ok(user, form):
|
|
raise ValueError("页面已过期,请刷新后重试")
|
|
new_password = form.get("new_password", "")
|
|
if new_password != form.get("confirm_password", ""):
|
|
raise ValueError("两次输入的新密码不一致")
|
|
if not self.valid_password(new_password):
|
|
raise ValueError("新密码至少 10 位,并同时包含字母和数字")
|
|
self.db.change_password(
|
|
user["id"], form.get("current_password", ""), new_password, self.client_ip
|
|
)
|
|
self.redirect("/?message=" + urllib.parse.quote("密码已修改"))
|
|
|
|
def render_dashboard(self, user: sqlite3.Row) -> None:
|
|
query = urllib.parse.parse_qs(urllib.parse.urlparse(self.path).query)
|
|
message = query.get("message", [""])[-1]
|
|
error = query.get("error", [""])[-1]
|
|
flash = ""
|
|
if message:
|
|
flash = f"<div class='flash'>{html.escape(message)}</div>"
|
|
elif error:
|
|
flash = f"<div class='flash error'>{html.escape(error)}</div>"
|
|
csrf = html.escape(user["csrf_token"], quote=True)
|
|
password_card = self.password_card(user, csrf)
|
|
if user["must_change_password"]:
|
|
content = f"""
|
|
<div class='shell'>{self.sidebar(user, csrf)}<main>
|
|
<div class='top'><div><h1>首次登录</h1><div class='muted'>修改初始密码后才能使用配置和同步功能。</div></div></div>
|
|
{flash}<div style='max-width:650px'>{password_card}</div>
|
|
</main></div>"""
|
|
self.html_response(HTTPStatus.OK, page("首次登录 · 配置管理后台", content))
|
|
return
|
|
row = self.db.config()
|
|
config = json.loads(row["config_json"])
|
|
config_card = self.config_card(user, csrf, row, config)
|
|
users_card = self.users_card(user, csrf)
|
|
audit_card = self.audit_card()
|
|
content = f"""
|
|
<div class='shell'>{self.sidebar(user, csrf)}<main>
|
|
<div class='top'><div><h1>模型配置中心</h1><div class='muted'>保存后,已登录桌面端会在启动或定时同步时自动应用。</div></div>
|
|
<div class='version'>CURRENT · v{int(row['version'])}</div></div>
|
|
{flash}<div class='grid'><section>{config_card}</section><section>{users_card}{password_card}{audit_card}</section></div>
|
|
</main></div>"""
|
|
self.html_response(HTTPStatus.OK, page("模型配置中心", content))
|
|
|
|
@staticmethod
|
|
def sidebar(user: sqlite3.Row, csrf: str) -> str:
|
|
return f"""
|
|
<aside><div class='brand'>ZHEN AI ADMIN<small>企微客服统一配置中心</small></div>
|
|
<div class='userbox'><b>{html.escape(user['username'])}</b><br><span class='role'>{ROLE_LABELS[user['role']]}</span></div>
|
|
<nav><a href='/'>模型配置</a><a href='#users'>用户与角色</a><a href='#security'>账号安全</a></nav>
|
|
<form method='post' action='/logout' style='position:absolute;bottom:25px;left:24px;right:24px'>
|
|
<input type='hidden' name='csrf' value='{csrf}'><button class='secondary' style='width:100%'>退出登录</button></form></aside>"""
|
|
|
|
@staticmethod
|
|
def password_card(user: sqlite3.Row, csrf: str) -> str:
|
|
notice = (
|
|
"<div class='notice'>当前仍在使用初始密码,必须修改后才能让桌面端登录。</div>"
|
|
if user["must_change_password"]
|
|
else ""
|
|
)
|
|
return f"""
|
|
<section class='card' id='security'><div class='cardhead'><div><h2>修改密码</h2><div class='muted'>密码至少 10 位,需包含字母和数字。</div></div></div>{notice}
|
|
<form method='post' action='/admin/password'><input type='hidden' name='csrf' value='{csrf}'>
|
|
<div class='formgrid'><div class='full'><label>当前密码</label><input type='password' name='current_password' required></div>
|
|
<div><label>新密码</label><input type='password' name='new_password' required></div>
|
|
<div><label>确认新密码</label><input type='password' name='confirm_password' required></div></div>
|
|
<div class='actions'><button type='submit'>更新密码</button></div></form></section>"""
|
|
|
|
@staticmethod
|
|
def config_card(
|
|
user: sqlite3.Row, csrf: str, row: sqlite3.Row, config: dict[str, Any]
|
|
) -> str:
|
|
can_edit = user["role"] in ("admin", "operator")
|
|
esc = lambda key: html.escape(str(config.get(key, "")), quote=True)
|
|
checked = lambda key: " checked" if config.get(key) else ""
|
|
disabled = " disabled" if not can_edit else ""
|
|
mcp = html.escape(
|
|
json.dumps(config.get("AI_MCP_SERVERS", []), ensure_ascii=False, indent=2)
|
|
)
|
|
submit = (
|
|
"<div class='actions'><button type='submit'>保存并发布配置</button></div>"
|
|
if can_edit
|
|
else "<div class='notice'>当前为只读角色,可查看配置但不能修改。</div>"
|
|
)
|
|
return f"""
|
|
<section class='card'><div class='cardhead'><div><h2>能力开关</h2><div class='muted'>控制桌面端自动回复、上下文与工具能力。</div></div><span class='version'>v{row['version']}</span></div>
|
|
<form method='post' action='/admin/config'><input type='hidden' name='csrf' value='{csrf}'>
|
|
<div class='switches'>
|
|
<label class='check'><input type='checkbox' name='AI_ENABLED' value='1'{checked('AI_ENABLED')}{disabled}>启用 AI 回复</label>
|
|
<label class='check'><input type='checkbox' name='AI_CONTEXT_ENABLED' value='1'{checked('AI_CONTEXT_ENABLED')}{disabled}>启用会话上下文</label>
|
|
<label class='check'><input type='checkbox' name='AI_USE_VISION' value='1'{checked('AI_USE_VISION')}{disabled}>启用视觉模式</label>
|
|
<label class='check'><input type='checkbox' name='AI_COUNTER_INSULT_ENABLED' value='1'{checked('AI_COUNTER_INSULT_ENABLED')}{disabled}>启用反辱骂策略</label>
|
|
<label class='check'><input type='checkbox' name='AI_MCP_ENABLED' value='1'{checked('AI_MCP_ENABLED')}{disabled}>启用 MCP 工具</label>
|
|
</div><div style='height:24px'></div>
|
|
<div class='cardhead'><div><h2>模型与身份</h2><div class='muted'>API Key 留空表示保持当前值;页面永不回显密钥。</div></div></div>
|
|
<div class='formgrid'>
|
|
<div><label>API 地址</label><input name='AI_API_BASE' value='{esc('AI_API_BASE')}' required{disabled}></div>
|
|
<div><label>模型名称</label><input name='AI_MODEL' value='{esc('AI_MODEL')}'{disabled}></div>
|
|
<div><label>API Key</label><input type='password' name='AI_API_KEY' placeholder='已保存;留空不修改'{disabled}></div>
|
|
<div><label>客服名称</label><input name='AI_AGENT_NAME' value='{esc('AI_AGENT_NAME')}' required{disabled}></div>
|
|
<div class='full'><label>机构名称</label><input name='AI_HOSPITAL_NAME' value='{esc('AI_HOSPITAL_NAME')}' required{disabled}></div>
|
|
<div><label>上下文轮数</label><input type='number' min='1' max='50' name='AI_CONTEXT_MAX_ROUNDS' value='{esc('AI_CONTEXT_MAX_ROUNDS')}'{disabled}></div>
|
|
<div><label>最大回复 tokens</label><input type='number' min='50' max='32000' name='AI_MAX_TOKENS' value='{esc('AI_MAX_TOKENS')}'{disabled}></div>
|
|
<div><label>温度</label><input type='number' min='0' max='2' step='.05' name='AI_TEMPERATURE' value='{esc('AI_TEMPERATURE')}'{disabled}></div>
|
|
<div><label>请求超时(秒)</label><input type='number' min='5' max='600' name='AI_TIMEOUT' value='{esc('AI_TIMEOUT')}'{disabled}></div>
|
|
</div><div style='height:24px'></div>
|
|
<div class='cardhead'><div><h2>MCP 服务器</h2><div class='muted'>填写 JSON 数组,与模型配置一同下发。</div></div></div>
|
|
<textarea name='AI_MCP_SERVERS'{disabled}>{mcp}</textarea>
|
|
<div style='max-width:240px;margin-top:12px'><label>单次最多工具轮数</label><input type='number' min='1' max='20' name='AI_MCP_MAX_ROUNDS' value='{esc('AI_MCP_MAX_ROUNDS')}'{disabled}></div>
|
|
{submit}</form><div class='tiny'>最后更新:{html.escape(row['updated_at'])} · {html.escape(row['updated_by_name'] or 'system')}</div></section>"""
|
|
|
|
def users_card(self, user: sqlite3.Row, csrf: str) -> str:
|
|
if user["role"] != "admin":
|
|
return f"""<section class='card' id='users'><h2>用户与角色</h2><div class='muted' style='margin-top:8px'>仅管理员可以管理登录账号。</div></section>"""
|
|
items = []
|
|
role_options = lambda current: "".join(
|
|
f"<option value='{role}'{' selected' if role == current else ''}>{ROLE_LABELS[role]}</option>"
|
|
for role in ROLES
|
|
)
|
|
for item in self.db.list_users():
|
|
active = " checked" if item["active"] else ""
|
|
items.append(
|
|
f"""<form class='user' method='post' action='/admin/users/update'>
|
|
<input type='hidden' name='csrf' value='{csrf}'><input type='hidden' name='user_id' value='{item['id']}'>
|
|
<div><div class='username'>{html.escape(item['username'])}</div><div class='tiny'>创建于 {html.escape(item['created_at'][:10])}</div></div>
|
|
<div><label>角色</label><select name='role'>{role_options(item['role'])}</select></div>
|
|
<div><label>状态</label><label class='check'><input type='checkbox' name='active' value='1'{active}>启用</label></div>
|
|
<div><label>重置密码(可留空)</label><input type='password' name='new_password' placeholder='至少 10 位'></div>
|
|
<div class='actions'><button class='secondary'>保存</button></div></form>"""
|
|
)
|
|
return f"""
|
|
<section class='card' id='users'><div class='cardhead'><div><h2>用户与角色</h2><div class='muted'>管理员管理全部功能,配置员可发布配置,只读用户仅同步。</div></div></div>
|
|
{''.join(items)}<div style='height:20px'></div><h2>新建用户</h2>
|
|
<form method='post' action='/admin/users/create'><input type='hidden' name='csrf' value='{csrf}'>
|
|
<div class='formgrid'><div><label>用户名</label><input name='username' required></div><div><label>角色</label><select name='role'>{role_options('viewer')}</select></div>
|
|
<div class='full'><label>初始密码</label><input type='password' name='password' placeholder='至少 10 位,包含字母和数字' required></div></div>
|
|
<div class='actions'><button type='submit'>创建用户</button></div></form></section>"""
|
|
|
|
def audit_card(self) -> str:
|
|
events = []
|
|
for item in self.db.recent_audit():
|
|
events.append(
|
|
f"<div class='event'><b>{html.escape(item['action'])}</b> · {html.escape(item['username'] or 'anonymous')}"
|
|
f"<div>{html.escape(item['created_at'])} · {html.escape(item['detail'])}</div></div>"
|
|
)
|
|
return f"""<section class='card'><div class='cardhead'><div><h2>最近操作</h2><div class='muted'>登录、配置和用户变更审计。</div></div></div><div class='audit'>{''.join(events) or '<div class="muted">暂无记录</div>'}</div></section>"""
|
|
|
|
|
|
def validate_config_form(form: dict[str, str], current: dict[str, Any]) -> dict[str, Any]:
|
|
config = {key: current.get(key) for key in CONFIG_KEYS}
|
|
for key in BOOL_KEYS:
|
|
config[key] = form.get(key) == "1"
|
|
for key in ("AI_API_BASE", "AI_MODEL", "AI_AGENT_NAME", "AI_HOSPITAL_NAME"):
|
|
config[key] = form.get(key, "").strip()
|
|
api_key = form.get("AI_API_KEY", "").strip()
|
|
if api_key:
|
|
config["AI_API_KEY"] = api_key
|
|
if not config["AI_API_BASE"]:
|
|
raise ValueError("API 地址不能为空")
|
|
if not config["AI_AGENT_NAME"] or not config["AI_HOSPITAL_NAME"]:
|
|
raise ValueError("客服名称和机构名称不能为空")
|
|
limits = {
|
|
"AI_CONTEXT_MAX_ROUNDS": (1, 50),
|
|
"AI_MAX_TOKENS": (50, 32000),
|
|
"AI_TIMEOUT": (5, 600),
|
|
"AI_MCP_MAX_ROUNDS": (1, 20),
|
|
}
|
|
for key, (minimum, maximum) in limits.items():
|
|
try:
|
|
value = int(form.get(key, ""))
|
|
except ValueError as exc:
|
|
raise ValueError(f"{key} 必须是整数") from exc
|
|
if not minimum <= value <= maximum:
|
|
raise ValueError(f"{key} 必须在 {minimum}-{maximum} 之间")
|
|
config[key] = value
|
|
try:
|
|
temperature = float(form.get("AI_TEMPERATURE", ""))
|
|
except ValueError as exc:
|
|
raise ValueError("温度必须是数字") from exc
|
|
if not 0 <= temperature <= 2:
|
|
raise ValueError("温度必须在 0-2 之间")
|
|
config["AI_TEMPERATURE"] = temperature
|
|
try:
|
|
servers = json.loads(form.get("AI_MCP_SERVERS", "[]") or "[]")
|
|
except ValueError as exc:
|
|
raise ValueError("MCP 服务器不是有效 JSON") from exc
|
|
if not isinstance(servers, list):
|
|
raise ValueError("MCP 服务器 JSON 根节点必须是数组")
|
|
config["AI_MCP_SERVERS"] = servers
|
|
return config
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
parser = argparse.ArgumentParser(description="企微客服助手配置管理后台")
|
|
parser.add_argument("--host", default=DEFAULT_HOST, help="监听地址,默认 127.0.0.1")
|
|
parser.add_argument(
|
|
"--port",
|
|
type=int,
|
|
default=DEFAULT_PORT,
|
|
help="首选监听端口,默认 8765;占用时自动递增",
|
|
)
|
|
parser.add_argument(
|
|
"--port-attempts",
|
|
type=int,
|
|
default=DEFAULT_PORT_ATTEMPTS,
|
|
help="端口被占用时最多尝试的端口数量,默认 100",
|
|
)
|
|
parser.add_argument("--db", type=Path, default=DEFAULT_DB, help="SQLite 数据库路径")
|
|
parser.add_argument(
|
|
"--runtime-file",
|
|
type=Path,
|
|
default=DEFAULT_RUNTIME_FILE,
|
|
help="实际端口发现文件路径",
|
|
)
|
|
parser.add_argument(
|
|
"--initial-admin-password",
|
|
default=os.environ.get("WECOM_ADMIN_INITIAL_PASSWORD", DEFAULT_ADMIN_PASSWORD),
|
|
help="首次创建数据库时的 admin 密码",
|
|
)
|
|
parser.add_argument(
|
|
"--reset-admin-password",
|
|
action="store_true",
|
|
help="交互式重置 admin 密码后退出",
|
|
)
|
|
return parser
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> None:
|
|
args = build_parser().parse_args(argv)
|
|
database = Database(args.db.resolve())
|
|
created = database.initialize(args.initial_admin_password)
|
|
if args.reset_admin_password:
|
|
first = getpass.getpass("新的 admin 密码:")
|
|
second = getpass.getpass("再次输入:")
|
|
if first != second:
|
|
raise SystemExit("两次输入不一致")
|
|
if not AdminHandler.valid_password(first):
|
|
raise SystemExit("密码至少 10 位,并同时包含字母和数字")
|
|
database.reset_admin_password(first)
|
|
print("admin 密码已重置,下次登录时必须再次修改。")
|
|
return
|
|
server, actual_port = create_server(
|
|
args.host,
|
|
args.port,
|
|
database,
|
|
max_attempts=args.port_attempts,
|
|
)
|
|
runtime_info = write_runtime_info(
|
|
args.runtime_file,
|
|
args.host,
|
|
actual_port,
|
|
local_sync_token=server.local_sync_token,
|
|
)
|
|
address = runtime_info["server_url"]
|
|
if actual_port != args.port:
|
|
print(f"端口 {args.port} 已被占用,已自动切换到 {actual_port}")
|
|
print(f"配置后台已启动:{address}")
|
|
if created:
|
|
print("首次登录账号:admin")
|
|
print(f"首次登录密码:{args.initial_admin_password}")
|
|
print("登录后必须立即修改初始密码。")
|
|
if args.host not in ("127.0.0.1", "localhost", "::1"):
|
|
print("警告:当前监听非本机地址;生产环境请通过 HTTPS 反向代理访问。")
|
|
try:
|
|
server.serve_forever(poll_interval=0.5)
|
|
except KeyboardInterrupt:
|
|
print("\n正在停止后台…")
|
|
finally:
|
|
server.server_close()
|
|
clear_runtime_info(args.runtime_file, actual_port)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|