fix(adapters): 补齐国际版 region 填写与 NameSilo 余额

审计发现两处「已声明但实际用不了」的缺口:

- 阿里云/腾讯云国际版与国内版共用适配器,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。
This commit is contained in:
gouki
2026-10-26 12:05:48 +00:00
parent 103f48e38b
commit 9e358fed6b
11 changed files with 128 additions and 7 deletions
+1 -1
View File
@@ -1 +1 @@
0.6.0
0.6.1
+3
View File
@@ -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"
+2
View File
@@ -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 = []
+24
View File
@@ -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:
+1
View File
@@ -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", []),
}
)
+3
View File
@@ -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"
+23 -1
View File
@@ -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()
+9 -3
View File
@@ -225,7 +225,7 @@ const AccountModal = {
</div>
<div v-if="currentFields.length" class="border border-slate-200 dark:border-slate-700 rounded-lg p-3 space-y-2">
<div class="text-xs font-medium text-slate-600 dark:text-slate-300">API 配置(供 SDK 同步,加密存储)</div>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}</span>
<label v-for="field in currentFields" :key="field" class="block"><span class="text-xs text-slate-400">{{ field }}<span v-if="isOptional(field)">(可选,留空用默认值)</span></span>
<input v-model="apiFields[field]" :type="isPlain(field) ? 'text' : 'password'" :placeholder="store.accountModal.editing ? '留空不修改' : '填写 ' + field" class="mt-1 w-full px-2 py-1.5 rounded border border-slate-300 dark:border-slate-700 bg-white dark:bg-slate-900 text-sm"></label>
</div>
<p v-if="store.accountModal.editing" class="text-xs text-slate-400">修改账号标识后,引用该账号的资产会自动同步更新。</p>
@@ -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 };
},
};
+28
View File
@@ -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",
}
+24 -2
View File
@@ -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
+10
View File
@@ -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"] # 区域不是密钥,明文显示