Files
vps-manager/app/services/account_service.py
T
gouki c5982a5706 feat(2fa): 动态码改为可选校验,未验证条目支持就地补验
痛点:secret 多为历史留存,录入那一刻手上常常没有能出码的验证器,
原先「绑定必须 secret + 当前 6 位码校验通过」直接把用户挡在门外。
改为「能验则验,没验就标」,抄错风险由角标持续提醒 + 后置补验兜住。

- 后端:新增 otp_verified_at 记账;code 可选(填了必须通过,错码 400
  且不覆盖已绑 secret);POST /{id}/otp/verify 补验转正;更换 secret
  一律重置为未验证;_migrate_assets_db 幂等补列并回填旧数据,避免升级
  后全量误报未验证
- 前端:列表区分「2FA 已验证/未验证」徽章,展开区未验证提醒条 + 就地
  补验输入;账号查看密码弹窗增加未验证提示;移除 CredentialModal.save()
  里的必填预校验(这是留空保存连请求都不发出的根因)
- 测试:pytest 74 绿(新增契约反转 + 6 个补验用例 + 迁移回填用例);
  Playwright 端到端 13/13(留空保存 → 未验证角标 → 错码 400 保持未验证
  → 正码补验转正 → 移动端 375px 无横向溢出 → 无 JS 运行时报错)
- 文档:goal-spec §1/§2/§4/§5/§6/§7/§9 同步新决策,替换旧的必填校验约定
2026-09-18 08:40:09 +00:00

190 lines
8.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""平台账号业务逻辑
账号与资产的关系: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.otp_verified = bool(cred and cred.otp_verified_at)
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}