1539 lines
66 KiB
Python
1539 lines
66 KiB
Python
# -*- 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()
|