diff --git a/VERSION b/VERSION
new file mode 100644
index 0000000..0d91a54
--- /dev/null
+++ b/VERSION
@@ -0,0 +1 @@
+0.3.0
diff --git a/app/database.py b/app/database.py
index 540499a..257e905 100644
--- a/app/database.py
+++ b/app/database.py
@@ -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(
diff --git a/app/main.py b/app/main.py
index dcd6c62..3fc7223 100644
--- a/app/main.py
+++ b/app/main.py
@@ -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,
+ }
diff --git a/app/models/asset.py b/app/models/asset.py
index 59ee896..b7f6ff4 100644
--- a/app/models/asset.py
+++ b/app/models/asset.py
@@ -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="创建时间")
diff --git a/app/routers/accounts.py b/app/routers/accounts.py
new file mode 100644
index 0000000..d5366e2
--- /dev/null
+++ b/app/routers/accounts.py
@@ -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)
diff --git a/app/schemas/account.py b/app/schemas/account.py
new file mode 100644
index 0000000..ac0ae4a
--- /dev/null
+++ b/app/schemas/account.py
@@ -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 # 引用该账号的资产数(列表接口填充)
diff --git a/app/services/account_service.py b/app/services/account_service.py
new file mode 100644
index 0000000..276adfa
--- /dev/null
+++ b/app/services/account_service.py
@@ -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}
diff --git a/app/templates/index.html b/app/templates/index.html
index 2043e6b..328dbd5 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -14,7 +14,7 @@
-
+