feat: 版本号管理机制(VERSION文件+git哈希,部署/启动/UI/health均展示) + 平台账号管理(accounts表CRUD,资产所属账号候选联动,重命名同步引用)

This commit is contained in:
gouki
2026-08-08 20:07:03 +00:00
parent 4883837dda
commit a33a2b71de
15 changed files with 373 additions and 16 deletions
+3 -3
View File
@@ -1,6 +1,6 @@
"""SQLite 数据库连接与初始化(分库)
- assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount
- assets.db Provider / Asset / VPSDetail / DomainDetail / AIAccount / Account
- metrics.dbMetricPoint / 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
View File
@@ -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 启动:版本 %scommit %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
View File
@@ -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="创建时间")
+54
View File
@@ -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)
+28
View File
@@ -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 # 引用该账号的资产数(列表接口填充)
+98
View File
@@ -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}
+1 -1
View File
@@ -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; }