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
+108 -11
View File
@@ -84,6 +84,10 @@ def _migrate_assets_db() -> None:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN renew_url VARCHAR")) conn.execute(sa.text("ALTER TABLE assets ADD COLUMN renew_url VARCHAR"))
if "cancel_url" not in cols: if "cancel_url" not in cols:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN cancel_url VARCHAR")) conn.execute(sa.text("ALTER TABLE assets ADD COLUMN cancel_url VARCHAR"))
if "account_id" not in cols:
conn.execute(sa.text("ALTER TABLE assets ADD COLUMN account_id INTEGER"))
# 资产账号引用从 name 字符串迁移到 account_id 外键(幂等)
_backfill_asset_account_id(conn)
if insp.has_table("providers"): if insp.has_table("providers"):
cols = {c["name"] for c in insp.get_columns("providers")} cols = {c["name"] for c in insp.get_columns("providers")}
if "last_synced_at" not in cols: if "last_synced_at" not in cols:
@@ -110,14 +114,24 @@ def _migrate_assets_db() -> None:
conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME")) conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME"))
# 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号 # 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号
_backfill_account_credentials(conn) _backfill_account_credentials(conn)
# 合并存量 (platform,name) 重复账号,为建唯一索引做准备
_dedupe_accounts(conn)
def _migrate_indexes() -> None: def _migrate_indexes() -> None:
"""为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)""" """为已有数据库补充复合索引(create_all 不会为已存在的表补索引,IF NOT EXISTS 幂等)"""
import sqlalchemy as sa import sqlalchemy as sa
# 旧的全局 name 唯一索引(早期 Account.name unique=True 产物)会阻止跨平台同名,
# 与新的 (platform,name) 联合唯一冲突,需先删除
with assets_engine.begin() as conn:
conn.execute(sa.text("DROP INDEX IF EXISTS ix_accounts_name"))
stmts = [ stmts = [
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"), (assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_provider_ext_type ON assets (provider_id, external_id, asset_type)"),
(assets_engine, "CREATE INDEX IF NOT EXISTS ix_assets_account_id ON assets (account_id)"),
# (platform, name) 联合唯一:同邮箱可跨平台复用,同平台内不重名
(assets_engine, "CREATE UNIQUE INDEX IF NOT EXISTS uq_accounts_platform_name ON accounts (platform, name)"),
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"), (metrics_engine, "CREATE INDEX IF NOT EXISTS ix_metric_points_asset_ts ON metric_points (asset_id, ts)"),
(metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"), (metrics_engine, "CREATE INDEX IF NOT EXISTS ix_security_checks_asset_ts ON security_checks (asset_id, ts)"),
] ]
@@ -163,6 +177,80 @@ def _backfill_provider_services(conn) -> None:
) )
def _dedupe_accounts(conn) -> None:
"""建 (platform,name) 唯一索引前的防护:合并存量重复账号。
platform 统一按 COALESCE(platform,'') 规范化比较(避免 NULL 绕过)。
重复时保留 id 最小者,将其余行的资产引用(account_id/account)与凭证并入后删除。
当前数据量小,预期无重复;此函数仅在发现重复时产生写操作。
"""
import sqlalchemy as sa
rows = conn.execute(
sa.text(
"SELECT COALESCE(platform,'') AS p, name, COUNT(*) AS c, MIN(id) AS keep_id "
"FROM accounts GROUP BY p, name HAVING c > 1"
)
).fetchall()
for p, name, _c, keep_id in rows:
dups = conn.execute(
sa.text(
"SELECT id FROM accounts WHERE COALESCE(platform,'') = :p AND name = :n AND id != :keep"
),
{"p": p, "n": name, "keep": keep_id},
).fetchall()
for (dup_id,) in dups:
# 资产引用并入保留行
conn.execute(
sa.text("UPDATE assets SET account_id = :keep WHERE account_id = :dup"),
{"keep": keep_id, "dup": dup_id},
)
# 凭证:保留行为空时才从重复行拷贝
conn.execute(
sa.text(
"UPDATE accounts SET "
"api_config_encrypted = COALESCE(api_config_encrypted, (SELECT api_config_encrypted FROM accounts WHERE id = :dup)), "
"login_password_encrypted = COALESCE(login_password_encrypted, (SELECT login_password_encrypted FROM accounts WHERE id = :dup)), "
"login_user = COALESCE(login_user, (SELECT login_user FROM accounts WHERE id = :dup)) "
"WHERE id = :keep"
),
{"keep": keep_id, "dup": dup_id},
)
conn.execute(sa.text("DELETE FROM accounts WHERE id = :dup"), {"dup": dup_id})
def _backfill_asset_account_id(conn) -> None:
"""资产账号引用迁移(幂等):把 Asset.account 的 name 字符串引用回填为 account_id 外键。
匹配规则:按 accounts.name 匹配;同名多个时优先 platform 与 asset.provider 一致者。
匹配不到(历史自由文本/未登记账号)则跳过并保留 account 字符串,供人工处理。
"""
import sqlalchemy as sa
rows = conn.execute(
sa.text(
"SELECT id, account, provider FROM assets "
"WHERE account_id IS NULL AND account IS NOT NULL AND account != ''"
)
).fetchall()
if not rows:
return
for asset_id, acc_name, provider in rows:
# 同名账号可能多个:优先 platform 与资产 provider 一致的
cand = conn.execute(
sa.text(
"SELECT id, COALESCE(platform,'') FROM accounts WHERE name = :n "
"ORDER BY (COALESCE(platform,'') = :prov) DESC, id ASC"
),
{"n": acc_name, "prov": provider or ""},
).fetchall()
if cand:
conn.execute(
sa.text("UPDATE assets SET account_id = :aid WHERE id = :id"),
{"aid": cand[0][0], "id": asset_id},
)
def _backfill_account_credentials(conn) -> None: def _backfill_account_credentials(conn) -> None:
"""凭证下沉迁移(幂等,仅在首次加列后产生效果): """凭证下沉迁移(幂等,仅在首次加列后产生效果):
@@ -179,14 +267,21 @@ def _backfill_account_credentials(conn) -> None:
from app.core.crypto import decrypt, encrypt from app.core.crypto import decrypt, encrypt
def _ensure_account(name: str, platform: str) -> int: def _ensure_account(name: str, platform: str) -> int:
row = conn.execute(sa.text("SELECT id FROM accounts WHERE name = :name"), {"name": name}).first() # 按 (platform, name) 查,避免同名跨平台账号混淆
row = conn.execute(
sa.text("SELECT id FROM accounts WHERE name = :name AND COALESCE(platform,'') = :platform"),
{"name": name, "platform": platform},
).first()
if row: if row:
return row[0] return row[0]
conn.execute( conn.execute(
sa.text("INSERT INTO accounts (name, platform, created_at) VALUES (:name, :platform, :ts)"), sa.text("INSERT INTO accounts (name, platform, created_at) VALUES (:name, :platform, :ts)"),
{"name": name, "platform": platform, "ts": datetime.now(timezone.utc).replace(tzinfo=None)}, {"name": name, "platform": platform, "ts": datetime.now(timezone.utc).replace(tzinfo=None)},
) )
return conn.execute(sa.text("SELECT id FROM accounts WHERE name = :name"), {"name": name}).first()[0] return conn.execute(
sa.text("SELECT id FROM accounts WHERE name = :name AND COALESCE(platform,'') = :platform"),
{"name": name, "platform": platform},
).first()[0]
# 1. 平台凭证下沉到默认账号 # 1. 平台凭证下沉到默认账号
for pid, slug, cfg in conn.execute( for pid, slug, cfg in conn.execute(
@@ -200,26 +295,27 @@ def _backfill_account_credentials(conn) -> None:
conn.execute( conn.execute(
sa.text( sa.text(
"UPDATE accounts SET api_config_encrypted = :cfg " "UPDATE accounts SET api_config_encrypted = :cfg "
"WHERE name = :name AND (api_config_encrypted IS NULL OR api_config_encrypted = '')" "WHERE name = :name AND COALESCE(platform,'') = :platform "
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
), ),
{"cfg": cfg, "name": acc_name}, {"cfg": cfg, "name": acc_name, "platform": slug},
) )
# 2. 无账号的 AI 资产按 provider 挂靠 # 2. 无账号的 AI 资产按 provider 挂靠account_id 外键 + account 字符串兼容)
rows = conn.execute( rows = conn.execute(
sa.text( sa.text(
"SELECT a.id, COALESCE(ai.provider, '') FROM assets a " "SELECT a.id, COALESCE(ai.provider, '') FROM assets a "
"JOIN ai_accounts ai ON ai.asset_id = a.id " "JOIN ai_accounts ai ON ai.asset_id = a.id "
"WHERE a.asset_type = 'ai_agent' AND (a.account IS NULL OR a.account = '')" "WHERE a.asset_type = 'ai_agent' AND a.account_id IS NULL AND (a.account IS NULL OR a.account = '')"
) )
).fetchall() ).fetchall()
for asset_id, provider in rows: for asset_id, provider in rows:
if not provider: if not provider:
continue continue
_ensure_account(provider, provider) acc_id = _ensure_account(provider, provider)
conn.execute( conn.execute(
sa.text("UPDATE assets SET account = :acc WHERE id = :id"), sa.text("UPDATE assets SET account = :acc, account_id = :aid WHERE id = :id"),
{"acc": provider, "id": asset_id}, {"acc": provider, "aid": acc_id, "id": asset_id},
) )
# 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密) # 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密)
key_row = conn.execute( key_row = conn.execute(
@@ -235,9 +331,10 @@ def _backfill_account_credentials(conn) -> None:
conn.execute( conn.execute(
sa.text( sa.text(
"UPDATE accounts SET api_config_encrypted = :cfg " "UPDATE accounts SET api_config_encrypted = :cfg "
"WHERE name = :name AND (api_config_encrypted IS NULL OR api_config_encrypted = '')" "WHERE name = :name AND COALESCE(platform,'') = :platform "
"AND (api_config_encrypted IS NULL OR api_config_encrypted = '')"
), ),
{"cfg": cfg_enc, "name": provider}, {"cfg": cfg_enc, "name": provider, "platform": provider},
) )
+11 -5
View File
@@ -5,14 +5,14 @@
- VPSDetail: VPS 资产详情(与 Asset 一对一关联) - VPSDetail: VPS 资产详情(与 Asset 一对一关联)
- DomainDetail: 域名资产详情(与 Asset 一对一关联) - DomainDetail: 域名资产详情(与 Asset 一对一关联)
- AIAccount: AI Agent 账号详情(与 Asset 一对一关联) - AIAccount: AI Agent 账号详情(与 Asset 一对一关联)
- Account: 平台账号字典(Asset.account 按名称引用) - Account: 平台账号字典(Asset.account_id 外键引用)
""" """
from datetime import date, datetime from datetime import date, datetime
from enum import Enum from enum import Enum
from typing import Optional from typing import Optional
from sqlalchemy import Index from sqlalchemy import Index, UniqueConstraint
from sqlmodel import Field, SQLModel from sqlmodel import Field, SQLModel
from app.core.timeutils import utcnow from app.core.timeutils import utcnow
@@ -65,7 +65,10 @@ class Asset(SQLModel, table=True):
default=None, description="取消订阅网址(去哪取消)" default=None, description="取消订阅网址(去哪取消)"
) )
account: Optional[str] = Field( account: Optional[str] = Field(
default=None, description="所属账号标识(如 Cloudflare 账号邮箱" default=None, description="所属账号标识(历史字段,迁移后由 account_id 取代,仅作迁移数据源"
)
account_id: Optional[int] = Field(
default=None, foreign_key="accounts.id", index=True, description="所属账号 ID(外键关联 accounts"
) )
expiry_date: Optional[date] = Field( expiry_date: Optional[date] = Field(
default=None, index=True, description="到期/续费日期,用于续费提醒" default=None, index=True, description="到期/续费日期,用于续费提醒"
@@ -201,15 +204,18 @@ class CloudflareDetail(SQLModel, table=True):
class Account(SQLModel, table=True): class Account(SQLModel, table=True):
"""平台账号字典:统一管理各平台下的账号(多账号场景区分归属) """平台账号字典:统一管理各平台下的账号(多账号场景区分归属)
资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用 资产的 Asset.account_id 字段外键引用本表,重命名账号不影响资产归属
唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用。
凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用), 凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用),
平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。 平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。
""" """
__tablename__ = "accounts" __tablename__ = "accounts"
# (platform, name) 联合唯一:同一邮箱/用户名可跨平台复用,同平台内不允许重名
__table_args__ = (UniqueConstraint("platform", "name", name="uq_accounts_platform_name"),)
id: Optional[int] = Field(default=None, primary_key=True) id: Optional[int] = Field(default=None, primary_key=True)
name: str = Field(unique=True, index=True, description="账号标识(如邮箱/用户名)") name: str = Field(index=True, description="账号标识(如邮箱/用户名)")
platform: Optional[str] = Field( platform: Optional[str] = Field(
default=None, index=True, description="所属平台(slug 或名称)" default=None, index=True, description="所属平台(slug 或名称)"
) )
+1 -1
View File
@@ -1,7 +1,7 @@
"""平台账号 CRUD 路由 """平台账号 CRUD 路由
统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。 统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。
资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产 资产的 Asset.account_id 外键引用账号;删除账号时引用资产的 account_id 置 NULL
凭证(登录密码/API 配置)存在账号上;账号维度的 SDK 测试/同步见 test/sync 端点。 凭证(登录密码/API 配置)存在账号上;账号维度的 SDK 测试/同步见 test/sync 端点。
""" """
+3 -2
View File
@@ -84,7 +84,7 @@ class AssetBase(SQLModel):
renewal_cycle: Optional[str] = None renewal_cycle: Optional[str] = None
renew_url: Optional[str] = None renew_url: Optional[str] = None
cancel_url: Optional[str] = None cancel_url: Optional[str] = None
account: Optional[str] = None account_id: Optional[int] = None
expiry_date: Optional[date] = None expiry_date: Optional[date] = None
auto_renew: bool = False auto_renew: bool = False
cost: float = 0 cost: float = 0
@@ -113,7 +113,7 @@ class AssetUpdate(SQLModel):
renewal_cycle: Optional[str] = None renewal_cycle: Optional[str] = None
renew_url: Optional[str] = None renew_url: Optional[str] = None
cancel_url: Optional[str] = None cancel_url: Optional[str] = None
account: Optional[str] = None account_id: Optional[int] = None
expiry_date: Optional[date] = None expiry_date: Optional[date] = None
auto_renew: Optional[bool] = None auto_renew: Optional[bool] = None
cost: Optional[float] = None cost: Optional[float] = None
@@ -185,6 +185,7 @@ class AssetRead(AssetBase):
updated_at: datetime updated_at: datetime
days_to_expiry: Optional[int] = None days_to_expiry: Optional[int] = None
provider_name: Optional[str] = None provider_name: Optional[str] = None
account_name: Optional[str] = None # 账号标识(展示用,由 service 按 account_id 填充)
vps_detail: Optional[VPSDetailRead] = None vps_detail: Optional[VPSDetailRead] = None
domain_detail: Optional[DomainDetailRead] = None domain_detail: Optional[DomainDetailRead] = None
ai_detail: Optional[AIAccountRead] = None ai_detail: Optional[AIAccountRead] = None
+34 -25
View File
@@ -1,7 +1,7 @@
"""平台账号业务逻辑 """平台账号业务逻辑
账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。 账号与资产的关系:Asset.account_id 外键关联账号(重命名账号不影响引用)。
重命名账号时同步更新所有引用资产,保证两边一致 唯一性:(platform, name) 联合唯一——同一邮箱/用户名可跨平台复用,同平台内不重名
凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。 凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。
""" """
@@ -16,19 +16,19 @@ from app.models.asset import Account, Asset
from app.schemas.account import AccountCreate, AccountRead, AccountUpdate from app.schemas.account import AccountCreate, AccountRead, AccountUpdate
def _asset_counts(session: Session) -> Dict[str, int]: def _asset_counts(session: Session) -> Dict[int, int]:
"""按 account 名称统计引用资产数""" """按 account_id 统计引用资产数"""
rows = session.exec( rows = session.exec(
select(Asset.account, func.count(Asset.id)) select(Asset.account_id, func.count(Asset.id))
.where(Asset.account.is_not(None)) # type: ignore[union-attr] .where(Asset.account_id.is_not(None)) # type: ignore[union-attr]
.group_by(Asset.account) .group_by(Asset.account_id)
).all() ).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 = 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_login_password = bool(account.login_password_encrypted)
read.has_api_config = bool(account.api_config_encrypted) read.has_api_config = bool(account.api_config_encrypted)
return read return read
@@ -47,13 +47,20 @@ def _get_account(session: Session, account_id: int) -> Account:
return account return account
def _check_name_taken(session: Session, name: str, exclude_id: int | None = None) -> None: def _norm_platform(platform: str | None) -> str:
stmt = select(Account).where(Account.name == name) """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: if exclude_id is not None:
stmt = stmt.where(Account.id != exclude_id) stmt = stmt.where(Account.id != exclude_id)
if session.exec(stmt).first(): if session.exec(stmt).first():
raise HTTPException( 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() name = data.name.strip()
if not name: if not name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
_check_name_taken(session, name) platform = _norm_platform(data.platform)
account = Account(name=name, platform=data.platform, remark=data.remark, login_user=data.login_user) _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.login_password_encrypted = crypto.encrypt(data.login_password)
account.api_config_encrypted = crypto.encrypt(data.api_config) account.api_config_encrypted = crypto.encrypt(data.api_config)
session.add(account) 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: def update_account(session: Session, account_id: int, data: AccountUpdate) -> AccountRead:
account = _get_account(session, account_id) 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: if data.name is not None:
new_name = data.name.strip() new_name = data.name.strip()
if not new_name: if not new_name:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空") raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="账号名称不能为空")
if new_name != account.name: if new_name != account.name or new_platform != _norm_platform(account.platform):
_check_name_taken(session, new_name, exclude_id=account.id) _check_name_taken(session, new_name, new_platform, 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)
account.name = new_name account.name = new_name
if data.platform is not None: if data.platform is not None:
account.platform = data.platform or None account.platform = new_platform
if data.remark is not None: if data.remark is not None:
account.remark = data.remark or None account.remark = data.remark or None
if data.login_user is not 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]: def delete_account(session: Session, account_id: int) -> Dict[str, int]:
account = _get_account(session, account_id) 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.delete(account)
session.commit() session.commit()
# 资产端保留原账号名文本(不级联清空),由用户自行处理
return {"affected_assets": affected} return {"affected_assets": affected}
+30 -1
View File
@@ -12,6 +12,7 @@ from sqlmodel import Session, select
from app.core import crypto from app.core import crypto
from app.models.asset import ( from app.models.asset import (
Account,
AIAccount, AIAccount,
Asset, Asset,
AssetStatus, AssetStatus,
@@ -93,11 +94,29 @@ def _provider_name(session: Session, asset: Asset) -> Optional[str]:
return None return None
def _account_name(session: Session, asset: Asset) -> Optional[str]:
"""取关联账号标识(展示用)"""
if asset.account_id:
account = session.get(Account, asset.account_id)
if account:
return account.name
return None
def _validate_account(session: Session, account_id: Optional[int]) -> None:
"""account_id 非空时确认账号存在,防外键悬空"""
if account_id is not None and not session.get(Account, account_id):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, detail=f"账号不存在:id={account_id}"
)
def _to_read(session: Session, asset: Asset, detail) -> AssetRead: def _to_read(session: Session, asset: Asset, detail) -> AssetRead:
"""组装 AssetRead 输出(主表 + 详情 + 计算字段)""" """组装 AssetRead 输出(主表 + 详情 + 计算字段)"""
read = AssetRead.model_validate(asset) read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date) read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = _provider_name(session, asset) read.provider_name = _provider_name(session, asset)
read.account_name = _account_name(session, asset)
if isinstance(detail, VPSDetail): if isinstance(detail, VPSDetail):
read.vps_detail = _detail_to_read(detail) read.vps_detail = _detail_to_read(detail)
elif isinstance(detail, DomainDetail): elif isinstance(detail, DomainDetail):
@@ -156,6 +175,12 @@ def _get_details_batch(session: Session, assets: list) -> dict:
if provider_ids: if provider_ids:
for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all(): for p in session.exec(select(Provider).where(Provider.id.in_(provider_ids))).all():
provider_map[p.id] = p.name provider_map[p.id] = p.name
# 批量查 Account 名
account_ids = {a.account_id for a in assets if a.account_id}
account_map = {}
if account_ids:
for acc in session.exec(select(Account).where(Account.id.in_(account_ids))).all():
account_map[acc.id] = acc.name
# 批量查各类型详情 # 批量查各类型详情
detail_map = {} detail_map = {}
for asset_type, (_, model) in DETAIL_MAP.items(): for asset_type, (_, model) in DETAIL_MAP.items():
@@ -163,7 +188,7 @@ def _get_details_batch(session: Session, assets: list) -> dict:
if typed_ids: if typed_ids:
for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all(): for d in session.exec(select(model).where(model.asset_id.in_(typed_ids))).all():
detail_map[d.asset_id] = d detail_map[d.asset_id] = d
return {"providers": provider_map, "details": detail_map} return {"providers": provider_map, "accounts": account_map, "details": detail_map}
def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead: def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
@@ -171,6 +196,7 @@ def _to_read_batch(session: Session, asset: Asset, batch: dict) -> AssetRead:
read = AssetRead.model_validate(asset) read = AssetRead.model_validate(asset)
read.days_to_expiry = _days_to_expiry(asset.expiry_date) read.days_to_expiry = _days_to_expiry(asset.expiry_date)
read.provider_name = batch["providers"].get(asset.provider_id) read.provider_name = batch["providers"].get(asset.provider_id)
read.account_name = batch.get("accounts", {}).get(asset.account_id)
detail = batch["details"].get(asset.id) detail = batch["details"].get(asset.id)
if isinstance(detail, VPSDetail): if isinstance(detail, VPSDetail):
read.vps_detail = _detail_to_read(detail) read.vps_detail = _detail_to_read(detail)
@@ -203,6 +229,7 @@ def _create_asset_no_commit(session: Session, data: AssetCreate) -> Asset:
asset_data = data.model_dump( asset_data = data.model_dump(
exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"} exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
) )
_validate_account(session, asset_data.get("account_id"))
asset = Asset(**asset_data) asset = Asset(**asset_data)
session.add(asset) session.add(asset)
session.flush() # 获取 asset.id,但不提交 session.flush() # 获取 asset.id,但不提交
@@ -239,6 +266,8 @@ def _update_asset_no_commit(session: Session, asset_id: int, data: AssetUpdate)
main_fields = data.model_dump( main_fields = data.model_dump(
exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"} exclude_unset=True, exclude={"vps_detail", "domain_detail", "ai_detail", "cloudflare_detail"}
) )
if "account_id" in main_fields:
_validate_account(session, main_fields["account_id"])
for key, value in main_fields.items(): for key, value in main_fields.items():
setattr(asset, key, value) setattr(asset, key, value)
session.add(asset) session.add(asset)
+10 -10
View File
@@ -169,7 +169,7 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type
).first() ).first()
def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_name: Optional[str] = None) -> dict: def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
created = updated = 0 created = updated = 0
for vps in adapter.list_vps(): for vps in adapter.list_vps():
existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS) existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS)
@@ -179,8 +179,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
if vps.monthly_cost is not None: if vps.monthly_cost is not None:
existing.cost = vps.monthly_cost existing.cost = vps.monthly_cost
existing.currency = vps.currency existing.currency = vps.currency
if account_name: if account_id:
existing.account = account_name existing.account_id = account_id
session.add(existing) session.add(existing)
detail = session.exec( detail = session.exec(
select(VPSDetail).where(VPSDetail.asset_id == existing.id) select(VPSDetail).where(VPSDetail.asset_id == existing.id)
@@ -201,7 +201,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
provider=provider.slug, provider=provider.slug,
provider_id=provider.id, provider_id=provider.id,
external_id=vps.external_id, external_id=vps.external_id,
account=account_name, account_id=account_id,
status=_norm_status(vps.status), status=_norm_status(vps.status),
cost=vps.monthly_cost or 0, cost=vps.monthly_cost or 0,
currency=vps.currency, currency=vps.currency,
@@ -224,7 +224,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, accoun
return {"created": created, "updated": updated} return {"created": created, "updated": updated}
def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_name: Optional[str] = None) -> dict: def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_id: Optional[int] = None) -> dict:
created = updated = 0 created = updated = 0
for dom in adapter.list_domains(): for dom in adapter.list_domains():
existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN) existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN)
@@ -232,8 +232,8 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, ac
existing.status = _norm_status(dom.status) existing.status = _norm_status(dom.status)
if dom.expiry_date: if dom.expiry_date:
existing.expiry_date = dom.expiry_date existing.expiry_date = dom.expiry_date
if account_name: if account_id:
existing.account = account_name existing.account_id = account_id
session.add(existing) session.add(existing)
detail = session.exec( detail = session.exec(
select(DomainDetail).where(DomainDetail.asset_id == existing.id) select(DomainDetail).where(DomainDetail.asset_id == existing.id)
@@ -250,7 +250,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, ac
provider=provider.slug, provider=provider.slug,
provider_id=provider.id, provider_id=provider.id,
external_id=dom.external_id, external_id=dom.external_id,
account=account_name, account_id=account_id,
status=_norm_status(dom.status), status=_norm_status(dom.status),
expiry_date=dom.expiry_date, expiry_date=dom.expiry_date,
) )
@@ -295,12 +295,12 @@ def sync_account(session: Session, account_id: int) -> dict:
if caps["list_vps"]: if caps["list_vps"]:
try: try:
result["vps"] = _sync_vps(session, provider, adapter, account.name) result["vps"] = _sync_vps(session, provider, adapter, account.id)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["vps_error"] = str(e) result["vps_error"] = str(e)
if caps["list_domains"]: if caps["list_domains"]:
try: try:
result["domains"] = _sync_domains(session, provider, adapter, account.name) result["domains"] = _sync_domains(session, provider, adapter, account.id)
except Exception as e: # noqa: BLE001 except Exception as e: # noqa: BLE001
result["domains_error"] = str(e) result["domains_error"] = str(e)
if caps["get_account"]: if caps["get_account"]:
+9 -8
View File
@@ -41,10 +41,10 @@ const AssetModal = {
<option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option> <option v-for="(l,k) in Fmt.STATUS_LABELS" :key="k" :value="k">{{ l }}</option>
</select></label> </select></label>
<label class="block"><span class="text-xs text-slate-500">所属账号</span> <label class="block"><span class="text-xs text-slate-500">所属账号</span>
<input v-model="f.account" list="account-options" placeholder="在「平台」页管理账号" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"> <select v-model.number="f.account_id" class="mt-1 w-full px-3 py-2 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm">
<datalist id="account-options"> <option :value="null">平台页管理账号</option>
<option v-for="a in store.accounts" :key="a.id" :value="a.name">{{ accountLabel(a) }}</option> <option v-for="a in store.accounts" :key="a.id" :value="a.id">{{ accountLabel(a) }}</option>
</datalist></label> </select></label>
<div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300"> <div class="col-span-2 flex gap-5 text-sm text-slate-600 dark:text-slate-300">
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label> <label class="flex items-center gap-2"><input type="checkbox" v-model="f.auto_renew"> 自动续费</label>
<label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label> <label class="flex items-center gap-2"><input type="checkbox" v-model="f.is_archived"> 已归档</label>
@@ -131,11 +131,12 @@ const AssetModal = {
const p = store.providers.find(x => x.id === f.value.provider_id); const p = store.providers.find(x => x.id === f.value.provider_id);
if (p) f.value.provider = p.slug; if (p) f.value.provider = p.slug;
} }
// 账号候选显示:说明优先,其次平台名(platform 存的是 slug,回退原值) // 账号候选显示:平台名 + 账号标识(同名账号靠平台前缀区分),说明优先
function accountLabel(a) { function accountLabel(a) {
if (a.remark) return a.remark + ' · ' + a.name;
const p = store.providers.find(x => x.slug === a.platform); const p = store.providers.find(x => x.slug === a.platform);
return p ? a.name + '' + p.name + '' : a.name; const pname = p ? p.name : (a.platform || '未指定平台');
if (a.remark) return pname + ' · ' + a.remark + '' + a.name + '';
return pname + ' · ' + a.name;
} }
return { store, Fmt, f, providersForType, onProviderChange, accountLabel, save: saveAsset }; return { store, Fmt, f, providersForType, onProviderChange, accountLabel, save: saveAsset };
}, },
@@ -362,7 +363,7 @@ const AccountsViewModal = {
return p ? p.name : slug; return p ? p.name : slug;
} }
function assetsOf(a) { function assetsOf(a) {
return store.assets.filter(x => x.account === a.name); return store.assets.filter(x => x.account_id === a.id);
} }
function toggle(id) { expanded[id] = !expanded[id]; } function toggle(id) { expanded[id] = !expanded[id]; }
// 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步 // 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步
+3 -3
View File
@@ -38,7 +38,7 @@ function applyDark() {
function emptyAssetForm() { function emptyAssetForm() {
return { return {
name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '', name: '', asset_type: 'vps', provider: '', provider_id: null, renewal_cycle: 'monthly', renew_url: '', cancel_url: '',
account: '', expiry_date: '', auto_renew: false, cost: 0, currency: 'USD', account_id: null, expiry_date: '', auto_renew: false, cost: 0, currency: 'USD',
status: 'active', is_archived: false, remark: '', status: 'active', is_archived: false, remark: '',
vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' }, vps_detail: { ip_address: '', tailscale_ip: '', region: '', os: '', cpu_cores: 1, memory_gb: 1, disk_gb: 20, bandwidth_gb: null, ssh_port: 22, panel_url: '', ssh_user: '', login_method: 'key', ssh_key: '', password: '', purpose: '' },
domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null }, domain_detail: { domain_name: '', registrar: '', dns_provider: '', cloudflare_account: '', is_using: true, redirect_target: '', bind_asset_id: null },
@@ -52,7 +52,7 @@ function buildAssetPayload(f) {
name: f.name, asset_type: f.asset_type, provider: f.provider, name: f.name, asset_type: f.asset_type, provider: f.provider,
provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null, provider_id: f.provider_id || null, renewal_cycle: f.renewal_cycle || null,
renew_url: f.renew_url || null, cancel_url: f.cancel_url || null, renew_url: f.renew_url || null, cancel_url: f.cancel_url || null,
account: f.account || null, expiry_date: f.expiry_date || null, account_id: f.account_id || null, expiry_date: f.expiry_date || null,
auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency, auto_renew: !!f.auto_renew, cost: Number(f.cost) || 0, currency: f.currency,
status: f.status, is_archived: !!f.is_archived, remark: f.remark || null, status: f.status, is_archived: !!f.is_archived, remark: f.remark || null,
}; };
@@ -113,7 +113,7 @@ function openAICreate(provider) {
} }
function openAssetEdit(a) { function openAssetEdit(a) {
const form = emptyAssetForm(); const form = emptyAssetForm();
['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; }); ['name', 'asset_type', 'provider', 'provider_id', 'renewal_cycle', 'renew_url', 'cancel_url', 'account_id', 'expiry_date', 'auto_renew', 'cost', 'currency', 'status', 'is_archived', 'remark'].forEach(k => { form[k] = a[k]; });
if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail); if (a.vps_detail) Object.assign(form.vps_detail, a.vps_detail);
if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail); if (a.domain_detail) Object.assign(form.domain_detail, a.domain_detail);
if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail); if (a.ai_detail) Object.assign(form.ai_detail, a.ai_detail);