萬盛學電腦網

您现在的位置: 萬盛學電腦網 >> 程式設計 >  >> 網路程式設計 >> PHP 程式設計

微信 Access Token 獲取與快取策略:避免重複請求導致失效的最佳實踐

Access Token 特性

  • 公眾號/小程式**全域唯一憑證**,所有 API 調用必須攜帶
  • 有效期 **7200 秒(2 小時)**,官方建議提前刷新
  • **重複獲取會導致上一個 Token 立即失效**
  • 儲存空間需預留 **≥ 512 字元**(未來可能擴充)
  • 每日調用上限:**2000 次**(帳號級)

核心問題:並發環境下的 Token 衝突

多台伺服器、多進程同時請求 Token → 互相踢掉 → API 呼叫失敗 → 業務中斷。

解決方案:分散式快取 + 單點刷新 + 容錯重試

1. Redis 分散式鎖實作(推薦生產環境)

<?php
class WeChatTokenManager
{
    private Redis $redis;
    private string $appId;
    private string $appSecret;
    private string $lockKey = 'wechat:token:lock';
    private string $tokenKey = 'wechat:access_token';
    private int $lockTtl = 10; // 鎖超時 10 秒
    private int $refreshBuffer = 200; // 提前 200 秒刷新

    public function __construct(Redis $redis, string $appId, string $appSecret)
    {
        $this->redis = $redis;
        $this->appId = $appId;
        $this->appSecret = $appSecret;
    }

    public function getToken(): string
    {
        // 1. 快取命中且未接近過期
        $cached = $this->redis->get($this->tokenKey);
        if ($cached) {
            $data = json_decode($cached, true);
            if ($data['expire'] > time() + $this->refreshBuffer) {
                return $data['token'];
            }
        }

        // 2. 嘗試獲取分散式鎖
        $lockAcquired = $this->redis->set($this->lockKey, getmypid(), ['nx', 'ex' => $this->lockTtl]);
        if (!$lockAcquired) {
            // 未獲取鎖:等待並重試(最多 3 次)
            for ($i = 0; $i < 3; $i++) {
                usleep(200000); // 200ms
                $cached = $this->redis->get($this->tokenKey);
                if ($cached) {
                    $data = json_decode($cached, true);
                    if ($data['expire'] > time()) return $data['token'];
                }
            }
            // 仍無有效 token,拋出異常由上層處理
            throw new RuntimeException('Unable to acquire valid access_token');
        }

        try {
            // 3. 雙重檢查:鎖內再次確認
            $cached = $this->redis->get($this->tokenKey);
            if ($cached) {
                $data = json_decode($cached, true);
                if ($data['expire'] > time() + $this->refreshBuffer) {
                    return $data['token'];
                }
            }

            // 4. 真正請求微信 API
            $tokenData = $this->fetchFromWeChat();
            
            // 5. 存入快取(提前 expireBuffer 秒過期,觸發下次刷新)
            $expireAt = time() + $tokenData['expires_in'] - $this->refreshBuffer;
            $this->redis->setex($this->tokenKey, $tokenData['expires_in'] - $this->refreshBuffer, json_encode([
                'token' => $tokenData['access_token'],
                'expire' => $expireAt,
            ]));

            return $tokenData['access_token'];
        } finally {
            // 6. 釋放鎖(僅刪除自己持有的鎖)
            $script = "if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end";
            $this->redis->eval($script, 1, $this->lockKey, getmypid());
        }
    }

    private function fetchFromWeChat(): array
    {
        $url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={$this->appId}&secret={$this->appSecret}";
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_TIMEOUT => 10,
            CURLOPT_SSL_VERIFYPEER => true, // 生產環境必開
            CURLOPT_CAINFO => '/etc/ssl/certs/ca-certificates.crt', // CA 憑證路徑
        ]);
        $response = curl_exec($ch);
        if (curl_errno($ch)) {
            throw new RuntimeException('cURL error: ' . curl_error($ch));
        }
        curl_close($ch);

        $data = json_decode($response, true);
        if (empty($data['access_token']) || !empty($data['errcode'])) {
            throw new RuntimeException("WeChat API error: " . ($data['errmsg'] ?? $response));
        }
        return $data;
    }
}
?>

2. 單機檔案鎖版本(中小專案、無 Redis)

<?php
class FileTokenManager
{
    private string $tokenFile;
    private string $lockFile;
    private string $appId;
    private string $appSecret;
    private int $refreshBuffer = 200;

    public function __construct(string $appId, string $appSecret, string $storageDir = '/tmp')
    {
        $this->appId = $appId;
        $this->appSecret = $appSecret;
        $this->tokenFile = $storageDir . '/wechat_access_token.json';
        $this->lockFile = $storageDir . '/wechat_access_token.lock';
    }

