/** * WeChat Access Token Manager * Manages the lifecycle of WeChat access tokens for content security APIs. * - Auto-refreshes tokens before expiry (2-hour validity, refresh 5 minutes early) * - Retry with exponential backoff on failure (2s, 4s, 8s) * - Marks service as unavailable after all retries exhausted */ const https = require('https'); // ============================================================ // Configuration // ============================================================ const TOKEN_VALIDITY_MS = 2 * 60 * 60 * 1000; // 2 hours const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry const RETRY_DELAYS = [2000, 4000, 8000]; // Exponential backoff delays in ms const WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token'; class WechatTokenManager { /** * @param {object} options * @param {string} options.appId - WeChat Mini Program App ID * @param {string} options.appSecret - WeChat Mini Program App Secret */ constructor(options = {}) { this.appId = options.appId || process.env.WX_APPID || ''; this.appSecret = options.appSecret || process.env.WX_APPSECRET || ''; this._accessToken = null; this._expiresAt = 0; this._refreshTimer = null; this._isAvailable = false; this._isRefreshing = false; this._retryCount = 0; } /** * Initialize the token manager and fetch the first token. * @returns {Promise} true if token obtained successfully */ async init() { console.log('[TokenManager] Initializing...'); const success = await this._refreshToken(); if (success) { this._scheduleRefresh(); } return success; } /** * Get the current valid access token. * If the token is expired or about to expire, refresh it first. * @returns {Promise} The access token, or null if unavailable */ async getAccessToken() { // If token is still valid (with margin), return it directly if (this._accessToken && Date.now() < this._expiresAt - TOKEN_REFRESH_MARGIN_MS) { return this._accessToken; } // Token expired or about to expire, try to refresh if (!this._isRefreshing) { const success = await this._refreshToken(); if (success) { this._scheduleRefresh(); } } return this._isAvailable ? this._accessToken : null; } /** * Check if the token manager is currently available. * @returns {boolean} */ isAvailable() { return this._isAvailable; } /** * Refresh the access token by calling WeChat API. * Implements retry with exponential backoff. * @returns {Promise} */ async _refreshToken() { if (this._isRefreshing) { // Wait for the ongoing refresh to complete return new Promise((resolve) => { const checkInterval = setInterval(() => { if (!this._isRefreshing) { clearInterval(checkInterval); resolve(this._isAvailable); } }, 100); }); } this._isRefreshing = true; for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) { try { const token = await this._fetchTokenFromWechat(); if (token) { this._accessToken = token; this._expiresAt = Date.now() + TOKEN_VALIDITY_MS; this._isAvailable = true; this._retryCount = 0; this._isRefreshing = false; console.log('[TokenManager] Token refreshed successfully, expires at:', new Date(this._expiresAt).toISOString()); return true; } } catch (err) { console.error(`[TokenManager] Fetch token failed (attempt ${attempt + 1}):`, err.message); } // Wait before retry (if there are more retries) if (attempt < RETRY_DELAYS.length) { const delay = RETRY_DELAYS[attempt]; console.log(`[TokenManager] Retrying in ${delay}ms...`); await this._sleep(delay); } } // All retries exhausted this._isAvailable = false; this._isRefreshing = false; this._accessToken = null; console.error('[TokenManager] All retry attempts exhausted. Service marked as unavailable.'); // Schedule a recovery attempt after 10 minutes setTimeout(() => { console.log('[TokenManager] Attempting recovery refresh...'); this._refreshToken().then((success) => { if (success) { this._scheduleRefresh(); } }); }, 10 * 60 * 1000); return false; } /** * Fetch access token from WeChat API. * @returns {Promise} */ _fetchTokenFromWechat() { return new Promise((resolve, reject) => { if (!this.appId || !this.appSecret) { reject(new Error('AppId or AppSecret not configured')); return; } const url = `${WECHAT_TOKEN_URL}?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`; https.get(url, (res) => { let data = ''; res.on('data', (chunk) => { data += chunk; }); res.on('end', () => { try { const result = JSON.parse(data); if (result.access_token) { resolve(result.access_token); } else { reject(new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`)); } } catch (e) { reject(new Error(`Invalid JSON response: ${data.substring(0, 100)}`)); } }); }).on('error', (err) => { reject(err); }); }); } /** * Schedule the next token refresh before expiry. */ _scheduleRefresh() { if (this._refreshTimer) { clearTimeout(this._refreshTimer); } const refreshAt = this._expiresAt - TOKEN_REFRESH_MARGIN_MS; const delay = Math.max(refreshAt - Date.now(), 60000); // At least 1 minute console.log(`[TokenManager] Next refresh scheduled in ${Math.round(delay / 1000)}s`); this._refreshTimer = setTimeout(async () => { console.log('[TokenManager] Scheduled refresh triggered'); const success = await this._refreshToken(); if (success) { this._scheduleRefresh(); } }, delay); } /** * Utility: sleep for a given number of milliseconds. * @param {number} ms * @returns {Promise} */ _sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } /** * Clean up resources. */ destroy() { if (this._refreshTimer) { clearTimeout(this._refreshTimer); this._refreshTimer = null; } this._accessToken = null; this._isAvailable = false; console.log('[TokenManager] Destroyed'); } } module.exports = WechatTokenManager;