credentials 表为登录凭据唯一事实源(站点×登录方式,含 oauth/2FA);账号密码读写重定向凭据层并幂等迁移历史数据;TOTP 按 RFC6238 零依赖自实现,绑定需当前动态码校验;Key Escrow 防 MASTER_KEY 遗失;前端新增凭据库视图与账号 2FA 联动。64 pytest + 16 浏览器端到端验证通过。
189 lines
8.1 KiB
Python
189 lines
8.1 KiB
Python
"""平台账号业务逻辑
|
||
|
||
账号与资产的关系: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}
|