From a33a2b71de9eba82006079ad9a6031538717764d Mon Sep 17 00:00:00 2001 From: gouki Date: Sat, 8 Aug 2026 20:07:03 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=89=88=E6=9C=AC=E5=8F=B7=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E6=9C=BA=E5=88=B6(VERSION=E6=96=87=E4=BB=B6+git?= =?UTF-8?q?=E5=93=88=E5=B8=8C,=E9=83=A8=E7=BD=B2/=E5=90=AF=E5=8A=A8/UI/hea?= =?UTF-8?q?lth=E5=9D=87=E5=B1=95=E7=A4=BA)=20+=20=E5=B9=B3=E5=8F=B0?= =?UTF-8?q?=E8=B4=A6=E5=8F=B7=E7=AE=A1=E7=90=86(accounts=E8=A1=A8CRUD,?= =?UTF-8?q?=E8=B5=84=E4=BA=A7=E6=89=80=E5=B1=9E=E8=B4=A6=E5=8F=B7=E5=80=99?= =?UTF-8?q?=E9=80=89=E8=81=94=E5=8A=A8,=E9=87=8D=E5=91=BD=E5=90=8D?= =?UTF-8?q?=E5=90=8C=E6=AD=A5=E5=BC=95=E7=94=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 1 + app/database.py | 6 +- app/main.py | 45 +++++++++++++-- app/models/asset.py | 20 ++++++- app/routers/accounts.py | 54 ++++++++++++++++++ app/schemas/account.py | 28 ++++++++++ app/services/account_service.py | 98 +++++++++++++++++++++++++++++++++ app/templates/index.html | 2 +- deploy/setup.sh | 1 + deploy/update.sh | 11 ++++ static/js/app.js | 7 ++- static/js/modals.js | 45 ++++++++++++++- static/js/store.js | 33 ++++++++++- static/js/views/providers.js | 29 +++++++++- static/js/views/settings.js | 9 ++- 15 files changed, 373 insertions(+), 16 deletions(-) create mode 100644 VERSION create mode 100644 app/routers/accounts.py create mode 100644 app/schemas/account.py create mode 100644 app/services/account_service.py 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 @@ - +