评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐: 适配层 - 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补 list_domains 与 get_account 余额 - NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到 到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由 - base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商 把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、 腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本 连同 MDN 链接弹给用户 动作层与 DNS - 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计 - 新增 Cloudflare DNS 记录的增删改查 定时同步 - scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30) - 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日 - sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期 会显示「成功 1」,用户以为数据拉到了、实际一条没有 数据一致性 - AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染, 缺了则所有按钮永远出不来 - import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步 把所有云端资产当新增再建一份 - _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未 实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量 补标签不覆盖,用户自建平台不动 前端如实展示 - cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回 价格,而汇总处又把 0 当「无费用」排除,两处口径不一致 - 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来 - DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底, 否则 Cloudflare 代理勾选框永远不出现 测试:257 -> 309 passed
196 lines
7.1 KiB
Python
196 lines
7.1 KiB
Python
"""vps-manager 后端入口
|
||
|
||
- 注册资产 CRUD 与统计路由
|
||
- 挂载本地静态资源(Vue/Tailwind/前端逻辑)
|
||
- 渲染前端 SPA 入口页面
|
||
- 启用 CORS(便于外部程序/其他 AI 跨域调用写入接口)
|
||
- 版本:由 VERSION 文件驱动
|
||
"""
|
||
|
||
from contextlib import asynccontextmanager, suppress
|
||
from pathlib import Path
|
||
import asyncio
|
||
import logging
|
||
import subprocess
|
||
|
||
from fastapi import FastAPI
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from fastapi.responses import FileResponse, HTMLResponse
|
||
from starlette.staticfiles import StaticFiles as StarletteStaticFiles
|
||
from jinja2 import Environment, FileSystemLoader
|
||
from sqlmodel import Session
|
||
|
||
from app.core.config import settings
|
||
from app.core.seed import seed_providers
|
||
from app.database import assets_engine, init_db
|
||
from app.routers import accounts, actions, agent, assets, credentials, monitor, notify, providers, ssl, stats, sync
|
||
from app.services import cleanup_service
|
||
|
||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||
STATIC_DIR = BASE_DIR / "static"
|
||
TEMPLATE_DIR = BASE_DIR / "app" / "templates"
|
||
STATIC_DIR.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def _asset_version() -> str:
|
||
"""静态资源版本号:取 static 目录内最新文件 mtime,代码更新后自动变更。
|
||
|
||
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
|
||
时存下的条目会被视为新鲜而不再回源验证)。
|
||
启动时计算一次并缓存:静态资源只在代码更新时变化,而更新后服务会重启,
|
||
避免每次页面请求都遍历 stat 整个 static 目录。
|
||
"""
|
||
global _ASSET_VERSION_CACHE
|
||
if _ASSET_VERSION_CACHE is None:
|
||
latest = 0
|
||
for p in STATIC_DIR.rglob("*"):
|
||
if p.is_file():
|
||
latest = max(latest, int(p.stat().st_mtime))
|
||
_ASSET_VERSION_CACHE = str(latest)
|
||
return _ASSET_VERSION_CACHE
|
||
|
||
|
||
_ASSET_VERSION_CACHE: str | None = None
|
||
|
||
|
||
def _read_app_version() -> str:
|
||
"""语义版本号:读取项目根目录 VERSION 文件(缺失时回退 dev)"""
|
||
try:
|
||
return (BASE_DIR / "VERSION").read_text(encoding="utf-8").strip() or "dev"
|
||
except OSError:
|
||
return "dev"
|
||
|
||
|
||
def _read_git_commit() -> str:
|
||
"""当前 git 短哈希:用于比对本地与线上代码是否一致(非 git 环境回退 unknown)"""
|
||
try:
|
||
out = subprocess.run(
|
||
["git", "rev-parse", "--short", "HEAD"],
|
||
cwd=BASE_DIR, capture_output=True, text=True, timeout=5,
|
||
)
|
||
return out.stdout.strip() or "unknown"
|
||
except Exception:
|
||
return "unknown"
|
||
|
||
|
||
APP_VERSION = _read_app_version()
|
||
GIT_COMMIT = _read_git_commit()
|
||
|
||
# 全局日志配置:统一格式,便于生产环境排查
|
||
logging.basicConfig(
|
||
level=logging.INFO,
|
||
format="%(asctime)s %(levelname)s [%(name)s] %(message)s",
|
||
)
|
||
|
||
# 直接使用 Jinja2 Environment 渲染(规避 Starlette Jinja2Templates 在 Python 3.14 下的缓存兼容问题)
|
||
jinja_env = Environment(loader=FileSystemLoader(TEMPLATE_DIR), autoescape=True)
|
||
|
||
|
||
def _warn_insecure_config() -> None:
|
||
"""把「暴露面 vs 密钥」的配置缺口打到启动日志里,避免静默裸奔
|
||
|
||
服务默认监听 0.0.0.0,而密钥留空时外部来源会被 fail-closed 拒绝:
|
||
这意味着 funnel/公网访问直接不可用,原因必须在日志里说清。
|
||
"""
|
||
log = logging.getLogger(__name__)
|
||
if not settings.API_KEY:
|
||
log.warning("未配置 API_KEY:外部来源的写操作一律 401,仅 tailnet/本机可用")
|
||
if not settings.AGENT_KEY:
|
||
log.warning(
|
||
"未配置 AGENT_KEY:外部来源的 Agent 上报一律 401(tailnet 内的 Agent 不受影响)"
|
||
)
|
||
|
||
|
||
@asynccontextmanager
|
||
async def lifespan(_: FastAPI):
|
||
"""应用启动时自动建表、初始化预设平台,并启动监控数据定期清理任务"""
|
||
init_db()
|
||
_warn_insecure_config()
|
||
with Session(assets_engine) as session:
|
||
seed_providers(session)
|
||
cleanup_task = asyncio.create_task(cleanup_service.cleanup_loop())
|
||
yield
|
||
cleanup_task.cancel()
|
||
# 等取消真正落地:只 cancel 不 await 的话,事件循环关闭时该 task 仍处于 pending,
|
||
# 会打出 "Task was destroyed but it is pending",也可能跳过清理协程的收尾逻辑
|
||
with suppress(asyncio.CancelledError):
|
||
await cleanup_task
|
||
|
||
|
||
app = FastAPI(title=settings.APP_NAME, version=f"{APP_VERSION} ({GIT_COMMIT})", lifespan=lifespan)
|
||
|
||
# 启动即打印版本号,部署后可通过 journalctl 快速确认线上运行版本
|
||
logging.getLogger(__name__).info("vps-manager 启动:版本 %s,commit %s", APP_VERSION, GIT_COMMIT)
|
||
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=[o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()],
|
||
allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||
allow_headers=["Content-Type", "X-API-Key", "X-Agent-Key"],
|
||
allow_credentials=True,
|
||
)
|
||
|
||
app.include_router(assets.router)
|
||
app.include_router(providers.router)
|
||
app.include_router(accounts.router)
|
||
app.include_router(credentials.router)
|
||
app.include_router(stats.router)
|
||
app.include_router(agent.router)
|
||
app.include_router(monitor.router)
|
||
app.include_router(sync.router)
|
||
app.include_router(actions.router)
|
||
app.include_router(notify.router)
|
||
app.include_router(ssl.router)
|
||
|
||
# 静态资源:no-cache(每次回源校验 ETag)。避免启发式缓存导致旧版本残留,
|
||
# 与 SW 的 no-store 回源配合,保证代码更新后立即生效。
|
||
class NoCacheStaticFiles(StarletteStaticFiles):
|
||
def file_response(self, *args, **kwargs):
|
||
resp = super().file_response(*args, **kwargs)
|
||
resp.headers.setdefault("Cache-Control", "no-cache")
|
||
return resp
|
||
|
||
app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
||
|
||
|
||
@app.get("/", include_in_schema=False)
|
||
def index() -> HTMLResponse:
|
||
"""前端 SPA 入口
|
||
|
||
no-cache:HTML 必须每次回源校验,否则浏览器启发式缓存会持有旧 HTML,
|
||
其中引用的 ?v= 静态资源版本号也是旧的,导致部署后用户看到旧版。
|
||
"""
|
||
html = jinja_env.get_template("index.html").render(
|
||
app_name=settings.APP_NAME, asset_version=_asset_version(),
|
||
app_version=APP_VERSION, git_commit=GIT_COMMIT,
|
||
)
|
||
return HTMLResponse(html, headers={"Cache-Control": "no-cache"})
|
||
|
||
|
||
@app.get("/sw.js", include_in_schema=False)
|
||
def service_worker() -> FileResponse:
|
||
"""Service Worker(置于根路径以使 scope 覆盖全站)
|
||
|
||
no-cache:保证浏览器每次导航都校验 SW 是否有更新,
|
||
否则浏览器可能长时间持有旧版 SW(默认更新检查间隔长)。
|
||
"""
|
||
return FileResponse(
|
||
STATIC_DIR / "sw.js",
|
||
media_type="application/javascript",
|
||
headers={
|
||
"Service-Worker-Allowed": "/",
|
||
"Cache-Control": "no-cache",
|
||
},
|
||
)
|
||
|
||
|
||
@app.get("/health")
|
||
def health_check() -> dict:
|
||
"""健康检查(version/commit 动态读取,便于验证自动更新与比对本地版本)"""
|
||
return {
|
||
"status": "ok",
|
||
"app": "vps-manager",
|
||
"version": APP_VERSION,
|
||
"commit": GIT_COMMIT,
|
||
}
|