多台伺服器、多進程同時請求 Token → 互相踢掉 → API 呼叫失敗 → 業務中斷。
<?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;
}
}
?>
<?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 版本
}
}
?>
# 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/钉钉
}
?>
<?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() 耗時、失敗率| 平台 | API Endpoint | 參數 | 備註 |
|---|---|---|---|
| 公眾號 | /cgi-bin/token | grant_type=client_credential&appid&secret | 2000 次/日 |
| 小程式 | /cgi-bin/token | 同上 | 2000 次/日,獨立額度 |
| 企業微信 | /cgi-bin/gettoken | corpid&corpsecret | 7200 秒,無每日限制 |
| 開放平台 | /cgi-bin/component/api_component_token | component_appid/appsecret/ticket | 元件 Access Token,12 小時 |
Access Token 是微信開發的「通行證」,集中管理、分散式鎖、主動刷新、失效重試,四大支柱缺一不可。建議封裝為獨立套件,專案間複用。