"""平台账号业务逻辑 账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。 唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名。 凭证层:API 配置加密存本表;登录密码的唯一事实源在 credentials 表 (credential_service.upsert_for_account 同事务同步),Read 仅返回布尔标记。 """ from typing import Dict, List, Optional 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.models.credential import Credential from app.schemas.account import AccountCreate, AccountRead, AccountUpdate from app.services import credential_service 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 _credential_map(session: Session, ids: List[Optional[int]]) -> Dict[int, Credential]: """批量取账号关联的凭据(填充 has_login_password / has_otp,避免 N+1)""" wanted = [i for i in ids if i] if not wanted: return {} rows = session.exec( select(Credential).where(Credential.id.in_(wanted)) # type: ignore[union-attr] ).all() return {c.id: c for c in rows} def _cred_of(session: Session, account: Account) -> Optional[Credential]: """取单个账号关联的凭据(未关联返回 None)""" return session.get(Credential, account.credential_id) if account.credential_id else None def _to_read( account: Account, counts: Dict[int, int], cred: Optional[Credential] = None ) -> AccountRead: read = AccountRead.model_validate(account) read.asset_count = counts.get(account.id, 0) # 登录密码唯一事实源在凭据表(本表 login_password_encrypted 已弃用) read.credential_id = account.credential_id read.has_login_password = bool(cred and cred.password_encrypted) read.has_otp = bool(cred and cred.otp_secret_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() creds = _credential_map(session, [a.credential_id for a in accounts]) return [_to_read(a, counts, creds.get(a.credential_id)) 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.api_config_encrypted = crypto.encrypt(data.api_config) # 登录密码重定向到凭据库(唯一事实源):同一事务创建并回填 credential_id; # 下方 commit 失败(如并发重名)时凭据随事务一并回滚 if data.login_password: credential_service.upsert_for_account(session, account, data.login_password) 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), _cred_of(session, account)) 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=不修改,''=清除,非空=重加密; # 已有凭据时即使密码未变也同步 site/username(账号改名/换平台保持一致) if data.login_password is not None or account.credential_id: credential_service.upsert_for_account(session, account, 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), _cred_of(session, account)) 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) cred = _cred_of(session, account) plain = crypto.decrypt(cred.password_encrypted) if cred else None if not plain: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="该账号未配置登录密码" ) return {"login_user": cred.username or account.login_user or "", "password": plain}