From 9e358fed6b904412f03e9fb6f3f745eca1c76447 Mon Sep 17 00:00:00 2001 From: gouki Date: Mon, 26 Oct 2026 12:05:48 +0000 Subject: [PATCH] =?UTF-8?q?fix(adapters):=20=E8=A1=A5=E9=BD=90=E5=9B=BD?= =?UTF-8?q?=E9=99=85=E7=89=88=20region=20=E5=A1=AB=E5=86=99=E4=B8=8E=20Nam?= =?UTF-8?q?eSilo=20=E4=BD=99=E9=A2=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计发现两处「已声明但实际用不了」的缺口: - 阿里云/腾讯云国际版与国内版共用适配器,region 只能靠代码默认值(cn-hangzhou / ap-guangzhou),账号弹窗里无处填写,海外账号查不到资源。新增 optional_config 机制,region 按可选明文渲染;留空仍走原默认值,不阻塞保存与同步。 - NameSilo 有 getAccountBalance 接口但未实现,平台卡片不显示余额。补充余额 与未出账消费(available_balance / unbilled_amount 分开报告)。 顺带修掉一个会丢凭证的隐患:编辑账号时前端只提交填写过的字段,后端原先整段 覆盖 api_config,只补 region 会把已存 AK/SK 抹掉;改为与已存 JSON 合并。 测试:449 passed(新增合并、余额、optional_config 用例),vitest 62 passed。 --- VERSION | 2 +- app/adapters/aliyun.py | 3 +++ app/adapters/base.py | 2 ++ app/adapters/namesilo.py | 24 ++++++++++++++++++++++++ app/adapters/registry.py | 1 + app/adapters/tencent.py | 3 +++ app/services/account_service.py | 24 +++++++++++++++++++++++- static/js/modals.js | 12 +++++++++--- tests/test_asset_service.py | 28 ++++++++++++++++++++++++++++ tests/test_new_adapters.py | 26 ++++++++++++++++++++++++-- tests/test_platform_adapters.py | 10 ++++++++++ 11 files changed, 128 insertions(+), 7 deletions(-) diff --git a/VERSION b/VERSION index a918a2a..ee6cdce 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.6.0 +0.6.1 diff --git a/app/adapters/aliyun.py b/app/adapters/aliyun.py index a3efc8a..4da9ef1 100644 --- a/app/adapters/aliyun.py +++ b/app/adapters/aliyun.py @@ -47,6 +47,9 @@ _STATUS_MAP = {"Running": "active", "Stopped": "stopped", "Stopping": "stopped", @register("aliyun-sdk", "alibabacloud-sdk") class AliyunAdapter(BaseAdapter): required_config = ["access_key_id", "access_key_secret"] + #: 国际版账号在国内默认区域上会查不到资源,region 必须可填(留空用 cn-hangzhou) + optional_config = ["region"] + plain_config = ["region"] def _region(self) -> str: return self.config.get("region") or "cn-hangzhou" diff --git a/app/adapters/base.py b/app/adapters/base.py index 5d18770..fb0a21b 100644 --- a/app/adapters/base.py +++ b/app/adapters/base.py @@ -228,6 +228,8 @@ class BaseAdapter(ABC): #: 适配器所需的配置字段名(如 ["api_key"] 或 ["access_key_id", "access_key_secret"]) required_config: list = [] + #: 可选配置字段(如区域),缺省时适配器用内置默认值;不填不阻塞保存/同步 + optional_config: list = [] #: required_config 中不属于密钥的字段(前端按明文输入,避免代理号/域名被遮住) plain_config: list = [] diff --git a/app/adapters/namesilo.py b/app/adapters/namesilo.py index 233a31b..90d8cb7 100644 --- a/app/adapters/namesilo.py +++ b/app/adapters/namesilo.py @@ -13,6 +13,7 @@ reply.code == 300 才是成功,其余(如 301 未授权、400 参数错) import httpx from app.adapters.base import ( + AccountInfo, BaseAdapter, NormalizedDNSRecord, NormalizedDomain, @@ -23,6 +24,13 @@ from app.adapters.registry import register SUCCESS_CODE = 300 +def _to_float(value): + try: + return float(str(value).replace(",", "")) + except (TypeError, ValueError): + return None + + @register("namesilo-api") class NamesiloAdapter(BaseAdapter): required_config = ["api_key"] @@ -59,6 +67,22 @@ class NamesiloAdapter(BaseAdapter): except Exception as e: # noqa: BLE001 return {"ok": False, "message": str(e)} + # ---------------- 账户 ---------------- + + def get_account(self) -> AccountInfo: + """账户余额(getAccountBalance) + + available_balance 是可用余额,unbilled_amount 是尚未出账的消费, + 两者分开返回,不能合并成一个数字——前者可花,后者会被扣走。 + """ + reply = self._call("getAccountBalance") + return AccountInfo( + balance=_to_float(reply.get("available_balance")), + currency="USD", + pending_charges=_to_float(reply.get("unbilled_amount")), + raw=reply, + ) + # ---------------- 只读清单 ---------------- def list_domains(self) -> list: diff --git a/app/adapters/registry.py b/app/adapters/registry.py index fb27ca0..4fc29af 100644 --- a/app/adapters/registry.py +++ b/app/adapters/registry.py @@ -88,6 +88,7 @@ def adapter_meta() -> list: "class": cls.__name__, "sdk_types": getattr(cls, "sdk_types", [sdk_type]), "required_config": getattr(cls, "required_config", []), + "optional_config": getattr(cls, "optional_config", []), "plain_config": getattr(cls, "plain_config", []), } ) diff --git a/app/adapters/tencent.py b/app/adapters/tencent.py index 3472ca6..d472540 100644 --- a/app/adapters/tencent.py +++ b/app/adapters/tencent.py @@ -53,6 +53,9 @@ def _service_of(host: str) -> str: @register("tencent-sdk", "tencent-intl-sdk") class TencentAdapter(BaseAdapter): required_config = ["secret_id", "secret_key"] + #: 国际版账号在国内默认区域上会查不到资源,region 必须可填(留空用 ap-guangzhou) + optional_config = ["region"] + plain_config = ["region"] def _region(self) -> str: return self.config.get("region") or "ap-guangzhou" diff --git a/app/services/account_service.py b/app/services/account_service.py index 249e3c5..2420baf 100644 --- a/app/services/account_service.py +++ b/app/services/account_service.py @@ -6,6 +6,7 @@ (credential_service.upsert_for_account 同事务同步),Read 仅返回布尔标记。 """ +import json from typing import Dict, List, Optional from fastapi import HTTPException, status @@ -79,6 +80,27 @@ def _norm_platform(platform: str | None) -> str: return (platform or "").strip() +def _merge_api_config(account: Account, incoming: str) -> str: + """编辑账号时只提交了部分字段:与已存 JSON 合并,避免「只补区域」把 Key 抹掉""" + try: + new = json.loads(incoming or "{}") + except (json.JSONDecodeError, TypeError): + return incoming + if not isinstance(new, dict): + return incoming + existing = {} + plain = crypto.decrypt(account.api_config_encrypted) + if plain: + try: + existing = json.loads(plain) + except (json.JSONDecodeError, TypeError): + existing = {} + if not isinstance(existing, dict): + existing = {} + existing.update({k: v for k, v in new.items() if v not in (None, "")}) + return json.dumps(existing) + + def _check_name_taken(session: Session, name: str, platform: str, exclude_id: int | None = None) -> None: """校验 (platform, name) 联合唯一:同平台内不允许重名,跨平台可复用 @@ -146,7 +168,7 @@ def update_account(session: Session, account_id: int, data: AccountUpdate) -> Ac if data.login_password is not None or account.credential_id: credential_service.upsert_for_account(session, account, data.login_password) if data.api_config is not None: - account.api_config_encrypted = crypto.encrypt(data.api_config) + account.api_config_encrypted = crypto.encrypt(_merge_api_config(account, data.api_config)) try: session.add(account) session.commit() diff --git a/static/js/modals.js b/static/js/modals.js index a04a0c5..8c3332d 100644 --- a/static/js/modals.js +++ b/static/js/modals.js @@ -225,7 +225,7 @@ const AccountModal = {
API 配置(供 SDK 同步,加密存储)
-

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

@@ -249,7 +249,13 @@ const AccountModal = { if (!p || !p.sdk_type) return null; return adapterMeta.value.find(x => (x.sdk_types || []).includes(p.sdk_type)) || null; }); - const currentFields = Vue.computed(() => (currentAdapter.value ? (currentAdapter.value.required_config || []) : [])); + // 必填字段 + 可选字段一起渲染,可选字段留空时适配器用内置默认值 + const currentFields = Vue.computed(() => { + if (!currentAdapter.value) return []; + return [...(currentAdapter.value.required_config || []), ...(currentAdapter.value.optional_config || [])]; + }); + const optionalFields = Vue.computed(() => (currentAdapter.value ? (currentAdapter.value.optional_config || []) : [])); + function isOptional(field) { return optionalFields.value.includes(field); } // 代理号、域名清单等非密钥字段按明文显示,否则用户无法核对输入 const plainFields = Vue.computed(() => (currentAdapter.value ? (currentAdapter.value.plain_config || []) : [])); function isPlain(field) { return plainFields.value.includes(field); } @@ -263,7 +269,7 @@ const AccountModal = { f.value.api_config = Object.keys(cfg).length ? JSON.stringify(cfg) : ''; saveAccount(); } - return { store, f, currentFields, apiFields, isPlain, save }; + return { store, f, currentFields, apiFields, isOptional, isPlain, save }; }, }; diff --git a/tests/test_asset_service.py b/tests/test_asset_service.py index 54067c4..b16e70b 100644 --- a/tests/test_asset_service.py +++ b/tests/test_asset_service.py @@ -17,6 +17,7 @@ Fernet key(不读 .env 的真实 MASTER_KEY)。 运行:.venv/bin/pytest tests/test_asset_service.py -v """ +import json from datetime import date, timedelta import pytest @@ -309,3 +310,30 @@ def test_get_expiring_includes_overdue_excludes_cancelled_sorted(engine): # 已过期(-5) 排在 30 天内(+10) 之前;far 超窗口、cancelled 已注销都不进 assert names == ["overdue", "soon"] assert result[0].days_to_expiry == -5 + + +# ----------------------------- account api_config merge ----------------------------- # +def test_update_account_merges_partial_api_config(engine, master_key): + """编辑账号只补 region 时,已存的 AK/SK 不能被整段 JSON 覆盖掉""" + from app.schemas.account import AccountCreate, AccountUpdate + from app.services import account_service + + with Session(engine) as s: + created = account_service.create_account( + s, + AccountCreate( + name="intl@example.com", + platform="aliyun-intl", + api_config='{"access_key_id": "AK", "access_key_secret": "SK"}', + ), + ) + account_service.update_account( + s, created.id, AccountUpdate(api_config='{"region": "ap-southeast-1"}') + ) + row = s.get(Account, created.id) + cfg = json.loads(crypto.decrypt(row.api_config_encrypted)) + assert cfg == { + "access_key_id": "AK", + "access_key_secret": "SK", + "region": "ap-southeast-1", + } diff --git a/tests/test_new_adapters.py b/tests/test_new_adapters.py index a8020f2..51cad16 100644 --- a/tests/test_new_adapters.py +++ b/tests/test_new_adapters.py @@ -628,6 +628,28 @@ def test_namesilo_unparseable_expiry_is_none(): assert adapter.list_domains()[0].expiry_date is None +def test_namesilo_balance_separates_available_and_unbilled(): + """可用余额与未出账消费必须分开报告,合并会把可花金额说多""" + adapter = NamesiloAdapter({"api_key": "k"}) + reply = { + "code": 300, + "available_balance": "12.34", + "unbilled_amount": "1.99", + } + with patch.object(NamesiloAdapter, "_call", return_value=reply): + acc = adapter.get_account() + assert acc.balance == 12.34 + assert acc.pending_charges == 1.99 + assert acc.currency == "USD" + + +def test_namesilo_balance_unparseable_is_none(): + adapter = NamesiloAdapter({"api_key": "k"}) + with patch.object(NamesiloAdapter, "_call", return_value={"code": 300, "available_balance": "N/A"}): + acc = adapter.get_account() + assert acc.balance is None + + def test_namesilo_fqdn_host_roundtrip(): """Namesilo 存子域(空/@ 表示裸域),台账展示需要完整域名""" assert NamesiloAdapter._fqdn("", "example.com") == "example.com" @@ -707,10 +729,10 @@ def test_namesilo_write_failure_returns_not_raises(): def test_namesilo_capabilities_domain_only(): - """纯域名注册商:无 VPS、无实例动作、无余额""" + """纯域名注册商:无 VPS、无实例动作;余额走 getAccountBalance""" caps = NamesiloAdapter({"api_key": "k"}).capabilities() assert caps["list_domains"] is True assert caps["list_dns_records"] is True + assert caps["get_account"] is True assert caps["list_vps"] is False - assert caps["get_account"] is False assert caps["start_instance"] is False diff --git a/tests/test_platform_adapters.py b/tests/test_platform_adapters.py index 2c03fd4..fd3989b 100644 --- a/tests/test_platform_adapters.py +++ b/tests/test_platform_adapters.py @@ -323,3 +323,13 @@ def test_adapter_meta_exposes_plain_config(): meta = {m["sdk_types"][0]: m for m in registry.adapter_meta()} assert meta["xinnet-api"]["required_config"] == ["client", "password", "domains"] assert set(meta["xinnet-api"]["plain_config"]) == {"client", "domains"} + + +def test_adapter_meta_exposes_optional_region(): + """阿里云/腾讯云国际版必须能填 region,否则默认国内区域查不到资源""" + meta = {m["sdk_types"][0]: m for m in registry.adapter_meta()} + for sdk_type in ("aliyun-sdk", "tencent-sdk"): + item = meta[sdk_type] + assert item["optional_config"] == ["region"] + assert "region" not in item["required_config"] # 国内账号不强制填 + assert "region" in item["plain_config"] # 区域不是密钥,明文显示