fix(account): 支持跨平台同名账号——(platform,name)联合唯一+Asset.account_id外键重构
This commit is contained in:
+108
-11
@@ -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
@@ -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,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 端点。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user