diff --git a/app/database.py b/app/database.py index 257e905..8cff2d6 100644 --- a/app/database.py +++ b/app/database.py @@ -98,6 +98,18 @@ def _migrate_assets_db() -> None: conn.execute(sa.text("ALTER TABLE ai_accounts ADD COLUMN api_key_encrypted VARCHAR")) # 迁移:将明文 api_key 加密后存入 api_key_encrypted,并清空原字段 _migrate_plaintext_api_keys(conn) + if insp.has_table("accounts"): + cols = {c["name"] for c in insp.get_columns("accounts")} + if "login_user" not in cols: + conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_user VARCHAR")) + if "login_password_encrypted" not in cols: + conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN login_password_encrypted VARCHAR")) + if "api_config_encrypted" not in cols: + conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN api_config_encrypted VARCHAR")) + if "last_synced_at" not in cols: + conn.execute(sa.text("ALTER TABLE accounts ADD COLUMN last_synced_at DATETIME")) + # 凭证下沉:平台 api_config 迁到默认账号;AI 资产自动挂靠账号 + _backfill_account_credentials(conn) def _migrate_indexes() -> None: @@ -151,6 +163,84 @@ def _backfill_provider_services(conn) -> None: ) +def _backfill_account_credentials(conn) -> None: + """凭证下沉迁移(幂等,仅在首次加列后产生效果): + + 1. 平台凭证下沉:api_config_encrypted 非空的 Provider → 确保存在 + 「{slug}-默认」账号并拷入凭证(不删平台原值,保留可回滚)。 + 2. AI 资产挂靠:account 为空的 ai_agent 资产 → 按 ai_accounts.provider + 找/建账号并关联;账号无 API 配置时把该资产的 api_key 写入账号配置。 + """ + import json + from datetime import datetime, timezone + + import sqlalchemy as sa + + from app.core.crypto import decrypt, encrypt + + def _ensure_account(name: str, platform: str) -> int: + row = conn.execute(sa.text("SELECT id FROM accounts WHERE name = :name"), {"name": name}).first() + if row: + return row[0] + conn.execute( + 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)}, + ) + return conn.execute(sa.text("SELECT id FROM accounts WHERE name = :name"), {"name": name}).first()[0] + + # 1. 平台凭证下沉到默认账号 + for pid, slug, cfg in conn.execute( + sa.text( + "SELECT id, slug, api_config_encrypted FROM providers " + "WHERE api_config_encrypted IS NOT NULL AND api_config_encrypted != ''" + ) + ).fetchall(): + acc_name = f"{slug}-默认" + _ensure_account(acc_name, slug) + conn.execute( + sa.text( + "UPDATE accounts SET api_config_encrypted = :cfg " + "WHERE name = :name AND (api_config_encrypted IS NULL OR api_config_encrypted = '')" + ), + {"cfg": cfg, "name": acc_name}, + ) + + # 2. 无账号的 AI 资产按 provider 挂靠 + rows = conn.execute( + sa.text( + "SELECT a.id, COALESCE(ai.provider, '') FROM assets a " + "JOIN ai_accounts ai ON ai.asset_id = a.id " + "WHERE a.asset_type = 'ai_agent' AND (a.account IS NULL OR a.account = '')" + ) + ).fetchall() + for asset_id, provider in rows: + if not provider: + continue + _ensure_account(provider, provider) + conn.execute( + sa.text("UPDATE assets SET account = :acc WHERE id = :id"), + {"acc": provider, "id": asset_id}, + ) + # 账号尚无 API 配置时,把该资产的 api_key 写入账号配置(解密后重组 JSON 再加密) + key_row = conn.execute( + sa.text("SELECT api_key_encrypted FROM ai_accounts WHERE asset_id = :id"), + {"id": asset_id}, + ).first() + if key_row and key_row[0]: + api_key = decrypt(key_row[0]) + if api_key: + cfg_json = json.dumps({"api_key": api_key}) + cfg_enc = encrypt(cfg_json) + if cfg_enc: + conn.execute( + sa.text( + "UPDATE accounts SET api_config_encrypted = :cfg " + "WHERE name = :name AND (api_config_encrypted IS NULL OR api_config_encrypted = '')" + ), + {"cfg": cfg_enc, "name": provider}, + ) + + def get_session() -> Generator[Session, None, None]: """资产库会话(默认) diff --git a/app/models/asset.py b/app/models/asset.py index b7f6ff4..ddc0928 100644 --- a/app/models/asset.py +++ b/app/models/asset.py @@ -202,6 +202,8 @@ class Account(SQLModel, table=True): """平台账号字典:统一管理各平台下的账号(多账号场景区分归属) 资产的 Asset.account 字段按名称引用本表,重命名时由服务层同步更新引用。 + 凭证层:登录用户名/密码(网站登录)+ API 配置 JSON(SDK 同步用), + 平台本身不再持有凭证(Provider.api_config_encrypted 已弃用,仅留历史值)。 """ __tablename__ = "accounts" @@ -212,4 +214,14 @@ class Account(SQLModel, table=True): default=None, index=True, description="所属平台(slug 或名称)" ) remark: Optional[str] = Field(default=None, description="备注") + login_user: Optional[str] = Field(default=None, description="登录用户名/邮箱") + login_password_encrypted: Optional[str] = Field( + default=None, description="加密的登录密码(Fernet)" + ) + api_config_encrypted: Optional[str] = Field( + default=None, description="加密的 API 配置 JSON(access_key/secret/api_key 等,供 SDK 同步)" + ) + last_synced_at: Optional[datetime] = Field( + default=None, description="最近一次 SDK 同步时间" + ) created_at: datetime = Field(default_factory=utcnow, description="创建时间") diff --git a/app/routers/accounts.py b/app/routers/accounts.py index d5366e2..f8d32f6 100644 --- a/app/routers/accounts.py +++ b/app/routers/accounts.py @@ -2,6 +2,7 @@ 统一入口 /api/accounts,写操作(POST/PUT/DELETE)受 API Key 保护。 资产的 Asset.account 按名称引用账号;重命名账号时服务层会同步更新引用资产。 +凭证(登录密码/API 配置)存在账号上;账号维度的 SDK 测试/同步见 test/sync 端点。 """ from typing import List @@ -12,7 +13,7 @@ from sqlmodel import Session from app.core.security import require_api_key from app.database import get_session from app.schemas.account import AccountCreate, AccountRead, AccountUpdate -from app.services import account_service +from app.services import account_service, sync_service router = APIRouter(prefix="/api/accounts", tags=["accounts"]) @@ -52,3 +53,21 @@ def update_account( ) def delete_account(account_id: int, session: Session = Depends(get_session)) -> dict: return account_service.delete_account(session, account_id) + + +@router.post( + "/{account_id}/test", + summary="测试账号凭证有效性(按账号所属平台的 SDK)", + dependencies=[Depends(require_api_key)], +) +def test_account(account_id: int, session: Session = Depends(get_session)) -> dict: + return sync_service.test_account(session, account_id) + + +@router.post( + "/{account_id}/sync", + summary="同步账号资产到本地库(产出自动挂到该账号名下)", + dependencies=[Depends(require_api_key)], +) +def sync_account(account_id: int, session: Session = Depends(get_session)) -> dict: + return sync_service.sync_account(session, account_id) diff --git a/app/schemas/account.py b/app/schemas/account.py index ac0ae4a..a50d135 100644 --- a/app/schemas/account.py +++ b/app/schemas/account.py @@ -1,4 +1,9 @@ -"""平台账号 Schema""" +"""平台账号 Schema + +凭证语义约定: +- login_password / api_config:Create 时传入即加密存储;Update 时 None 表示不修改。 +- Read 永不返回凭证明文/密文,只给布尔标记(has_login_password / has_api_config)。 +""" from datetime import datetime from typing import Optional @@ -10,19 +15,27 @@ class AccountBase(SQLModel): name: str platform: Optional[str] = None remark: Optional[str] = None + login_user: Optional[str] = None class AccountCreate(AccountBase): - pass + login_password: Optional[str] = None # 登录密码(明文传入,加密存储) + api_config: Optional[str] = None # API 配置 JSON 字符串(加密存储,供 SDK 同步) class AccountUpdate(SQLModel): name: Optional[str] = None platform: Optional[str] = None remark: Optional[str] = None + login_user: Optional[str] = None + login_password: Optional[str] = None # None = 不修改 + api_config: Optional[str] = None # None = 不修改 class AccountRead(AccountBase): id: int created_at: datetime asset_count: int = 0 # 引用该账号的资产数(列表接口填充) + has_login_password: bool = False + has_api_config: bool = False + last_synced_at: Optional[datetime] = None diff --git a/app/services/account_service.py b/app/services/account_service.py index 276adfa..966abdf 100644 --- a/app/services/account_service.py +++ b/app/services/account_service.py @@ -2,6 +2,7 @@ 账号与资产的关系:Asset.account 按名称引用账号(字符串,兼容历史自由文本)。 重命名账号时同步更新所有引用资产,保证两边一致。 +凭证层:登录密码与 API 配置加密存储,Read 仅返回布尔标记。 """ from typing import Dict, List @@ -10,6 +11,7 @@ from fastapi import HTTPException, status from sqlalchemy import func from sqlmodel import Session, select +from app.core import crypto from app.models.asset import Account, Asset from app.schemas.account import AccountCreate, AccountRead, AccountUpdate @@ -27,6 +29,8 @@ def _asset_counts(session: Session) -> Dict[str, int]: def _to_read(account: Account, counts: Dict[str, int]) -> AccountRead: read = AccountRead.model_validate(account) read.asset_count = counts.get(account.name, 0) + read.has_login_password = bool(account.login_password_encrypted) + read.has_api_config = bool(account.api_config_encrypted) return read @@ -58,7 +62,9 @@ def create_account(session: Session, data: AccountCreate) -> AccountRead: 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) + account = Account(name=name, platform=data.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) session.commit() session.refresh(account) @@ -83,6 +89,13 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac account.platform = data.platform or None 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 = 不修改;空串 = 清除;非空 = 重新加密存储 + if data.login_password is not None: + account.login_password_encrypted = crypto.encrypt(data.login_password) + if data.api_config is not None: + account.api_config_encrypted = crypto.encrypt(data.api_config) session.add(account) session.commit() session.refresh(account) diff --git a/app/services/sync_service.py b/app/services/sync_service.py index 825cded..67c18be 100644 --- a/app/services/sync_service.py +++ b/app/services/sync_service.py @@ -3,6 +3,10 @@ 将适配器返回的标准化资产(NormalizedVPS / NormalizedDomain / AccountInfo) 写入或更新到资产库。以 (provider_id, external_id) 作为去重键, 已存在则更新状态/详情,不存在则新建资产。 + +凭证层级:API 配置存在账号(Account.api_config_encrypted)上, +同步按账号维度进行(test_account/sync_account);同步产出的资产 +自动挂到该账号名下(Asset.account)。平台不再持有凭证。 """ import json @@ -20,6 +24,7 @@ from app.core import crypto from app.core.timeutils import utcnow from app.models.asset import ( AIAccount, + Account, Asset, AssetStatus, AssetType, @@ -32,7 +37,7 @@ _VALID_STATUS = {s.value for s in AssetStatus} def _load_config(provider: Provider) -> dict: - """解密平台的 API 配置 JSON""" + """(已弃用)解密平台级 API 配置,仅为兼容历史数据保留""" plain = crypto.decrypt(provider.api_config_encrypted) if not plain: return {} @@ -42,6 +47,30 @@ def _load_config(provider: Provider) -> dict: return {} +def _load_account_config(account: Account) -> dict: + """解密账号的 API 配置 JSON""" + plain = crypto.decrypt(account.api_config_encrypted) + if not plain: + return {} + try: + return json.loads(plain) + except (json.JSONDecodeError, TypeError): + return {} + + +def _find_provider_by_platform(session: Session, platform: str) -> Provider: + """按账号的 platform(slug 或名称)定位平台""" + provider = session.exec(select(Provider).where(Provider.slug == platform)).first() + if not provider: + provider = session.exec(select(Provider).where(Provider.name == platform)).first() + if not provider: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"账号所属平台不存在:{platform}(请先在账号编辑中选择有效平台)", + ) + return provider + + def _get_provider(session: Session, provider_id: int) -> Provider: provider = session.get(Provider, provider_id) if not provider: @@ -49,7 +78,7 @@ def _get_provider(session: Session, provider_id: int) -> Provider: return provider -def _build_adapter(provider: Provider) -> BaseAdapter: +def _build_adapter(provider: Provider, config: dict) -> BaseAdapter: if not provider.sdk_type: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="该平台未配置 SDK 类型(sdk_type)" @@ -59,7 +88,27 @@ def _build_adapter(provider: Provider) -> BaseAdapter: status_code=status.HTTP_400_BAD_REQUEST, detail=f"暂不支持的 SDK 类型:{provider.sdk_type}", ) - return registry.get_adapter(provider.sdk_type, _load_config(provider)) + return registry.get_adapter(provider.sdk_type, config) + + +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 _build_adapter_for_account(session: Session, account: Account): + """按账号构建适配器:平台定 sdk_type,账号提供凭证""" + if not account.platform: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="该账号未指定所属平台,无法确定 SDK 类型", + ) + provider = _find_provider_by_platform(session, account.platform) + config = _load_account_config(account) + adapter = _build_adapter(provider, config) + return provider, adapter def _norm_status(raw: Optional[str]) -> AssetStatus: @@ -72,15 +121,37 @@ def _missing_config(adapter: BaseAdapter) -> list: def test_provider(session: Session, provider_id: int) -> dict: - """测试平台连接 / 凭证有效性""" + """(兼容入口)测试平台连接:凭证已下沉到账号,自动找该平台第一个配了凭证的账号""" provider = _get_provider(session, provider_id) - adapter = _build_adapter(provider) - base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type} + account = _first_account_with_config(session, provider) + if not account: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置", + ) + return test_account(session, account.id) + + +def _first_account_with_config(session: Session, provider: Provider): + """找该平台下第一个配置了 API 凭证的账号(兼容旧入口)""" + return session.exec( + select(Account) + .where(Account.platform == provider.slug) + .where(Account.api_config_encrypted.is_not(None)) # type: ignore[union-attr] + .order_by(Account.id.asc()) + ).first() + + +def test_account(session: Session, account_id: int) -> dict: + """测试账号凭证有效性(按账号 platform 定 SDK,凭证取自账号)""" + account = _get_account(session, account_id) + provider, adapter = _build_adapter_for_account(session, account) + base = {"capabilities": adapter.capabilities(), "sdk_type": provider.sdk_type, "account": account.name} missing = _missing_config(adapter) if missing: return { "ok": False, - "message": f"缺少凭证配置:{', '.join(missing)}(请在平台编辑里填写 API 配置)", + "message": f"缺少凭证配置:{', '.join(missing)}(请在账号编辑里填写 API 配置)", **base, } result = adapter.test_connection() @@ -98,7 +169,7 @@ def _find_asset(session: Session, provider_id: int, external_id: str, asset_type ).first() -def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dict: +def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter, account_name: Optional[str] = None) -> dict: created = updated = 0 for vps in adapter.list_vps(): existing = _find_asset(session, provider.id, vps.external_id, AssetType.VPS) @@ -108,6 +179,8 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic if vps.monthly_cost is not None: existing.cost = vps.monthly_cost existing.currency = vps.currency + if account_name: + existing.account = account_name session.add(existing) detail = session.exec( select(VPSDetail).where(VPSDetail.asset_id == existing.id) @@ -128,6 +201,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic provider=provider.slug, provider_id=provider.id, external_id=vps.external_id, + account=account_name, status=_norm_status(vps.status), cost=vps.monthly_cost or 0, currency=vps.currency, @@ -150,7 +224,7 @@ def _sync_vps(session: Session, provider: Provider, adapter: BaseAdapter) -> dic return {"created": created, "updated": updated} -def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> dict: +def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter, account_name: Optional[str] = None) -> dict: created = updated = 0 for dom in adapter.list_domains(): existing = _find_asset(session, provider.id, dom.external_id, AssetType.DOMAIN) @@ -158,6 +232,8 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> existing.status = _norm_status(dom.status) if dom.expiry_date: existing.expiry_date = dom.expiry_date + if account_name: + existing.account = account_name session.add(existing) detail = session.exec( select(DomainDetail).where(DomainDetail.asset_id == existing.id) @@ -174,6 +250,7 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> provider=provider.slug, provider_id=provider.id, external_id=dom.external_id, + account=account_name, status=_norm_status(dom.status), expiry_date=dom.expiry_date, ) @@ -192,39 +269,51 @@ def _sync_domains(session: Session, provider: Provider, adapter: BaseAdapter) -> def sync_provider(session: Session, provider_id: int) -> dict: - """同步平台资产到本地库""" + """(兼容入口)同步平台资产:凭证已下沉到账号,自动找该平台第一个配了凭证的账号""" provider = _get_provider(session, provider_id) - adapter = _build_adapter(provider) + account = _first_account_with_config(session, provider) + if not account: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="凭证已改为在账号上配置:请先在该平台的账号管理中新建账号并填写 API 配置", + ) + return sync_account(session, account.id) + + +def sync_account(session: Session, account_id: int) -> dict: + """同步账号资产到本地库(凭证取自账号,同步产出自动挂到该账号名下)""" + account = _get_account(session, account_id) + provider, adapter = _build_adapter_for_account(session, account) missing = _missing_config(adapter) if missing: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, - detail=f"缺少凭证配置:{', '.join(missing)}(请先在平台编辑里填写 API 配置)", + detail=f"缺少凭证配置:{', '.join(missing)}(请先在账号编辑里填写 API 配置)", ) caps = adapter.capabilities() - result = {"provider": provider.slug, "sdk_type": provider.sdk_type} + result = {"provider": provider.slug, "sdk_type": provider.sdk_type, "account": account.name} if caps["list_vps"]: try: - result["vps"] = _sync_vps(session, provider, adapter) + result["vps"] = _sync_vps(session, provider, adapter, account.name) except Exception as e: # noqa: BLE001 result["vps_error"] = str(e) if caps["list_domains"]: try: - result["domains"] = _sync_domains(session, provider, adapter) + result["domains"] = _sync_domains(session, provider, adapter, account.name) except Exception as e: # noqa: BLE001 result["domains_error"] = str(e) if caps["get_account"]: try: - result["account"] = adapter.get_account().to_dict() + result["account_info"] = adapter.get_account().to_dict() except Exception as e: # noqa: BLE001 - result["account_error"] = str(e) + result["account_info_error"] = str(e) - provider.last_synced_at = utcnow() - session.add(provider) + account.last_synced_at = utcnow() + session.add(account) session.commit() - result["last_synced_at"] = provider.last_synced_at.isoformat() - logger.info("同步平台 provider=%s result=%s", provider.slug, result) + result["last_synced_at"] = account.last_synced_at.isoformat() + logger.info("同步账号 account=%s provider=%s result=%s", account.name, provider.slug, result) return result diff --git a/static/js/modals.js b/static/js/modals.js index f0648e7..ff425e5 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -160,14 +160,10 @@ const ProviderModal = { - + -
-
API 凭证(加密存储)
- -
-
该 SDK 类型暂无预设凭证字段(可后续扩展)
+
API 凭证请在平台账号中配置:保存后在平台卡片点「账号」→ 新增账号并填写 API 配置,即可测试/同步。
@@ -179,19 +175,14 @@ const ProviderModal = {
`, setup() { const f = Vue.computed(() => store.providerModal.form); - const adapterMeta = Vue.ref([]); - const apiFields = Vue.reactive({}); + const sdkOptions = Vue.ref([]); Vue.onMounted(async () => { - try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ } - }); - const sdkOptions = Vue.computed(() => { - const opts = []; - for (const m of adapterMeta.value) for (const t of (m.sdk_types || [])) opts.push({ type: t, fields: m.required_config || [] }); - return opts; - }); - const currentFields = Vue.computed(() => { - const opt = sdkOptions.value.find(o => o.type === (f.value && f.value.sdk_type)); - return opt ? opt.fields : []; + try { + const r = await Api.get('/providers/adapters'); + const opts = []; + for (const m of (r.adapters || [])) for (const t of (m.sdk_types || [])) opts.push(t); + sdkOptions.value = opts; + } catch (e) { /* ignore */ } }); // 提供服务多选:services 逗号分隔字符串 <-> checkbox 集合 const servicesSet = Vue.computed(() => new Set((f.value && f.value.services || '').split(',').map(s => s.trim()).filter(Boolean))); @@ -201,17 +192,8 @@ const ProviderModal = { if (idx >= 0) arr.splice(idx, 1); else arr.push(k); f.value.services = arr.join(','); } - // 模态框每次打开时清空上次残留的凭证输入,避免串号 - Vue.watch(() => store.providerModal.show, (show) => { - if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; } - }); - function save() { - const cfg = {}; - for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field]; - if (Object.keys(cfg).length) f.value.api_config = JSON.stringify(cfg); - saveProvider(); - } - return { store, Fmt, f, sdkOptions, currentFields, servicesSet, toggleService, apiFields, save }; + // 凭证已下沉到账号层,平台表单不再收集 API 凭证 + return { store, Fmt, f, sdkOptions, servicesSet, toggleService, save: saveProvider }; }, }; @@ -234,6 +216,17 @@ const AccountModal = { +
+ + +
+
+
API 配置(供 SDK 同步,加密存储)
+ +

修改账号标识后,引用该账号的资产会自动同步更新。

@@ -244,7 +237,29 @@ const AccountModal = {
`, setup() { const f = Vue.computed(() => store.accountModal.form); - return { store, f, save: saveAccount }; + const adapterMeta = Vue.ref([]); + const apiFields = Vue.reactive({}); + Vue.onMounted(async () => { + try { const r = await Api.get('/providers/adapters'); adapterMeta.value = r.adapters || []; } catch (e) { /* ignore */ } + }); + // 按账号所属平台的 sdk_type 动态渲染所需凭证字段 + const currentFields = Vue.computed(() => { + const p = store.providers.find(x => x.slug === (f.value && f.value.platform)); + if (!p || !p.sdk_type) return []; + const m = adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type)); + return m ? (m.required_config || []) : []; + }); + // 每次打开清空残留输入,避免串号 + Vue.watch(() => store.accountModal.show, (show) => { + if (show) { for (const k of Object.keys(apiFields)) delete apiFields[k]; } + }); + function save() { + const cfg = {}; + for (const field of currentFields.value) if (apiFields[field]) cfg[field] = apiFields[field]; + f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : ''; + saveAccount(); + } + return { store, f, currentFields, apiFields, save }; }, }; @@ -270,9 +285,14 @@ const AccountsViewModal = {
-
{{ a.remark || a.name }}
+
{{ a.remark || a.name }} + 🔑登录 + ⚙️API +
{{ a.name }} · 关联 {{ assetsOf(a).length }} 个资产
+ +
@@ -322,13 +342,39 @@ const AccountsViewModal = { return store.assets.filter(x => x.account === a.name); } function toggle(id) { expanded[id] = !expanded[id]; } + // 账号所属平台配了 sdk_type 且账号有 API 配置时,提供测试/同步 + function providerOf(a) { + return store.providers.find(p => p.slug === a.platform) || store.providers.find(p => p.name === a.platform); + } + function canSync(a) { + const p = providerOf(a); + return !!(a.has_api_config && p && p.sdk_type); + } + async function testAcct(a) { + try { + const r = await Api.post('/accounts/' + a.id + '/test', {}); + alert(r.ok ? '✅ ' + r.message : '❌ ' + r.message); + } catch (e) { alert('测试失败:' + e.message); } + } + async function syncAcct(a) { + try { + const r = await Api.post('/accounts/' + a.id + '/sync', {}); + const lines = ['同步完成']; + if (r.vps) lines.push('VPS:新增 ' + r.vps.created + ' / 更新 ' + r.vps.updated); + if (r.domains) lines.push('域名:新增 ' + r.domains.created + ' / 更新 ' + r.domains.updated); + if (r.vps_error) lines.push('VPS 错误:' + r.vps_error); + if (r.domains_error) lines.push('域名错误:' + r.domains_error); + alert(lines.join('\n')); + await Promise.all([loadAccounts(), loadAssets()]); + } catch (e) { alert('同步失败:' + e.message); } + } // 每次打开重置展开状态 Vue.watch(() => store.accountsModal.show, (show) => { if (show) for (const k of Object.keys(expanded)) delete expanded[k]; }); return { store, Fmt, expanded, singleProvider, title, accountList, groups, - platformName, assetsOf, toggle, + platformName, assetsOf, toggle, canSync, testAcct, syncAcct, add: () => openAccountCreate(store.accountsModal.providerSlug || ''), edit: openAccountEdit, del: deleteAccount, }; diff --git a/static/js/store.js b/static/js/store.js index 1b9682a..0b43b2f 100644 --- a/static/js/store.js +++ b/static/js/store.js @@ -141,15 +141,16 @@ async function deleteAsset(a) { /* ---------------- 平台操作 ---------------- */ function openProviderCreate() { - store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '', api_config: '' } }; + store.providerModal = { show: true, editing: null, form: { slug: '', name: '', name_en: '', category: 'vps', services: '', website: '', console_url: '', sdk_type: '', enabled: true, remark: '' } }; } function openProviderEdit(p) { - store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '', api_config: '' } }; + store.providerModal = { show: true, editing: p.id, form: { slug: p.slug, name: p.name, name_en: p.name_en || '', category: p.category, services: p.services || '', website: p.website || '', console_url: p.console_url || '', sdk_type: p.sdk_type || '', enabled: p.enabled, remark: p.remark || '' } }; } async function saveProvider() { store.error = ''; const f = store.providerModal.form; - const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null, api_config: f.api_config || null }; + // 凭证已下沉到账号层,平台不再携带 api_config + const payload = { slug: f.slug, name: f.name, name_en: f.name_en || null, category: f.category, services: f.services || null, website: f.website || null, console_url: f.console_url || null, sdk_type: f.sdk_type || null, enabled: !!f.enabled, remark: f.remark || null }; try { if (store.providerModal.editing) await Api.put('/providers/' + store.providerModal.editing, payload); else await Api.post('/providers', payload); @@ -173,15 +174,21 @@ function openAccountsView(providerSlug) { store.accountsModal = { show: true, providerSlug: providerSlug || null }; } function openAccountCreate(platform) { - store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '' } }; + store.accountModal = { show: true, editing: null, form: { name: '', platform: platform || '', remark: '', login_user: '', login_password: '', api_config: '' } }; } function openAccountEdit(a) { - store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '' } }; + // login_password / api_config 留空 = 不修改原凭证 + store.accountModal = { show: true, editing: a.id, form: { name: a.name, platform: a.platform || '', remark: a.remark || '', login_user: a.login_user || '', login_password: '', api_config: '' } }; } async function saveAccount() { store.error = ''; const f = store.accountModal.form; - const payload = { name: f.name, platform: f.platform || null, remark: f.remark || null }; + const payload = { + name: f.name, platform: f.platform || null, remark: f.remark || null, + login_user: f.login_user || null, + login_password: f.login_password || null, + api_config: f.api_config || null, + }; try { if (store.accountModal.editing) await Api.put('/accounts/' + store.accountModal.editing, payload); else await Api.post('/accounts', payload); diff --git a/static/js/views/providers.js b/static/js/views/providers.js index 71649c9..a04db74 100644 --- a/static/js/views/providers.js +++ b/static/js/views/providers.js @@ -4,7 +4,7 @@ const ProvidersView = {
- 🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。在「资产」中添加项目时选择归属平台;支持 API 对接的平台配置好密钥后,可「测试连接」或「同步资产」拉取真实数据。 + 🏢 平台 = 资产的服务商(Vultr / 新网 / OpenAI…)。凭证(登录信息/API Key)配在账号上:点平台卡片「👤 账号」新增账号并填写凭证,即可在账号上「测试/同步」拉取真实数据。
@@ -30,19 +30,14 @@ const ProvidersView = {
- 已配置 API - 未配置 API {{ p.enabled ? '启用' : '停用' }}
最后同步:{{ fmtTime(p.last_synced_at) }}
- -
-
{{ results[p.id].text }}
`, setup() { - const testing = Vue.reactive({}); - const syncing = Vue.reactive({}); - const results = Vue.reactive({}); - const supported = Vue.ref([]); - const capabilities = Vue.ref({}); + // 凭证已下沉到账号层:平台卡片不再提供测试/同步(见账号弹窗) // 平台关联资产数:优先按 provider_id 归并,其次按资产里填的平台名/slug const assetCounts = Vue.computed(() => { const m = {}; @@ -86,38 +77,10 @@ const ProvidersView = { function serviceList(p) { return (p.services || '').split(',').map(s => s.trim()).filter(Boolean); } - Vue.onMounted(async () => { - try { const r = await Api.get('/providers/adapters'); supported.value = r.supported_types || []; capabilities.value = r.capabilities || {}; } catch (e) { /* ignore */ } - }); - function isSupported(t) { return !!t && supported.value.includes(t); } - function canSync(t) { const c = capabilities.value[t]; return !!(c && (c.list_vps || c.list_domains)); } function fmtTime(iso) { if (!iso) return ''; return new Date(iso).toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' }); } - async function test(p) { - testing[p.id] = true; delete results[p.id]; - try { - const r = await Api.post('/providers/' + p.id + '/test', {}); - results[p.id] = { ok: r.ok, text: (r.ok ? '✓ ' : '✗ ') + r.message }; - } catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; } - finally { testing[p.id] = false; } - } - async function sync(p) { - syncing[p.id] = true; delete results[p.id]; - try { - const r = await Api.post('/providers/' + p.id + '/sync', {}); - const parts = []; - if (r.vps) parts.push('VPS 新增' + r.vps.created + '/更新' + r.vps.updated); - if (r.domains) parts.push('域名 新增' + r.domains.created + '/更新' + r.domains.updated); - if (r.vps_error) parts.push('VPS错误:' + r.vps_error); - if (r.domains_error) parts.push('域名错误:' + r.domains_error); - if (r.account && r.account.balance !== null && r.account.balance !== undefined) parts.push('余额 ' + r.account.balance); - results[p.id] = { ok: true, text: '✓ 同步完成:' + (parts.join(',') || '无变化') }; - await loadAll(); - } catch (e) { results[p.id] = { ok: false, text: '✗ ' + e.message }; } - finally { syncing[p.id] = false; } - } - return { store, Fmt, testing, syncing, results, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, isSupported, canSync, fmtTime, test, sync, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider }; + return { store, Fmt, countOf, accountsCountOf, serviceList, showAll, toggleShowAll, visibleProviders, hiddenCount, fmtTime, seed: seedProviders, create: openProviderCreate, edit: openProviderEdit, del: deleteProvider, openAccounts: (p) => openAccountsView(p.slug), manageAccounts: () => openAccountsView(null), goAssets: openAssetsByProvider }; }, };