    public function getToken(): string
    {
        // 讀取快取
        if (file_exists($this->tokenFile)) {
            $data = json_decode(file_get_contents($this->tokenFile), true);
            if ($data && $data['expire'] > time() + $this->refreshBuffer) {
                return $data['token'];
            }
        }

        // 檔案鎖
        $fp = fopen($this->lockFile, 'c+');
        if (!flock($fp, LOCK_EX)) {
            throw new RuntimeException('Cannot acquire lock');
        }

        try {
            // 雙重檢查
            if (file_exists($this->tokenFile)) {
                $data = json_decode(file_get_contents($this->tokenFile), true);
                if ($data && $data['expire'] > time() + $this->refreshBuffer) {
                    return $data['token'];
                }
            }

            $tokenData = $this->fetchFromWeChat();
            $expireAt = time() + $tokenData['expires_in'] - $this->refreshBuffer;
            file_put_contents($this->tokenFile, json_encode([
                'token' => $tokenData['access_token'],
                'expire' => $expireAt,
            ]), LOCK_EX);

            return $tokenData['access_token'];
        } finally {
            flock($fp, LOCK_UN);
            fclose($fp);
        }
    }

    private function fetchFromWeChat(): array
    {
        // 同 Redis 版本
    }
}
?>

3. 定時任務主動刷新(被動獲取的補充)


# crontab - 每 100 分鐘執行一次(Token 有效期 7200 秒,留足緩衝)
*/100 * * * * /usr/bin/php /var/www/app/refresh_wechat_token.php >> /var/log/wechat_token.log 2>&1
<?php
// refresh_wechat_token.php
require 'vendor/autoload.php';
$manager = new WeChatTokenManager($redis, 'APP_ID', 'APP_SECRET');
try {
    $token = $manager->getToken(); // 觸發主動刷新
    echo "Token refreshed: " . substr($token, 0, 10) . "...\n";
} catch (Throwable $e) {
    error_log("Token refresh failed: " . $e->getMessage());
    // 可加入告警:發送 Slack/Email/钉钉
}
?>

Token 使用範例:統一 HTTP 客戶端

<?php
class WeChatHttpClient
{
    private WeChatTokenManager $tokenManager;

    public function __construct(WeChatTokenManager $tokenManager)
    {
        $this->tokenManager = $tokenManager;
    }

    public function get(string $endpoint, array $query = []): array
    {
        $url = 'https://api.weixin.qq.com' . $endpoint . '?access_token=' . $this->tokenManager->getToken();
        if ($query) $url .= '&' . http_build_query($query);
        return $this->request('GET', $url);
    }

    public function post(string $endpoint, array $data): array
    {
        $url = 'https://api.weixin.qq.com' . $endpoint . '?access_token=' . $this->tokenManager->getToken();
        return $this->request('POST', $url, json_encode($data, JSON_UNESCAPED_UNICODE));
    }

    private function request(string $method, string $url, string $body = ''): array
    {
        $ch = curl_init($url);
        curl_setopt_array($ch, [
            CURLOPT_CUSTOMREQUEST => $method,
            CURLOPT_POSTFIELDS => $body ?: null,
            CURLOPT_RETURNTRANSFER => true,
            CURLOPT_HTTPHEADER => $body ? ['Content-Type: application/json'] : [],
            CURLOPT_TIMEOUT => 15,
            CURLOPT_SSL_VERIFYPEER => true,
        ]);
        $response = curl_exec($ch);
        if (curl_errno($ch)) throw new RuntimeException(curl_error($ch));
        curl_close($ch);

        $data = json_decode($response, true);
        if (isset($data['errcode']) && $data['errcode'] !== 0) {
            // 42001/40001: token 失效,清除快取重試一次
            if (in_array($data['errcode'], [42001, 40001])) {
                $this->tokenManager->invalidate(); // 實作 invalidate() 刪除快取
                return $this->request($method, $url, $body); // 遞歸重試 1 次
            }
            throw new RuntimeException("WeChat API error: {$data['errmsg']} ({$data['errcode']})");
        }
        return $data;
    }
}
?>

監控與告警

  • 監控 getToken() 耗時、失敗率
  • 監控 Redis 鎖爭用情況
  • 告警:連續 3 次刷新失敗、Token 即將過期未刷新、API 回傳 42001/40001
  • 儀表板顯示:當前 Token 剩餘有效期、最後刷新時間、今日調用次數

小程式/開放平台/企業微信 差異

平台API Endpoint參數備註
公眾號/cgi-bin/tokengrant_type=client_credential&appid&secret2000 次/日
小程式/cgi-bin/token同上2000 次/日,獨立額度
企業微信/cgi-bin/gettokencorpid&corpsecret7200 秒,無每日限制
開放平台/cgi-bin/component/api_component_tokencomponent_appid/appsecret/ticket元件 Access Token,12 小時

Access Token 是微信開發的「通行證」,集中管理、分散式鎖、主動刷新、失效重試,四大支柱缺一不可。建議封裝為獨立套件,專案間複用。

copyright © 萬盛學電腦網 all rights reserved