Files
vps-manager/app/adapters/tencent.py
T
gouki 46faadbcae feat(multicloud): 补齐上线前的多云管理缺口(适配器/动作层/DNS/定时同步)
评估发现项目只有台账能力,缺失真正的多云管理链路。本次按缺口逐项补齐:

适配层
- 新增 aws / linode / namesilo 适配器,registry 登记;阿里云、腾讯云补
  list_domains 与 get_account 余额
- NormalizedVPS 补 expiry_date + charge_type:此前云端同步进来的 VPS 拿不到
  到期日,永远进不了续费提醒——而续费提醒正是这套台账存在的理由
- base.py 统一云端错误可读化(http_error_message / cloud_error_text):各厂商
  把原因放的位置都不同(Linode errors[].reason、Cloudflare errors[].message、
  腾讯 Response.Error.Message、AWS XML),此前直接 str(e) 会把 httpx 的多行文本
  连同 MDN 链接弹给用户

动作层与 DNS
- 新增 /api/actions:实例启停/重启/续费,写操作全部二次确认 + 审计
- 新增 Cloudflare DNS 记录的增删改查

定时同步
- scripts/sync_all_assets.py + /api/sync/assets + vps-sync-assets.timer(08:30)
- 08:30 而非随机时刻:必须早于 09:00 的续费检查,否则提醒读到上一天的旧到期日
- sync_all_assets 修正计数:每一项子同步都失败时不再计为「成功」,否则凭证过期
  会显示「成功 1」,用户以为数据拉到了、实际一条没有

数据一致性
- AssetRead 输出 external_id(只出不进):前端「云操作」「DNS」按钮靠它渲染,
  缺了则所有按钮永远出不来
- import_assets 落库 external_id:导出带了去重键而导入丢弃,会让迁移后首次同步
  把所有云端资产当新增再建一份
- _align_preset_providers 取代 _backfill_provider_services:库里声明但注册表未
  实现的 sdk_type 清空(声明支持却不支持,比明说不支持更糟),services 只增量
  补标签不覆盖,用户自建平台不动

前端如实展示
- cost 为 0 统一显示「未填」而非「0 USD」:0 既可能是没填、也可能是云端不返回
  价格,而汇总处又把 0 当「无费用」排除,两处口径不一致
- 总览「本年支出」增加 year_cost_missing,把合计的覆盖缺口说出来
- DNS 弹窗补 MX/SRV 优先级与备注;列表请求失败时 provider 用资产自带 slug 兜底,
  否则 Cloudflare 代理勾选框永远不出现

测试:257 -> 309 passed
2026-10-12 00:43:37 +00:00

