仰望星辰工作室

refactor(dnspod): 迁移DNSPod适配到腾讯云API 3.0版本

Y yang1145 提交于 2026-08-14 04:33 · 6ec32c5 ·父提交 180e591
refactor(dnspod): 迁移DNSPod适配到腾讯云API 3.0版本

1. 更新后台配置表单,将旧版DNSPod Token改为腾讯云SecretId/SecretKey
2. 重构DNSPod适配类,使用腾讯云官方API 3.0接口和TC3-HMAC-SHA256签名
3. 替换所有旧版API端点和请求参数,兼容原有业务输出格式
4. 移除旧版login_token鉴权方式,改用标准腾讯云签名认证
2 个文件变更 +134 -97 13172193298@163.com
•app_plugins/domain_shop/admin/dns_provider.php +7 -6
•app_plugins/domain_shop/lib/provider/dnspod.php +127 -91
变更内容
diff --git a/app_plugins/domain_shop/admin/dns_provider.php b/app_plugins/domain_shop/admin/dns_provider.php
index 6990de8..a5a8aeb 100644
--- a/app_plugins/domain_shop/admin/dns_provider.php
+++ b/app_plugins/domain_shop/admin/dns_provider.php
@@ -28,13 +28,14 @@ $providers = $DB->get_all_prepare("SELECT * FROM plg_dns_provider order by id as
               <input type="text" class="form-control" id="name" placeholder="如:我的 DNSPod 账号">
             </div>
             <div class="form-group">
-              <label>API Token ID</label>
-              <input type="text" class="form-control" id="api_id" placeholder="DNSPod API Token ID">
-              <small>在 DNSPod 控制台 → 安全设置 → API Token 中创建</small>
+              <label>SecretId(腾讯云 API 密钥 ID)</label>
+              <input type="text" class="form-control" id="api_id" placeholder="形如 AKIDxxxxxxxxxxxxxxxx">
+              <small>腾讯云控制台 → 访问管理 → API 密钥管理 中创建,形如 AKID 开头</small>
             </div>
             <div class="form-group">
-              <label>API Token Secret</label>
-              <input type="text" class="form-control" id="api_secret" placeholder="DNSPod API Token Secret">
+              <label>SecretKey(腾讯云 API 密钥 Key)</label>
+              <input type="text" class="form-control" id="api_secret" placeholder="腾讯云 SecretKey">
+              <small>DNSPod 已停用旧版 API Token,请改用腾讯云 API 3.0 密钥(SecretId/SecretKey)</small>
             </div>
             <div class="form-group">
               <label class="btn-block">是否启用</label>
@@ -62,7 +63,7 @@ $providers = $DB->get_all_prepare("SELECT * FROM plg_dns_provider order by id as
             <table class="table table-bordered table-striped">
               <thead>
                 <tr>
-                  <th>ID</th><th>类型</th><th>名称</th><th>Token ID</th>
+                  <th>ID</th><th>类型</th><th>名称</th><th>SecretId</th>
                   <th>状态</th><th>添加时间</th><th>操作</th>
                 </tr>
               </thead>
diff --git a/app_plugins/domain_shop/lib/provider/dnspod.php b/app_plugins/domain_shop/lib/provider/dnspod.php
index 65880ef..13bb717 100644
--- a/app_plugins/domain_shop/lib/provider/dnspod.php
+++ b/app_plugins/domain_shop/lib/provider/dnspod.php
@@ -1,18 +1,22 @@
 <?php
 /**
- * domain_shop 插件 - DNSPod API 适配器
- * 文档:https://cloud.tencent.com/document/product/1427
+ * domain_shop 插件 - DNSPod(腾讯云 API 3.0)适配器
+ * 文档:https://cloud.tencent.com/document/api/1427
  *
- * 鉴权:API Token(ID + Secret),HTTP Header:LoginToken
- * 签名:无签名要求,Token 直接传递
+ * 旧版 DNSPod API(dnsapi.cn + login_token)已停止维护,此处迁移至腾讯云 API 3.0:
+ * - 接口域名:dnspod.tencentcloudapi.com,Version=2021-03-23
+ * - 鉴权:腾讯云 API 密钥(SecretId + SecretKey),TC3-HMAC-SHA256 签名
+ * - 请求:POST + application/json
  */
 if (!defined('IN_CRONLITE')) exit;
 
 class DomainShop_DNSPod
 {
-	private $apiId;
-	private $apiSecret;
-	private $endpoint = 'https://dnsapi.cn/';
+	private $apiId;       // 腾讯云 SecretId
+	private $apiSecret;   // 腾讯云 SecretKey
+	private $endpoint = 'https://dnspod.tencentcloudapi.com';
+	private $service = 'dnspod';
+	private $version = '2021-03-23';
 	private $lastErr = '';
 
 	public function __construct($apiId, $apiSecret)
@@ -32,36 +36,41 @@ class DomainShop_DNSPod
 	 */
 	public function listDomains()
 	{
-		$res = $this->call('Domain.List', []);
-		if (empty($res['ok'])) return [];
-		$body = $res['body'];
-		$arr = json_decode($body, true);
-		if (!is_array($arr) || ($arr['status']['code'] ?? '0') !== '1') {
-			$this->lastErr = $arr['status']['message'] ?? 'Domain.List 失败';
-			return [];
-		}
-		$domains = $arr['domains'] ?? [];
+		$resp = $this->call('DescribeDomainList', ['Type' => 'ALL', 'Limit' => 1000]);
+		if ($resp === false) return [];
 		$out = [];
-		foreach ($domains as $d) {
-			$out[] = ['id' => (string)$d['id'], 'name' => $d['name'] ?? ''];
+		foreach (($resp['DomainList'] ?? []) as $d) {
+			$out[] = ['id' => (string)($d['DomainId'] ?? ''), 'name' => $d['Name'] ?? ''];
 		}
 		return $out;
 	}
 
 	/**
-	 * 取某域名下记录列表
+	 * 取某域名下记录列表(字段映射为小写风格,与旧适配器输出保持一致)
 	 */
 	public function listRecords($domain)
 	{
-		$res = $this->call('Record.List', ['domain' => $domain]);
-		if (empty($res['ok'])) return [];
-		$body = $res['body'];
-		$arr = json_decode($body, true);
-		if (!is_array($arr) || ($arr['status']['code'] ?? '0') !== '1') {
-			$this->lastErr = $arr['status']['message'] ?? 'Record.List 失败';
-			return [];
+		$resp = $this->call('DescribeRecordList', [
+			'Domain' => $domain,
+			'Offset' => 0,
+			'Limit' => 1000,
+			'ErrorOnEmpty' => 'no',
+		]);
+		if ($resp === false) return [];
+		$out = [];
+		foreach (($resp['RecordList'] ?? []) as $r) {
+			$out[] = [
+				'id' => (string)($r['RecordId'] ?? ''),
+				'name' => $r['Name'] ?? '',
+				'type' => $r['Type'] ?? '',
+				'value' => $r['Value'] ?? '',
+				'line' => $r['Line'] ?? '',
+				'ttl' => $r['TTL'] ?? 0,
+				'mx' => $r['MX'] ?? 0,
+				'status' => $r['Status'] ?? '',
+			];
 		}
-		return $arr['records'] ?? [];
+		return $out;
 	}
 
 	/**
@@ -71,26 +80,18 @@ class DomainShop_DNSPod
 	public function createRecord($domain, $name, $type, $value, $ttl = 600, $mx = 0)
 	{
 		$params = [
-			'domain' => $domain,
-			'sub_domain' => $name,
-			'record_type' => strtoupper($type),
-			'record_line' => '默认',
-			'value' => $value,
-			'ttl' => (int)$ttl,
+			'Domain' => $domain,
+			'SubDomain' => $name,
+			'RecordType' => strtoupper($type),
+			'RecordLine' => '默认',
+			'Value' => $value,
+			'TTL' => (int)$ttl,
 		];
-		if (strtoupper($type) === 'MX') $params['mx'] = (int)$mx;
+		if (strtoupper($type) === 'MX') $params['MX'] = (int)$mx;
 
-		$res = $this->call('Record.Create', $params);
-		if (empty($res['ok'])) {
-			$this->lastErr = 'HTTP 请求失败:' . ($res['error'] ?? '');
-			return false;
-		}
-		$arr = json_decode($res['body'], true);
-		if (!is_array($arr) || ($arr['status']['code'] ?? '0') !== '1') {
-			$this->lastErr = $arr['status']['message'] ?? 'Record.Create 失败';
-			return false;
-		}
-		return (string)($arr['record']['id'] ?? '');
+		$resp = $this->call('CreateRecord', $params);
+		if ($resp === false) return false;
+		return (string)($resp['RecordId'] ?? '');
 	}
 
 	/**
@@ -99,24 +100,18 @@ class DomainShop_DNSPod
 	public function updateRecord($domain, $recordId, $name, $type, $value, $ttl = 600, $mx = 0)
 	{
 		$params = [
-			'domain' => $domain,
-			'record_id' => $recordId,
-			'sub_domain' => $name,
-			'record_type' => strtoupper($type),
-			'record_line' => '默认',
-			'value' => $value,
-			'ttl' => (int)$ttl,
+			'Domain' => $domain,
+			'RecordId' => (int)$recordId,
+			'SubDomain' => $name,
+			'RecordType' => strtoupper($type),
+			'RecordLine' => '默认',
+			'Value' => $value,
+			'TTL' => (int)$ttl,
 		];
-		if (strtoupper($type) === 'MX') $params['mx'] = (int)$mx;
+		if (strtoupper($type) === 'MX') $params['MX'] = (int)$mx;
 
-		$res = $this->call('Record.Modify', $params);
-		if (empty($res['ok'])) return false;
-		$arr = json_decode($res['body'], true);
-		if (!is_array($arr) || ($arr['status']['code'] ?? '0') !== '1') {
-			$this->lastErr = $arr['status']['message'] ?? 'Record.Modify 失败';
-			return false;
-		}
-		return true;
+		$resp = $this->call('ModifyRecord', $params);
+		return $resp !== false;
 	}
 
 	/**
@@ -124,67 +119,108 @@ class DomainShop_DNSPod
 	 */
 	public function deleteRecord($domain, $recordId)
 	{
-		$res = $this->call('Record.Remove', ['domain' => $domain, 'record_id' => $recordId]);
-		if (empty($res['ok'])) return false;
-		$arr = json_decode($res['body'], true);
-		if (!is_array($arr) || ($arr['status']['code'] ?? '0') !== '1') {
-			$this->lastErr = $arr['status']['message'] ?? 'Record.Remove 失败';
-			return false;
-		}
-		return true;
+		$resp = $this->call('DeleteRecord', ['Domain' => $domain, 'RecordId' => (int)$recordId]);
+		return $resp !== false;
 	}
 
 	/**
-	 * 调用 DNSPod API
-	 * 优先使用 mnbt_http_post(带安全策略),失败回退到 cURL
+	 * 调用腾讯云 DNSPod API 3.0
+	 * @return array|false 解析后的 Response 数组,失败返回 false(lastErr 记录原因)
 	 */
 	private function call($action, array $params)
 	{
-		$url = $this->endpoint . $action;
-		$body = http_build_query($params, '', '&');
+		$timestamp = time();
+		$payload = json_encode($params, JSON_UNESCAPED_UNICODE);
+		$authorization = $this->sign($action, $payload, $timestamp);
+
 		$headers = [
-			'Content-Type: application/x-www-form-urlencoded',
-			'User-Agent: MNBT-DomainShop/1.0',
+			'Content-Type: application/json; charset=utf-8',
+			'X-TC-Action: ' . $action,
+			'X-TC-Timestamp: ' . $timestamp,
+			'X-TC-Version: ' . $this->version,
+			'Authorization: ' . $authorization,
 		];
 
-		// 用插件引擎的 HTTP 出站函数(有内网/协议安全策略)
+		// 用插件引擎的 HTTP 出站函数(有内网/协议安全策略),失败回退到 cURL
 		if (function_exists('mnbt_http_post')) {
-			$res = mnbt_http_post($url, $body, [
+			$res = mnbt_http_post($this->endpoint, $payload, [
 				'timeout' => 15,
 				'headers' => $headers,
 			]);
 			if (!empty($res['ok'])) {
-				return ['ok' => true, 'body' => $res['body'] ?? ''];
+				return $this->parseResponse($res['body'] ?? '');
 			}
 			$this->lastErr = $res['error'] ?? 'mnbt_http_post 失败';
-			// DNSPod 是公网 API,不会触发内网限制;如果失败回退到 cURL
 		}
 
-		// 回退:原生 cURL
 		if (!function_exists('curl_init')) {
 			$this->lastErr = 'PHP 未启用 cURL 扩展';
-			return ['ok' => false, 'error' => $this->lastErr];
+			return false;
 		}
-		$ch = curl_init($url);
+		$ch = curl_init($this->endpoint);
 		curl_setopt_array($ch, [
 			CURLOPT_POST => true,
-			CURLOPT_POSTFIELDS => $params,
+			CURLOPT_POSTFIELDS => $payload,
 			CURLOPT_RETURNTRANSFER => true,
 			CURLOPT_TIMEOUT => 15,
-			CURLOPT_HTTPHEADER => ['Expect:'],
+			CURLOPT_HTTPHEADER => $headers,
 		]);
-		// 注意:DNSPod API Token 通过 user token 字段传递
-		$params['login_token'] = $this->apiId . ',' . $this->apiSecret;
-		$params['format'] = 'json';
-		curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params, '', '&'));
 		$body = curl_exec($ch);
 		$err = curl_error($ch);
-		$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
 		curl_close($ch);
 		if ($body === false) {
 			$this->lastErr = 'cURL 错误:' . $err;
-			return ['ok' => false, 'error' => $this->lastErr];
+			return false;
+		}
+		return $this->parseResponse($body);
+	}
+
+	/**
+	 * 解析 API 3.0 响应(统一包在 Response 中;存在 Error 视为失败)
+	 */
+	private function parseResponse($body)
+	{
+		$arr = json_decode($body, true);
+		if (!is_array($arr) || !isset($arr['Response'])) {
+			$this->lastErr = '响应解析失败:' . mb_substr((string)$body, 0, 200);
+			return false;
+		}
+		$resp = $arr['Response'];
+		if (isset($resp['Error'])) {
+			$this->lastErr = ($resp['Error']['Message'] ?? '') ?: ($resp['Error']['Code'] ?? 'API 错误');
+			return false;
 		}
-		return ['ok' => true, 'body' => $body];
+		return $resp;
+	}
+
+	/**
+	 * TC3-HMAC-SHA256 签名(腾讯云 API 3.0 签名方法 v3)
+	 */
+	private function sign($action, $payload, $timestamp)
+	{
+		$algorithm = 'TC3-HMAC-SHA256';
+		$host = 'dnspod.tencentcloudapi.com';
+		$date = gmdate('Y-m-d', $timestamp);
+
+		// 1. 拼接规范请求串
+		$contentType = 'application/json; charset=utf-8';
+		$canonicalHeaders = "content-type:$contentType\nhost:$host\nx-tc-action:" . strtolower($action) . "\n";
+		$signedHeaders = 'content-type;host;x-tc-action';
+		$hashedRequestPayload = hash('sha256', $payload);
+		$canonicalRequest = "POST\n/\n\n$canonicalHeaders\n$signedHeaders\n$hashedRequestPayload";
+
+		// 2. 拼接待签名字符串
+		$credentialScope = "$date/{$this->service}/tc3_request";
+		$hashedCanonicalRequest = hash('sha256', $canonicalRequest);
+		$stringToSign = "$algorithm\n$timestamp\n$credentialScope\n$hashedCanonicalRequest";
+
+		// 3. 计算派生签名密钥与签名
+		$secretDate = hash_hmac('sha256', $date, 'TC3' . $this->apiSecret, true);
+		$secretService = hash_hmac('sha256', $this->service, $secretDate, true);
+		$secretSigning = hash_hmac('sha256', 'tc3_request', $secretService, true);
+		$signature = hash_hmac('sha256', $stringToSign, $secretSigning);
+
+		// 4. 拼接 Authorization
+		return "$algorithm Credential={$this->apiId}/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature";
 	}
 }