gengx
This commit is contained in:
@@ -0,0 +1,792 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user