793 lines
34 KiB
Python
793 lines
34 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 hmac
|
||
import json
|
||
import os
|
||
import time
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
from fastapi import Depends, FastAPI, HTTPException, Request, status
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from pydantic import BaseModel, Field, field_validator
|
||
|
||
import admin_backend as backend
|
||
import archive_api
|
||
|
||
# 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 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 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 = ""
|
||
# 选择性审核:命中才转人工,其余自动发送。逐条形状由
|
||
# `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
|
||
|
||
|
||
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") -> FastAPI:
|
||
database = backend.Database(Path(db_path).resolve())
|
||
# 每个碰这个库的服务启动时都要把结构升到当前版本。只起 API、不起网页后台的
|
||
# 部署,以前会撞上 `no such column`——而报错里看不出是漏了迁移。
|
||
database.migrate()
|
||
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 "")
|
||
|
||
async 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 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
|
||
|
||
# ── 认证 ─────────────────────────────────────────────────────────────
|
||
@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/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/stats/model-calls")
|
||
async def model_call_stats(
|
||
days: int = 7, _: Principal = Depends(require("stats:read"))
|
||
) -> dict:
|
||
return {"days": days, **database.model_call_stats(days)}
|
||
|
||
@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",
|
||
_: Principal = Depends(require("stats:read")),
|
||
) -> dict:
|
||
return database.list_model_calls(days, limit, offset, q, purpose)
|
||
|
||
# ── 桌面端配置 ───────────────────────────────────────────────────────
|
||
# 这一组是从老网页后台整体搬过来的("能力开关 / 模型与身份 / 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", ""),
|
||
),
|
||
}
|
||
|
||
@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:
|
||
row = database.release()
|
||
return {
|
||
"latest_version": row["latest_version"],
|
||
"download_url": row["download_url"],
|
||
"release_notes": row["release_notes"],
|
||
"force_upgrade": bool(row["force_upgrade"]),
|
||
"updated_at": row["updated_at"],
|
||
"updated_by": row["updated_by_name"] or "system",
|
||
}
|
||
|
||
@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 "",
|
||
}
|
||
)
|
||
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),
|
||
)
|
||
return {"ok": True, **release}
|
||
|
||
# ── 模型连通性测试 ───────────────────────────────────────────────────
|
||
@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) -> dict:
|
||
supplied = request.headers.get("x-desktop-sync-key", "")
|
||
if not supplied or not hmac.compare_digest(supplied, backend.DESKTOP_SYNC_KEY):
|
||
raise HTTPException(status_code=401, detail="同步凭证无效")
|
||
# 这一份的结构由 `admin_backend.desktop_config_payload` 统一给出。
|
||
# 两处各写一份的话迟早漂移,表现成"某台客户端少了个字段",极难查。
|
||
return backend.desktop_config_payload(
|
||
database,
|
||
scheme=request.headers.get("x-forwarded-proto", request.url.scheme),
|
||
host=request.headers.get("host", ""),
|
||
)
|
||
|
||
# 桌面端回流一次编排调用。补上这条之前,客户端改指 8766 会丢掉全部调用留痕
|
||
# ——配置照常同步、回复照常发,唯独调用记录一条不进库,而那恰恰是出事后用来
|
||
# 解释"这句话怎么来的"的东西。它是老后台退役的最后一块拼图。
|
||
@app.post("/api/v2/model/calls")
|
||
async def log_model_call(request: Request) -> dict:
|
||
supplied = request.headers.get("x-desktop-sync-key", "")
|
||
if not supplied or not hmac.compare_digest(supplied, backend.DESKTOP_SYNC_KEY):
|
||
raise HTTPException(status_code=401, detail="同步凭证无效")
|
||
try:
|
||
record = await request.json()
|
||
except Exception:
|
||
record = {}
|
||
# 落库失败也返回 200:这是观测数据,不能因为它出问题就让桌面端以为
|
||
# 回复流程失败了。`log_model_call` 内部已经吞掉异常并打日志。
|
||
if isinstance(record, dict):
|
||
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,
|
||
desktop_sync_key=backend.DESKTOP_SYNC_KEY,
|
||
)
|
||
|
||
# ── 前端静态资源 ─────────────────────────────────────────────────────
|
||
# 构建产物存在时由本服务一起托管,前后端同源——省掉生产环境的 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(
|
||
"--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()
|
||
|
||
# 建库并按需播种 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()
|