1783 lines
78 KiB
Python
1783 lines
78 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 http.client
|
||
import ipaddress
|
||
import json
|
||
import os
|
||
import re
|
||
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"
|
||
DEFAULT_APP_VERSION = "1.0.0"
|
||
DEFAULT_DESKTOP_SYNC_KEY = "wcrpa-v1-H3q9mT7xK2pN8cR5vL4sF6dB1yG0uJ"
|
||
DESKTOP_SYNC_KEY = os.environ.get(
|
||
"WECOM_DESKTOP_SYNC_KEY", DEFAULT_DESKTOP_SYNC_KEY
|
||
).strip()
|
||
ROLES = ("admin", "operator", "viewer")
|
||
ROLE_LABELS = {"admin": "管理员", "operator": "配置员", "viewer": "只读用户"}
|
||
APP_VERSION_PATTERN = re.compile(
|
||
r"^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)"
|
||
r"(?:[-+][0-9A-Za-z.-]+)?$"
|
||
)
|
||
PBKDF2_ITERATIONS = 310_000
|
||
CONFIG_KEYS = (
|
||
"AI_ENABLED",
|
||
"AI_PROVIDER_TYPE",
|
||
"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",
|
||
}
|
||
PROVIDER_TYPES = {
|
||
"openai": "OpenAI 兼容(GPT / DeepSeek / vLLM / SGLang 等)",
|
||
"dify": "Dify 应用(chat-messages 接口)",
|
||
"comfyui": "ComfyUI 文生图",
|
||
}
|
||
|
||
|
||
def pbkdf2_sha256(
|
||
password: bytes, salt: bytes, iterations: int = PBKDF2_ITERATIONS
|
||
) -> bytes:
|
||
"""PBKDF2-HMAC-SHA256,兼容未编译 OpenSSL PBKDF2 的 Python。"""
|
||
native_pbkdf2 = getattr(hashlib, "pbkdf2_hmac", None)
|
||
if callable(native_pbkdf2):
|
||
return native_pbkdf2("sha256", password, salt, iterations)
|
||
|
||
# SHA-256 的输出正好是本项目需要的 32 字节,因此只需计算一个块。
|
||
current = hmac.new(password, salt + b"\x00\x00\x00\x01", hashlib.sha256).digest()
|
||
derived = int.from_bytes(current, "big")
|
||
for _ in range(1, iterations):
|
||
current = hmac.new(password, current, hashlib.sha256).digest()
|
||
derived ^= int.from_bytes(current, "big")
|
||
return derived.to_bytes(32, "big")
|
||
|
||
|
||
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 = pbkdf2_sha256(password.encode("utf-8"), salt)
|
||
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 = pbkdf2_sha256(password.encode("utf-8"), salt)
|
||
return hmac.compare_digest(actual, expected)
|
||
|
||
|
||
def token_hash(token: str) -> str:
|
||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||
|
||
|
||
def provider_type(config: dict[str, Any]) -> str:
|
||
value = str(config.get("AI_PROVIDER_TYPE") or "").strip().lower()
|
||
if value in PROVIDER_TYPES:
|
||
return value
|
||
base = str(config.get("AI_API_BASE") or "").lower()
|
||
if "chat-messages" in base or "completion-messages" in base:
|
||
return "dify"
|
||
if "system_stats" in base or "comfyui" in base:
|
||
return "comfyui"
|
||
try:
|
||
if urllib.parse.urlparse(base).port == 8188:
|
||
return "comfyui"
|
||
except ValueError:
|
||
pass
|
||
return "openai"
|
||
|
||
|
||
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_PROVIDER_TYPE": "openai",
|
||
"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})
|
||
defaults["AI_PROVIDER_TYPE"] = provider_type(defaults)
|
||
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 app_release (
|
||
id INTEGER PRIMARY KEY CHECK(id = 1),
|
||
latest_version TEXT NOT NULL,
|
||
download_url TEXT NOT NULL DEFAULT '',
|
||
release_notes TEXT NOT NULL DEFAULT '',
|
||
force_upgrade INTEGER NOT NULL DEFAULT 0,
|
||
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,
|
||
),
|
||
)
|
||
if db.execute("SELECT COUNT(*) FROM app_release").fetchone()[0] == 0:
|
||
admin = db.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
|
||
db.execute(
|
||
"""INSERT INTO app_release
|
||
(id,latest_version,download_url,release_notes,force_upgrade,updated_at,updated_by)
|
||
VALUES (1,?,?,?,0,?,?)""",
|
||
(
|
||
DEFAULT_APP_VERSION,
|
||
"",
|
||
"",
|
||
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
|
||
|
||
def release(self) -> sqlite3.Row:
|
||
with self.connect() as db:
|
||
return db.execute(
|
||
"""SELECT r.*, u.username AS updated_by_name
|
||
FROM app_release r LEFT JOIN users u ON u.id=r.updated_by WHERE r.id=1"""
|
||
).fetchone()
|
||
|
||
def save_release(
|
||
self,
|
||
latest_version: str,
|
||
download_url: str,
|
||
release_notes: str,
|
||
force_upgrade: bool,
|
||
user_id: int,
|
||
ip: str,
|
||
) -> None:
|
||
with self.connect() as db:
|
||
db.execute(
|
||
"""UPDATE app_release SET latest_version=?,download_url=?,release_notes=?,
|
||
force_upgrade=?,updated_at=?,updated_by=? WHERE id=1""",
|
||
(
|
||
latest_version,
|
||
download_url,
|
||
release_notes,
|
||
int(force_upgrade),
|
||
now_text(),
|
||
user_id,
|
||
),
|
||
)
|
||
self._audit(
|
||
db,
|
||
user_id,
|
||
"release.update",
|
||
f"version={latest_version}, force={int(force_upgrade)}",
|
||
ip,
|
||
)
|
||
db.commit()
|
||
|
||
@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()
|
||
|
||
|
||
def _model_endpoint(api_base: str, provider: str) -> str:
|
||
base = str(api_base or "").strip().rstrip("/")
|
||
parsed = urllib.parse.urlparse(base)
|
||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||
raise ValueError("API 地址必须是完整的 http 或 https 地址")
|
||
if parsed.query or parsed.fragment:
|
||
raise ValueError("API 地址不能包含查询参数或片段")
|
||
try:
|
||
parsed.port
|
||
except ValueError as exc:
|
||
raise ValueError("API 地址中的端口无效") from exc
|
||
path = (parsed.path or "").rstrip("/")
|
||
lower_path = path.lower()
|
||
if provider == "dify":
|
||
if lower_path.endswith(("/chat-messages", "/completion-messages")):
|
||
return base
|
||
if lower_path.endswith("/v1"):
|
||
return f"{base}/chat-messages"
|
||
return f"{base}/v1/chat-messages"
|
||
if provider == "comfyui":
|
||
if lower_path.endswith("/system_stats"):
|
||
return base
|
||
return f"{base}/system_stats"
|
||
if lower_path.endswith("/chat/completions"):
|
||
return base
|
||
if re.match(r"^/v1/.+", path):
|
||
return base
|
||
return f"{base}/chat/completions"
|
||
|
||
|
||
def model_test_config(values: dict[str, Any], current: dict[str, Any]) -> dict[str, Any]:
|
||
"""合并页面临时值与已保存密钥,不写入数据库。"""
|
||
api_base_value = (
|
||
values.get("AI_API_BASE") if "AI_API_BASE" in values else current.get("AI_API_BASE")
|
||
)
|
||
model_value = values.get("AI_MODEL") if "AI_MODEL" in values else current.get("AI_MODEL")
|
||
api_base = str(api_base_value or "").strip()
|
||
model = str(model_value or "").strip()
|
||
merged_provider = {
|
||
"AI_PROVIDER_TYPE": values.get(
|
||
"AI_PROVIDER_TYPE", current.get("AI_PROVIDER_TYPE")
|
||
),
|
||
"AI_API_BASE": api_base,
|
||
}
|
||
provider = provider_type(merged_provider)
|
||
if "AI_PROVIDER_TYPE" in values:
|
||
requested_provider = str(values.get("AI_PROVIDER_TYPE") or "").strip().lower()
|
||
if requested_provider not in PROVIDER_TYPES:
|
||
raise ValueError("服务类型无效")
|
||
provider = requested_provider
|
||
supplied_key = str(values.get("AI_API_KEY") or "").strip()
|
||
api_key = supplied_key or str(current.get("AI_API_KEY") or "").strip()
|
||
raw_timeout = values.get("AI_TIMEOUT", current.get("AI_TIMEOUT", 30))
|
||
try:
|
||
timeout = int(raw_timeout)
|
||
except (TypeError, ValueError) as exc:
|
||
raise ValueError("请求超时必须是整数") from exc
|
||
timeout = min(60, max(5, timeout))
|
||
endpoint = _model_endpoint(api_base, provider)
|
||
if provider == "dify" and not api_key:
|
||
raise ValueError("Dify 连通性测试需要 API Key")
|
||
if provider == "openai" and not model:
|
||
raise ValueError("OpenAI 兼容接口的模型名称不能为空")
|
||
return {
|
||
"api_base": api_base,
|
||
"api_key": api_key,
|
||
"model": model,
|
||
"timeout": timeout,
|
||
"endpoint": endpoint,
|
||
"provider_type": provider,
|
||
}
|
||
|
||
|
||
def _safe_endpoint_label(endpoint: str) -> str:
|
||
parsed = urllib.parse.urlparse(endpoint)
|
||
host = parsed.hostname or ""
|
||
if parsed.port:
|
||
host = f"{host}:{parsed.port}"
|
||
return urllib.parse.urlunparse((parsed.scheme, host, parsed.path, "", "", ""))
|
||
|
||
|
||
def _remote_error_detail(raw: bytes, api_key: str) -> str:
|
||
text = raw.decode("utf-8", errors="replace")[:1000].strip()
|
||
try:
|
||
data = json.loads(text)
|
||
error = data.get("error") if isinstance(data, dict) else None
|
||
if isinstance(error, dict):
|
||
text = str(error.get("message") or error.get("code") or text)
|
||
elif isinstance(data, dict):
|
||
text = str(data.get("message") or data.get("detail") or text)
|
||
except (TypeError, ValueError):
|
||
pass
|
||
if api_key:
|
||
text = text.replace(api_key, "[已隐藏]")
|
||
return " ".join(text.split())[:300]
|
||
|
||
|
||
def _model_answer(data: dict[str, Any], provider: str) -> str:
|
||
if provider == "dify":
|
||
answer = data.get("answer")
|
||
if answer is None and isinstance(data.get("data"), dict):
|
||
answer = data["data"].get("answer")
|
||
return str(answer or "").strip()
|
||
if provider == "comfyui":
|
||
return "ComfyUI system_stats 可用" if data else ""
|
||
choices = data.get("choices")
|
||
if not isinstance(choices, list) or not choices:
|
||
return ""
|
||
message = choices[0].get("message") if isinstance(choices[0], dict) else None
|
||
content = message.get("content") if isinstance(message, dict) else ""
|
||
if isinstance(content, list):
|
||
content = " ".join(
|
||
str(item.get("text") or "") for item in content if isinstance(item, dict)
|
||
)
|
||
return str(content or "").strip()
|
||
|
||
|
||
def _perform_http_request(
|
||
endpoint: str,
|
||
*,
|
||
method: str,
|
||
headers: dict[str, str],
|
||
payload: dict[str, Any] | None,
|
||
timeout: int,
|
||
) -> tuple[int, bytes]:
|
||
"""直接使用 http.client,避免部分精简 Python 缺少 urllib HTTPSHandler。"""
|
||
parsed = urllib.parse.urlparse(endpoint)
|
||
host = parsed.hostname
|
||
if not host:
|
||
raise ValueError("API 地址缺少主机名")
|
||
path = urllib.parse.urlunparse(("", "", parsed.path or "/", "", parsed.query, ""))
|
||
if parsed.scheme == "https":
|
||
connection_class = getattr(http.client, "HTTPSConnection", None)
|
||
if connection_class is None:
|
||
raise OSError("当前后端 Python 环境缺少 HTTPS/SSL 支持")
|
||
elif parsed.scheme == "http":
|
||
connection_class = http.client.HTTPConnection
|
||
else:
|
||
raise ValueError(f"不支持的 URL 协议:{parsed.scheme or '空'}")
|
||
connection = connection_class(host, parsed.port, timeout=timeout)
|
||
body = None if payload is None else json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||
try:
|
||
connection.request(method, path, body=body, headers=headers)
|
||
response = connection.getresponse()
|
||
raw = response.read(1_000_001)
|
||
return int(response.status), raw
|
||
finally:
|
||
connection.close()
|
||
|
||
|
||
def test_model_connection(config: dict[str, Any]) -> dict[str, Any]:
|
||
"""向模型发出一个最小请求,返回不含密钥的诊断结果。"""
|
||
endpoint = str(config["endpoint"])
|
||
api_key = str(config.get("api_key") or "")
|
||
provider_type_value = str(config.get("provider_type") or "openai")
|
||
provider = PROVIDER_TYPES.get(provider_type_value, provider_type_value)
|
||
if provider_type_value == "dify":
|
||
payload = {
|
||
"inputs": {},
|
||
"query": "连通性测试:请只回复 OK。",
|
||
"response_mode": "blocking",
|
||
"user": "zhen-ai-backend-test",
|
||
}
|
||
method = "POST"
|
||
elif provider_type_value == "comfyui":
|
||
payload = None
|
||
method = "GET"
|
||
else:
|
||
payload = {
|
||
"model": config["model"],
|
||
"messages": [{"role": "user", "content": "连通性测试:请只回复 OK。"}],
|
||
"stream": False,
|
||
}
|
||
method = "POST"
|
||
headers = {
|
||
"Content-Type": "application/json",
|
||
"Accept": "application/json",
|
||
"User-Agent": "ZhenAI-Backend-Connectivity-Test/1.0",
|
||
}
|
||
if api_key:
|
||
headers["Authorization"] = f"Bearer {api_key}"
|
||
started = time.perf_counter()
|
||
try:
|
||
status, raw = _perform_http_request(
|
||
endpoint,
|
||
method=method,
|
||
headers=headers,
|
||
payload=payload,
|
||
timeout=int(config["timeout"]),
|
||
)
|
||
latency_ms = round((time.perf_counter() - started) * 1000)
|
||
if len(raw) > 1_000_000:
|
||
raise ValueError("模型响应过大,已停止读取")
|
||
if not 200 <= status < 300:
|
||
detail = _remote_error_detail(raw, api_key)
|
||
labels = {
|
||
400: "请求参数不被模型服务接受",
|
||
401: "API Key 无效或缺少鉴权",
|
||
403: "当前 API Key 没有访问权限",
|
||
404: "接口地址或模型名称不存在",
|
||
429: "请求受限、余额不足或调用频率过高",
|
||
}
|
||
message = labels.get(status, f"模型服务返回 HTTP {status}")
|
||
if detail:
|
||
message += f":{detail}"
|
||
return {
|
||
"ok": False,
|
||
"provider": provider,
|
||
"model": str(config.get("model") or "-"),
|
||
"endpoint": _safe_endpoint_label(endpoint),
|
||
"http_status": status,
|
||
"latency_ms": latency_ms,
|
||
"message": message,
|
||
}
|
||
try:
|
||
data = json.loads(raw.decode("utf-8"))
|
||
except (UnicodeDecodeError, ValueError) as exc:
|
||
raise ValueError("模型已响应,但返回内容不是有效 JSON") from exc
|
||
if not isinstance(data, dict):
|
||
raise ValueError("模型已响应,但返回 JSON 不是对象")
|
||
answer = _model_answer(data, provider_type_value)
|
||
if not answer:
|
||
raise ValueError("模型已响应,但未返回可识别的回复内容")
|
||
if api_key:
|
||
answer = answer.replace(api_key, "[已隐藏]")
|
||
return {
|
||
"ok": True,
|
||
"provider": provider,
|
||
"model": str(config.get("model") or "-"),
|
||
"endpoint": _safe_endpoint_label(endpoint),
|
||
"http_status": status,
|
||
"latency_ms": latency_ms,
|
||
"message": f"连接成功,模型回复:{answer[:120]}",
|
||
}
|
||
except (TimeoutError, OSError, http.client.HTTPException, ValueError) as exc:
|
||
latency_ms = round((time.perf_counter() - started) * 1000)
|
||
reason = getattr(exc, "reason", exc)
|
||
message = "连接超时" if isinstance(reason, TimeoutError) else str(reason)
|
||
if api_key:
|
||
message = message.replace(api_key, "[已隐藏]")
|
||
return {
|
||
"ok": False,
|
||
"provider": provider,
|
||
"model": str(config.get("model") or "-"),
|
||
"endpoint": _safe_endpoint_label(endpoint),
|
||
"http_status": None,
|
||
"latency_ms": latency_ms,
|
||
"message": f"连接失败:{' '.join(message.split())[:300]}",
|
||
}
|
||
|
||
|
||
def model_test_result_page(result: dict[str, Any]) -> str:
|
||
ok = bool(result.get("ok"))
|
||
title = "模型连接成功" if ok else "模型连接失败"
|
||
tone = "flash" if ok else "flash error"
|
||
http_status = result.get("http_status")
|
||
status_text = str(http_status) if http_status is not None else "未建立 HTTP 响应"
|
||
body = f"""
|
||
<div class='loginwrap'><section class='login'>
|
||
<div class='brand' style='color:var(--accent)'>ZHEN AI ADMIN</div>
|
||
<h1>{title}</h1>
|
||
<div class='{tone}'>{html.escape(str(result.get('message') or ''))}</div>
|
||
<div class='formgrid'>
|
||
<div><label>协议</label><div>{html.escape(str(result.get('provider') or ''))}</div></div>
|
||
<div><label>耗时</label><div>{int(result.get('latency_ms') or 0)} ms</div></div>
|
||
<div class='full'><label>请求地址</label><div>{html.escape(str(result.get('endpoint') or ''))}</div></div>
|
||
<div><label>模型</label><div>{html.escape(str(result.get('model') or ''))}</div></div>
|
||
<div><label>HTTP 状态</label><div>{html.escape(status_text)}</div></div>
|
||
</div>
|
||
<div class='actions'><a class='button' href='/'>返回配置后台</a></div>
|
||
<div class='tiny'>测试不会保存页面配置,也不会显示 API Key。</div>
|
||
</section></div>"""
|
||
return page(title, body)
|
||
|
||
|
||
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, *, roles: tuple[str, ...] | None = None
|
||
) -> 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
|
||
if roles and user["role"] not in roles:
|
||
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)
|
||
|
||
def desktop_sync_authorized(self) -> bool:
|
||
supplied = self.headers.get("X-Desktop-Sync-Key", "")
|
||
return bool(
|
||
supplied
|
||
and DESKTOP_SYNC_KEY
|
||
and hmac.compare_digest(supplied, DESKTOP_SYNC_KEY)
|
||
)
|
||
|
||
def config_payload(self) -> dict[str, Any]:
|
||
row = self.db.config()
|
||
release = self.db.release()
|
||
return {
|
||
"version": row["version"],
|
||
"updated_at": row["updated_at"],
|
||
"updated_by": row["updated_by_name"] or "system",
|
||
"config": json.loads(row["config_json"]),
|
||
"release": {
|
||
"latest_version": release["latest_version"],
|
||
"download_url": release["download_url"],
|
||
"release_notes": release["release_notes"],
|
||
"force_upgrade": bool(release["force_upgrade"]),
|
||
"updated_at": release["updated_at"],
|
||
},
|
||
}
|
||
|
||
@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:
|
||
self.json_response(HTTPStatus.OK, self.config_payload())
|
||
elif path == "/api/v1/desktop/config":
|
||
if self.desktop_sync_authorized():
|
||
self.json_response(HTTPStatus.OK, self.config_payload())
|
||
else:
|
||
self.json_response(HTTPStatus.UNAUTHORIZED, {"error": "桌面端同步凭证无效"})
|
||
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/model/test":
|
||
self.web_test_model()
|
||
elif path == "/admin/release":
|
||
self.web_save_release()
|
||
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()
|
||
elif path == "/api/v1/model/test":
|
||
self.api_test_model()
|
||
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_test_model(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"])
|
||
try:
|
||
result = test_model_connection(model_test_config(form, current))
|
||
except ValueError as exc:
|
||
result = {
|
||
"ok": False,
|
||
"provider": "-",
|
||
"model": form.get("AI_MODEL", ""),
|
||
"endpoint": "",
|
||
"http_status": None,
|
||
"latency_ms": 0,
|
||
"message": str(exc),
|
||
}
|
||
self.db.audit(
|
||
user["id"],
|
||
"model.test",
|
||
f"ok={int(bool(result['ok']))}, model={str(result.get('model') or '')[:80]}, "
|
||
f"endpoint={str(result.get('endpoint') or '')[:200]}, http={result.get('http_status')}",
|
||
self.client_ip,
|
||
)
|
||
self.html_response(HTTPStatus.OK, model_test_result_page(result))
|
||
|
||
def api_test_model(self) -> None:
|
||
user = self.require_api_auth(roles=("admin", "operator"))
|
||
if not user:
|
||
return
|
||
current = json.loads(self.db.config()["config_json"])
|
||
result = test_model_connection(model_test_config(self.json_body(), current))
|
||
self.db.audit(
|
||
user["id"],
|
||
"model.test.api",
|
||
f"ok={int(bool(result['ok']))}, model={str(result.get('model') or '')[:80]}, "
|
||
f"endpoint={str(result.get('endpoint') or '')[:200]}, http={result.get('http_status')}",
|
||
self.client_ip,
|
||
)
|
||
status = HTTPStatus.OK if result["ok"] else HTTPStatus.BAD_GATEWAY
|
||
self.json_response(status, result)
|
||
|
||
def web_save_release(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("页面已过期,请刷新后重试")
|
||
release = validate_release_form(form)
|
||
self.db.save_release(
|
||
release["latest_version"],
|
||
release["download_url"],
|
||
release["release_notes"],
|
||
release["force_upgrade"],
|
||
user["id"],
|
||
self.client_ip,
|
||
)
|
||
self.redirect(
|
||
"/?message="
|
||
+ urllib.parse.quote(f"桌面端版本策略已更新为 v{release['latest_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()
|
||
release = self.db.release()
|
||
config = json.loads(row["config_json"])
|
||
config_card = self.config_card(user, csrf, row, config)
|
||
release_card = self.release_card(user, csrf, release)
|
||
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'>应用 v{html.escape(release['latest_version'])} · 配置 v{int(row['version'])}</div></div>
|
||
{flash}<div class='grid'><section>{config_card}</section><section>{release_card}{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='#release'>版本升级</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 ""
|
||
selected_provider = provider_type(config)
|
||
provider_options = "".join(
|
||
f"<option value='{key}'{' selected' if key == selected_provider else ''}>"
|
||
f"{html.escape(label)}</option>"
|
||
for key, label in PROVIDER_TYPES.items()
|
||
)
|
||
mcp = html.escape(
|
||
json.dumps(config.get("AI_MCP_SERVERS", []), ensure_ascii=False, indent=2)
|
||
)
|
||
submit = (
|
||
"<div class='actions'>"
|
||
"<button class='secondary' type='submit' formaction='/admin/model/test' "
|
||
"formtarget='_blank'>测试模型连通性</button>"
|
||
"<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 class='full'><label>服务类型</label><select name='AI_PROVIDER_TYPE'{disabled}>{provider_options}</select></div>
|
||
<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>"""
|
||
|
||
@staticmethod
|
||
def release_card(user: sqlite3.Row, csrf: str, release: sqlite3.Row) -> str:
|
||
can_edit = user["role"] in ("admin", "operator")
|
||
disabled = " disabled" if not can_edit else ""
|
||
checked = " checked" if release["force_upgrade"] else ""
|
||
latest = html.escape(release["latest_version"], quote=True)
|
||
download_url = html.escape(release["download_url"], quote=True)
|
||
notes = html.escape(release["release_notes"])
|
||
submit = (
|
||
"<div class='actions'><button type='submit'>保存版本策略</button></div>"
|
||
if can_edit
|
||
else "<div class='notice'>当前为只读角色,不能修改版本策略。</div>"
|
||
)
|
||
return f"""
|
||
<section class='card' id='release'><div class='cardhead'><div><h2>桌面端版本升级</h2>
|
||
<div class='muted'>软件每次打开都会检查这里的版本策略。</div></div><span class='version'>v{latest}</span></div>
|
||
<form method='post' action='/admin/release'><input type='hidden' name='csrf' value='{csrf}'>
|
||
<div class='formgrid'>
|
||
<div class='full'><label>最新版本号</label><input name='latest_version' value='{latest}' placeholder='例如 1.0.1' required{disabled}></div>
|
||
<div class='full'><label>升级下载地址</label><input type='url' name='download_url' value='{download_url}' placeholder='https://...'{disabled}></div>
|
||
<div class='full'><label>更新说明</label><textarea name='release_notes' style='min-height:92px'{disabled}>{notes}</textarea></div>
|
||
<div class='full'><label class='check'><input type='checkbox' name='force_upgrade' value='1'{checked}{disabled}>强制升级(旧版本只能升级或退出)</label></div>
|
||
</div>
|
||
<div class='notice'>开启强制升级前,请确认下载地址可正常打开。版本号与桌面端不一致时会立即生效。</div>
|
||
{submit}</form><div class='tiny'>最后更新:{html.escape(release['updated_at'])} · {html.escape(release['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"
|
||
provider = form.get("AI_PROVIDER_TYPE", "").strip().lower()
|
||
if provider not in PROVIDER_TYPES:
|
||
raise ValueError("服务类型无效")
|
||
config["AI_PROVIDER_TYPE"] = provider
|
||
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 validate_release_form(form: dict[str, str]) -> dict[str, Any]:
|
||
version = form.get("latest_version", "").strip()
|
||
if not APP_VERSION_PATTERN.fullmatch(version):
|
||
raise ValueError("版本号格式应为 1.0.0,可选填写 v 前缀")
|
||
if version.lower().startswith("v"):
|
||
version = version[1:]
|
||
download_url = form.get("download_url", "").strip()
|
||
if download_url:
|
||
parsed = urllib.parse.urlparse(download_url)
|
||
if parsed.scheme not in ("http", "https") or not parsed.netloc:
|
||
raise ValueError("升级下载地址必须是完整的 http 或 https 地址")
|
||
force_upgrade = form.get("force_upgrade") == "1"
|
||
if force_upgrade and not download_url:
|
||
raise ValueError("开启强制升级前必须填写升级下载地址")
|
||
release_notes = form.get("release_notes", "").strip()
|
||
if len(download_url) > 1000:
|
||
raise ValueError("升级下载地址过长")
|
||
if len(release_notes) > 4000:
|
||
raise ValueError("更新说明不能超过 4000 字")
|
||
return {
|
||
"latest_version": version,
|
||
"download_url": download_url,
|
||
"release_notes": release_notes,
|
||
"force_upgrade": force_upgrade,
|
||
}
|
||
|
||
|
||
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()
|