feat: 版本号管理机制(VERSION文件+git哈希,部署/启动/UI/health均展示) + 平台账号管理(accounts表CRUD,资产所属账号候选联动,重命名同步引用)
This commit is contained in:
+3
-3
@@ -1,6 +1,6 @@
|
||||
"""SQLite 数据库连接与初始化(分库)
|
||||
|
||||
- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount
|
||||
- assets.db :Provider / Asset / VPSDetail / DomainDetail / AIAccount / Account
|
||||
- metrics.db:MetricPoint / ServerInfo / SecurityCheck / EventLog
|
||||
"""
|
||||
|
||||
@@ -47,7 +47,7 @@ engine = assets_engine
|
||||
|
||||
def init_db() -> None:
|
||||
"""分库建表(幂等,可重复调用)"""
|
||||
from app.models.asset import AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
||||
from app.models.asset import Account, AIAccount, Asset, CloudflareDetail, DomainDetail, VPSDetail # noqa: F401
|
||||
from app.models.monitor import ( # noqa: F401
|
||||
EventLog,
|
||||
MetricPoint,
|
||||
@@ -57,7 +57,7 @@ def init_db() -> None:
|
||||
from app.models.provider import Provider # noqa: F401
|
||||
from app.models.ssl import SiteCert, Subdomain # noqa: F401
|
||||
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert]
|
||||
asset_models = [Provider, Asset, VPSDetail, DomainDetail, AIAccount, CloudflareDetail, Subdomain, SiteCert, Account]
|
||||
metric_models = [MetricPoint, ServerInfo, SecurityCheck, EventLog]
|
||||
|
||||
SQLModel.metadata.create_all(
|
||||
|
||||
+40
-5
@@ -10,6 +10,7 @@ from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
import asyncio
|
||||
import logging
|
||||
import subprocess
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
@@ -21,7 +22,7 @@ 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 agent, assets, monitor, notify, providers, ssl, stats, sync
|
||||
from app.routers import accounts, agent, assets, monitor, notify, providers, ssl, stats, sync
|
||||
from app.services import cleanup_service
|
||||
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
@@ -50,6 +51,30 @@ def _asset_version() -> str:
|
||||
|
||||
_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,
|
||||
@@ -71,7 +96,10 @@ async def lifespan(_: FastAPI):
|
||||
cleanup_task.cancel()
|
||||
|
||||
|
||||
app = FastAPI(title=settings.APP_NAME, version="0.2.0", lifespan=lifespan)
|
||||
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,
|
||||
@@ -83,6 +111,7 @@ app.add_middleware(
|
||||
|
||||
app.include_router(assets.router)
|
||||
app.include_router(providers.router)
|
||||
app.include_router(accounts.router)
|
||||
app.include_router(stats.router)
|
||||
app.include_router(agent.router)
|
||||
app.include_router(monitor.router)
|
||||
@@ -105,7 +134,8 @@ app.mount("/static", NoCacheStaticFiles(directory=STATIC_DIR), name="static")
|
||||
def index() -> HTMLResponse:
|
||||
"""前端 SPA 入口"""
|
||||
html = jinja_env.get_template("index.html").render(
|
||||
app_name=settings.APP_NAME, asset_version=_asset_version()
|
||||
app_name=settings.APP_NAME, asset_version=_asset_version(),
|
||||
app_version=APP_VERSION, git_commit=GIT_COMMIT,
|
||||
)
|
||||
return HTMLResponse(html)
|
||||
|
||||
@@ -129,5 +159,10 @@ def service_worker() -> FileResponse:
|
||||
|
||||
@app.get("/health")
|
||||
def health_check() -> dict:
|
||||
"""健康检查(version 动态读取,便于验证自动更新)"""
|
||||
return {"status": "ok", "app": "vps-manager", "version": app.version}
|
||||
"""健康检查(version/commit 动态读取,便于验证自动更新与比对本地版本)"""
|
||||
return {
|
||||
"status": "ok",
|
||||
"app": "vps-manager",
|
||||
"version": APP_VERSION,
|
||||
"commit": GIT_COMMIT,
|
||||
}
|
||||
|
||||
+19
-1
@@ -1,10 +1,11 @@
|
||||
"""资产数据库模型
|
||||
|
||||
包含四种核心资产模型:
|
||||
包含五种核心模型:
|
||||
- Asset: 资产主表(统一登记 VPS/域名/AI 账号/Cloudflare 等一切资产)
|
||||
- VPSDetail: VPS 资产详情(与 Asset 一对一关联)
|
||||
- DomainDetail: 域名资产详情(与 Asset 一对一关联)
|
||||
- AIAccount: AI Agent 账号详情(与 Asset 一对一关联)
|
||||
- Account: 平台账号字典(Asset.account 按名称引用)
|
||||
"""
|
||||
|
||||
from datetime import date, datetime
|
||||
@@ -195,3 +196,20 @@ class CloudflareDetail(SQLModel, table=True):
|
||||
default=None, index=True, description="关联的 zone/域名"
|
||||
)
|
||||
status: Optional[str] = Field(default=None, description="子资产状态")
|
||||
|
||||
|
||||
class Account(SQLModel, table=True):
|
||||
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
|
||||
|
||||
资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用。
|
||||
"""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
|
||||
id: Optional[int] = Field(default=None, primary_key=True)
|
||||
name: str = Field(unique=True, index=True, description="账号标识(如邮箱/用户名)")
|
||||
platform: Optional[str] = Field(
|
||||
default=None, index=True, description="所属平台(slug 或名称)"
|
||||
)
|
||||
remark: Optional[str] = Field(default=None, description="备注")
|
||||
created_at: datetime = Field(default_factory=utcnow, description="创建时间")
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
"""平台账号 CRUD 路由
|
||||
|
||||
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
|
||||
资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产。
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from sqlmodel import Session
|
||||
|
||||
from app.core.security import require_api_key
|
||||
from app.database import get_session
|
||||
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||
from app.services import account_service
|
||||
|
||||
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
|
||||
|
||||
|
||||
@router.get("", response_model=List[AccountRead], summary="账号列表(含关联资产数)")
|
||||
def list_accounts(session: Session = Depends(get_session)):
|
||||
return account_service.list_accounts(session)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AccountRead,
|
||||
status_code=201,
|
||||
summary="创建账号",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def create_account(data: AccountCreate, session: Session = Depends(get_session)):
|
||||
return account_service.create_account(session, data)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{account_id}",
|
||||
response_model=AccountRead,
|
||||
summary="更新账号(重命名会同步更新引用该账号的资产)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def update_account(
|
||||
account_id: int, data: AccountUpdate, session: Session = Depends(get_session)
|
||||
):
|
||||
return account_service.update_account(session, account_id, data)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{account_id}",
|
||||
summary="删除账号(资产中已填的账号名不受影响)",
|
||||
dependencies=[Depends(require_api_key)],
|
||||
)
|
||||
def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict:
|
||||
return account_service.delete_account(session, account_id)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""平台账号 Schema"""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from sqlmodel import SQLModel
|
||||
|
||||
|
||||
class AccountBase(SQLModel):
|
||||
name: str
|
||||
platform: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class AccountCreate(AccountBase):
|
||||
pass
|
||||
|
||||
|
||||
class AccountUpdate(SQLModel):
|
||||
name: Optional[str] = None
|
||||
platform: Optional[str] = None
|
||||
remark: Optional[str] = None
|
||||
|
||||
|
||||
class AccountRead(AccountBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
asset_count: int = 0 # 引用该账号的资产数(列表接口填充)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""平台账号业务逻辑
|
||||
|
||||
账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。
|
||||
重命名账号时同步更新所有引用资产,保证两边一致。
|
||||
"""
|
||||
|
||||
from typing import Dict, List
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func
|
||||
from sqlmodel import Session, select
|
||||
|
||||
from app.models.asset import Account, Asset
|
||||
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
|
||||
|
||||
|
||||
def _asset_counts(session: Session) -> Dict[str, int]:
|
||||
"""按 account 名称统计引用资产数"""
|
||||
rows = session.exec(
|
||||
select(Asset.account, func.count(Asset.id))
|
||||
.where(Asset.account.is_not(None)) # type: ignore[union-attr]
|
||||
.group_by(Asset.account)
|
||||
).all()
|
||||
return {name: cnt for name, cnt in rows}
|
||||
|
||||
|
||||
def _to_read(account: Account, counts: Dict[str, int]) -> AccountRead:
|
||||
read = AccountRead.model_validate(account)
|
||||
read.asset_count = counts.get(account.name, 0)
|
||||
return read
|
||||
|
||||
|
||||
def list_accounts(session: Session) -> List[AccountRead]:
|
||||
counts = _asset_counts(session)
|
||||
accounts = session.exec(select(Account).order_by(Account.name.asc())).all()
|
||||
return [_to_read(a, counts) for a in accounts]
|
||||
|
||||
|
||||
def _get_account(session: Session, account_id: int) -> Account:
|
||||
account = session.get(Account, account_id)
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="账号不存在")
|
||||
return account
|
||||
|
||||
|
||||
def _check_name_taken(session: Session, name: str, exclude_id: int | None = None) -> None:
|
||||
stmt = select(Account).where(Account.name == name)
|
||||
if exclude_id is not None:
|
||||
stmt = stmt.where(Account.id != exclude_id)
|
||||
if session.exec(stmt).first():
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST, detail=f"账号已存在:{name}"
|
||||
)
|
||||
|
||||
|
||||
def create_account(session: Session, data: AccountCreate) -> AccountRead:
|
||||
name = data.name.strip()
|
||||
if not name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
|
||||
_check_name_taken(session, name)
|
||||
account = Account(name=name, platform=data.platform, remark=data.remark)
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session))
|
||||
|
||||
|
||||
def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
|
||||
account = _get_account(session, account_id)
|
||||
if data.name is not None:
|
||||
new_name = data.name.strip()
|
||||
if not new_name:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
|
||||
if new_name != account.name:
|
||||
_check_name_taken(session, new_name, exclude_id=account.id)
|
||||
# 同步更新引用该账号的资产,避免重命名后资产端失联
|
||||
assets = session.exec(select(Asset).where(Asset.account == account.name)).all()
|
||||
for a in assets:
|
||||
a.account = new_name
|
||||
session.add(a)
|
||||
account.name = new_name
|
||||
if data.platform is not None:
|
||||
account.platform = data.platform or None
|
||||
if data.remark is not None:
|
||||
account.remark = data.remark or None
|
||||
session.add(account)
|
||||
session.commit()
|
||||
session.refresh(account)
|
||||
return _to_read(account, _asset_counts(session))
|
||||
|
||||
|
||||
def delete_account(session: Session, account_id: int) -> Dict[str, int]:
|
||||
account = _get_account(session, account_id)
|
||||
affected = len(session.exec(select(Asset.id).where(Asset.account == account.name)).all())
|
||||
session.delete(account)
|
||||
session.commit()
|
||||
# 资产端保留原账号名文本(不级联清空),由用户自行处理
|
||||
return {"affected_assets": affected}
|
||||
@@ -14,7 +14,7 @@
|
||||
<script>tailwind.config = { darkMode: 'class' };</script>
|
||||
<script src="/static/js/vue.global.prod.js?v={{ asset_version }}"></script>
|
||||
<script src="/static/js/chart.umd.js?v={{ asset_version }}"></script>
|
||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}" };</script>
|
||||
<script>window.APP_CONFIG = { appName: "{{ app_name }}", version: "{{ app_version }}", commit: "{{ git_commit }}" };</script>
|
||||
<style>
|
||||
html { -webkit-tap-highlight-color: transparent; }
|
||||
::-webkit-scrollbar { width: 8px; height: 8px; }
|
||||
|
||||
Reference in New Issue
Block a user