Files

159 lines
6.5 KiB
Python

"""平台账号业务逻辑
账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
"""
from typing import Dict, List
from fastapi import HTTPException, status
from sqlalchemy import func
from sqlalchemy.exc import IntegrityError
from sqlmodel import Session, select
from app.core import crypto
from app.models.asset import Account, Asset
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
def _asset_counts(session: Session) -> Dict[int, int]:
"""按 account_id 统计引用资产数"""
rows = session.exec(
select(Asset.account_id, func.count(Asset.id))
.where(Asset.account_id.is_not(None)) # type: ignore[union-attr]
.group_by(Asset.account_id)
).all()
return {aid: cnt for aid, cnt in rows}
def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
read = AccountRead.model_validate(account)
read.asset_count = counts.get(account.id, 0)
read.has_login_password = bool(account.login_password_encrypted)
read.has_api_config = bool(account.api_config_encrypted)
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 _norm_platform(platform: str | None) -> str:
"""platform 规范化为非空字符串(避免 NULL 绕过 (platform,name) 唯一约束)"""
return (platform or "").strip()
def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None:
"""校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用
platform 用 coalesce 归一化匹配:历史数据的 NULL 与空串视为同一平台,
避免出现同名账号在不同"空平台"上各存一条。
"""
stmt = select(Account).where(
Account.name == name,
func.coalesce(Account.platform, "") == platform,
)
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="账号名称不能为空")
platform = _norm_platform(data.platform)
_check_name_taken(session, name, platform)
account = Account(name=name, platform=platform, remark=data.remark, login_user=data.login_user)
account.login_password_encrypted = crypto.encrypt(data.login_password)
account.api_config_encrypted = crypto.encrypt(data.api_config)
try:
session.add(account)
session.commit()
except IntegrityError:
# 并发创建同名账号时唯一约束兜底:检查与提交之间存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{name}",
)
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)
# platform 可能随本次更新变化,校验重名时用更新后的值
new_platform = _norm_platform(data.platform) if data.platform is not None else _norm_platform(account.platform)
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 or new_platform != _norm_platform(account.platform):
_check_name_taken(session, new_name, new_platform, exclude_id=account.id)
account.name = new_name
if data.platform is not None:
account.platform = new_platform
if data.remark is not None:
account.remark = data.remark or None
if data.login_user is not None:
account.login_user = data.login_user or None
# 凭证:None = 不修改;空串 = 清除;非空 = 重新加密存储
if data.login_password is not None:
account.login_password_encrypted = crypto.encrypt(data.login_password)
if data.api_config is not None:
account.api_config_encrypted = crypto.encrypt(data.api_config)
try:
session.add(account)
session.commit()
except IntegrityError:
# 重命名撞上已有账号时唯一约束兜底,与 _check_name_taken 存在竞态窗口
session.rollback()
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{account.name}",
)
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)
# 引用该账号的资产:account_id 置 NULL(保留资产,仅解除关联)
assets = session.exec(select(Asset).where(Asset.account_id == account_id)).all()
for a in assets:
a.account_id = None
session.add(a)
affected = len(assets)
session.delete(account)
session.commit()
return {"affected_assets": affected}
def reveal_password(session: Session, account_id: int) -> Dict[str, str]:
"""解密返回账号登录密码明文(供前端「查看密码」功能)。
安全说明:接口受 API Key 保护;密码本可逆加密存储,此处合法解密还原。
"""
account = _get_account(session, account_id)
plain = crypto.decrypt(account.login_password_encrypted)
if not plain:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码"
)
return {"login_user": account.login_user or "", "password": plain}