Files
kefu/deploy/ark-chat-config-20260917/before/wechat_rpa/admin_backend.py
T
2026-09-21 10:34:06 +08:00

3225 lines
143 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""配置后台的数据层:库结构、账号与角色、配置校验、模型连通性测试。
**这个模块不再自己起 HTTP 服务。** 原来那套服务端渲染的网页后台(8765)已经
整体退役,界面由 Vue 管理端承担,接口由 `admin_api.py`(8766)提供。
留下来的是被三方共用的地基:
- `Database` —— 建表与迁移、用户/角色/令牌、配置版本、模型清单、调用留痕
- 配置层 —— `CONFIG_KEYS` / `CONFIG_DEFAULTS` / `effective_config` /
`validate_config_form` / `validate_review_rules`
- `desktop_config_payload` —— 下发给桌面端的那一整份,两处调用方共用一份实现
- 模型连通性测试、网关地址推算、`write_runtime_info` 本机发现
`admin_api.py` 和 `model_gateway.py` 都 import 它,所以文件本身不能删——
退役的只是那层 HTTP 界面。
"""
from __future__ import annotations
import argparse
import base64
import hashlib
import hmac
import html
import http.client
import ipaddress
import json
import os
import re
import secrets
import sqlite3
import sys
import model_protocol
import threading
import time
import urllib.parse
from collections import defaultdict, deque
from datetime import datetime, timedelta
from http.cookies import SimpleCookie
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"
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
DESKTOP_SESSION_LIFETIME_SECONDS = 365 * 24 * 60 * 60
# 下发给桌面端的配置项。
#
# 这里**没有**模型连接参数(服务类型 / API 地址 / API Key / 模型名称 / 温度 /
# max_tokens / 超时)。它们全部搬到了「模型清单 + 角色编排」:
#
# * 同一件事有两个地方能配,迟早会不一致,而且出问题时没人说得清以哪边为准。
# * 密钥留在这份配置里就必须明文下发到每一台客户端。模型清单里是加密存储、
# 接口只返遮罩值,真正的调用走模型网关——密钥根本不出后端。
# * 一个客户只能配一个模型。best-of-N、裁判、降级链在这种形状下无从谈起。
#
# 留下的都是**客户端行为**,不是模型参数:开不开自动回复、记几轮上下文、
# 人格叫什么、MCP 工具连哪里。
CONFIG_KEYS = (
"AI_ENABLED",
"AI_DEVELOPMENT_MODE",
"AI_USE_VISION",
"AI_UI_GUARD_ENABLED",
"AI_CONTEXT_ENABLED",
"AI_CONTEXT_MAX_ROUNDS",
"AI_COUNTER_INSULT_ENABLED",
"AI_AGENT_NAME",
"AI_HOSPITAL_NAME",
"AI_MCP_ENABLED",
"AI_MCP_MAX_ROUNDS",
"AI_MCP_SERVERS",
"AI_GATEWAY_URL",
# 桌面软件启动时先从管理端公开配置读取这个地址,再向 ZYT 发登录请求。
# 只存域名/基础 URL,不含账号、密码或令牌。
"ZYT_LOGIN_API_URL",
"ARCHIVE_AUTO_BACKUP_ENABLED",
"ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES",
# 选择性审核规则:命中的才进人工审核队列,其余照常自动发送。
# 见 CONFIG_DEFAULTS 里 AI_REVIEW_RULES 的形状说明。
"AI_REVIEW_RULES",
)
# 已经被「角色编排」取代的旧字段。库里的老记录还带着它们,读的时候原样保留
# (不删用户数据),但不再校验、不再下发、界面上也不再出现。
RETIRED_CONFIG_KEYS = (
"AI_PROVIDER_TYPE",
"AI_API_BASE",
"AI_API_KEY",
"AI_MODEL",
"AI_MAX_TOKENS",
"AI_TEMPERATURE",
"AI_TIMEOUT",
)
BOOL_KEYS = {
"AI_ENABLED",
"AI_DEVELOPMENT_MODE",
"AI_USE_VISION",
"AI_UI_GUARD_ENABLED",
"AI_CONTEXT_ENABLED",
"AI_COUNTER_INSULT_ENABLED",
"AI_MCP_ENABLED",
"ARCHIVE_AUTO_BACKUP_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 _since_text(days: int) -> str:
""""最近 N 天"的起点,格式必须和 `now_text()` 写进库里的完全一致。
`created_at` 是 TEXT,窗口过滤是字符串比较——比较双方格式不一样的话,日期
相同的那一天会比错(ISO 的 'T' 大于空格),该留的行被悄悄滤掉。
"""
return (
datetime.now().astimezone() - timedelta(days=days)
).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"
DEFAULT_GATEWAY_PORT = 8770
GATEWAY_ANSWER_PATH = "/v1/answer"
# 反代时网关挂在这个路径前缀下。桌面端只认后台一个地址,网关在哪由后台告诉它。
GATEWAY_PROXY_PREFIX = "/gateway"
# 后台自己直连时用的端口。命中这些说明没走反代,网关就在同一台机器的
# DEFAULT_GATEWAY_PORT 上。
_DIRECT_BACKEND_PORTS = {"8765", "8766", "8767"}
def derive_gateway_url(configured: str, scheme: str, host: str) -> str:
"""算出桌面端该把模型请求发到哪儿。
这是"只改一个域名"的关键:桌面端配置里只有后台地址,网关地址由后台在同步
时告诉它。运维改网关位置只需要动后台一处,不用挨个改客户端。
三种情况:
* 后台配了绝对地址 → 原样使用(网关在别的域名/别的机器上时用这个)
* 后台配了 `/xxx` → 拼在后台自己的域名后面(同域名不同路径)
* 留空(默认) → 按后台自己的地址推:
直连 127.0.0.1:8765 这种 → 同机的 8770 端口
其它(说明走了反代) → 同域名的 /gateway 前缀
留空时的两种推法必须分开:本机开发是三个端口各跑各的,而反代后面只有 80/443
对外,公网根本连不到 8770。用一套规则套两种部署,总有一边是错的。
"""
scheme = (scheme or "http").lower()
host = str(host or "").strip()
value = str(configured or "").strip()
if value:
if value.startswith(("http://", "https://")):
return value.rstrip("/")
if value.startswith("/"):
return f"{scheme}://{host}{value.rstrip('/')}" if host else value.rstrip("/")
# 既不是绝对地址也不是路径,多半是漏了协议头。当成同域名下的路径处理,
# 比直接拿去发请求(会得到一个看不懂的 URL 解析错误)好。
return f"{scheme}://{host}/{value.strip('/')}" if host else value
if not host:
return f"http://127.0.0.1:{DEFAULT_GATEWAY_PORT}{GATEWAY_ANSWER_PATH}"
hostname, _, port = host.partition(":")
if port in _DIRECT_BACKEND_PORTS:
return f"{scheme}://{hostname}:{DEFAULT_GATEWAY_PORT}{GATEWAY_ANSWER_PATH}"
return f"{scheme}://{host}{GATEWAY_PROXY_PREFIX}{GATEWAY_ANSWER_PATH}"
# CONFIG_KEYS 每一项的出厂默认值。`load_initial_config` 拿它给全新的库打底;
# `effective_config` 拿它给已存在的库补漏——两处默认值只能有一份,不然新库和
# 老库升级后看到的默认行为会悄悄不一样。
CONFIG_DEFAULTS: dict[str, Any] = {
"AI_ENABLED": True,
"AI_DEVELOPMENT_MODE": False,
"AI_USE_VISION": False,
"AI_UI_GUARD_ENABLED": True,
"AI_CONTEXT_ENABLED": True,
"AI_CONTEXT_MAX_ROUNDS": 5,
"AI_COUNTER_INSULT_ENABLED": False,
"AI_AGENT_NAME": "客服",
"AI_HOSPITAL_NAME": "",
"AI_MCP_ENABLED": False,
"AI_MCP_MAX_ROUNDS": 5,
"AI_MCP_SERVERS": [],
# 留空 = 按后台自己的地址推算,见 derive_gateway_url
"AI_GATEWAY_URL": "",
# 留空 = 使用管理 API 启动参数 / ZYT_API_BASE_URL 环境变量。
"ZYT_LOGIN_API_URL": "",
# 桌面软件启动后等待该间隔执行第一次增量归档,之后按相同间隔重复执行。
"ARCHIVE_AUTO_BACKUP_ENABLED": True,
"ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES": 60,
# 选择性审核:命中下面任意一条规则的关键词,这一条回复才会停下来等人工
# 确认;没命中的照常自动发送。
#
# 形状:[{"id": str, "label": str, "keywords": [str, ...], "enabled": bool}, ...]
# 关键词按子串、不分大小写,同时比对"客户这句话"和"模型准备发的回复"——
# 客户问的和模型答的任何一边沾上,都算命中,宁可多审一条,不能漏审一条。
#
# 这里给的是投用时的起步规则,照着桌面端界面上原来那三个装饰性标签
# (诊断/用药调整/投诉退款)配的,运营可以随时在「桌面端 → 客户端配置」里
# 改词、加规则、关掉某一条。
#
# 另外有一条不在这份列表里、写死在代码里、关不掉:模型裁判把这条回复判成
# "高风险"时,不管有没有命中关键词,一样要停下来等人工。裁判只要配了就会
# 一直在打分(不受 shadow/score_only/arbitrate 影响),这个信号比关键词
# 匹配更贴近内容本身,没有理由建议关掉它。
"AI_REVIEW_RULES": [
{
"id": "diagnosis",
"label": "诊断",
"keywords": ["确诊", "诊断", "是不是得了", "是不是患了", "是什么病"],
"enabled": True,
},
{
"id": "medication",
"label": "用药调整",
"keywords": ["加量", "减量", "停药", "换药", "能不能吃", "剂量"],
"enabled": True,
},
{
"id": "complaint",
"label": "投诉退款",
"keywords": ["投诉", "退款", "退货", "举报", "曝光", "消协"],
"enabled": True,
},
],
}
# ── 开箱即用的智能体 ─────────────────────────────────────────────────────────
# 为什么要播种而不是交一个空列表给运营自己配:空清单意味着"这个功能装好了但什么
# 都不做",第一个打开页面的人看不出它能干嘛,也没有可以照着改的样例。这两个角色
# 是照着现在真实在跑的业务分的——问诊在一边,挂号/订单/投诉在另一边。
#
# 里面那条「不许说系统编码」的禁止措辞规则不是凑数的:线上真的发生过客户发来
# 血糖值「13」,模型回"这串像系统编码",客户澄清之后又回了一次同样的话。提示词
# 里已经写了不许这么说,但提示词拦不住——所以在出口再挡一道。
DEFAULT_AGENT_RULES_SHARED = [
{
"id": "no-encoding-excuse",
"label": "不许把客户的话说成乱码",
"type": "forbid",
"keywords": [
"系统编码", "乱码", "无法解析", "无法确认具体意思", "这串内容", "这串字符",
],
"instruction": "",
"reply": "",
"enabled": True,
},
{
"id": "number-is-an-answer",
"label": "纯数字按数值理解",
"type": "guide",
"keywords": [],
"instruction": (
"客户只发数字(13、6.8、130)时,那是在回答你上一句问的东西:按上下文"
"当成血糖值、时间点、年龄或病程,接着这个数往下说,并把缺的关键信息"
"问清(空腹还是餐后、什么时候测的)。严禁说它是编码、乱码或无法识别。"
),
"reply": "",
"enabled": True,
},
]
DEFAULT_AGENTS = [
{
"id": "health-advisor",
"name": "健康顾问",
"role": "糖尿病专科医生",
"description": "血糖、用药、饮食运动、并发症等专业问题",
"persona": (
"你负责所有和身体、血糖、用药有关的问题:先接住对方的情绪,再给一两条"
"具体可操作的建议,能落地比讲全面重要。不开处方、不让人自行加减药;"
"出现危险信号时明确提醒尽快就诊。"
),
"keywords": [
"血糖", "糖尿病", "胰岛素", "降糖药", "糖化", "空腹", "餐后",
"并发症", "饮食", "运动", "头晕", "低血糖",
],
"rules": [
*DEFAULT_AGENT_RULES_SHARED,
{
"id": "ask-context-once",
"label": "一次只问一件事",
"type": "guide",
"keywords": ["血糖", "空腹", "餐后"],
"instruction": (
"缺测量时间/用药情况时,这一条消息里只问最关键的那一个,"
"不要一次抛出一串问题。"
),
"reply": "",
"enabled": True,
},
],
"enabled": True,
"priority": 10,
},
{
"id": "customer-manager",
"name": "客户经理",
"role": "客户经理",
"description": "挂号预约、面诊链接、订单物流、投诉退款",
"persona": (
"你负责流程类事情:挂号预约、面诊安排、订单物流、投诉退款。"
"客户只是咨询时不要抢着挂号;只有明确要约才登记。任何单号、物流状态、"
"预约时间都必须是真实查到的,查不到就说去核实,绝不编。"
),
"keywords": [
"挂号", "预约", "面诊", "链接", "订单", "物流", "快递", "单号",
"发票", "退款", "退货", "投诉",
],
"rules": [
*DEFAULT_AGENT_RULES_SHARED,
{
"id": "consult-link-timing",
"label": "面诊链接口径",
"type": "reply",
"keywords": ["链接什么时候发", "怎么进入面诊", "把链接发我", "面诊链接"],
"instruction": "",
"reply": "面诊链接会在面诊时间提前 1 小时发给您,到点前留意下消息就行。",
"enabled": True,
},
],
"enabled": True,
"priority": 20,
},
]
def _seed_default_agents(db) -> None:
"""第一次建库时写入默认智能体和协作方案。
只在**两张表都还是空的**时候动手。运营把默认角色删光是一个明确的决定,
下次重启又给他装回来,那是跟人对着干。
"""
has_agent = db.execute("SELECT COUNT(*) FROM agents").fetchone()[0]
has_plan = db.execute("SELECT COUNT(*) FROM agent_plan").fetchone()[0]
if has_agent or has_plan:
return
now = now_text()
for item in DEFAULT_AGENTS:
db.execute(
"""INSERT INTO agents
(id,name,role,description,persona,keywords,rules_json,
enabled,priority,created_at,updated_at,updated_by)
VALUES (?,?,?,?,?,?,?,1,?,?,?,NULL)""",
(
item["id"],
item["name"],
item["role"],
item["description"],
item["persona"],
",".join(item["keywords"]),
json.dumps(item["rules"], ensure_ascii=False),
item["priority"],
now,
now,
),
)
db.execute(
"""INSERT INTO agent_plan
(mode,primary_id,active_ids,updated_at,updated_by)
VALUES ('collaborate',?,?,?,NULL)""",
(
DEFAULT_AGENTS[0]["id"],
",".join(item["id"] for item in DEFAULT_AGENTS),
now,
),
)
ALL_TENANTS = "*"
"""「全部」:调用统计不限租户。
和 `archive_store.ALL_TENANTS` 是同一个约定。两个模块各写一份常量而不是互相
导入,是因为 `admin_backend` 是 `archive_store` 的下层(前者建库、后者建归档
表),反过来导入会绕成一个环。
"""
def tenant_filter(tenant_id: Any, column: str = "tenant_id") -> tuple[str, list[str]]:
"""把租户范围翻成一段 SQL 条件和它的参数。
三种形状对应界面上账号下拉框的三档:
"default" 旧版未归属数据——老记录的 tenant_id 是空串或字面量 default
"*" 全部(有 desktop-account:all 的管理员)
一组 id 全部(只被授权到若干客户端账号的管理员)
"""
if isinstance(tenant_id, str) or tenant_id is None:
values = [str(tenant_id or "default")]
else:
values = [str(item) for item in tenant_id if str(item or "").strip()]
if not values:
values = ["default"]
if values == [ALL_TENANTS]:
# 恒真而不是空串:调用方都在 f-string 里拼 `WHERE {clause}`。
return "1=1", []
if values == ["default"]:
return f"{column} IN ('', 'default')", []
if len(values) == 1:
return f"{column} = ?", values
placeholders = ",".join("?" for _ in values)
return f"{column} IN ({placeholders})", values
def effective_config(stored: dict[str, Any]) -> dict[str, Any]:
"""把库里存的一份 config_json 投影成 CONFIG_KEYS,缺的/空的都补上默认值。
这里补的是两种情况,缺一不可:
* 键根本不存在——`AI_GATEWAY_URL` 这种是后来才加进 CONFIG_KEYS 的,
建库更早的那些库的 config_json 里压根没有这一项。
* 键存在但值是 `null`——`ai_settings.json` 曾经把未设置的开关存成
JSON null,那份文件当初就是拿去初始化这张表的,null 就这样被
原样搬进了库里。
不补的后果:这两种情况 `stored.get(key)` 都会返回 `None`,直接发给前端。
浏览器上的表单把它转存成 JS 的 `null`,保存时再发回来,撞上接口的类型
校验——`Input should be a valid boolean, input: null`。修好这一处,
发出去的从一开始就是干净的值,根本不会走到那步。
"""
return {
key: stored[key] if stored.get(key) is not None else CONFIG_DEFAULTS[key]
for key in CONFIG_KEYS
}
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(CONFIG_DEFAULTS)
if isinstance(saved, dict):
# 只取真正有值的。`null` 是"没设",不是一个值——拿它盖掉默认值,得到的
# 配置连自家接口的类型校验都过不了(布尔字段变成 None → 422)。
defaults.update({
key: saved[key]
for key in CONFIG_KEYS
if key in saved and saved[key] is not None
})
return defaults
def valid_password(value: str) -> bool:
"""密码强度下限:10 位以上,且同时含字母和数字。
原来挂在老网页后台的请求处理类上。老后台退役了,但这条规则本身和界面无关,
改密、建用户、重置 admin 三条路径都要用同一把尺子——留在模块级,谁都能用。
"""
value = str(value or "")
return (
len(value) >= 10
and any(c.isalpha() for c in value)
and any(c.isdigit() for c in value)
)
def desktop_config_payload(
db: "Database",
*,
scheme: str = "http",
host: str = "",
desktop_account: Any = None,
) -> dict[str, Any]:
"""桌面端同步要的那一整份:配置 + 网关地址 + 模型清单 + 版本信息。
同样是从老后台搬出来的。搬而不是各写一份,是因为这份结构决定了每一台客户端
拿到什么——两份实现迟早会漂移,而漂移的表现是"某台客户端少了个字段",
极难查。
"""
row = db.config()
release = db.release()
stored = json.loads(row["config_json"])
config = effective_config(stored)
if desktop_account is not None:
if desktop_account["backup_enabled"] is not None:
config["ARCHIVE_AUTO_BACKUP_ENABLED"] = bool(
desktop_account["backup_enabled"]
)
if desktop_account["backup_interval_minutes"] is not None:
config["ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES"] = int(
desktop_account["backup_interval_minutes"]
)
# 智能体随配置一起下发。放进 config 而不是另起一段,是因为桌面端的
# `ai_config.apply_settings` 只认 config 里的键——另起一段就得在客户端同步
# 逻辑里再加一条分支,而那条分支老版本客户端没有,等于新配置静默不生效。
config["AI_AGENTS"] = db.agents()
config["AI_AGENT_PLAN"] = db.agent_plan()
return {
"version": row["version"],
"updated_at": row["updated_at"],
"updated_by": row["updated_by_name"] or "system",
# 只下发 CONFIG_KEYS。已退休的模型参数(尤其是 AI_API_KEY)留在库里是为了
# 不删用户数据,但绝不能再发出去——密钥不出后端是这次改版的全部意义。
"config": config,
"account": {
"id": int(desktop_account["id"]),
"sn": str(desktop_account["zyt_sn"]),
"nickname": str(desktop_account["nickname"]),
"tenant_id": str(desktop_account["tenant_id"]),
} if desktop_account is not None else None,
# 桌面端只配后台地址一个,网关在哪由这里告诉它。
"gateway": {
"enabled": True,
"url": derive_gateway_url(
str(stored.get("AI_GATEWAY_URL") or ""), scheme, host
),
},
# 模型清单和角色编排随配置一起下发。密钥永远不出后端——桌面端拿到的是
# 遮罩值,真正的调用要走模型网关。
"models": db.model_providers(),
"roles": db.model_roles(),
"release": {
"latest_version": release["latest_version"],
"download_url": release["download_url"],
"release_notes": release["release_notes"],
"force_upgrade": bool(release["force_upgrade"]),
"auto_install": bool(release["auto_install"]),
"package_sha256": str(release["package_sha256"] or ""),
"package_size": int(release["package_size"] or 0),
"signature_required": bool(release["signature_required"]),
"release_channel": str(release["release_channel"] or "stable"),
"rollout_percent": int(release["rollout_percent"] or 100),
"status": str(release["status"] or "published"),
"updated_at": release["updated_at"],
},
}
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 migrate(self) -> None:
"""把库的结构升到当前版本。不建管理员,不写任何业务数据。
每一个碰这个库的服务启动时都该调它。以前只有网页后台(console)在
`initialize()` 里顺带做了迁移,于是单独起 JSON API 或模型网关时,库还是老
结构——第一次查询直接 `no such column`,而部署的人只会看到服务起不来,
看不出是因为漏了哪一步。
"""
self.initialize("", seed_admin=False)
def initialize(self, initial_password: str, *, seed_admin: bool = True) -> 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,
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 desktop_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
zyt_user_id TEXT NOT NULL UNIQUE,
tenant_id TEXT NOT NULL UNIQUE,
zyt_sn TEXT NOT NULL DEFAULT '',
nickname TEXT NOT NULL DEFAULT '',
mobile_masked TEXT NOT NULL DEFAULT '',
avatar TEXT NOT NULL DEFAULT '',
zyt_root INTEGER NOT NULL DEFAULT 0,
zyt_role_ids_json TEXT NOT NULL DEFAULT '[]',
zyt_permissions_json TEXT NOT NULL DEFAULT '[]',
zyt_permissions_known INTEGER NOT NULL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','disabled')),
backup_enabled INTEGER,
backup_interval_minutes INTEGER,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_login_at TEXT NOT NULL DEFAULT '',
last_online_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS desktop_devices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES desktop_accounts(id)
ON DELETE CASCADE,
device_id TEXT NOT NULL,
device_name TEXT NOT NULL DEFAULT '',
app_version TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_seen_at TEXT NOT NULL DEFAULT '',
revoked_at TEXT NOT NULL DEFAULT '',
UNIQUE(account_id, device_id)
);
CREATE TABLE IF NOT EXISTS desktop_sessions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER NOT NULL REFERENCES desktop_accounts(id)
ON DELETE CASCADE,
device_row_id INTEGER NOT NULL REFERENCES desktop_devices(id)
ON DELETE CASCADE,
token_digest TEXT NOT NULL UNIQUE,
created_at TEXT NOT NULL,
expires_at INTEGER NOT NULL,
last_used_at TEXT NOT NULL,
revoked_at TEXT NOT NULL DEFAULT ''
);
CREATE TABLE IF NOT EXISTS admin_account_scope (
admin_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
desktop_account_id INTEGER NOT NULL REFERENCES desktop_accounts(id)
ON DELETE CASCADE,
created_at TEXT NOT NULL,
PRIMARY KEY(admin_user_id, desktop_account_id)
);
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,
auto_install INTEGER NOT NULL DEFAULT 0,
package_sha256 TEXT NOT NULL DEFAULT '',
package_size INTEGER NOT NULL DEFAULT 0,
signature_required INTEGER NOT NULL DEFAULT 0,
release_channel TEXT NOT NULL DEFAULT 'stable',
rollout_percent INTEGER NOT NULL DEFAULT 100,
status TEXT NOT NULL DEFAULT 'published',
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS app_release_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
latest_version TEXT NOT NULL,
download_url TEXT NOT NULL DEFAULT '',
release_notes TEXT NOT NULL DEFAULT '',
force_upgrade INTEGER NOT NULL DEFAULT 0,
auto_install INTEGER NOT NULL DEFAULT 0,
package_sha256 TEXT NOT NULL DEFAULT '',
package_size INTEGER NOT NULL DEFAULT 0,
signature_required INTEGER NOT NULL DEFAULT 0,
release_channel TEXT NOT NULL DEFAULT 'stable',
rollout_percent INTEGER NOT NULL DEFAULT 100,
status TEXT NOT NULL DEFAULT 'published',
created_at TEXT NOT NULL,
created_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS desktop_update_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT,
account_id INTEGER REFERENCES desktop_accounts(id) ON DELETE SET NULL,
device_id TEXT NOT NULL,
current_version TEXT NOT NULL DEFAULT '',
target_version TEXT NOT NULL,
status TEXT NOT NULL,
progress INTEGER NOT NULL DEFAULT 0,
error TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
UNIQUE(device_id, target_version)
);
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 TABLE IF NOT EXISTS model_providers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
kind TEXT NOT NULL
CHECK(kind IN ('dify','openai','claude','comfyui')),
base_url TEXT NOT NULL,
-- auto:按接口类型补全路径(api.openai.com/v1 → .../v1/chat/completions)
-- exact:地址原样使用,一个字符不加。给路径不按套路的服务用。
endpoint_mode TEXT NOT NULL DEFAULT 'auto'
CHECK(endpoint_mode IN ('auto','exact')),
api_key_enc TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL DEFAULT '',
capabilities TEXT NOT NULL DEFAULT 'text',
max_tokens INTEGER NOT NULL DEFAULT 500,
temperature REAL NOT NULL DEFAULT 0.35,
timeout_ms INTEGER NOT NULL DEFAULT 30000,
max_inflight INTEGER NOT NULL DEFAULT 32,
rpm_limit INTEGER NOT NULL DEFAULT 0,
enabled INTEGER NOT NULL DEFAULT 1,
health TEXT NOT NULL DEFAULT 'unknown',
health_checked_at TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS model_roles (
version INTEGER PRIMARY KEY AUTOINCREMENT,
answer_ids TEXT NOT NULL DEFAULT '',
judge_id TEXT NOT NULL DEFAULT '',
vision_id TEXT NOT NULL DEFAULT '',
fallback_ids TEXT NOT NULL DEFAULT '',
judge_mode TEXT NOT NULL DEFAULT 'shadow'
CHECK(judge_mode IN ('shadow','score_only','arbitrate')),
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
-- 角色标签(客户经理 / 健康顾问 / 售后…),只用于界面和提示词
role TEXT NOT NULL DEFAULT '',
description TEXT NOT NULL DEFAULT '',
-- 人设:这个角色怎么说话、管哪一摊事
persona TEXT NOT NULL DEFAULT '',
-- 协作时认领本轮的关键词,逗号分隔
keywords TEXT NOT NULL DEFAULT '',
-- 规则清单,形状见 validate_agent_rules
rules_json TEXT NOT NULL DEFAULT '[]',
enabled INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 100,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS agent_plan (
version INTEGER PRIMARY KEY AUTOINCREMENT,
-- single:只上一个角色(后台那个"切换"就是改这里)
-- collaborate:多个同时在场,按关键词认领本轮
mode TEXT NOT NULL DEFAULT 'single'
CHECK(mode IN ('single','collaborate')),
primary_id TEXT NOT NULL DEFAULT '',
active_ids TEXT NOT NULL DEFAULT '',
updated_at TEXT NOT NULL,
updated_by INTEGER REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS model_calls (
id INTEGER PRIMARY KEY AUTOINCREMENT,
desktop_account_id INTEGER REFERENCES desktop_accounts(id),
tenant_id TEXT NOT NULL DEFAULT '',
device_id TEXT NOT NULL DEFAULT '',
task_id TEXT NOT NULL DEFAULT '',
roles_version INTEGER NOT NULL DEFAULT 0,
judge_mode TEXT NOT NULL DEFAULT '',
chosen TEXT NOT NULL DEFAULT '',
judge_winner TEXT NOT NULL DEFAULT '',
judge_score REAL NOT NULL DEFAULT 0,
judge_risk TEXT NOT NULL DEFAULT '',
candidates_json TEXT NOT NULL DEFAULT '[]',
total_ms INTEGER NOT NULL DEFAULT 0,
customer_text TEXT NOT NULL DEFAULT '',
reply_text TEXT NOT NULL DEFAULT '',
review_reason TEXT NOT NULL DEFAULT '',
purpose TEXT NOT NULL DEFAULT 'chat',
created_at TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_tokens_digest ON auth_tokens(token_digest);
CREATE INDEX IF NOT EXISTS idx_desktop_sessions_digest
ON desktop_sessions(token_digest);
CREATE INDEX IF NOT EXISTS idx_desktop_sessions_account
ON desktop_sessions(account_id, revoked_at, expires_at);
CREATE UNIQUE INDEX IF NOT EXISTS uq_desktop_active_session
ON desktop_sessions(account_id) WHERE revoked_at='';
CREATE INDEX IF NOT EXISTS idx_desktop_devices_account
ON desktop_devices(account_id, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_release_history_created
ON app_release_history(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_update_reports_target
ON desktop_update_reports(target_version, status, updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_log(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_model_calls_created
ON model_calls(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_model_calls_device
ON model_calls(device_id, created_at DESC);
"""
)
# RBAC 必须在 users 迁移之前装好:迁移后的 users.role 是指向 roles
# 表的外键,角色行不存在的话整张表建不起来。
import rbac
rbac.seed(db, now_text())
self._migrate_role_check(db)
self._add_missing_columns(db)
_seed_default_agents(db)
if not seed_admin:
db.commit()
return False
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()
@staticmethod
def _desktop_tenant_id(zyt_user_id: str) -> str:
"""生成归档层可接受、且不会泄露外部用户主键的稳定租户标识。"""
digest = hashlib.sha256(str(zyt_user_id).encode("utf-8")).hexdigest()[:24]
return f"zyt-{digest}"
def create_desktop_session(
self,
identity: dict[str, Any],
device_id: str,
device_name: str,
app_version: str,
ip: str,
lifetime: int = DESKTOP_SESSION_LIFETIME_SECONDS,
*,
interactive_login: bool = False,
) -> tuple[str, dict[str, Any]]:
"""建立唯一桌面会话;同一 ZYT 账号的新登录会立即撤销旧设备。"""
zyt_user_id = str(identity.get("user_id") or "").strip()
device_id = str(device_id or "").strip()
if not zyt_user_id:
raise ValueError("ZYT 用户标识不能为空")
if not device_id or len(device_id) > 128:
raise ValueError("设备标识不正确")
token = secrets.token_urlsafe(48)
stamp = now_text()
expires_at = int(time.time()) + max(
300, min(int(lifetime), 10 * DESKTOP_SESSION_LIFETIME_SECONDS)
)
tenant_id = self._desktop_tenant_id(zyt_user_id)
mobile = str(identity.get("mobile") or "")
if len(mobile) >= 7:
mobile = f"{mobile[:3]}****{mobile[-4:]}"
permissions_value = identity.get("permissions")
permissions = (
sorted(
{
str(item).strip()
for item in permissions_value
if str(item).strip()
}
)
if isinstance(permissions_value, list)
else []
)
role_ids: list[int] = []
if isinstance(identity.get("role_ids"), list):
for item in identity["role_ids"]:
try:
role_ids.append(int(item))
except (TypeError, ValueError):
continue
zyt_root = int(bool(identity.get("root")) or "*" in permissions)
permissions_known = int(
bool(identity.get("permissions_known", isinstance(permissions_value, list)))
)
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
db.execute(
"""INSERT INTO desktop_accounts
(zyt_user_id,tenant_id,zyt_sn,nickname,mobile_masked,avatar,
zyt_root,zyt_role_ids_json,zyt_permissions_json,
zyt_permissions_known,status,
created_at,updated_at,last_login_at,last_online_at)
VALUES (?,?,?,?,?,?,?,?,?,?,'active',?,?,?,?)
ON CONFLICT(zyt_user_id) DO UPDATE SET
zyt_sn=excluded.zyt_sn,nickname=excluded.nickname,
mobile_masked=excluded.mobile_masked,avatar=excluded.avatar,
zyt_root=excluded.zyt_root,
zyt_role_ids_json=excluded.zyt_role_ids_json,
zyt_permissions_json=excluded.zyt_permissions_json,
zyt_permissions_known=excluded.zyt_permissions_known,
updated_at=excluded.updated_at,last_login_at=excluded.last_login_at,
last_online_at=excluded.last_online_at""",
(
zyt_user_id,
tenant_id,
str(identity.get("sn") or "")[:128],
str(identity.get("nickname") or "")[:191],
mobile[:32],
str(identity.get("avatar") or "")[:1024],
zyt_root,
json.dumps(sorted(set(role_ids)), ensure_ascii=False),
json.dumps(permissions, ensure_ascii=False),
permissions_known,
stamp,
stamp,
stamp,
stamp,
),
)
account = db.execute(
"SELECT * FROM desktop_accounts WHERE zyt_user_id=?", (zyt_user_id,)
).fetchone()
if account is None or str(account["status"]) != "active":
db.rollback()
raise ValueError("该桌面账号已被管理端禁用")
existing_device = db.execute(
"SELECT id,revoked_at FROM desktop_devices WHERE account_id=? AND device_id=?",
(int(account["id"]), device_id),
).fetchone()
if (
existing_device is not None
and str(existing_device["revoked_at"] or "")
and not interactive_login
):
db.rollback()
raise ValueError("当前设备已被管理端强制下线,请重新输入密码登录")
if interactive_login:
# 真正输入密码登录的新设备接管唯一会话;旧设备之后即使保留着
# ZYT 缓存令牌,也不能静默换取新的桌面会话。
db.execute(
"""UPDATE desktop_devices SET revoked_at=?,updated_at=?
WHERE account_id=? AND device_id<>? AND revoked_at=''""",
(stamp, stamp, int(account["id"]), device_id),
)
db.execute(
"""INSERT INTO desktop_devices
(account_id,device_id,device_name,app_version,created_at,updated_at,
last_seen_at,revoked_at)
VALUES (?,?,?,?,?,?,?,'')
ON CONFLICT(account_id,device_id) DO UPDATE SET
device_name=excluded.device_name,app_version=excluded.app_version,
updated_at=excluded.updated_at,last_seen_at=excluded.last_seen_at,
revoked_at=''""",
(
int(account["id"]),
device_id,
str(device_name or "")[:120],
str(app_version or "")[:64],
stamp,
stamp,
stamp,
),
)
device = db.execute(
"SELECT id FROM desktop_devices WHERE account_id=? AND device_id=?",
(int(account["id"]), device_id),
).fetchone()
# 唯一登录的真正约束在同一个写事务里完成,避免两台电脑同时登录时
# 各自都以为自己是唯一会话。
db.execute(
"UPDATE desktop_sessions SET revoked_at=? WHERE account_id=? AND revoked_at=''",
(stamp, int(account["id"])),
)
db.execute(
"""INSERT INTO desktop_sessions
(account_id,device_row_id,token_digest,created_at,expires_at,last_used_at)
VALUES (?,?,?,?,?,?)""",
(
int(account["id"]),
int(device["id"]),
token_hash(token),
stamp,
expires_at,
stamp,
),
)
self._audit(
db,
None,
"desktop.login",
f"account={account['id']} device={device_id[:64]}",
ip,
)
db.commit()
payload = dict(account)
payload.update(
{
"device_id": device_id,
"expires_at": expires_at,
}
)
return token, payload
def desktop_session(self, token: str) -> sqlite3.Row | None:
if not token:
return None
now = int(time.time())
stamp = now_text()
with self.connect() as db:
row = db.execute(
"""SELECT a.*,s.id AS session_id,s.expires_at,
d.device_id,d.device_name,d.app_version
FROM desktop_sessions s
JOIN desktop_accounts a ON a.id=s.account_id
JOIN desktop_devices d ON d.id=s.device_row_id
WHERE s.token_digest=? AND s.revoked_at='' AND s.expires_at>=?
AND a.status='active' AND d.revoked_at=''""",
(token_hash(token), now),
).fetchone()
if row is not None:
# 每次正常心跳都把有效期向后顺延一年。管理端强制下线会写入
# session/device 的 revoked_at;被撤销后上面的查询取不到行,
# 因而不能被心跳重新续活。
renewed_expires_at = now + DESKTOP_SESSION_LIFETIME_SECONDS
db.execute(
"""UPDATE desktop_sessions
SET last_used_at=?,expires_at=MAX(expires_at,?) WHERE id=?""",
(stamp, renewed_expires_at, int(row["session_id"])),
)
db.execute(
"UPDATE desktop_accounts SET last_online_at=? WHERE id=?",
(stamp, int(row["id"])),
)
db.execute(
"UPDATE desktop_devices SET last_seen_at=? WHERE account_id=? AND device_id=?",
(stamp, int(row["id"]), str(row["device_id"])),
)
db.commit()
row = db.execute(
"""SELECT a.*,s.id AS session_id,s.expires_at,
d.device_id,d.device_name,d.app_version
FROM desktop_sessions s
JOIN desktop_accounts a ON a.id=s.account_id
JOIN desktop_devices d ON d.id=s.device_row_id
WHERE s.id=? AND s.revoked_at='' AND a.status='active'
AND d.revoked_at=''""",
(int(row["session_id"]),),
).fetchone()
return row
def revoke_desktop_session(self, token: str) -> None:
if not token:
return
with self.connect() as db:
db.execute(
"UPDATE desktop_sessions SET revoked_at=? WHERE token_digest=?",
(now_text(), token_hash(token)),
)
db.commit()
def desktop_accounts(
self, allowed_ids: list[int] | None = None, *, include_counts: bool = True
) -> list[dict[str, Any]]:
allowed = None if allowed_ids is None else sorted({int(item) for item in allowed_ids})
where = ""
params: tuple[Any, ...] = ()
if allowed is not None:
if not allowed:
return []
where = f" WHERE a.id IN ({','.join('?' for _ in allowed)})"
params = tuple(allowed)
archive_counts = (
"""(SELECT COUNT(*) FROM archive_source_account sa
WHERE sa.tenant_id=a.tenant_id) AS source_account_count,
(SELECT COUNT(*) FROM archive_message m
WHERE m.tenant_id=a.tenant_id) AS message_count"""
if include_counts else "0 AS source_account_count,0 AS message_count"
)
now_epoch = int(time.time())
online_cutoff = (
datetime.now().astimezone() - timedelta(seconds=20)
).isoformat(timespec="seconds")
with self.connect() as db:
rows = db.execute(
f"""SELECT a.*,
(SELECT COUNT(*) FROM desktop_devices d
WHERE d.account_id=a.id AND d.revoked_at='') AS device_count,
(SELECT COUNT(*) FROM desktop_sessions s
JOIN desktop_devices active_device
ON active_device.id=s.device_row_id
WHERE s.account_id=a.id AND s.revoked_at=''
AND s.expires_at>=? AND active_device.revoked_at=''
AND active_device.last_seen_at>=?) AS active_session_count,
(SELECT d.device_name FROM desktop_devices d
WHERE d.account_id=a.id ORDER BY d.last_seen_at DESC LIMIT 1)
AS current_device_name,
(SELECT d.app_version FROM desktop_devices d
WHERE d.account_id=a.id ORDER BY d.last_seen_at DESC LIMIT 1)
AS app_version,
{archive_counts}
FROM desktop_accounts a{where}
ORDER BY a.last_online_at DESC,a.id DESC""",
(now_epoch, online_cutoff, *params),
).fetchall()
result: list[dict[str, Any]] = []
for row in rows:
account = dict(row)
account["is_online"] = bool(
account.get("status") == "active"
and int(account.get("active_session_count") or 0) > 0
)
result.append(account)
return result
def desktop_account_ids_for_admin(self, admin_user_id: int) -> list[int]:
with self.connect() as db:
rows = db.execute(
"""SELECT desktop_account_id FROM admin_account_scope
WHERE admin_user_id=? ORDER BY desktop_account_id""",
(int(admin_user_id),),
).fetchall()
return [int(row[0]) for row in rows]
def desktop_account(self, account_id: int) -> sqlite3.Row | None:
with self.connect() as db:
return db.execute(
"SELECT * FROM desktop_accounts WHERE id=?", (int(account_id),)
).fetchone()
def admin_can_access_desktop_account(
self, admin_user_id: int, desktop_account_id: int
) -> bool:
with self.connect() as db:
return db.execute(
"""SELECT 1 FROM admin_account_scope
WHERE admin_user_id=? AND desktop_account_id=?""",
(int(admin_user_id), int(desktop_account_id)),
).fetchone() is not None
def set_desktop_account_admins(
self,
account_id: int,
admin_user_ids: list[int],
actor_id: int,
ip: str,
) -> None:
wanted = sorted({int(item) for item in admin_user_ids})
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
if db.execute(
"SELECT 1 FROM desktop_accounts WHERE id=?", (int(account_id),)
).fetchone() is None:
raise ValueError("桌面账号不存在")
if wanted:
placeholders = ",".join("?" for _ in wanted)
count = db.execute(
f"SELECT COUNT(*) FROM users WHERE id IN ({placeholders}) AND active=1",
tuple(wanted),
).fetchone()[0]
if int(count) != len(wanted):
raise ValueError("包含不存在或已停用的管理端用户")
db.execute(
"DELETE FROM admin_account_scope WHERE desktop_account_id=?",
(int(account_id),),
)
db.executemany(
"""INSERT INTO admin_account_scope
(admin_user_id,desktop_account_id,created_at) VALUES (?,?,?)""",
[(item, int(account_id), now_text()) for item in wanted],
)
self._audit(
db,
actor_id,
"desktop.account.scope",
f"id={account_id} admins={','.join(str(item) for item in wanted)}",
ip,
)
db.commit()
def desktop_account_admin_ids(self, account_id: int) -> list[int]:
with self.connect() as db:
rows = db.execute(
"""SELECT admin_user_id FROM admin_account_scope
WHERE desktop_account_id=? ORDER BY admin_user_id""",
(int(account_id),),
).fetchall()
return [int(row[0]) for row in rows]
def update_desktop_account(
self,
account_id: int,
*,
status: str,
backup_enabled: bool | None,
backup_interval_minutes: int | None,
actor_id: int,
ip: str,
) -> dict[str, Any]:
if status not in {"active", "disabled"}:
raise ValueError("账号状态不正确")
if backup_interval_minutes is not None and not 1 <= backup_interval_minutes <= 10080:
raise ValueError("备份间隔必须在 1 到 10080 分钟之间")
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
row = db.execute(
"SELECT id FROM desktop_accounts WHERE id=?", (int(account_id),)
).fetchone()
if row is None:
raise ValueError("桌面账号不存在")
db.execute(
"""UPDATE desktop_accounts SET status=?,backup_enabled=?,
backup_interval_minutes=?,updated_at=? WHERE id=?""",
(
status,
None if backup_enabled is None else int(bool(backup_enabled)),
backup_interval_minutes,
now_text(),
int(account_id),
),
)
if status != "active":
db.execute(
"UPDATE desktop_sessions SET revoked_at=? WHERE account_id=? AND revoked_at=''",
(now_text(), int(account_id)),
)
self._audit(db, actor_id, "desktop.account.update", f"id={account_id} status={status}", ip)
db.commit()
return dict(
db.execute("SELECT * FROM desktop_accounts WHERE id=?", (int(account_id),)).fetchone()
)
def revoke_desktop_devices(self, account_id: int, actor_id: int, ip: str) -> None:
stamp = now_text()
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
db.execute(
"UPDATE desktop_devices SET revoked_at=?,updated_at=? WHERE account_id=?",
(stamp, stamp, int(account_id)),
)
db.execute(
"UPDATE desktop_sessions SET revoked_at=? WHERE account_id=? AND revoked_at=''",
(stamp, int(account_id)),
)
self._audit(db, actor_id, "desktop.devices.revoke", f"id={account_id}", ip)
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 audit_entries(self, limit: int = 200) -> list[dict[str, Any]]:
"""审计日志。谁在什么时候改了什么——出事之后唯一能回溯的东西。"""
limit = max(1, min(int(limit or 200), 1000))
with self.connect() as db:
rows = db.execute(
"""SELECT a.id, a.action, a.detail, a.ip_address, a.created_at,
u.username
FROM audit_log a LEFT JOIN users u ON u.id = a.user_id
ORDER BY a.id DESC LIMIT ?""",
(limit,),
).fetchall()
return [{key: row[key] for key in row.keys()} for row in rows]
def model_call_stats(
self, days: int = 7, tenant_id: Any = "default"
) -> dict[str, Any]:
"""调用统计。回答的是这三个问题:
· 各出口被选中的比例 → 第二个模型到底值不值那一倍成本
· 裁判分数的分布 → 现有回复的真实水平,够好就没必要上双模型
· 高风险占比 → 该不该把 score_only 打开、审核阈值定在哪
这些是决定要不要花第二份钱的唯一依据,拍脑袋定不出来。
只统计 `purpose='chat'`:界面守卫的内部识别调用也会经过网关,条数远多于
真实对话。把它们算进来,裁判分布量的就成了"布局识别答得准不准",而这张
表要回答的是"发给客户的回复够不够好"——两件事,不能混在一个平均值里。
"""
days = max(1, min(int(days or 7), 90))
since = _since_text(days)
tenant_clause, tenant_params = tenant_filter(tenant_id)
base_params: list[Any] = [since, *tenant_params]
base_where = (
f"created_at >= ? AND purpose = 'chat' AND {tenant_clause}"
)
with self.connect() as db:
total = db.execute(
f"SELECT COUNT(*) AS n FROM model_calls WHERE {base_where}",
base_params,
).fetchone()["n"]
chosen = db.execute(
f"""SELECT chosen, COUNT(*) AS n FROM model_calls
WHERE {base_where} AND chosen != ''
GROUP BY chosen ORDER BY n DESC""",
base_params,
).fetchall()
risk = db.execute(
f"""SELECT judge_risk, COUNT(*) AS n FROM model_calls
WHERE {base_where} AND judge_risk != ''
GROUP BY judge_risk""",
base_params,
).fetchall()
# 分数分桶:0.0~0.2 / 0.2~0.4 / … 直接在 SQL 里算,别把几万行拉回来
buckets = db.execute(
f"""SELECT CAST(judge_score * 5 AS INTEGER) AS bucket, COUNT(*) AS n
FROM model_calls
WHERE {base_where} AND judge_winner != ''
GROUP BY bucket ORDER BY bucket""",
base_params,
).fetchall()
judged = db.execute(
f"""SELECT COUNT(*) AS n, AVG(judge_score) AS avg_score
FROM model_calls
WHERE {base_where} AND judge_winner != ''""",
base_params,
).fetchone()
latency = db.execute(
f"""SELECT AVG(total_ms) AS avg_ms, MAX(total_ms) AS max_ms
FROM model_calls WHERE {base_where}""",
base_params,
).fetchone()
return {
"since": since,
"total": int(total),
"judged": int(judged["n"] or 0),
"avg_score": round(float(judged["avg_score"] or 0.0), 3),
"avg_ms": int(latency["avg_ms"] or 0),
"max_ms": int(latency["max_ms"] or 0),
"chosen": [{"provider": row["chosen"], "count": row["n"]} for row in chosen],
"risk": {row["judge_risk"]: row["n"] for row in risk},
"score_buckets": [
{
"range": f"{min(int(row['bucket']), 4) * 0.2:.1f}~"
f"{(min(int(row['bucket']), 4) + 1) * 0.2:.1f}",
"count": row["n"],
}
for row in buckets
],
}
def list_model_calls(
self,
days: int = 7,
limit: int = 50,
offset: int = 0,
q: str = "",
purpose: str = "chat",
tenant_id: Any = "default",
) -> dict[str, Any]:
"""按时间倒序列出单次调用,用于定位"这句话到底是怎么被回复的"。
`model_call_stats` 只回答"最近风险占比多高"这类聚合问题;出了具体问题
要查某一条——客户说了什么、模型候选都答了什么、选中的是哪个、有没有
触发审核、为什么——只能一条条翻,这就是这个方法的用途。
默认只看 `chat`:界面守卫每轮轮询都要问一次模型,条数是真实对话的几十
倍,混在一起这张表就没法用了。`purpose=""` 表示全都要(内部调用同样在
花钱,需要时得能查)。
"""
days = max(1, min(int(days or 7), 90))
limit = max(1, min(int(limit or 50), 200))
offset = max(0, int(offset or 0))
since = _since_text(days)
clauses = ["created_at >= ?"]
params: list[Any] = [since]
tenant_clause, tenant_params = tenant_filter(tenant_id)
clauses.append(tenant_clause)
params.extend(tenant_params)
wanted = str(purpose or "").strip().lower()
if wanted:
clauses.append("purpose = ?")
params.append(wanted)
needle = str(q or "").strip()
if needle:
clauses.append("(customer_text LIKE ? OR reply_text LIKE ?)")
like = f"%{needle}%"
params += [like, like]
where = " AND ".join(clauses)
with self.connect() as db:
total = db.execute(
f"SELECT COUNT(*) AS n FROM model_calls WHERE {where}", params
).fetchone()["n"]
rows = db.execute(
f"""SELECT id, desktop_account_id, tenant_id, device_id, task_id,
roles_version, judge_mode, chosen,
judge_winner, judge_score, judge_risk, candidates_json,
total_ms, customer_text, reply_text, review_reason,
purpose, created_at
FROM model_calls WHERE {where}
ORDER BY id DESC LIMIT ? OFFSET ?""",
(*params, limit, offset),
).fetchall()
items = []
for row in rows:
item = {key: row[key] for key in row.keys()}
try:
item["candidates"] = json.loads(item.pop("candidates_json") or "[]")
except ValueError:
item["candidates"] = []
item.pop("candidates_json", None)
items.append(item)
return {"total": int(total), "items": items}
# ── 角色与权限 ────────────────────────────────────────────────────────
@staticmethod
def _add_missing_columns(db) -> None:
"""给已经存在的表补新加的列。
用 ADD COLUMN 而不是重建表:重建要关外键、搬数据、改名,任何一步出错都
可能把用户的模型清单弄丢,而这里只是加一个带默认值的可选列。
"""
wanted = {
"desktop_accounts": {
"zyt_root": "INTEGER NOT NULL DEFAULT 0",
"zyt_role_ids_json": "TEXT NOT NULL DEFAULT '[]'",
"zyt_permissions_json": "TEXT NOT NULL DEFAULT '[]'",
"zyt_permissions_known": "INTEGER NOT NULL DEFAULT 0",
},
"model_providers": {
"endpoint_mode": "TEXT NOT NULL DEFAULT 'auto'",
},
"app_release": {
"auto_install": "INTEGER NOT NULL DEFAULT 0",
"package_sha256": "TEXT NOT NULL DEFAULT ''",
"package_size": "INTEGER NOT NULL DEFAULT 0",
"signature_required": "INTEGER NOT NULL DEFAULT 0",
"release_channel": "TEXT NOT NULL DEFAULT 'stable'",
"rollout_percent": "INTEGER NOT NULL DEFAULT 100",
"status": "TEXT NOT NULL DEFAULT 'published'",
},
"model_calls": {
"desktop_account_id": "INTEGER REFERENCES desktop_accounts(id)",
"tenant_id": "TEXT NOT NULL DEFAULT ''",
"customer_text": "TEXT NOT NULL DEFAULT ''",
"reply_text": "TEXT NOT NULL DEFAULT ''",
"review_reason": "TEXT NOT NULL DEFAULT ''",
"purpose": "TEXT NOT NULL DEFAULT 'chat'",
},
}
for table, columns in wanted.items():
exists = {
row[1] for row in db.execute(f"PRAGMA table_info({table})")
}
if not exists: # 表还没建出来,SCHEMA 里已经带上了
continue
for name, spec in columns.items():
if name not in exists:
db.execute(f"ALTER TABLE {table} ADD COLUMN {name} {spec}")
Database._ensure_model_calls_task_unique(db)
Database._backfill_guard_purpose(db)
db.commit()
@staticmethod
def _backfill_guard_purpose(db) -> None:
"""把历史上那些"界面识别"调用从客户调用日志里摘出去。
`purpose` 是后加的列,老记录一律落成默认的 `chat`,于是调用日志里一眼
望过去全是布局识别的 JSON、客户消息全空——真要查"某句话为什么这么回"
反而找不到。这些内部调用有非常明确的指纹:候选文本里带着分类器专用的
JSON 字段名,正常客服回复不可能出现。只改分类标记,一行都不删。
"""
exists = {row[1] for row in db.execute("PRAGMA table_info(model_calls)")}
if "purpose" not in exists or "candidates_json" not in exists:
return
db.execute(
"""UPDATE model_calls SET purpose = 'guard'
WHERE purpose = 'chat' AND (
candidates_json LIKE '%navigation_right_ratio%'
OR candidates_json LIKE '%reply_capable%'
)"""
)
@staticmethod
def _ensure_model_calls_task_unique(db) -> None:
"""`task_id` 唯一索引要求先清掉历史重复值。
老版本拿会话指纹当 task_id:同一个会话里每一轮对话都复用同一个值,
直接建唯一索引会撞上已经存在的重复数据。这里只清空重复项里较旧那些
的 task_id(只留最新一行),不删除任何一行——观测数据不因为一次迁移
丢失,只是把从来就不是真正调用标识的值收回成空。
"""
exists = {row[1] for row in db.execute("PRAGMA table_info(model_calls)")}
if "task_id" not in exists:
return
try:
db.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_model_calls_task "
"ON model_calls(task_id) WHERE task_id != ''"
)
except sqlite3.IntegrityError:
db.execute(
"""UPDATE model_calls SET task_id = ''
WHERE task_id != '' AND id NOT IN (
SELECT MAX(id) FROM model_calls
WHERE task_id != '' GROUP BY task_id
)"""
)
db.execute(
"CREATE UNIQUE INDEX IF NOT EXISTS idx_model_calls_task "
"ON model_calls(task_id) WHERE task_id != ''"
)
def _migrate_role_check(self, db) -> None:
"""把 users.role 上写死三个角色的 CHECK 约束换成外键。
原来的 `CHECK(role IN ('admin','operator','viewer'))` 让"新增一个角色"变
成一次代码改动 + 发版。SQLite 不支持 DROP CONSTRAINT,只能重建表。
重建必须**先关掉外键约束**——这是 SQLite 官方流程里的第一步,我上一版漏
了,结果在真实库上炸了:`auth_tokens.user_id` 引用 `users`,开着外键
`DROP TABLE users` 直接 FOREIGN KEY constraint failed。更糟的是
executescript 处于自动提交状态,失败前建好的 `users_new` 会留在库里,
下次启动又撞上同一个坑。所以这里还要先清掉上一次的残留。
`PRAGMA foreign_keys` 在事务内是空操作,必须在 BEGIN 之前设置。
"""
row = db.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name='users'"
).fetchone()
needs_migration = bool(row) and "CHECK(role IN" in (row["sql"] or "")
leftover = db.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='users_new'"
).fetchone()
if leftover and not needs_migration:
# 迁移其实已经成功过,users_new 是更早一次失败留下的垃圾
db.execute("DROP TABLE users_new")
db.commit()
return
if not needs_migration:
return
# 角色表里没有的角色值会让 INSERT 撞外键。真实库里出现过手工改库、
# 或早期版本遗留的值,这里统一落到 viewer 而不是让整个迁移失败——
# 权限收紧是安全的方向,直接崩掉服务不是。
known = {item[0] for item in db.execute("SELECT code FROM roles")}
orphans = [
item[0]
for item in db.execute("SELECT DISTINCT role FROM users")
if item[0] not in known
]
db.commit() # PRAGMA 必须在事务外
db.execute("PRAGMA foreign_keys = OFF")
try:
db.execute("BEGIN IMMEDIATE")
if leftover:
db.execute("DROP TABLE users_new")
if orphans:
db.execute(
"UPDATE users SET role='viewer' WHERE role NOT IN "
f"({','.join('?' for _ in known)})",
tuple(sorted(known)),
)
db.execute(
"""CREATE TABLE users_new (
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 REFERENCES roles(code),
active INTEGER NOT NULL DEFAULT 1,
must_change_password INTEGER NOT NULL DEFAULT 1,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL
)"""
)
db.execute(
"""INSERT INTO users_new
SELECT id,username,password_salt,password_digest,role,active,
must_change_password,created_at,updated_at FROM users"""
)
db.execute("DROP TABLE users")
db.execute("ALTER TABLE users_new RENAME TO users")
broken = db.execute("PRAGMA foreign_key_check").fetchall()
if broken:
raise sqlite3.IntegrityError(
f"迁移后外键校验未通过:{broken[:3]}"
)
db.commit()
except Exception:
db.rollback()
raise
finally:
db.execute("PRAGMA foreign_keys = ON")
if orphans:
print(f"[RBAC] {len(orphans)} 个未知角色已降级为 viewer:{orphans}")
print("[RBAC] users.role 已从写死的 CHECK 迁移为角色表外键")
def roles(self) -> list[dict[str, Any]]:
import rbac
with self.connect() as db:
rows = db.execute(
"""SELECT r.*, (SELECT COUNT(*) FROM users u WHERE u.role=r.code)
AS user_count
FROM roles r ORDER BY r.builtin DESC, r.code"""
).fetchall()
return [
{
**{key: row[key] for key in row.keys()},
"builtin": bool(row["builtin"]),
"permissions": sorted(rbac.permissions_of(db, row["code"])),
}
for row in rows
]
def permission_catalog(self) -> list[dict[str, str]]:
with self.connect() as db:
rows = db.execute(
"SELECT code,name,group_name FROM permissions ORDER BY group_name,code"
).fetchall()
return [{key: row[key] for key in row.keys()} for row in rows]
def permissions_for_user(self, user_id: int) -> set[str]:
"""一个用户实际拥有的权限码。前端的路由守卫和按钮显隐都以它为准。"""
import rbac
with self.connect() as db:
row = db.execute(
"SELECT role, active FROM users WHERE id=?", (int(user_id),)
).fetchone()
if row is None or not row["active"]:
return set()
return rbac.permissions_of(db, row["role"])
def save_role(
self, code: str, name: str, permissions: list[str], user_id: int, ip: str
) -> dict[str, Any]:
import rbac
code = str(code or "").strip().lower()
if not code or not code.replace("_", "").replace("-", "").isalnum():
raise ValueError("角色编码只能用字母、数字、下划线和连字符")
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
existing = db.execute("SELECT * FROM roles WHERE code=?", (code,)).fetchone()
now = now_text()
if existing is None:
db.execute(
"""INSERT INTO roles (code,name,builtin,created_at,updated_at)
VALUES (?,?,0,?,?)""",
(code, str(name or code), now, now),
)
else:
db.execute(
"UPDATE roles SET name=?,updated_at=? WHERE code=?",
(str(name or existing["name"]), now, code),
)
granted = rbac.grant(db, code, permissions, now)
self._audit(
db, user_id, "role.save", f"{code} → {len(granted)} 项权限", ip
)
db.commit()
return {"code": code, "permissions": sorted(granted)}
def delete_role(self, code: str, user_id: int, ip: str) -> None:
"""删角色。内置角色和仍有人在用的角色都不许删。"""
import rbac
code = str(code or "").strip().lower()
if code in rbac.BUILTIN_ROLE_CODES:
raise ValueError("内置角色不可删除")
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
used = db.execute(
"SELECT COUNT(*) AS n FROM users WHERE role=?", (code,)
).fetchone()["n"]
if used:
raise ValueError(f"还有 {used} 个用户在用这个角色,请先改他们的角色")
db.execute("DELETE FROM roles WHERE code=?", (code,))
self._audit(db, user_id, "role.delete", code, ip)
db.commit()
# ── 模型清单与角色编排 ────────────────────────────────────────────────
def _secret_key(self) -> bytes:
import secret_box
cached = getattr(self, "_secret_key_cache", None)
if cached is None:
cached = secret_box.load_or_create_key(Path(self.path).parent)
self._secret_key_cache = cached
return cached
def model_providers(self, *, include_secrets: bool = False) -> list[dict[str, Any]]:
"""模型清单。默认只返回遮罩后的密钥——接口层永不回显明文。"""
import secret_box
with self.connect() as db:
rows = db.execute(
"SELECT * FROM model_providers ORDER BY enabled DESC, name"
).fetchall()
items = []
for row in rows:
item = {key: row[key] for key in row.keys()}
encrypted = item.pop("api_key_enc", "")
plain = ""
if encrypted:
try:
plain = secret_box.decrypt(encrypted, self._secret_key())
except Exception:
# 主密钥换过或密文损坏。这一路必须显式标成不可用,而不是
# 拿空密钥去调用,换回一个看不懂的 401。
item["health"] = "down"
item["last_error"] = "密钥解密失败,请重新填写"
item["enabled"] = bool(item.get("enabled", 1))
item["timeout"] = float(item.get("timeout_ms", 30000)) / 1000.0
item.setdefault("endpoint_mode", "auto")
# 算好的实际请求地址一并给出去。不给的话,"填的地址"和"真正会请求
# 的地址"之间隔着一层拼接规则,配错了只能等 404 才发现。
item["endpoint"] = model_protocol.endpoint_url(
str(item.get("kind") or ""),
str(item.get("base_url") or ""),
str(item.get("endpoint_mode") or "auto"),
)
if include_secrets:
item["api_key"] = plain
else:
item["api_key_masked"] = secret_box.masked(plain)
items.append(item)
return items
def save_model_provider(self, item: dict[str, Any], user_id: int, ip: str) -> str:
"""新增或更新一条模型出口。api_key 留空表示「不改动原密钥」。"""
import secret_box
provider_id = str(item.get("id") or "").strip()
if not provider_id:
raise ValueError("模型 ID 不能为空")
kind = str(item.get("kind") or "").strip().lower()
if kind not in {"dify", "openai", "claude", "comfyui"}:
raise ValueError(f"不支持的接口类型:{kind}")
base_url = str(item.get("base_url") or "").strip()
if not base_url:
raise ValueError("接口地址不能为空")
endpoint_mode = str(item.get("endpoint_mode") or "auto").strip().lower()
if endpoint_mode not in model_protocol.ENDPOINT_MODES:
raise ValueError(f"不支持的地址模式:{endpoint_mode}")
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
existing = db.execute(
"SELECT api_key_enc FROM model_providers WHERE id=?", (provider_id,)
).fetchone()
raw_key = str(item.get("api_key") or "")
if raw_key:
key_enc = secret_box.encrypt(raw_key, self._secret_key())
elif existing is not None:
key_enc = existing["api_key_enc"]
else:
key_enc = ""
now = now_text()
db.execute(
"""INSERT INTO model_providers
(id,name,kind,base_url,endpoint_mode,api_key_enc,model,capabilities,
max_tokens,temperature,timeout_ms,max_inflight,rpm_limit,
enabled,health,health_checked_at,created_at,updated_at,updated_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,'',?,?,?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, kind=excluded.kind,
base_url=excluded.base_url,
endpoint_mode=excluded.endpoint_mode,
api_key_enc=excluded.api_key_enc,
model=excluded.model, capabilities=excluded.capabilities,
max_tokens=excluded.max_tokens, temperature=excluded.temperature,
timeout_ms=excluded.timeout_ms, max_inflight=excluded.max_inflight,
rpm_limit=excluded.rpm_limit, enabled=excluded.enabled,
updated_at=excluded.updated_at, updated_by=excluded.updated_by""",
(
provider_id,
str(item.get("name") or provider_id),
kind,
base_url,
endpoint_mode,
key_enc,
str(item.get("model") or ""),
str(item.get("capabilities") or "text"),
int(item.get("max_tokens") or 500),
float(item.get("temperature") or 0.35),
int(item.get("timeout_ms") or 30000),
int(item.get("max_inflight") or 32),
int(item.get("rpm_limit") or 0),
1 if item.get("enabled", True) else 0,
str(item.get("health") or "unknown"),
now,
now,
user_id,
),
)
self._audit(db, user_id, "model.provider.save", provider_id, ip)
db.commit()
return provider_id
def delete_model_provider(self, provider_id: str, user_id: int, ip: str) -> None:
"""删一条出口。仍被角色编排引用的不许删——否则回复链路会直接断。"""
provider_id = str(provider_id or "").strip()
roles = self.model_roles()
referenced = set(
part.strip()
for key in ("answer_ids", "judge_id", "vision_id", "fallback_ids")
for part in str(roles.get(key) or "").split(",")
if part.strip()
)
if provider_id in referenced:
raise ValueError("该模型仍被角色编排引用,请先在编排里移除再删除")
with self.connect() as db:
db.execute("DELETE FROM model_providers WHERE id=?", (provider_id,))
self._audit(db, user_id, "model.provider.delete", provider_id, ip)
db.commit()
def model_roles(self) -> dict[str, Any]:
with self.connect() as db:
row = db.execute(
"SELECT * FROM model_roles ORDER BY version DESC LIMIT 1"
).fetchone()
if row is None:
return {
"version": 0,
"answer_ids": "",
"judge_id": "",
"vision_id": "",
"fallback_ids": "",
"judge_mode": "shadow",
}
return {key: row[key] for key in row.keys()}
def save_model_roles(self, roles: dict[str, Any], user_id: int, ip: str) -> int:
"""写一版新编排。编排是追加版本而不是原地改——回滚时直接指回旧版本。"""
mode = str(roles.get("judge_mode") or "shadow").lower()
if mode not in {"shadow", "score_only", "arbitrate"}:
raise ValueError(f"不支持的裁判模式:{mode}")
known = {item["id"] for item in self.model_providers()}
answer_ids = [
part.strip()
for part in str(roles.get("answer_ids") or "").split(",")
if part.strip()
]
if not answer_ids:
raise ValueError("至少要指定一个答题模型")
for key in answer_ids + [roles.get("judge_id"), roles.get("vision_id")]:
key = str(key or "").strip()
if key and key not in known:
raise ValueError(f"模型清单里没有 {key}")
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
cursor = db.execute(
"""INSERT INTO model_roles
(answer_ids,judge_id,vision_id,fallback_ids,judge_mode,
updated_at,updated_by)
VALUES (?,?,?,?,?,?,?)""",
(
",".join(answer_ids),
str(roles.get("judge_id") or "").strip(),
str(roles.get("vision_id") or "").strip(),
str(roles.get("fallback_ids") or "").strip(),
mode,
now_text(),
user_id,
),
)
version = int(cursor.lastrowid)
self._audit(
db, user_id, "model.roles.save", f"version={version} mode={mode}", ip
)
db.commit()
return version
# ── 智能体 ───────────────────────────────────────────────────────────
def agents(self) -> list[dict[str, Any]]:
"""智能体清单。返回的形状就是下发给桌面端、`agent_rules` 直接消费的形状。"""
with self.connect() as db:
rows = db.execute(
"SELECT * FROM agents ORDER BY priority, name"
).fetchall()
items = []
for row in rows:
try:
rules = json.loads(row["rules_json"] or "[]")
except ValueError:
rules = []
items.append({
"id": row["id"],
"name": row["name"],
"role": row["role"],
"description": row["description"],
"persona": row["persona"],
"keywords": [
part.strip()
for part in str(row["keywords"] or "").split(",")
if part.strip()
],
"rules": rules if isinstance(rules, list) else [],
"enabled": bool(row["enabled"]),
"priority": int(row["priority"]),
"updated_at": row["updated_at"],
})
return items
def save_agent(self, item: dict[str, Any], user_id: int, ip: str) -> str:
"""新增或更新一个智能体。"""
cleaned = validate_agent(item)
now = now_text()
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
db.execute(
"""INSERT INTO agents
(id,name,role,description,persona,keywords,rules_json,
enabled,priority,created_at,updated_at,updated_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(id) DO UPDATE SET
name=excluded.name, role=excluded.role,
description=excluded.description, persona=excluded.persona,
keywords=excluded.keywords, rules_json=excluded.rules_json,
enabled=excluded.enabled, priority=excluded.priority,
updated_at=excluded.updated_at,
updated_by=excluded.updated_by""",
(
cleaned["id"],
cleaned["name"],
cleaned["role"],
cleaned["description"],
cleaned["persona"],
",".join(cleaned["keywords"]),
json.dumps(cleaned["rules"], ensure_ascii=False),
1 if cleaned["enabled"] else 0,
cleaned["priority"],
now,
now,
user_id,
),
)
self._audit(db, user_id, "agent.save", cleaned["id"], ip)
db.commit()
return cleaned["id"]
def delete_agent(self, agent_id: str, user_id: int, ip: str) -> None:
"""删一个智能体。还在岗的不许删——删了那份方案就指向一个不存在的角色。"""
agent_id = str(agent_id or "").strip()
plan = self.agent_plan()
if agent_id == plan["primary_id"] or agent_id in plan["active_ids"]:
raise ValueError("该智能体还在岗,请先在「启用与协作」里换下来再删除")
with self.connect() as db:
db.execute("DELETE FROM agents WHERE id=?", (agent_id,))
self._audit(db, user_id, "agent.delete", agent_id, ip)
db.commit()
def agent_plan(self) -> dict[str, Any]:
"""当前生效的启用方案。和角色编排一样按版本追加,回滚就是指回旧版本。"""
with self.connect() as db:
row = db.execute(
"SELECT * FROM agent_plan ORDER BY version DESC LIMIT 1"
).fetchone()
if row is None:
return {
"version": 0,
"mode": "single",
"primary_id": "",
"active_ids": [],
"updated_at": "",
}
return {
"version": int(row["version"]),
"mode": row["mode"],
"primary_id": row["primary_id"],
"active_ids": [
part.strip()
for part in str(row["active_ids"] or "").split(",")
if part.strip()
],
"updated_at": row["updated_at"],
}
def save_agent_plan(self, plan: dict[str, Any], user_id: int, ip: str) -> int:
known = {item["id"] for item in self.agents() if item["enabled"]}
cleaned = validate_agent_plan(plan, known)
with self.connect() as db:
db.execute("BEGIN IMMEDIATE")
cursor = db.execute(
"""INSERT INTO agent_plan
(mode,primary_id,active_ids,updated_at,updated_by)
VALUES (?,?,?,?,?)""",
(
cleaned["mode"],
cleaned["primary_id"],
",".join(cleaned["active_ids"]),
now_text(),
user_id,
),
)
version = int(cursor.lastrowid)
self._audit(
db,
user_id,
"agent.plan.save",
f"version={version} mode={cleaned['mode']}",
ip,
)
db.commit()
return version
def log_model_call(self, record: dict[str, Any]) -> None:
"""记一次编排调用。写失败绝不能影响回复——这是观测,不是业务。
桌面端和模型网关可能对同一次调用各报一次(网关看得到候选和裁判,桌面端
才知道这条回复触发没触发审核规则)。两边共用 `task_id`:谁先落盘谁建行,
后到的那边只把自己独有的 `review_reason` 补上去,不会覆盖对方已经写好
的候选/裁判数据,最终一次调用只留一行。`task_id` 留空(本地无网关路径
目前就是这样)时不去重,按老行为直接插入。
"""
try:
judge = record.get("judge") or {}
with self.connect() as db:
db.execute(
"""INSERT INTO model_calls
(desktop_account_id,tenant_id,device_id,task_id,
roles_version,judge_mode,chosen,
judge_winner,judge_score,judge_risk,candidates_json,
total_ms,customer_text,reply_text,review_reason,
purpose,created_at)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
ON CONFLICT(task_id) WHERE task_id != '' DO UPDATE SET
review_reason=excluded.review_reason""",
(
record.get("desktop_account_id"),
str(record.get("tenant_id") or ""),
str(record.get("device_id") or ""),
str(record.get("task_id") or ""),
int(record.get("roles_version") or 0),
str(record.get("judge_mode") or ""),
str(record.get("chosen") or ""),
str(judge.get("winner") or ""),
float(judge.get("score") or 0.0),
str(judge.get("risk") or ""),
json.dumps(record.get("candidates") or [], ensure_ascii=False),
int(record.get("total_ms") or 0),
str(record.get("customer_text") or ""),
str(record.get("reply_text") or ""),
str(record.get("review_reason") or ""),
# 桌面端补报的都是真实对话:内部识别调用不经过这条路。
str(record.get("purpose") or "chat"),
now_text(),
),
)
db.commit()
except Exception as exc:
print(f"[模型调用日志] 落库失败(不影响回复): {exc}")
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,
*,
auto_install: bool = False,
package_sha256: str = "",
package_size: int = 0,
signature_required: bool = False,
release_channel: str = "stable",
rollout_percent: int = 100,
status: str = "published",
) -> None:
with self.connect() as db:
db.execute(
"""UPDATE app_release SET latest_version=?,download_url=?,release_notes=?,
force_upgrade=?,auto_install=?,package_sha256=?,package_size=?,
signature_required=?,release_channel=?,rollout_percent=?,status=?,
updated_at=?,updated_by=? WHERE id=1""",
(
latest_version,
download_url,
release_notes,
int(force_upgrade),
int(auto_install),
package_sha256,
int(package_size),
int(signature_required),
release_channel,
int(rollout_percent),
status,
now_text(),
user_id,
),
)
db.execute(
"""INSERT INTO app_release_history
(latest_version,download_url,release_notes,force_upgrade,
auto_install,package_sha256,package_size,signature_required,
release_channel,rollout_percent,status,created_at,created_by)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
latest_version,
download_url,
release_notes,
int(force_upgrade),
int(auto_install),
package_sha256,
int(package_size),
int(signature_required),
release_channel,
int(rollout_percent),
status,
now_text(),
user_id,
),
)
self._audit(
db,
user_id,
"release.update",
f"version={latest_version}, force={int(force_upgrade)}",
ip,
)
db.commit()
def release_history(self, limit: int = 30) -> list[dict[str, Any]]:
with self.connect() as db:
rows = db.execute(
"""SELECT r.*,u.username AS created_by_name
FROM app_release_history r
LEFT JOIN users u ON u.id=r.created_by
ORDER BY r.id DESC LIMIT ?""",
(max(1, min(int(limit), 100)),),
).fetchall()
return [{key: row[key] for key in row.keys()} for row in rows]
def record_update_report(
self,
account_id: int | None,
device_id: str,
current_version: str,
target_version: str,
status: str,
progress: int,
error: str,
) -> None:
stamp = now_text()
with self.connect() as db:
db.execute(
"""INSERT INTO desktop_update_reports
(account_id,device_id,current_version,target_version,status,
progress,error,created_at,updated_at)
VALUES (?,?,?,?,?,?,?,?,?)
ON CONFLICT(device_id,target_version) DO UPDATE SET
account_id=excluded.account_id,
current_version=excluded.current_version,
status=excluded.status,progress=excluded.progress,
error=excluded.error,updated_at=excluded.updated_at""",
(
account_id,
str(device_id or "")[:128],
str(current_version or "")[:64],
str(target_version or "")[:64],
str(status or "")[:32],
max(0, min(int(progress), 100)),
str(error or "")[:1000],
stamp,
stamp,
),
)
db.commit()
def update_report_summary(self, target_version: str = "") -> dict[str, Any]:
with self.connect() as db:
where = " WHERE target_version=?" if target_version else ""
params = (target_version,) if target_version else ()
rows = db.execute(
"SELECT status,COUNT(*) AS count FROM desktop_update_reports"
+ where
+ " GROUP BY status",
params,
).fetchall()
recent = db.execute(
"""SELECT r.*,a.zyt_sn,a.nickname
FROM desktop_update_reports r
LEFT JOIN desktop_accounts a ON a.id=r.account_id"""
+ where
+ " ORDER BY r.updated_at DESC LIMIT 100",
params,
).fetchall()
return {
"counts": {str(row["status"]): int(row["count"]) for row in rows},
"items": [
{key: row[key] for key in row.keys()}
for row in recent
],
}
@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, mode: str = "auto") -> str:
"""连通性测试要请求的地址。
`mode="exact"` 时地址原样使用——和真正调用模型时走的是同一条规则
(`model_protocol.endpoint_url`)。两边规则不一致的话,测试会去戳一个和实际
调用不同的地址:测通了照样用不了,或者反过来,最没用的那种测试。
"""
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
if str(mode or "auto").strip().lower() == "exact":
return base
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_mode = str(values.get("AI_ENDPOINT_MODE") or "auto").strip().lower()
endpoint = _model_endpoint(api_base, provider, endpoint_mode)
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,
"endpoint_mode": endpoint_mode,
}
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]}",
}
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 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
_REVIEW_RULE_ID_PATTERN = re.compile(r"[^a-z0-9]+")
def _slugify_rule_id(label: str, taken: set[str]) -> str:
"""从规则名造一个可以当 id 用的短串,撞了就加个序号。
前端的规则编辑器只让人填"名称"和"关键词",不该逼着运营去想一个英文 id。
这里自动造、自动去重,界面上就当它不存在。
"""
base = _REVIEW_RULE_ID_PATTERN.sub("-", label.strip().lower()).strip("-") or "rule"
candidate = base
suffix = 2
while candidate in taken:
candidate = f"{base}-{suffix}"
suffix += 1
return candidate
def validate_review_rules(raw: Any) -> list[dict[str, Any]]:
"""校验一份「选择性审核规则」清单。
形状见 `CONFIG_DEFAULTS["AI_REVIEW_RULES"]` 上面那段注释。这里管的是"存进去
的必须是能被 `wechat_bot._reply_needs_review` 安全消费的形状"——不是每一种
错误都值得单独报,但空关键词、非法类型这些会让规则悄悄失效或者让桌面端在
读配置时炸掉,必须挡在保存这一步。
"""
if not isinstance(raw, list):
raise ValueError("审核规则 JSON 根节点必须是数组")
if len(raw) > 50:
raise ValueError("审核规则最多 50 条")
seen_ids: set[str] = set()
cleaned: list[dict[str, Any]] = []
for index, item in enumerate(raw, start=1):
if not isinstance(item, dict):
raise ValueError(f"第 {index} 条审核规则格式不对,应该是一个对象")
label = str(item.get("label") or "").strip()
if not label:
raise ValueError(f"第 {index} 条审核规则没有填名称")
if len(label) > 40:
raise ValueError(f"第 {index} 条审核规则名称过长(上限 40 字)")
keywords_raw = item.get("keywords")
if not isinstance(keywords_raw, list):
raise ValueError(f"规则「{label}」的关键词必须是数组")
keywords = [str(word).strip() for word in keywords_raw if str(word).strip()]
if not keywords:
raise ValueError(f"规则「{label}」至少要有一个关键词,否则永远不会命中")
if len(keywords) > 30:
raise ValueError(f"规则「{label}」的关键词最多 30 个")
for word in keywords:
if len(word) > 40:
raise ValueError(f"规则「{label}」里有关键词超过 40 字,像是填错了")
rule_id = str(item.get("id") or "").strip() or _slugify_rule_id(label, seen_ids)
if rule_id in seen_ids:
rule_id = _slugify_rule_id(label, seen_ids)
seen_ids.add(rule_id)
cleaned.append({
"id": rule_id,
"label": label,
"keywords": keywords,
"enabled": bool(item.get("enabled", True)),
})
return cleaned
AGENT_RULE_TYPES = ("guide", "forbid", "reply")
AGENT_PLAN_MODES = ("single", "collaborate")
def validate_agent_rules(raw: Any) -> list[dict[str, Any]]:
"""校验一个智能体的规则清单。
三种规则,各自的必填项不一样,所以不能用一套字段校验糊过去:
guide 必须有 instruction(要注入给模型的那句话)。关键词可以为空——
空 = 每轮都注入,这是有意义的配置,不是漏填。
forbid 必须有关键词(不许出现的词)。reply 可以为空,空的时候走内置兜底。
reply 关键词和话术都必须有,少一个这条规则要么永不命中、要么命中了
没话可说。
形状和 `agent_rules.normalize_rules` 是一对:那边对脏数据是丢掉,这边是报错。
保存时报错,运营当场就能改;线上读到脏数据再报错,就只能是半夜被叫起来。
"""
if not isinstance(raw, list):
raise ValueError("规则必须是数组")
if len(raw) > 30:
raise ValueError("单个智能体最多 30 条规则")
seen_ids: set[str] = set()
cleaned: list[dict[str, Any]] = []
for index, item in enumerate(raw, start=1):
if not isinstance(item, dict):
raise ValueError(f"第 {index} 条规则格式不对,应该是一个对象")
label = str(item.get("label") or "").strip()
if not label:
raise ValueError(f"第 {index} 条规则没有填名称")
if len(label) > 40:
raise ValueError(f"规则「{label[:20]}…」名称过长(上限 40 字)")
kind = str(item.get("type") or "guide").strip().lower()
if kind not in AGENT_RULE_TYPES:
raise ValueError(f"规则「{label}」的类型不支持:{kind}")
keywords_raw = item.get("keywords") or []
if not isinstance(keywords_raw, list):
raise ValueError(f"规则「{label}」的关键词必须是数组")
keywords: list[str] = []
for word in keywords_raw:
word = str(word).strip()
if not word or word in keywords:
continue
if len(word) > 40:
raise ValueError(f"规则「{label}」里有关键词超过 40 字,像是填错了")
keywords.append(word)
if len(keywords) > 30:
raise ValueError(f"规则「{label}」的关键词最多 30 个")
instruction = str(item.get("instruction") or "").strip()
reply = str(item.get("reply") or "").strip()
if len(instruction) > 500:
raise ValueError(f"规则「{label}」的补充指令过长(上限 500 字)")
if len(reply) > 300:
raise ValueError(f"规则「{label}」的话术过长(上限 300 字)")
if kind == "guide" and not instruction:
raise ValueError(f"规则「{label}」是补充指令,必须填要告诉模型的话")
if kind in ("forbid", "reply") and not keywords:
raise ValueError(f"规则「{label}」至少要有一个关键词,否则永远不会命中")
if kind == "reply" and not reply:
raise ValueError(f"规则「{label}」是固定口径,必须填要发给客户的话术")
rule_id = str(item.get("id") or "").strip() or _slugify_rule_id(label, seen_ids)
if rule_id in seen_ids:
rule_id = _slugify_rule_id(label, seen_ids)
seen_ids.add(rule_id)
cleaned.append({
"id": rule_id,
"label": label,
"type": kind,
"keywords": keywords,
"instruction": instruction,
"reply": reply,
"enabled": bool(item.get("enabled", True)),
})
return cleaned
def validate_agent(item: Any) -> dict[str, Any]:
"""校验一个智能体。返回的形状就是下发给桌面端的形状。"""
if not isinstance(item, dict):
raise ValueError("智能体格式不对,应该是一个对象")
name = str(item.get("name") or "").strip()
if not name:
raise ValueError("智能体名称不能为空")
if len(name) > 40:
raise ValueError("智能体名称过长(上限 40 字)")
agent_id = str(item.get("id") or "").strip() or _slugify_rule_id(name, set())
if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", agent_id):
raise ValueError("智能体 ID 只能是字母、数字、下划线或连字符(1-64 位)")
role = str(item.get("role") or "").strip() or name
if len(role) > 40:
raise ValueError("角色标签过长(上限 40 字)")
description = str(item.get("description") or "").strip()
if len(description) > 200:
raise ValueError("职责说明过长(上限 200 字)")
persona = str(item.get("persona") or "").strip()
if len(persona) > 4000:
raise ValueError("人设过长(上限 4000 字)")
keywords_raw = item.get("keywords") or []
if not isinstance(keywords_raw, list):
raise ValueError("负责话题必须是数组")
keywords: list[str] = []
for word in keywords_raw:
word = str(word).strip()
if word and word not in keywords:
keywords.append(word)
if len(keywords) > 30:
raise ValueError("负责话题最多 30 个")
try:
priority = int(item.get("priority", 100))
except (TypeError, ValueError) as exc:
raise ValueError("优先级必须是整数") from exc
if not 1 <= priority <= 999:
raise ValueError("优先级必须在 1-999 之间")
return {
"id": agent_id,
"name": name,
"role": role,
"description": description,
"persona": persona,
"keywords": keywords,
"rules": validate_agent_rules(item.get("rules") or []),
"enabled": bool(item.get("enabled", True)),
"priority": priority,
}
def validate_agent_plan(plan: Any, known_ids: set[str]) -> dict[str, Any]:
"""校验启用方案。引用不存在的智能体一律拒绝——这种配置下发出去就是静默失效。"""
if not isinstance(plan, dict):
raise ValueError("启用方案格式不对,应该是一个对象")
mode = str(plan.get("mode") or "single").strip().lower()
if mode not in AGENT_PLAN_MODES:
raise ValueError(f"不支持的协作模式:{mode}")
primary_id = str(plan.get("primary_id") or "").strip()
raw_active = plan.get("active_ids") or []
if isinstance(raw_active, str):
raw_active = raw_active.split(",")
if not isinstance(raw_active, (list, tuple)):
raise ValueError("在岗智能体必须是数组")
active_ids: list[str] = []
for value in raw_active:
value = str(value).strip()
if value and value not in active_ids:
active_ids.append(value)
for value in [primary_id, *active_ids]:
if value and value not in known_ids:
raise ValueError(f"智能体清单里没有 {value}")
if mode == "single":
if not primary_id:
raise ValueError("单角色模式要指定一个当前生效的智能体")
active_ids = [primary_id]
else:
if primary_id and primary_id not in active_ids:
active_ids.insert(0, primary_id)
if len(active_ids) < 2:
raise ValueError("协作模式至少要有两个在岗智能体,否则就是单角色")
if not primary_id:
primary_id = active_ids[0]
return {"mode": mode, "primary_id": primary_id, "active_ids": active_ids}
def validate_config_form(form: dict[str, str], current: dict[str, Any]) -> dict[str, Any]:
"""校验并合并一份桌面端配置。
模型连接参数已经搬去「模型清单 + 角色编排」,这里不再接收也不再校验。库里
老记录带着的那几个字段原样保留(见 RETIRED_CONFIG_KEYS)——删掉是删用户
数据,而且万一要回滚就没得可回。
"""
config = {key: current.get(key) for key in CONFIG_KEYS}
for key in RETIRED_CONFIG_KEYS:
if key in current:
config[key] = current[key]
for key in BOOL_KEYS:
config[key] = form.get(key) == "1"
for key in ("AI_AGENT_NAME", "AI_HOSPITAL_NAME"):
config[key] = form.get(key, "").strip()
if not config["AI_AGENT_NAME"] or not config["AI_HOSPITAL_NAME"]:
raise ValueError("客服名称和机构名称不能为空")
limits = {
"AI_CONTEXT_MAX_ROUNDS": (1, 50),
"AI_MCP_MAX_ROUNDS": (1, 20),
"ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES": (1, 10080),
}
for key, (minimum, maximum) in limits.items():
try:
value = int(form.get(key, current.get(key, CONFIG_DEFAULTS.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:
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
gateway_url = form.get("AI_GATEWAY_URL", "").strip()
if gateway_url and not gateway_url.startswith(("http://", "https://", "/")):
# 这里必须拦住。放过去的话会被拼成
# https://你的域名/gw.example.com/v1/answer 这种没意义的地址,
# 而失败要等到桌面端下次发模型请求才暴露出来。
raise ValueError(
"模型网关地址要么写完整地址(http:// 或 https:// 开头),"
"要么写以 / 开头的路径;留空表示按后台地址自动推算"
)
if len(gateway_url) > 500:
raise ValueError("模型网关地址过长")
config["AI_GATEWAY_URL"] = gateway_url
login_api_url = form.get("ZYT_LOGIN_API_URL", "").strip().rstrip("/")
if login_api_url:
parsed = urllib.parse.urlparse(login_api_url)
if (
parsed.scheme not in ("http", "https")
or not parsed.netloc
or parsed.username is not None
or parsed.password is not None
or parsed.query
or parsed.fragment
):
raise ValueError(
"ZYT 登录 API 域名必须是完整的 http 或 https 地址,"
"不能包含账号、密码、查询参数或锚点"
)
if len(login_api_url) > 500:
raise ValueError("ZYT 登录 API 域名过长")
config["ZYT_LOGIN_API_URL"] = login_api_url
try:
rules_raw = json.loads(form.get("AI_REVIEW_RULES", "[]") or "[]")
except ValueError as exc:
raise ValueError("审核规则不是有效 JSON") from exc
config["AI_REVIEW_RULES"] = validate_review_rules(rules_raw)
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)
internal_package = download_url.startswith(
(
"/api/v2/desktop/update/packages/",
"/api/v2/desktop/update/cos-packages/",
)
)
if not internal_package and (
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 字")
package_sha256 = form.get("package_sha256", "").strip().lower()
if package_sha256 and not re.fullmatch(r"[0-9a-f]{64}", package_sha256):
raise ValueError("安装包 SHA256 格式不正确")
try:
package_size = max(0, int(form.get("package_size", "0") or 0))
rollout_percent = int(form.get("rollout_percent", "100") or 100)
except (TypeError, ValueError) as exc:
raise ValueError("安装包大小或灰度比例格式不正确") from exc
if not 1 <= rollout_percent <= 100:
raise ValueError("灰度比例必须在 1 到 100 之间")
release_channel = form.get("release_channel", "stable").strip().lower()
if release_channel not in {"stable", "beta"}:
raise ValueError("发布渠道只能是 stable 或 beta")
release_state = form.get("status", "published").strip().lower()
if release_state not in {"published", "paused"}:
raise ValueError("发布状态只能是 published 或 paused")
auto_install = form.get("auto_install") == "1"
signature_required = form.get("signature_required") == "1"
if auto_install and (not download_url or not package_sha256):
raise ValueError("自动安装必须提供安装包地址和 SHA256")
return {
"latest_version": version,
"download_url": download_url,
"release_notes": release_notes,
"force_upgrade": force_upgrade,
"auto_install": auto_install,
"package_sha256": package_sha256,
"package_size": package_size,
"signature_required": signature_required,
"release_channel": release_channel,
"rollout_percent": rollout_percent,
"status": release_state,
}