360 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""腾讯云适配器(含国际版,TC3-HMAC-SHA256 签名)
API 文档:https://cloud.tencent.com/document/api/213/15753
所需配置:{"secret_id": "...", "secret_key": "...", "region": "ap-guangzhou"}
国际版(intl)将 region 设为海外区域即可,端点统一为 *.tencentcloudapi.com。
TC3 签名对各产品一致,只有 host / service / version 不同,故 _call 接受这三者:
- CVM cvm.tencentcloudapi.com 2017-03-12 实例清单与启停/重启/续费
- Billing billing.tencentcloudapi.com 2018-07-09 账户余额(单位:分)
- Domain domain.tencentcloudapi.com 2018-08-08 注册域名清单(含到期日)
- DNSPod dnspod.tencentcloudapi.com 2021-03-23 解析记录增删改查
DNS 的 zone_id 即域名本身(DNSPod 用域名而非 zone uuid 定位)。
"""
import hashlib
import hmac
import json
import time
import httpx
from app.adapters.base import (
AccountInfo,
BaseAdapter,
NormalizedDNSRecord,
NormalizedDomain,
NormalizedVPS,
parse_iso_date,
)
from app.adapters.registry import register
CVM_HOST = "cvm.tencentcloudapi.com"
CVM_VERSION = "2017-03-12"
BILLING_HOST = "billing.tencentcloudapi.com"
BILLING_VERSION = "2018-07-09"
DOMAIN_HOST = "domain.tencentcloudapi.com"
DOMAIN_VERSION = "2018-08-08"
DNSPOD_HOST = "dnspod.tencentcloudapi.com"
DNSPOD_VERSION = "2021-03-23"
ALGORITHM = "TC3-HMAC-SHA256"
_STATUS_MAP = {"RUNNING": "active", "STOPPED": "stopped", "STOPPING": "stopped", "STARTING": "active"}
_CHARGE_TYPE_MAP = {"PREPAID": "prepaid", "POSTPAID_BY_HOUR": "postpaid", "POSTPAID_BY_MONTH": "postpaid"}
def _service_of(host: str) -> str:
"""从 host 推导 TC3 credential scope 用的 service 名"""
return host.split(".")[0]
@register("tencent-sdk", "tencent-intl-sdk")
class TencentAdapter(BaseAdapter):
required_config = ["secret_id", "secret_key"]
def _region(self) -> str:
return self.config.get("region") or "ap-guangzhou"
@staticmethod
def _hmac_sha256(key: bytes, msg: str) -> bytes:
return hmac.new(key, msg.encode(), hashlib.sha256).digest()
def _build_authorization(
self, action: str, payload_json: str, timestamp: int, date: str, host: str, service: str
):
secret_id = self.config.get("secret_id", "")
secret_key = self.config.get("secret_key", "")
content_type = "application/json; charset=utf-8"
canonical_headers = (
f"content-type:{content_type}\nhost:{host}\nx-tc-action:{action.lower()}\n"
)
signed_headers = "content-type;host;x-tc-action"
hashed_payload = hashlib.sha256(payload_json.encode()).hexdigest()
canonical_request = (
f"POST\n/\n\n{canonical_headers}\n{signed_headers}\n{hashed_payload}"
)
credential_scope = f"{date}/{service}/tc3_request"
hashed_canonical = hashlib.sha256(canonical_request.encode()).hexdigest()
string_to_sign = (
f"{ALGORITHM}\n{timestamp}\n{credential_scope}\n{hashed_canonical}"
)
secret_date = self._hmac_sha256(("TC3" + secret_key).encode(), date)
secret_service = self._hmac_sha256(secret_date, service)
secret_signing = self._hmac_sha256(secret_service, "tc3_request")
signature = hmac.new(secret_signing, string_to_sign.encode(), hashlib.sha256).hexdigest()
return (
f"{ALGORITHM} Credential={secret_id}/{credential_scope}, "
f"SignedHeaders={signed_headers}, Signature={signature}"
)
def _call(
self,
action: str,
payload: dict = None,
host: str = CVM_HOST,
version: str = CVM_VERSION,
with_region: bool = True,
) -> dict:
payload_json = json.dumps(payload or {})
timestamp = int(time.time())
date = time.strftime("%Y-%m-%d", time.gmtime(timestamp))
service = _service_of(host)
authorization = self._build_authorization(
action, payload_json, timestamp, date, host, service
)
headers = {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
"Host": host,
"X-TC-Action": action,
"X-TC-Timestamp": str(timestamp),
"X-TC-Version": version,
}
# Billing / Domain / DNSPod 是中心化服务,带 Region 反而可能报参数错误
if with_region:
headers["X-TC-Region"] = self._region()
with httpx.Client(timeout=30) as client:
resp = client.post(f"https://{host}", content=payload_json, headers=headers)
resp.raise_for_status()
data = resp.json()
response = data.get("Response", {})
if response.get("Error"):
err = response["Error"]
raise RuntimeError(f"{err.get('Code')}: {err.get('Message')}")
return response
def test_connection(self) -> dict:
try:
data = self._call("DescribeRegions")
count = len(data.get("RegionSet", []))
return {"ok": True, "message": f"连接成功,可用区域 {count} 个"}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:凭证无效"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": str(e)}
# ---------------- 只读清单 ----------------
def list_vps(self) -> list:
# 分页拉取:单次最多 100 条,按 TotalCount 翻页,避免实例超 100 台时漏同步
result = []
offset = 0
while True:
data = self._call("DescribeInstances", {"Limit": 100, "Offset": offset})
inst_set = data.get("InstanceSet", [])
for inst in inst_set:
public_ips = inst.get("PublicIpAddresses", [])
charge = inst.get("InstanceChargeType")
result.append(
NormalizedVPS(
external_id=inst.get("InstanceId"),
name=inst.get("InstanceName") or inst.get("InstanceId"),
ip_address=public_ips[0] if public_ips else None,
region=inst.get("Placement", {}).get("Zone"),
os=inst.get("OsName"),
cpu_cores=inst.get("CPU"),
memory_gb=inst.get("Memory"),
disk_gb=inst.get("SystemDisk", {}).get("DiskSize"),
status=_STATUS_MAP.get(inst.get("InstanceState"), inst.get("InstanceState") or "unknown"),
currency="CNY",
# 预付费实例必须带上到期日,否则续费提醒对云端同步的机器完全失效
expiry_date=parse_iso_date(inst.get("ExpiredTime")),
charge_type=_CHARGE_TYPE_MAP.get(charge, charge),
raw=inst,
)
)
total = int(data.get("TotalCount") or 0)
offset += len(inst_set)
if not inst_set or offset >= total:
break
return result
def list_domains(self) -> list:
"""注册域名清单(domain.tencentcloudapi.com DescribeDomainNameList)"""
result = []
offset = 0
while True:
data = self._call(
"DescribeDomainNameList",
{"Offset": offset, "Limit": 100},
host=DOMAIN_HOST,
version=DOMAIN_VERSION,
with_region=False,
)
domains = data.get("DomainSet", [])
for d in domains:
result.append(
NormalizedDomain(
external_id=d.get("DomainName") or d.get("DomainId"),
domain_name=d.get("DomainName"),
registrar="tencent",
# 该接口返回 ExpireDate(毫秒时间戳)或 ExpireTime(ISO),两者都兼容
expiry_date=parse_iso_date(d.get("ExpireTime") or self._ms_to_iso(d.get("ExpireDate"))),
status="active",
raw=d,
)
)
total = int(data.get("TotalCount") or 0)
offset += len(domains)
if not domains or offset >= total:
break
return result
@staticmethod
def _ms_to_iso(value) -> str:
"""毫秒时间戳 -> ISO 字符串(parse_iso_date 只取前 10 位)"""
try:
return time.strftime("%Y-%m-%d", time.gmtime(int(value) / 1000))
except (TypeError, ValueError):
return ""
def get_account(self) -> AccountInfo:
"""账户余额(billing.tencentcloudapi.com DescribeAccountBalance)
注意 Balance 单位是「分」,必须除 100 才是元,否则台账金额会放大百倍。
"""
data = self._call(
"DescribeAccountBalance",
host=BILLING_HOST,
version=BILLING_VERSION,
with_region=False,
)
balance = data.get("Balance")
return AccountInfo(
balance=round(balance / 100, 2) if isinstance(balance, (int, float)) else None,
currency="CNY",
raw=data,
)
# ---------------- 实例管理动作 ----------------
def _action(self, action: str, payload: dict, label: str) -> dict:
try:
data = self._call(action, payload)
return {"ok": True, "message": f"{label}指令已下发", "raw": data}
except httpx.HTTPStatusError as e:
return {"ok": False, "message": f"HTTP {e.response.status_code}:{label}失败"}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"{label}失败:{e}"}
def start_instance(self, external_id: str) -> dict:
return self._action("StartInstances", {"InstanceIds": [external_id]}, "开机")
def stop_instance(self, external_id: str) -> dict:
# SOFT_FIRST:先正常关机,超时才强制;直接 HARD 有丢数据风险
return self._action(
"StopInstances",
{"InstanceIds": [external_id], "StopType": "SOFT_FIRST", "StoppedBehavior": "KEEP_CHARGING"},
"关机",
)
def reboot_instance(self, external_id: str) -> dict:
return self._action(
"RebootInstances", {"InstanceIds": [external_id], "RebootType": "SOFT_FIRST"}, "重启"
)
def renew_instance(self, external_id: str, period_months: int = 1) -> dict:
# 仅对 PREPAID 实例有效;Period 单位为月
return self._action(
"RenewInstances",
{
"InstanceIds": [external_id],
"InstanceChargePrepaid": {
"RenewFlag": "NOTIFY_AND_MANUAL_RENEW",
"Period": period_months,
},
},
f"续费 {period_months} 个月",
)
# ---------------- DNS 记录管理 ----------------
def _dnspod(self, action: str, payload: dict) -> dict:
return self._call(
action, payload, host=DNSPOD_HOST, version=DNSPOD_VERSION, with_region=False
)
def list_dns_records(self, zone_id: str) -> list:
data = self._dnspod("DescribeRecordList", {"Domain": zone_id})
result = []
for r in data.get("RecordList", []):
sub = r.get("Name") or "@"
result.append(
NormalizedDNSRecord(
record_id=str(r.get("RecordId") or r.get("ID")),
zone_id=zone_id,
name=self._fqdn(sub, zone_id),
type=r.get("Type"),
content=r.get("Value"),
ttl=int(r.get("TTL")) if r.get("TTL") else None,
priority=int(r["MX"]) if r.get("MX") else None,
comment=r.get("Comment"),
raw=r,
)
)
return result
@staticmethod
def _fqdn(sub: str, zone_id: str) -> str:
"""DNSPod 存的是子域(Name,如 www / @),拼成完整域名供台账展示"""
if not sub or sub == "@":
return zone_id
return f"{sub}.{zone_id}"
@staticmethod
def _sub(name: str, zone_id: str) -> str:
"""反向:把完整域名拆回子域"""
if name == zone_id:
return "@"
suffix = f".{zone_id}"
return name[: -len(suffix)] if name.endswith(suffix) else name
def create_dns_record(self, zone_id: str, record: dict) -> dict:
payload = {
"Domain": zone_id,
"SubDomain": self._sub(record.get("name", ""), zone_id),
"RecordType": record.get("type", "A"),
"RecordLine": record.get("line") or "默认",
"Value": record.get("content", ""),
"TTL": record.get("ttl") or 600,
}
if record.get("priority"):
payload["MX"] = record["priority"]
try:
data = self._dnspod("CreateRecord", payload)
return {
"ok": True,
"message": "记录已创建",
"record_id": str(data.get("RecordId", "")),
"raw": data,
}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"创建失败:{e}"}
def update_dns_record(self, zone_id: str, record_id: str, record: dict) -> dict:
payload = {
"Domain": zone_id,
"RecordId": record_id,
"SubDomain": self._sub(record.get("name", ""), zone_id),
"RecordType": record.get("type", "A"),
"RecordLine": record.get("line") or "默认",
"Value": record.get("content", ""),
"TTL": record.get("ttl") or 600,
}
if record.get("priority"):
payload["MX"] = record["priority"]
try:
data = self._dnspod("ModifyRecord", payload)
return {"ok": True, "message": "记录已更新", "raw": data}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"更新失败:{e}"}
def delete_dns_record(self, zone_id: str, record_id: str) -> dict:
try:
data = self._dnspod("DeleteRecord", {"Domain": zone_id, "RecordId": record_id})
return {"ok": True, "message": "记录已删除", "raw": data}
except Exception as e: # noqa: BLE001
return {"ok": False, "message": f"删除失败:{e}"}