fix(account): 支持跨平台同名账号——(platform,name)联合唯一+Asset.account_id外键重构

This commit is contained in:
gouki
2026-08-11 07:27:01 +00:00
parent bc1b804931
commit b4705331c0
9 changed files with 209 additions and 66 deletions
+34 -25
View File
@@ -1,7 +1,7 @@
"""平台账号业务逻辑
账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。
重命名账号时同步更新所有引用资产,保证两边一致
账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
"""
@@ -16,19 +16,19 @@ from app.models.asset import Account, Asset
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
def _asset_counts(session: Session) -> Dict[str, int]:
"""按 account 名称统计引用资产数"""
def _asset_counts(session: Session) -> Dict[int, int]:
"""按 account_id 统计引用资产数"""
rows = session.exec(
select(Asset.account, func.count(Asset.id))
.where(Asset.account.is_not(None)) # type: ignore[union-attr]
.group_by(Asset.account)
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 {name: cnt for name, cnt in rows}
return {aid: cnt for aid, cnt in rows}
def _to_read(account: Account, counts: Dict[str, int]) -> AccountRead:
def _to_read(account: Account, counts: Dict[int, int]) -> AccountRead:
read = AccountRead.model_validate(account)
read.asset_count = counts.get(account.name, 0)
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
@@ -47,13 +47,20 @@ def _get_account(session: Session, account_id: int) -> Account:
return account
def _check_name_taken(session: Session, name: str, exclude_id: int | None = None) -> None:
stmt = select(Account).where(Account.name == name)
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) 联合唯一:同平台内不允许重名,跨平台可复用"""
stmt = select(Account).where(Account.name == name, 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}"
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"该平台下账号已存在:{name}",
)
@@ -61,8 +68,9 @@ 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, login_user=data.login_user)
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)
session.add(account)
@@ -73,20 +81,17 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead:
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:
_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)
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 = data.platform or None
account.platform = new_platform
if data.remark is not None:
account.remark = data.remark or None
if data.login_user is not None:
@@ -104,10 +109,14 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac
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())
# 引用该账号的资产: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}