Merge feature/add_skin into master: resolve all conflicts
- GameGlobal.js: keep upstream SERVER_URL with /ws suffix - en.js/zh.js: merge both settings.nickname and settings.profile keys - SettingsScene.js: keep both nickname row and profile button - server/index.js: merge express app + content security proxy with noServer WebSocket mode and path validation - Add .gitignore for node_modules and .codebuddy
This commit is contained in:
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 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<boolean>} 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<string|null>} 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<boolean>}
|
||||
*/
|
||||
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<string|null>}
|
||||
*/
|
||||
_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<void>}
|
||||
*/
|
||||
_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;
|
||||
Reference in New Issue
Block a user