Files
kefu/wechat_rpa/admin_api.py
T
2026-09-21 10:34:06 +08:00

1539 lines
66 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 -*-
"""管理后台 JSON API(FastAPI)。
当初另起一个,是因为老的 `admin_backend.py` 是服务端渲染的 HTML 后台——表单 POST
加整页刷新。Vue 前端要的是 JSON API + 明确的权限清单,两种形态没法在一个处理器
里长期共存。
绞杀者模式已经走完:老网页后台(8765)整体退役,`admin_backend.py` 只剩数据层
(Database / 配置校验 / 模型测试),这个模块是唯一的 HTTP 入口。
admin_backend.py 数据层,被本模块和 model_gateway.py 共用
admin_api.py JSON API + Vue 前端静态托管 + 桌面端同步,全在这一个
权限的判法只有一种:`Depends(require("model:write"))`。代码里**永远不判角色名**
——判角色名就等于把运营策略焊死在代码里,加个角色都得发版。
跑起来:
uvicorn admin_api:create_app --factory --host 127.0.0.1 --port 8766
# 或
python admin_api.py --db backend.db --port 8766
"""
from __future__ import annotations
import argparse
import asyncio
import hashlib
import hmac
import json
import os
import re
import time
import urllib.error
import urllib.parse
from pathlib import Path
from typing import Any, Callable
from fastapi import Depends, FastAPI, HTTPException, Query, Request, status
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel, Field, field_validator
import admin_backend as backend
import app_version
import archive_api
import knowledge_api
import zyt_auth
# 账号下拉框里「全部」的取值。用 -1 而不是 0:0 早就有含义了(旧版未归属数据),
# 而"不传"在 FastAPI 里就是 None,同样已经被占用。
ALL_ACCOUNTS = -1
# Vue 开发服务器的默认端口。生产环境前端和 API 同源,这些只在开发时用得上。
DEV_ORIGINS = [
"http://localhost:5173", "http://127.0.0.1:5173",
"http://localhost:3000", "http://127.0.0.1:3000",
]
# ── 请求体 ───────────────────────────────────────────────────────────────────
class LoginBody(BaseModel):
username: str
password: str
device_name: str = "web"
class PasswordBody(BaseModel):
current_password: str
new_password: str
class RoleBody(BaseModel):
code: str
name: str = ""
permissions: list[str] = Field(default_factory=list)
class UserBody(BaseModel):
username: str = ""
password: str = ""
role: str = "viewer"
active: bool = True
class DesktopExchangeBody(BaseModel):
device_id: str = Field(min_length=8, max_length=128)
device_name: str = Field(default="", max_length=120)
app_version: str = Field(default="", max_length=64)
interactive_login: bool = False
class DesktopZytSessionBody(BaseModel):
zyt_token: str = Field(min_length=1, max_length=8192, repr=False)
class DesktopAccountBody(BaseModel):
status: str = "active"
backup_enabled: bool | None = None
backup_interval_minutes: int | None = Field(default=None, ge=1, le=10080)
class DesktopAccountScopeBody(BaseModel):
admin_user_ids: list[int] = Field(default_factory=list)
class ProviderBody(BaseModel):
id: str
name: str = ""
kind: str
base_url: str
# auto:按接口类型补全路径;exact:地址原样使用,一个字符不加
endpoint_mode: str = "auto"
api_key: str = "" # 留空 = 保持原密钥不动
model: str = ""
capabilities: str = "text"
max_tokens: int = 500
temperature: float = 0.35
timeout_ms: int = 30000
max_inflight: int = 32
rpm_limit: int = 0
enabled: bool = True
class RolesPlanBody(BaseModel):
answer_ids: str
judge_id: str = ""
vision_id: str = ""
fallback_ids: str = ""
judge_mode: str = "shadow"
class AgentRuleBody(BaseModel):
"""一条角色规则。
三种类型的必填项不同,逐条校验在 `admin_backend.validate_agent_rules` 里——
这里只保证形状是个对象、字段名对得上。把"guide 必须有 instruction"这种规则
写进 Pydantic,等于同一套约束维护两份,迟早对不上。
"""
id: str = ""
label: str = ""
# guide 补充指令 / forbid 禁止措辞 / reply 固定口径
type: str = "guide"
keywords: list[str] = Field(default_factory=list)
instruction: str = ""
reply: str = ""
enabled: bool = True
class AgentBody(BaseModel):
"""一个智能体 = 人设 + 一组规则。"""
id: str = "" # 留空 = 按名称自动生成
name: str
role: str = ""
description: str = ""
persona: str = ""
keywords: list[str] = Field(default_factory=list)
rules: list[AgentRuleBody] = Field(default_factory=list)
enabled: bool = True
priority: int = 100
class AgentPlanBody(BaseModel):
"""启用与协作方案。single 就是"切换到某一个",collaborate 是"多个同时在场"。"""
mode: str = "single"
primary_id: str = ""
active_ids: list[str] = Field(default_factory=list)
class ConfigBody(BaseModel):
"""桌面端下发配置。
字段名保持和老后台的表单 `name` 完全一致(大写下划线),这样两边共用同一个
`validate_config_form`——校验规则只有一份,不会出现"网页后台拦得住、新后台
放得过"这种两套标准。
**这里没有模型连接参数**。服务类型 / API 地址 / API Key / 模型名称 / 温度 /
max_tokens / 超时全部搬到了「模型清单 + 角色编排」,理由见
`admin_backend.CONFIG_KEYS` 上面那段。剩下的都是客户端行为。
"""
AI_ENABLED: bool = False
AI_DEVELOPMENT_MODE: bool = False
AI_USE_VISION: bool = False
AI_UI_GUARD_ENABLED: bool = False
AI_CONTEXT_ENABLED: bool = False
AI_CONTEXT_MAX_ROUNDS: int = 8
AI_COUNTER_INSULT_ENABLED: bool = False
AI_AGENT_NAME: str = ""
AI_HOSPITAL_NAME: str = ""
AI_MCP_ENABLED: bool = False
AI_MCP_MAX_ROUNDS: int = 5
AI_MCP_SERVERS: list[Any] = Field(default_factory=list)
# 留空 = 按后台自己的地址推算。桌面端不配这个,由后台同步时下发。
AI_GATEWAY_URL: str = ""
# 桌面软件启动时先向本管理端读取该地址,再把账号密码发给 ZYT。
ZYT_LOGIN_API_URL: str = ""
ARCHIVE_AUTO_BACKUP_ENABLED: bool = True
ARCHIVE_AUTO_BACKUP_INTERVAL_MINUTES: int = 60
# 选择性审核:命中才转人工,其余自动发送。逐条形状由
# `admin_backend.validate_review_rules` 校验,这里只接住一个数组。
AI_REVIEW_RULES: list[Any] = Field(default_factory=list)
@field_validator("*", mode="before")
@classmethod
def _null_means_unset(cls, value: Any, info: Any) -> Any:
"""把请求体里的 `null` 当成"没填",换成这一项的出厂默认值。
正常情况下 GET /config 现在不会再回 null,前端也就没有 null 可发。这里
挡的是另一种情况:浏览器里开着这次修复之前拉取的旧页面,表单里还揣着
当时的 null;用户点保存,POST 出去的照样是 null。没有这道,Pydantic 的
严格类型校验会直接拒收,报一句"Input should be a valid boolean"——
对着这句话,人只会怀疑是不是自己填错了什么,看不出问题出在缓存的旧页面。
"""
if value is None:
return backend.CONFIG_DEFAULTS.get(info.field_name)
return value
class ReleaseBody(BaseModel):
latest_version: str
download_url: str = ""
release_notes: str = ""
force_upgrade: bool = False
auto_install: bool = False
package_sha256: str = ""
package_size: int = Field(default=0, ge=0)
signature_required: bool = False
release_channel: str = "stable"
rollout_percent: int = Field(default=100, ge=1, le=100)
status: str = "published"
class ReleasePackagePrepareBody(BaseModel):
file_name: str = Field(min_length=1, max_length=512)
package_sha256: str = Field(pattern=r"^[0-9a-fA-F]{64}$")
package_size: int = Field(gt=0, le=1024 * 1024 * 1024)
class ReleasePackagePartBody(BaseModel):
part_number: int = Field(ge=1, le=10000)
etag: str = Field(min_length=1, max_length=512)
class ReleasePackageMultipartCompleteBody(BaseModel):
upload_id: str = Field(min_length=1, max_length=2048)
parts: list[ReleasePackagePartBody] = Field(min_length=1, max_length=10000)
class UpdateReportBody(BaseModel):
current_version: str = Field(default="", max_length=64)
target_version: str = Field(min_length=1, max_length=64)
status: str = Field(min_length=1, max_length=32)
progress: int = Field(default=0, ge=0, le=100)
error: str = Field(default="", max_length=1000)
class ModelTestBody(BaseModel):
"""连通性测试。
两种用法:给 `provider_id` 就测模型清单里那一条(密钥从库里解出来,永远不
经过前端);不给就用页面上临时填的值测——正在新建一个模型、还没保存的时候
需要这个。
"""
provider_id: str = ""
kind: str = ""
base_url: str = ""
endpoint_mode: str = ""
api_key: str = "" # 留空且指定了 provider_id = 用库里存的密钥
model: str = ""
timeout_seconds: int = 20
def _public_users(database) -> list[dict[str, Any]]:
"""用户列表的对外投影。密码盐和摘要一个字节都不能出接口。"""
return [
{
"id": row["id"],
"username": row["username"],
"role": row["role"],
"active": bool(row["active"]),
"must_change_password": bool(row["must_change_password"]),
"created_at": row["created_at"],
"updated_at": row["updated_at"],
}
for row in database.list_users()
]
class Principal:
"""当前请求的调用者:用户行 + 他实际拥有的权限码。"""
def __init__(self, user, permissions: set[str]):
self.user = user
self.permissions = permissions
@property
def id(self) -> int:
return int(self.user["id"])
def payload(self) -> dict[str, Any]:
return {
"id": self.id,
"username": self.user["username"],
"role": self.user["role"],
"must_change_password": bool(self.user["must_change_password"]),
"permissions": sorted(self.permissions),
}
def _frontend_dist() -> Path:
"""Vue 前端的构建产物目录。
单独抽出来是为了能在测试里替换掉——不然测试结果取决于本机有没有跑过
`vite build`,同一份代码在开发机和 CI 上表现不一样。
"""
return Path(__file__).resolve().parent.parent / "admin-web" / "apps" / "web-antd" / "dist"
def create_app(
db_path: Path | str = "backend.db",
zyt_token_verifier: Callable[[str], dict[str, Any]] | None = None,
zyt_patient_searcher: Callable[[str, str, int, int], dict[str, Any]] | None = None,
) -> FastAPI:
database = backend.Database(Path(db_path).resolve())
# 每个碰这个库的服务启动时都要把结构升到当前版本。只起 API、不起网页后台的
# 部署,以前会撞上 `no such column`——而报错里看不出是漏了迁移。
database.migrate()
release_directory = database.path.parent / "releases"
release_directory.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="企微客服助手管理 API", version="2.0")
app.add_middleware(
CORSMiddleware,
allow_origins=DEV_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
def client_ip(request: Request) -> str:
forwarded = request.headers.get("x-forwarded-for", "").split(",")[0].strip()
return forwarded or (request.client.host if request.client else "")
def current(request: Request) -> Principal:
raw = request.headers.get("authorization", "")
token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
user = database.session(token) if token else None
if user is not None and str(user["token_kind"]) != "api":
user = None
if user is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, detail="登录已失效,请重新登录"
)
return Principal(user, database.permissions_for_user(int(user["id"])))
def current_desktop(request: Request):
raw = request.headers.get("authorization", "")
token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
account = database.desktop_session(token)
if account is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="桌面登录已失效,请重新登录",
)
return account
def require(*codes: str):
"""权限依赖。只认权限码,不认角色名。"""
async def guard(principal: Principal = Depends(current)) -> Principal:
missing = [code for code in codes if code not in principal.permissions]
if missing:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"缺少权限:{'、'.join(missing)}",
)
return principal
return guard
def resolve_admin_tenant(
principal: Principal, account_id: int | None
) -> str | list[str]:
"""把界面上选的客户端账号翻成这次查询的租户范围。
下拉框有三档,这里是它们的含义:
ALL_ACCOUNTS(-1) 全部。有 `desktop-account:all` 的返回 `"*"`(不限
租户);只被授权到若干账号的返回那几个账号的租户,
「全部」对他就是「我能看的全部」,不是所有人的全部
0 / 不传 旧版未归属数据(tenant_id 为空或 default 的老记录)
具体账号 id 那个账号的租户,且必须先过授权检查
返回值有两种形状(一个租户 / 一组租户),下游统一交给
`archive_store.tenant_scope` 和 `admin_backend.tenant_filter` 去翻译成
SQL 条件——范围判断只在这一处做,散到各个接口里迟早会漏一个。
"""
if account_id == ALL_ACCOUNTS:
if "desktop-account:all" in principal.permissions:
return backend.ALL_TENANTS
scoped = database.desktop_account_ids_for_admin(principal.id)
if not scoped:
# 没有被限制到任何账号 = 不受限,和上面一样能看全部。
return backend.ALL_TENANTS
tenants = []
for item in scoped:
account = database.desktop_account(int(item))
if account is not None:
tenants.append(str(account["tenant_id"]))
if not tenants:
raise HTTPException(status_code=403, detail="没有已授权的客户端账号")
return tenants
if not account_id:
if (
"desktop-account:all" not in principal.permissions
and database.desktop_account_ids_for_admin(principal.id)
):
raise HTTPException(status_code=403, detail="请选择已授权的客户端账号")
return "default"
account = database.desktop_account(int(account_id))
if account is None:
raise HTTPException(status_code=404, detail="客户端账号不存在")
if (
"desktop-account:all" not in principal.permissions
and not database.admin_can_access_desktop_account(principal.id, int(account_id))
):
raise HTTPException(status_code=403, detail="无权查看该客户端账号的数据")
return str(account["tenant_id"])
def desktop_login_api_url() -> tuple[str, bool]:
"""返回桌面登录服务地址,以及它是否来自管理端持久化配置。"""
stored = json.loads(database.config()["config_json"])
configured = str(
backend.effective_config(stored).get("ZYT_LOGIN_API_URL") or ""
).strip().rstrip("/")
return (configured or zyt_auth.api_base_url(), bool(configured))
def release_payload(row: Any) -> dict[str, Any]:
keys = set(row.keys()) if hasattr(row, "keys") else set()
def value(name: str, default: Any = "") -> Any:
return row[name] if name in keys else default
return {
"latest_version": str(value("latest_version")),
"download_url": str(value("download_url")),
"release_notes": str(value("release_notes")),
"force_upgrade": bool(value("force_upgrade", 0)),
"auto_install": bool(value("auto_install", 0)),
"package_sha256": str(value("package_sha256")),
"package_size": int(value("package_size", 0) or 0),
"signature_required": bool(value("signature_required", 0)),
"release_channel": str(value("release_channel", "stable")),
"rollout_percent": int(value("rollout_percent", 100) or 100),
"status": str(value("status", "published")),
"updated_at": str(value("updated_at", value("created_at", ""))),
"updated_by": str(
value("updated_by_name", value("created_by_name", "system"))
or "system"
),
}
# ── 桌面账号认证 ─────────────────────────────────────────────────────
@app.get("/api/v2/desktop/auth/config")
async def desktop_auth_config() -> dict:
login_api_url, configured = desktop_login_api_url()
return {
"zyt_api_url": login_api_url,
"terminal": zyt_auth.WECOM_RPA_TERMINAL,
"configured": configured,
}
@app.post("/api/v2/desktop/auth/exchange")
async def desktop_auth_exchange(body: DesktopExchangeBody, request: Request) -> dict:
raw = request.headers.get("authorization", "")
zyt_token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
if not zyt_token:
raise HTTPException(status_code=401, detail="缺少 ZYT 登录令牌")
if zyt_token_verifier is not None:
verifier = zyt_token_verifier
else:
login_api_url, _configured = desktop_login_api_url()
verifier = lambda token: zyt_auth.verify_desktop_token(
token, api_url=login_api_url
)
try:
identity = await asyncio.to_thread(verifier, zyt_token)
token, account = database.create_desktop_session(
identity,
body.device_id,
body.device_name,
body.app_version,
client_ip(request),
interactive_login=body.interactive_login,
)
archive_store = getattr(app.state, "archive_store", None)
if archive_store is not None:
archive_store.save_zyt_session(
str(account["tenant_id"]),
zyt_token,
str(identity.get("user_id") or ""),
)
except (zyt_auth.ZytAuthError, ValueError) as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
return {
"access_token": token,
"expires_in": max(0, int(account["expires_at"]) - int(time.time())),
"account": {
"id": int(account["id"]),
"sn": str(account["zyt_sn"]),
"nickname": str(account["nickname"]),
"tenant_id": str(account["tenant_id"]),
"device_id": str(account["device_id"]),
},
}
@app.post("/api/v2/desktop/auth/zyt-session")
async def desktop_refresh_zyt_session(
body: DesktopZytSessionBody, request: Request,
account: Any = Depends(current_desktop),
) -> dict:
raw = request.headers.get("authorization", "")
desktop_token = raw[7:].strip() if raw.lower().startswith("bearer ") else ""
if zyt_token_verifier is not None:
verifier = zyt_token_verifier
else:
login_api_url, _configured = desktop_login_api_url()
verifier = lambda token: zyt_auth.verify_desktop_token(token, api_url=login_api_url)
try:
identity = await asyncio.to_thread(verifier, body.zyt_token.strip())
if (not isinstance(identity, dict) or not identity.get("user_id")
or str(identity.get("terminal")) != str(zyt_auth.WECOM_RPA_TERMINAL)
or str(identity.get("status") or "") != "active"):
raise zyt_auth.ZytAuthError("ZYT 桌面身份验证失败,请重新验证账号")
except zyt_auth.ZytAuthError as exc:
# The identity adapter preserves transport/JSON causes. An outage must
# not send an otherwise logged-in user into a reauthentication loop.
cause = exc.__cause__
http_code = cause.code if isinstance(cause, urllib.error.HTTPError) else None
if http_code == 403 or any(word in str(exc) for word in ("权限", "无权")):
raise HTTPException(status_code=403, detail="ZYT 账号无权验证患者查询身份,请联系管理员。") from exc
unavailable = (
(http_code is not None and http_code != 401)
or (http_code is None and isinstance(cause, (urllib.error.URLError, OSError, ValueError, TypeError)))
or any(word in str(exc) for word in ("无法连接", "无效数据", "身份信息不完整", "配置不正确", "尚未部署"))
)
if unavailable:
raise HTTPException(status_code=502, detail={
"code": "zyt_unavailable",
"message": "ZYT 身份服务暂不可用,请稍后重试。",
}) from exc
raise HTTPException(status_code=409, detail={
"code": "zyt_session_expired",
"message": "ZYT 患者查询登录已过期,请重新验证账号。",
}) from exc
if str(identity["user_id"]).strip() != str(account["zyt_user_id"]):
raise HTTPException(status_code=403, detail="请使用当前桌面账号重新验证,不能切换到其他账号")
archive_store = getattr(app.state, "archive_store", None)
if archive_store is None:
raise HTTPException(status_code=503, detail="患者查询服务暂不可用")
try:
await asyncio.to_thread(
archive_store.refresh_zyt_session, desktop_token, int(account["id"]),
str(account["tenant_id"]), body.zyt_token, identity,
)
except PermissionError as exc:
raise HTTPException(status_code=401, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=403, detail=str(exc)) from exc
return {"ok": True}
@app.get("/api/v2/desktop/me")
async def desktop_me(account: Any = Depends(current_desktop)) -> dict:
return {
"id": int(account["id"]),
"sn": str(account["zyt_sn"]),
"nickname": str(account["nickname"]),
"tenant_id": str(account["tenant_id"]),
"device_id": str(account["device_id"]),
"expires_at": int(account["expires_at"]),
}
@app.post("/api/v2/desktop/auth/logout")
async def desktop_auth_logout(
request: Request, account: Any = Depends(current_desktop)
) -> dict:
raw = request.headers.get("authorization", "")
database.revoke_desktop_session(raw[7:].strip())
archive_store = getattr(app.state, "archive_store", None)
if archive_store is not None:
archive_store.clear_zyt_session(str(account["tenant_id"]))
database.audit(
None,
"desktop.logout",
f"account={account['id']} device={account['device_id']}",
client_ip(request),
)
return {"ok": True}
# ── 管理端认证 ───────────────────────────────────────────────────────
@app.post("/api/v2/auth/login")
async def login(body: LoginBody, request: Request) -> dict:
ip = client_ip(request)
key = f"{ip}:{body.username.lower()}"
if not backend.LOGIN_LIMITER.allowed(key):
raise HTTPException(status_code=429, detail="登录失败次数过多,请稍后再试")
user = database.authenticate(body.username, body.password)
if not user:
backend.LOGIN_LIMITER.failure(key)
database.audit(None, "login.failed", f"username={body.username}", ip)
raise HTTPException(status_code=401, detail="用户名或密码不正确")
backend.LOGIN_LIMITER.success(key)
token, _ = database.create_token(
int(user["id"]), "api", body.device_name, 30 * 86400
)
database.audit(int(user["id"]), "login.api", body.device_name, ip)
principal = Principal(user, database.permissions_for_user(int(user["id"])))
# 首次登录不拦 API:改密页面本身也要调接口。前端凭
# must_change_password 把用户锁在改密页上,比后端一刀切 403 好用得多
# ——老实现那样连改密接口都调不通,只能去网页后台改。
return {
"access_token": token,
"expires_in": 30 * 86400,
"user": principal.payload(),
}
@app.post("/api/v2/auth/logout")
async def logout(request: Request, principal: Principal = Depends(current)) -> dict:
raw = request.headers.get("authorization", "")
database.revoke(raw[7:].strip() if raw.lower().startswith("bearer ") else "")
database.audit(principal.id, "logout.api", "", client_ip(request))
return {"ok": True}
@app.get("/api/v2/me")
async def me(principal: Principal = Depends(current)) -> dict:
"""前端路由守卫和按钮显隐的唯一数据源。"""
return principal.payload()
@app.post("/api/v2/me/password")
async def change_password(
body: PasswordBody, request: Request, principal: Principal = Depends(current)
) -> dict:
if not backend.valid_password(body.new_password):
raise HTTPException(status_code=400, detail="密码至少 10 位,且需同时含字母和数字")
try:
database.change_password(
principal.id, body.current_password, body.new_password, client_ip(request)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"ok": True}
# ── 角色与权限 ───────────────────────────────────────────────────────
@app.get("/api/v2/permissions")
async def permissions(_: Principal = Depends(require("role:write"))) -> dict:
return {"permissions": database.permission_catalog()}
@app.get("/api/v2/roles")
async def list_roles(_: Principal = Depends(require("user:read"))) -> dict:
return {"roles": database.roles()}
@app.post("/api/v2/roles")
async def save_role(
body: RoleBody, request: Request, principal: Principal = Depends(require("role:write"))
) -> dict:
try:
saved = database.save_role(
body.code, body.name, body.permissions, principal.id, client_ip(request)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"role": saved, "roles": database.roles()}
@app.delete("/api/v2/roles/{code}")
async def delete_role(
code: str, request: Request, principal: Principal = Depends(require("role:write"))
) -> dict:
try:
database.delete_role(code, principal.id, client_ip(request))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"roles": database.roles()}
# ── 用户 ─────────────────────────────────────────────────────────────
@app.get("/api/v2/users")
async def list_users(_: Principal = Depends(require("user:read"))) -> dict:
return {"users": _public_users(database)}
@app.post("/api/v2/users")
async def create_user(
body: UserBody, request: Request, principal: Principal = Depends(require("user:write"))
) -> dict:
if not backend.valid_password(body.password):
raise HTTPException(status_code=400, detail="密码至少 10 位,且需同时含字母和数字")
known = {item["code"] for item in database.roles()}
if body.role not in known:
raise HTTPException(status_code=400, detail=f"角色不存在:{body.role}")
try:
database.create_user(
body.username, body.password, body.role, principal.id, client_ip(request)
)
except Exception as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"users": _public_users(database)}
@app.put("/api/v2/users/{user_id}")
async def update_user(
user_id: int,
body: UserBody,
request: Request,
principal: Principal = Depends(require("user:write")),
) -> dict:
known = {item["code"] for item in database.roles()}
if body.role not in known:
raise HTTPException(status_code=400, detail=f"角色不存在:{body.role}")
try:
database.update_user(
user_id, body.role, body.active, principal.id, client_ip(request)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"users": _public_users(database)}
# ── 客户端账号与设备 ─────────────────────────────────────────────────
@app.get("/api/v2/desktop-accounts")
def list_desktop_accounts(
include_counts: bool = True,
principal: Principal = Depends(current),
) -> dict:
allowed = (
None
if "desktop-account:all" in principal.permissions
else database.desktop_account_ids_for_admin(principal.id)
)
return {
"accounts": database.desktop_accounts(allowed, include_counts=include_counts),
"can_view_all": "desktop-account:all" in principal.permissions,
# 尚未分配任何账号的旧角色仍能查看升级前的未归属数据;一旦分配账号,
# 就必须明确选择授权账号,不能再借默认值绕过范围限制。
"can_view_legacy": (
"desktop-account:all" in principal.permissions or not allowed
),
}
@app.put("/api/v2/desktop-accounts/{account_id}")
async def update_desktop_account(
account_id: int,
body: DesktopAccountBody,
request: Request,
principal: Principal = Depends(require("desktop-account:write")),
) -> dict:
resolve_admin_tenant(principal, account_id)
try:
account = database.update_desktop_account(
account_id,
status=body.status,
backup_enabled=body.backup_enabled,
backup_interval_minutes=body.backup_interval_minutes,
actor_id=principal.id,
ip=client_ip(request),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
if str(account.get("status") or "") == "disabled":
archive_store = getattr(app.state, "archive_store", None)
if archive_store is not None:
archive_store.clear_zyt_session(str(account["tenant_id"]))
return {"account": account}
@app.post("/api/v2/desktop-accounts/{account_id}/revoke-devices")
async def revoke_desktop_devices(
account_id: int,
request: Request,
principal: Principal = Depends(require("desktop-account:write")),
) -> dict:
tenant = resolve_admin_tenant(principal, account_id)
database.revoke_desktop_devices(account_id, principal.id, client_ip(request))
archive_store = getattr(app.state, "archive_store", None)
if archive_store is not None:
archive_store.clear_zyt_session(tenant)
return {"ok": True}
@app.get("/api/v2/desktop-accounts/{account_id}/admins")
async def desktop_account_admins(
account_id: int,
principal: Principal = Depends(require("desktop-account:read")),
) -> dict:
resolve_admin_tenant(principal, account_id)
return {"admin_user_ids": database.desktop_account_admin_ids(account_id)}
@app.put("/api/v2/desktop-accounts/{account_id}/admins")
async def update_desktop_account_admins(
account_id: int,
body: DesktopAccountScopeBody,
request: Request,
principal: Principal = Depends(require("desktop-account:write")),
) -> dict:
resolve_admin_tenant(principal, account_id)
try:
database.set_desktop_account_admins(
account_id, body.admin_user_ids, principal.id, client_ip(request)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"admin_user_ids": database.desktop_account_admin_ids(account_id)}
# ── 模型清单与编排 ───────────────────────────────────────────────────
@app.get("/api/v2/models")
async def list_models(_: Principal = Depends(require("model:read"))) -> dict:
return {
"models": database.model_providers(),
"roles": database.model_roles(),
"kinds": ["dify", "openai", "claude", "comfyui"],
"judge_modes": ["shadow", "score_only", "arbitrate"],
}
@app.post("/api/v2/models")
async def save_model(
body: ProviderBody,
request: Request,
principal: Principal = Depends(require("model:write")),
) -> dict:
try:
database.save_model_provider(
body.model_dump(), principal.id, client_ip(request)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"models": database.model_providers()}
@app.delete("/api/v2/models/{provider_id}")
async def delete_model(
provider_id: str,
request: Request,
principal: Principal = Depends(require("model:write")),
) -> dict:
try:
database.delete_model_provider(provider_id, principal.id, client_ip(request))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"models": database.model_providers()}
@app.post("/api/v2/models/plan")
async def save_plan(
body: RolesPlanBody,
request: Request,
principal: Principal = Depends(require("model:write")),
) -> dict:
try:
version = database.save_model_roles(
body.model_dump(), principal.id, client_ip(request)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"version": version, "roles": database.model_roles()}
# ── 智能体 ───────────────────────────────────────────────────────────
# 模型清单管的是"用哪个模型",这里管的是"用什么身份、按什么规矩说话"。分开
# 是因为这两件事的变更频率差一个数量级:模型半年动一次,话术一周动三次。
@app.get("/api/v2/agents")
async def list_agents(_: Principal = Depends(require("agent:read"))) -> dict:
return {
"agents": database.agents(),
"plan": database.agent_plan(),
"rule_types": list(backend.AGENT_RULE_TYPES),
"plan_modes": list(backend.AGENT_PLAN_MODES),
}
@app.post("/api/v2/agents")
async def save_agent(
body: AgentBody,
request: Request,
principal: Principal = Depends(require("agent:write")),
) -> dict:
try:
database.save_agent(body.model_dump(), principal.id, client_ip(request))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"agents": database.agents(), "plan": database.agent_plan()}
@app.delete("/api/v2/agents/{agent_id}")
async def delete_agent(
agent_id: str,
request: Request,
principal: Principal = Depends(require("agent:write")),
) -> dict:
try:
database.delete_agent(agent_id, principal.id, client_ip(request))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"agents": database.agents(), "plan": database.agent_plan()}
@app.post("/api/v2/agents/plan")
async def save_agent_plan(
body: AgentPlanBody,
request: Request,
principal: Principal = Depends(require("agent:write")),
) -> dict:
try:
version = database.save_agent_plan(
body.model_dump(), principal.id, client_ip(request)
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"version": version, "plan": database.agent_plan()}
# ── 运营 ─────────────────────────────────────────────────────────────
@app.get("/api/v2/stats/model-calls")
async def model_call_stats(
days: int = 7,
account_id: int | None = None,
principal: Principal = Depends(require("stats:read")),
) -> dict:
return {
"days": days,
**database.model_call_stats(
days, resolve_admin_tenant(principal, account_id)
),
}
@app.get("/api/v2/stats/model-calls/log")
async def model_call_log(
days: int = 7,
limit: int = 50,
offset: int = 0,
q: str = "",
# chat = 真实对话(默认);guard = 界面识别;留空 = 全都要
purpose: str = "chat",
account_id: int | None = None,
principal: Principal = Depends(require("stats:read")),
) -> dict:
return database.list_model_calls(
days,
limit,
offset,
q,
purpose,
resolve_admin_tenant(principal, account_id),
)
@app.get("/api/v2/stats/model-usage")
async def model_usage_stats(
days: int = Query(default=7, ge=1, le=90),
purpose: str = Query(default="", pattern="^(chat|guard|knowledge)?$"),
provider_id: str = Query(default="", max_length=256),
model: str = Query(default="", max_length=512),
account_id: int | None = None,
principal: Principal = Depends(require("stats:read")),
) -> dict:
return database.model_usage_stats(
days, resolve_admin_tenant(principal, account_id), purpose, provider_id, model)
@app.get("/api/v2/stats/model-usage/log")
async def model_usage_log(
days: int = Query(default=7, ge=1, le=90),
limit: int = Query(default=50, ge=1, le=200),
offset: int = Query(default=0, ge=0),
purpose: str = Query(default="", pattern="^(chat|guard|knowledge)?$"),
provider_id: str = Query(default="", max_length=256),
model: str = Query(default="", max_length=512),
account_id: int | None = None,
principal: Principal = Depends(require("stats:read")),
) -> dict:
return database.list_model_usage(
days, limit, offset, resolve_admin_tenant(principal, account_id), purpose, provider_id, model)
# ── 桌面端配置 ───────────────────────────────────────────────────────
# 这一组是从老网页后台整体搬过来的("能力开关 / 模型与身份 / MCP 服务器"
# 三个区块)。桌面端启动和定时同步都拉这份配置,改错一个字段全线客户端
# 立刻受影响——所以校验函数直接复用老后台那一份,不另写一套。
@app.get("/api/v2/config")
async def get_config(
request: Request, _: Principal = Depends(require("config:read"))
) -> dict:
row = database.config()
stored = json.loads(row["config_json"])
# 只回当前在用的字段。库里老记录还带着已退休的模型参数(不删用户
# 数据),但把它们发给前端只会让人以为那里还能改。
config = backend.effective_config(stored)
return {
"config": config,
"version": int(row["version"]),
"updated_at": row["updated_at"],
"updated_by": row["updated_by_name"] or "system",
"bool_keys": sorted(backend.BOOL_KEYS),
# 已经搬走的字段,连同它们现在的去处。前端拿它渲染一条指路说明,
# 而不是让人对着一个"以前在这儿的东西不见了"的页面发愣。
"retired_keys": list(backend.RETIRED_CONFIG_KEYS),
"model_settings_moved_to": "AI 模型 → 模型清单 / 角色编排",
# 留空时实际会下发什么。不显示出来的话,"自动推算"对使用的人就是
# 一个黑盒——出问题时没法判断是推错了还是网关没起。
"gateway_url_effective": backend.derive_gateway_url(
str(config.get("AI_GATEWAY_URL") or ""),
request.headers.get("x-forwarded-proto", request.url.scheme),
request.headers.get("host", ""),
),
# 登录窗口在用户输入账号前会读取公开配置接口。这里显示它最终会
# 拿到哪个地址,便于管理员保存后当场核对。
"login_api_url_effective": desktop_login_api_url()[0],
}
@app.post("/api/v2/config")
async def save_config(
body: ConfigBody,
request: Request,
principal: Principal = Depends(require("config:write")),
) -> dict:
current = json.loads(database.config()["config_json"])
# validate_config_form 收的是 HTML 表单那种"全是字符串"的字典,布尔用
# "1"/"" 表示。这里把 JSON 转成它认识的形状,而不是把它改成认 JSON——
# 老后台还在跑,改它等于同时动两个正在服务的系统。
form: dict[str, str] = {}
for key, value in body.model_dump().items():
if key in backend.BOOL_KEYS:
form[key] = "1" if value else ""
elif key in ("AI_MCP_SERVERS", "AI_REVIEW_RULES"):
form[key] = json.dumps(value, ensure_ascii=False)
else:
form[key] = str(value)
try:
config = backend.validate_config_form(form, current)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
version = database.save_config(config, principal.id, client_ip(request))
return {"ok": True, "version": version}
# ── 桌面端版本升级 ───────────────────────────────────────────────────
@app.get("/api/v2/release")
async def get_release(_: Principal = Depends(require("config:read"))) -> dict:
return release_payload(database.release())
@app.post("/api/v2/release")
async def save_release(
body: ReleaseBody,
request: Request,
principal: Principal = Depends(require("release:write")),
) -> dict:
try:
release = backend.validate_release_form(
{
"latest_version": body.latest_version,
"download_url": body.download_url,
"release_notes": body.release_notes,
"force_upgrade": "1" if body.force_upgrade else "",
"auto_install": "1" if body.auto_install else "",
"package_sha256": body.package_sha256,
"package_size": str(body.package_size),
"signature_required": "1" if body.signature_required else "",
"release_channel": body.release_channel,
"rollout_percent": str(body.rollout_percent),
"status": body.status,
}
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
database.save_release(
release["latest_version"],
release["download_url"],
release["release_notes"],
release["force_upgrade"],
principal.id,
client_ip(request),
auto_install=release["auto_install"],
package_sha256=release["package_sha256"],
package_size=release["package_size"],
signature_required=release["signature_required"],
release_channel=release["release_channel"],
rollout_percent=release["rollout_percent"],
status=release["status"],
)
return {"ok": True, **release}
@app.post("/api/v2/release/package/prepare")
async def prepare_release_package(
body: ReleasePackagePrepareBody,
request: Request,
principal: Principal = Depends(require("release:write")),
) -> dict:
"""签发 COS 直传地址;EXE 内容不会进入管理端进程或本机磁盘。"""
try:
store = app.state.archive_store
prepared = store.prepare_release_package(
{
"original_filename": body.file_name,
"sha256": body.package_sha256.lower(),
"size_bytes": body.package_size,
}
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status_code=502, detail=f"COS 预上传失败:{exc}"
) from exc
database.audit(
principal.id,
"release.package.prepare",
f"file={prepared['file_name']} size={prepared['package_size']}",
client_ip(request),
)
return prepared
@app.post("/api/v2/release/package/{media_id}/complete")
async def complete_release_package(
media_id: str,
request: Request,
principal: Principal = Depends(require("release:write")),
) -> dict:
try:
package = app.state.archive_store.complete_release_package(media_id)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc).strip("'")) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"COS 文件校验失败:{exc}") from exc
database.audit(
principal.id,
"release.package.complete",
f"media={media_id} size={package['package_size']}",
client_ip(request),
)
return package
@app.post("/api/v2/release/package/{media_id}/multipart-complete")
async def complete_multipart_release_package(
media_id: str,
body: ReleasePackageMultipartCompleteBody,
request: Request,
principal: Principal = Depends(require("release:write")),
) -> dict:
try:
package = app.state.archive_store.complete_multipart_release_package(
media_id,
body.upload_id,
[part.model_dump() for part in body.parts],
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc).strip("'")) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"COS 分块合并失败:{exc}") from exc
database.audit(
principal.id,
"release.package.complete",
f"media={media_id} multipart=1 size={package['package_size']}",
client_ip(request),
)
return package
@app.get("/api/v2/releases")
async def release_history(
_: Principal = Depends(require("config:read")),
) -> dict:
current = release_payload(database.release())
return {
"history": [release_payload(row) for row in database.release_history()],
"reports": database.update_report_summary(current["latest_version"]),
}
@app.get("/api/v2/desktop/update/packages/{filename}")
async def desktop_update_package(filename: str) -> FileResponse:
if Path(filename).name != filename or not filename.lower().endswith(".exe"):
raise HTTPException(status_code=404, detail="升级包不存在")
target = (release_directory / filename).resolve()
if target.parent != release_directory.resolve() or not target.is_file():
raise HTTPException(status_code=404, detail="升级包不存在")
return FileResponse(
target,
media_type="application/vnd.microsoft.portable-executable",
filename=filename,
)
@app.get("/api/v2/desktop/update/cos-packages/{media_id}/{filename}")
async def desktop_update_cos_package(media_id: str, filename: str) -> RedirectResponse:
try:
url = app.state.archive_store.release_package_download_url(
media_id, filename
)
except KeyError as exc:
raise HTTPException(status_code=404, detail=str(exc).strip("'")) from exc
except RuntimeError as exc:
raise HTTPException(status_code=409, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(status_code=502, detail=f"COS 下载签名失败:{exc}") from exc
return RedirectResponse(url=url, status_code=307)
@app.get("/api/v2/desktop/update/manifest")
async def desktop_update_manifest(
request: Request,
current_version: str = "",
device_id: str = "",
channel: str = "stable",
) -> dict:
release = release_payload(database.release())
latest = release["latest_version"]
eligible = release["status"] == "published" and release["release_channel"] == channel
if eligible and release["rollout_percent"] < 100:
bucket = int(
hashlib.sha256(f"{device_id}:{latest}".encode("utf-8")).hexdigest()[:8],
16,
) % 100 + 1
eligible = bucket <= release["rollout_percent"]
update_available = bool(
eligible and app_version.is_newer_version(latest, current_version)
)
download_url = release["download_url"]
if download_url.startswith("/"):
forwarded_scheme = str(
request.headers.get("x-forwarded-proto") or request.url.scheme
).split(",", 1)[0].strip()
forwarded_host = str(
request.headers.get("x-forwarded-host")
or request.headers.get("host")
or request.url.netloc
).split(",", 1)[0].strip()
public_base = (
f"{forwarded_scheme}://{forwarded_host}/"
if forwarded_scheme and forwarded_host
else str(request.base_url)
)
download_url = urllib.parse.urljoin(
public_base, download_url.lstrip("/")
)
return {
**release,
"download_url": download_url,
"update_available": update_available,
"eligible": eligible,
}
@app.post("/api/v2/desktop/update/report")
async def desktop_update_report(
body: UpdateReportBody,
account: Any = Depends(current_desktop),
) -> dict:
allowed = {
"available",
"downloading",
"downloaded",
"installing",
"installed",
"failed",
"deferred",
}
if body.status not in allowed:
raise HTTPException(status_code=400, detail="升级状态不正确")
if not app_version.normalize_version(body.target_version):
raise HTTPException(status_code=400, detail="目标版本号不正确")
database.record_update_report(
int(account["id"]),
str(account["device_id"]),
body.current_version,
body.target_version,
body.status,
body.progress,
body.error,
)
return {"ok": True}
# ── 模型连通性测试 ───────────────────────────────────────────────────
@app.post("/api/v2/models/test")
async def test_model(
body: ModelTestBody,
request: Request,
principal: Principal = Depends(require("model:write")),
) -> dict:
"""真的向模型发一次最小请求,返回不含密钥的诊断结果。
要 `model:write` 而不是 `model:read`:这个动作会拿着真实密钥向外网发请
求,还可能产生费用。只读用户能看模型清单,但不该能替公司花钱。
密钥来源有两处,都不经过前端:指定了 `provider_id` 就从库里解密取;否则
用请求里临时填的(新建模型还没保存时要用)。
"""
kind = str(body.kind or "").strip().lower()
base_url = str(body.base_url or "").strip()
model = str(body.model or "").strip()
api_key = str(body.api_key or "").strip()
endpoint_mode = str(body.endpoint_mode or "").strip().lower()
label = ""
if body.provider_id:
match = next(
(
item
for item in database.model_providers(include_secrets=True)
if str(item.get("id")) == body.provider_id
),
None,
)
if match is None:
raise HTTPException(status_code=404, detail="模型不存在")
label = str(match.get("name") or match.get("id") or "")
kind = kind or str(match.get("kind") or "")
base_url = base_url or str(match.get("base_url") or "")
model = model or str(match.get("model") or "")
# 前端提交空密钥 = 用库里存的那把。这样"测一下现有配置通不通"
# 不需要把密钥再发一遍,也就没有它在网络上多走一趟的机会。
api_key = api_key or str(match.get("api_key") or "")
endpoint_mode = endpoint_mode or str(match.get("endpoint_mode") or "auto")
if kind == "claude":
# 老后台的测试器只认 openai / dify / comfyui 三种。Claude 是新增
# 的出口类型,端点和鉴权头都不一样,硬套 openai 只会得到 404。
raise HTTPException(
status_code=400,
detail="Claude 出口暂不支持一键连通性测试,请在模型网关的调用统计里确认",
)
if kind not in backend.PROVIDER_TYPES:
raise HTTPException(
status_code=400,
detail=f"不支持的接口类型:{kind or '(空)'}",
)
if not base_url:
raise HTTPException(status_code=400, detail="接口地址不能为空")
try:
config = backend.model_test_config(
{
"AI_PROVIDER_TYPE": kind,
"AI_API_BASE": base_url,
"AI_MODEL": model,
"AI_API_KEY": api_key,
"AI_TIMEOUT": body.timeout_seconds,
"AI_ENDPOINT_MODE": endpoint_mode or "auto",
},
{},
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
result = backend.test_model_connection(config)
database.audit(
principal.id,
"model.test",
f"provider={label or body.provider_id or '临时'}, ok={int(bool(result['ok']))}, "
f"http={result.get('http_status')}, endpoint={str(result.get('endpoint') or '')[:200]}",
client_ip(request),
)
# 故意返回 200 而不是 502:测试本身跑成功了,"连不上"是它的结论,
# 不是这个接口的失败。返 5xx 会让前端的错误拦截器弹一个通用报错,
# 把真正有用的诊断信息盖掉。
return {"result": result, "label": label}
# ── 桌面端同步 ───────────────────────────────────────────────────────
# 桌面客户端拉的就是这一个接口。老后台(8765)已退役,这里是唯一来源;
# 客户端仍保留 v2→v1 回退,是为了兼容还没升级到新服务端的部署。
@app.get("/api/v2/desktop/config")
async def desktop_config(
request: Request, account: Any = Depends(current_desktop)
) -> dict:
# 这一份的结构由 `admin_backend.desktop_config_payload` 统一给出。
# 两处各写一份的话迟早漂移,表现成"某台客户端少了个字段",极难查。
payload = backend.desktop_config_payload(
database,
scheme=request.headers.get("x-forwarded-proto", request.url.scheme),
host=request.headers.get("host", ""),
desktop_account=account,
)
knowledge = app.state.knowledge_store
tenant = str(account["tenant_id"])
counts = await asyncio.to_thread(knowledge.stats, tenant)
settings = await asyncio.to_thread(knowledge.settings, tenant)
payload["knowledge"] = {"published": counts.get("published", 0),
"enabled": bool(settings["enabled"]), "updated_at": settings["updated_at"]}
return payload
# 桌面端回流一次编排调用。补上这条之前,客户端改指 8766 会丢掉全部调用留痕
# ——配置照常同步、回复照常发,唯独调用记录一条不进库,而那恰恰是出事后用来
# 解释"这句话怎么来的"的东西。它是老后台退役的最后一块拼图。
@app.post("/api/v2/model/calls")
async def log_model_call(
request: Request, account: Any = Depends(current_desktop)
) -> dict:
try:
record = await request.json()
except Exception:
record = {}
# 落库失败也返回 200:这是观测数据,不能因为它出问题就让桌面端以为
# 回复流程失败了。`log_model_call` 内部已经吞掉异常并打日志。
if isinstance(record, dict):
record["desktop_account_id"] = int(account["id"])
record["tenant_id"] = str(account["tenant_id"])
database.log_model_call(record)
return {"ok": True}
@app.get("/api/v2/audit")
async def audit_log(
limit: int = 200, _: Principal = Depends(require("audit:read"))
) -> dict:
return {"entries": database.audit_entries(limit)}
@app.get("/api/v2/health")
async def health() -> dict:
return {"status": "ok", "time": time.strftime("%Y-%m-%d %H:%M:%S")}
# 聊天归档是独立新模块,只复用当前登录、权限和审计能力。
# 必须在 SPA 通配路由之前注册,避免 /api 请求被静态页入口截住。
archive_api.register_archive_routes(
app,
database,
current,
require,
client_ip,
current_desktop=current_desktop,
resolve_admin_tenant=resolve_admin_tenant,
zyt_api_url=lambda: desktop_login_api_url()[0],
zyt_patient_searcher=zyt_patient_searcher,
)
knowledge_api.register_knowledge_routes(
app, database, require, client_ip, resolve_admin_tenant, current_desktop,
)
# ── 前端静态资源 ─────────────────────────────────────────────────────
# 构建产物存在时由本服务一起托管,前后端同源——省掉生产环境的 CORS 配置,
# 也省掉一个 Nginx location。开发时前端跑 Vite dev server,走它自己的代理。
dist = _frontend_dist()
if (dist / "index.html").is_file():
from fastapi.staticfiles import StaticFiles
from starlette.responses import FileResponse
# 判据用 index.html 而不是目录本身:构建中途、或者只剩一个空 dist 目录
# 的时候,目录是在的但没有可服务的东西,挂上去只会让 SPA 兜底路由把
# 每个请求都回一个不存在的文件。
#
# 静态子目录也必须逐个确认存在——StaticFiles 的构造函数遇到不存在的
# 目录**直接抛异常**,整个服务起不来。vite 默认输出的是 js/ 和 css/,
# 不是 assets/;写死 assets 的话,一旦真的有了构建产物,API 就崩在启动。
for name in ("assets", "js", "css", "static"):
sub = dist / name
if sub.is_dir():
app.mount(f"/{name}", StaticFiles(directory=sub), name=name)
@app.get("/{full_path:path}")
async def spa(full_path: str):
"""SPA 的 history 路由:非 /api 的路径一律回 index.html。
少了这一条,用户在 /system/roles 上按 F5 会拿到 404——前端路由是
浏览器端的,服务器上并不存在那个文件。
"""
if full_path.startswith("api/"):
raise HTTPException(status_code=404, detail="接口不存在")
candidate = dist / full_path
if full_path and candidate.is_file():
return FileResponse(candidate)
return FileResponse(dist / "index.html")
return app
def main() -> None:
parser = argparse.ArgumentParser(description="管理后台 JSON API")
parser.add_argument("--db", default="backend.db")
parser.add_argument("--host", default="127.0.0.1")
parser.add_argument("--port", type=int, default=8766)
parser.add_argument(
"--zyt-api-url",
default=os.environ.get("ZYT_API_BASE_URL", zyt_auth.DEFAULT_ZYT_API_BASE_URL),
help="ZYT 用户 API 根地址,例如 https://example.com",
)
parser.add_argument(
"--runtime-file",
default=str(backend.DEFAULT_RUNTIME_FILE),
help="发布实际监听地址,供同机桌面端自动发现",
)
parser.add_argument(
"--initial-admin-password",
default=os.environ.get(
"WECOM_ADMIN_INITIAL_PASSWORD", backend.DEFAULT_ADMIN_PASSWORD
),
help="首次创建数据库时的 admin 密码",
)
parser.add_argument(
"--reset-admin-password",
action="store_true",
help="交互式重置 admin 密码后退出",
)
args = parser.parse_args()
zyt_auth.DEFAULT_ZYT_API_BASE_URL = str(args.zyt_api_url).strip().rstrip("/")
# 建库并按需播种 admin。这两件事以前只有老网页后台的 main() 做——不搬过来的
# 话,全新部署起来之后库里一个用户都没有,Vue 管理端登录页永远进不去,而且
# 报的是"用户名或密码不正确",完全看不出是根本没建过账号。
database = backend.Database(Path(args.db).resolve())
created = database.initialize(args.initial_admin_password)
if args.reset_admin_password:
import getpass
first = getpass.getpass("新的 admin 密码:")
if first != getpass.getpass("再次输入:"):
raise SystemExit("两次输入不一致")
if not backend.valid_password(first):
raise SystemExit("密码至少 10 位,并同时包含字母和数字")
database.reset_admin_password(first)
print("admin 密码已重置,下次登录时必须再次修改。")
return
if created:
print("首次登录账号:admin")
print(f"首次登录密码:{args.initial_admin_password}")
print("登录后必须立即修改初始密码。")
if args.host not in ("127.0.0.1", "localhost", "::1"):
print("警告:当前监听非本机地址;生产环境请通过 HTTPS 反向代理访问。")
import uvicorn
# 发布本机地址,桌面端靠它自动发现后台。
#
# 这件事以前只有老网页后台(8765)做。不搬过来的话,老后台一停,桌面端的
# 自动发现就永远指向一个没人监听的 8765,报出来的是"无法连接后台"——而真正
# 在跑的 8766 从来没被试过。这是老后台能退役的前提之一。
runtime_path = Path(args.runtime_file)
backend.write_runtime_info(runtime_path, args.host, args.port)
try:
uvicorn.run(
create_app(args.db), host=args.host, port=args.port, log_level="info"
)
finally:
backend.clear_runtime_info(runtime_path, args.port)
if __name__ == "__main__":
main()