# -*- 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 (
"
"
" "
f"{html.escape(title)} {body}"
)
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"{html.escape(error)}
" if error else ""
)
body = f"""
ZHEN AI ADMIN
配置管理后台 统一管理桌面端账号、角色和 AI 模型参数。
{message}
"""
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"{html.escape(message)}
"
elif error:
flash = f"{html.escape(error)}
"
csrf = html.escape(user["csrf_token"], quote=True)
password_card = self.password_card(user, csrf)
if user["must_change_password"]:
content = f"""
{self.sidebar(user, csrf)}
{flash}{password_card}
"""
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"""
{self.sidebar(user, csrf)}
模型配置中心 保存后,已登录桌面端会在启动或定时同步时自动应用。
CURRENT · v{int(row['version'])}
{flash}{users_card}{password_card}{audit_card}
"""
self.html_response(HTTPStatus.OK, page("模型配置中心", content))
@staticmethod
def sidebar(user: sqlite3.Row, csrf: str) -> str:
return f"""
"""
@staticmethod
def password_card(user: sqlite3.Row, csrf: str) -> str:
notice = (
"当前仍在使用初始密码,必须修改后才能让桌面端登录。
"
if user["must_change_password"]
else ""
)
return f"""
"""
@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 = (
"保存并发布配置
"
if can_edit
else "当前为只读角色,可查看配置但不能修改。
"
)
return f"""
最后更新:{html.escape(row['updated_at'])} · {html.escape(row['updated_by_name'] or 'system')}
"""
def users_card(self, user: sqlite3.Row, csrf: str) -> str:
if user["role"] != "admin":
return f""""""
items = []
role_options = lambda current: "".join(
f"{ROLE_LABELS[role]} "
for role in ROLES
)
for item in self.db.list_users():
active = " checked" if item["active"] else ""
items.append(
f"""
{html.escape(item['username'])}
创建于 {html.escape(item['created_at'][:10])}
角色 {role_options(item['role'])}
状态 启用
重置密码(可留空)
保存
"""
)
return f"""
用户与角色 管理员管理全部功能,配置员可发布配置,只读用户仅同步。
{''.join(items)}
新建用户
创建用户
"""
def audit_card(self) -> str:
events = []
for item in self.db.recent_audit():
events.append(
f"{html.escape(item['action'])} · {html.escape(item['username'] or 'anonymous')}"
f"
{html.escape(item['created_at'])} · {html.escape(item['detail'])}
"
)
return f"""{''.join(events) or '
暂无记录
'}
"""
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()