Files
kefu/wechat_rpa/admin_api.py
T
2026-08-27 14:04:28 +08:00

793 lines
34 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""管理后台 JSON APIFastAPI)。
当初另起一个,是因为老的 `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()