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:
jakciehan
2026-05-12 07:05:20 +08:00
parent 38294c040c
commit d263c7bf48
48 changed files with 10480 additions and 25 deletions
@@ -0,0 +1,199 @@
/**
* Audit Logger
* Logs content security audit events with content desensitization.
* Logs are retained for at least 180 days.
*
* Features:
* - Content desensitization: only first 3 and last 3 chars kept, middle replaced with ***
* - Access Token never logged in plaintext
* - Structured JSON log format
* - Automatic log rotation
*/
const fs = require('fs');
const path = require('path');
// ============================================================
// Configuration
// ============================================================
const LOG_DIR = path.join(__dirname, '..', 'logs', 'audit');
const LOG_RETENTION_DAYS = 180;
const LOG_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // Rotate daily
class AuditLogger {
constructor() {
this._ensureLogDir();
this._currentLogFile = this._getLogFilePath(new Date());
this._rotationTimer = null;
this._startRotation();
}
/**
* Log an audit event.
* @param {object} entry
* @param {string} entry.userId - User identifier (openid)
* @param {string} entry.contentType - 'text' or 'image'
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @param {string} entry.result - 'pass', 'reject', 'error', 'rejected'
* @param {string} entry.reason - Reason for the result
* @param {number} [entry.duration] - Duration of the check in ms
*/
logAudit(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
userId: entry.userId || 'unknown',
contentType: entry.contentType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
scene: entry.scene || 0,
result: entry.result || 'unknown',
reason: entry.reason || '',
duration: entry.duration || 0,
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write log:', err.message);
}
}
/**
* Log a violation event.
* @param {object} entry
* @param {string} entry.userId - User identifier
* @param {string} entry.violationType - Type of violation
* @param {string} entry.contentSummary - Desensitized content
* @param {string} entry.action - Action taken (e.g., 'mute_24h')
*/
logViolation(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'violation',
userId: entry.userId || 'unknown',
violationType: entry.violationType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
action: entry.action || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write violation log:', err.message);
}
}
/**
* Log a report event.
* @param {object} entry
* @param {string} entry.reporterId - Reporter's user ID (kept confidential)
* @param {string} entry.targetUserId - Reported user's ID
* @param {string} entry.contentSummary - Desensitized reported content
* @param {string} entry.reason - Report reason
*/
logReport(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'report',
reporterId: entry.reporterId ? '***' : 'unknown', // Keep reporter confidential
targetUserId: entry.targetUserId || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
reason: entry.reason || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write report log:', err.message);
}
}
/**
* Ensure the log directory exists.
*/
_ensureLogDir() {
try {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
} catch (err) {
console.error('[AuditLogger] Failed to create log directory:', err.message);
}
}
/**
* Get the log file path for a given date.
* @param {Date} date
* @returns {string}
*/
_getLogFilePath(date) {
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
return path.join(LOG_DIR, `audit-${dateStr}.log`);
}
/**
* Start daily log rotation.
*/
_startRotation() {
this._rotationTimer = setInterval(() => {
const newLogFile = this._getLogFilePath(new Date());
if (newLogFile !== this._currentLogFile) {
this._currentLogFile = newLogFile;
console.log('[AuditLogger] Rotated to new log file:', newLogFile);
}
// Clean up old logs
this._cleanOldLogs();
}, LOG_ROTATION_INTERVAL_MS);
}
/**
* Remove log files older than the retention period.
*/
_cleanOldLogs() {
try {
const files = fs.readdirSync(LOG_DIR);
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
const filePath = path.join(LOG_DIR, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log('[AuditLogger] Deleted old log file:', file);
}
}
} catch (err) {
console.error('[AuditLogger] Failed to clean old logs:', err.message);
}
}
/**
* Sanitize content: keep only first 3 and last 3 chars, replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
/**
* Clean up resources.
*/
destroy() {
if (this._rotationTimer) {
clearInterval(this._rotationTimer);
this._rotationTimer = null;
}
console.log('[AuditLogger] Destroyed');
}
}
module.exports = AuditLogger;
@@ -0,0 +1,302 @@
/**
* Content Security API Routes
* Express routes for content security checking, sensitive words, and reporting.
*/
const express = require('express');
const multer = require('multer');
const ContentSecurityService = require('./contentSecurityService');
const ViolationService = require('./violationService');
const ReportService = require('./reportService');
const sensitiveWords = require('./sensitiveWords');
// Configure multer for image uploads (memory storage, max 1MB)
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 1024 * 1024 }, // 1MB max
});
/**
* Create and return the content security router.
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
* @returns {express.Router}
*/
function createContentSecurityRouter(options = {}) {
const router = express.Router();
const contentSecurity = new ContentSecurityService({
tokenManager: options.tokenManager,
logger: options.logger,
});
const violationService = options.violationService || new ViolationService({
logger: options.logger,
});
const reportService = options.reportService || new ReportService({
logger: options.logger,
violationService,
});
// ============================================================
// Middleware: Extract game_id from request
// Priority: X-Game-Id header > game_id body/query param > DEFAULT_GAME_ID
// ============================================================
const DEFAULT_GAME_ID = process.env.DEFAULT_GAME_ID || 'tankwar';
function extractGameId(req) {
return req.headers['x-game-id'] || req.body?.game_id || req.query?.game_id || DEFAULT_GAME_ID;
}
// ============================================================
// POST /api/content/check-text - Text content security check
// ============================================================
router.post('/check-text', express.json(), async (req, res) => {
const { openid, content, scene } = req.body;
const gameId = extractGameId(req);
// Validate required fields
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40001,
errmsg: 'openid is required',
});
}
if (!content || typeof content !== 'string') {
return res.status(400).json({
errcode: 40002,
errmsg: 'content is required',
});
}
if (typeof scene !== 'number' || ![1, 2, 3, 4].includes(scene)) {
return res.status(400).json({
errcode: 40003,
errmsg: 'scene must be 1(nickname), 2(chat), 3(signature), or 4(description)',
});
}
try {
// First, do server-side local sensitive word check
const localCheck = sensitiveWords.checkText(content);
if (localCheck.hasViolation) {
// Auto-record violation
violationService.recordViolation({
userId: openid,
gameId,
violationType: 'local_sensitive_word',
contentSummary: content.substring(0, 20),
scene,
});
return res.json({
pass: false,
errcode: 0,
errmsg: '内容违规,请修改',
suggest: 'risky',
label: 20000,
localCheck: true,
categories: localCheck.categories,
game_id: gameId,
});
}
// Then, call WeChat msgSecCheck API
const result = await contentSecurity.checkTextContent(openid, content, scene);
// Auto-record violation if msgSecCheck rejects
if (!result.pass && result.label > 0) {
violationService.recordViolation({
userId: openid,
gameId,
violationType: `wechat_label_${result.label}`,
contentSummary: content.substring(0, 20),
scene,
});
}
return res.json({ ...result, game_id: gameId });
} catch (err) {
console.error('[API] check-text error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// POST /api/content/check-image - Image content security check
// ============================================================
router.post('/check-image', upload.single('image'), async (req, res) => {
const gameId = extractGameId(req);
if (!req.file) {
return res.status(400).json({
errcode: 40004,
errmsg: 'image file is required',
});
}
// Check file size (multer already enforces this, but double-check)
if (req.file.size > 1024 * 1024) {
return res.status(400).json({
errcode: 40005,
errmsg: '图片大小不能超过1MB',
});
}
try {
const result = await contentSecurity.checkImageContent(req.file.buffer);
// Auto-record violation if image is rejected
if (!result.pass && req.body && req.body.openid) {
violationService.recordViolation({
userId: req.body.openid,
gameId,
violationType: 'image_violation',
contentSummary: `image_${req.file.size}bytes`,
scene: 5,
});
}
return res.json({ ...result, game_id: gameId });
} catch (err) {
console.error('[API] check-image error:', err.message);
return res.status(500).json({
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
});
}
});
// ============================================================
// GET /api/content/sensitive-words - Get sensitive word list for client caching
// ============================================================
router.get('/sensitive-words', (req, res) => {
const version = req.query.version;
// If client sends current version and it matches, return 304
if (version && version === sensitiveWords.getVersion()) {
return res.status(304).json({
version: sensitiveWords.getVersion(),
updated: false,
});
}
return res.json({
version: sensitiveWords.getVersion(),
updated: true,
words: sensitiveWords.getAllWords(),
categories: sensitiveWords.getWordsByCategory(),
});
});
// ============================================================
// GET /api/user/mute-status - Check if a user is muted
// ============================================================
router.get('/user/mute-status', (req, res) => {
const openid = req.query.openid;
const gameId = extractGameId(req);
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const status = violationService.getMuteStatus(openid, gameId);
return res.json({ ...status, game_id: gameId });
});
// ============================================================
// GET /api/user/violation-summary - Get violation summary for a user
// ============================================================
router.get('/user/violation-summary', (req, res) => {
const openid = req.query.openid;
const gameId = extractGameId(req);
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const summary = violationService.getViolationSummary(openid, gameId);
return res.json(summary);
});
// ============================================================
// POST /api/content/report - Submit a content report
// ============================================================
router.post('/report', express.json(), (req, res) => {
const { contentId, targetUserId, contentType, contentSummary, reporterId, reason } = req.body;
const gameId = extractGameId(req);
// Validate required fields
if (!contentId || !targetUserId || !contentType || !reporterId || !reason) {
return res.status(400).json({
errcode: 40007,
errmsg: 'Missing required fields: contentId, targetUserId, contentType, reporterId, reason',
});
}
// Validate reason
const validReasons = Object.values(ReportService.REPORT_REASONS);
if (!validReasons.includes(reason)) {
return res.status(400).json({
errcode: 40008,
errmsg: `Invalid reason. Must be one of: ${validReasons.join(', ')}`,
});
}
const result = reportService.submitReport({
contentId,
targetUserId,
contentType,
contentSummary: contentSummary || '',
reporterId,
reason,
gameId,
});
if (!result.success) {
if (result.reportCount === 0) {
return res.status(400).json({
errcode: 40009,
errmsg: '举报提交失败',
});
}
// Already reported
return res.json({
success: false,
message: '您已举报过该内容',
reportCount: result.reportCount,
game_id: gameId,
});
}
return res.json({
success: true,
message: '举报已提交',
reportCount: result.reportCount,
autoTakenDown: result.autoTakenDown,
game_id: gameId,
});
});
// ============================================================
// POST /api/content/check-text - Auto-record violation on reject
// (Extended: records violation when content is rejected)
// ============================================================
// This is already handled in the check-text route above,
// but we also add violation recording here for server-side content.
// The check-text endpoint above will auto-record violations
// when msgSecCheck returns a rejection.
return router;
}
module.exports = { createContentSecurityRouter };
@@ -0,0 +1,475 @@
/**
* Content Security Service
* Provides unified methods for text and image content security checking
* via WeChat's msgSecCheck and imgSecCheck APIs.
*
* Features:
* - checkTextContent(openid, content, scene): Text content audit
* - checkImageContent(imageBuffer): Image content audit
* - 3-second timeout for each API call
* - Rate limiting queue (5000 requests/minute for msgSecCheck)
*/
const https = require('https');
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const MSG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/msg_sec_check';
const IMG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/img_sec_check';
const API_TIMEOUT_MS = 3000; // 3 seconds timeout
const RATE_LIMIT_PER_MINUTE = 5000;
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
// Scene mapping for msgSecCheck
const SCENE_MAP = {
NICKNAME: 1,
CHAT: 2,
SIGNATURE: 3,
DESCRIPTION: 4,
};
class ContentSecurityService {
/**
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {object} [options.logger] - Optional custom logger
*/
constructor(options = {}) {
this.tokenManager = options.tokenManager;
this.logger = options.logger || new AuditLogger();
// Rate limiting
this._requestTimestamps = [];
this._requestQueue = [];
this._isProcessingQueue = false;
}
/**
* Check text content for security violations using msgSecCheck.
* @param {string} openid - User's openid
* @param {string} content - Text content to check
* @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description)
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>}
*/
async checkTextContent(openid, content, scene) {
const startTime = Date.now();
// Validate scene value
if (!Object.values(SCENE_MAP).includes(scene)) {
const result = {
pass: false,
errcode: -1,
errmsg: 'Invalid scene value',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'invalid_scene',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${MSG_SEC_CHECK_URL}?access_token=${token}`;
const postData = JSON.stringify({
openid,
scene,
version: 2,
content,
});
const apiResult = await this._callWechatAPI(url, postData, 'msgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0 && apiResult.result && apiResult.result.suggest === 'pass';
const result = {
pass,
errcode: apiResult.errcode || 0,
errmsg: apiResult.errmsg || '',
suggest: apiResult.result ? apiResult.result.suggest : 'risky',
label: apiResult.result ? apiResult.result.label : 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: pass ? 'pass' : 'reject',
reason: pass ? 'content_safe' : `label_${result.label}`,
duration,
});
return result;
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] msgSecCheck error:', err.message);
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'error',
reason: err.message,
duration,
});
// On error, reject the content (fail-closed: safety over availability)
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
}
}
/**
* Check image content for security violations using imgSecCheck.
* @param {Buffer} imageBuffer - Image data buffer
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
*/
async checkImageContent(imageBuffer) {
const startTime = Date.now();
// Check image size (max 1MB)
if (imageBuffer.length > 1024 * 1024) {
const result = {
pass: false,
errcode: -1,
errmsg: '图片大小不能超过1MB',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'image_too_large',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${IMG_SEC_CHECK_URL}?access_token=${token}`;
// Build multipart form data for imgSecCheck
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`;
const suffix = `\r\n--${boundary}--\r\n`;
const buffer = Buffer.concat([
Buffer.from(prefix),
imageBuffer,
Buffer.from(suffix),
]);
const apiResult = await this._callWechatAPIWithBuffer(url, buffer, boundary, 'imgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0;
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: pass ? 'pass' : 'reject',
reason: pass ? 'image_safe' : `errcode_${apiResult.errcode}`,
duration,
});
return {
pass,
errcode: apiResult.errcode || 0,
errmsg: pass ? 'ok' : '图片内容违规,请更换',
};
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] imgSecCheck error:', err.message);
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'error',
reason: err.message,
duration,
});
// Fail-closed
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
}
}
/**
* Call WeChat API with JSON POST data.
* @param {string} url
* @param {string} postData
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPI(url, postData, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(postData);
req.end();
});
}
/**
* Call WeChat API with multipart form data (for image upload).
* @param {string} url
* @param {Buffer} buffer
* @param {string} boundary
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPIWithBuffer(url, buffer, boundary, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': buffer.length,
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(buffer);
req.end();
});
}
/**
* Enforce rate limiting: queue requests if exceeding 5000/min.
* @returns {Promise<void>}
*/
async _enforceRateLimit() {
const now = Date.now();
// Clean old timestamps outside the current window
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE) {
this._requestTimestamps.push(now);
return;
}
// Rate limited - wait until a slot opens
return new Promise((resolve) => {
this._requestQueue.push(resolve);
this._processQueue();
});
}
/**
* Process queued requests as rate limit slots become available.
*/
_processQueue() {
if (this._isProcessingQueue || this._requestQueue.length === 0) return;
this._isProcessingQueue = true;
const processNext = () => {
const now = Date.now();
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE && this._requestQueue.length > 0) {
this._requestTimestamps.push(now);
const nextResolve = this._requestQueue.shift();
nextResolve();
processNext();
} else if (this._requestQueue.length > 0) {
// Wait for the oldest timestamp to expire
const oldestTs = this._requestTimestamps[0];
const waitTime = RATE_LIMIT_WINDOW_MS - (now - oldestTs) + 100;
setTimeout(processNext, waitTime);
} else {
this._isProcessingQueue = false;
}
};
processNext();
}
/**
* Sanitize content for logging: keep only first 3 and last 3 chars,
* replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
}
// Export scene mapping for external use
ContentSecurityService.SCENE_MAP = SCENE_MAP;
module.exports = ContentSecurityService;
@@ -0,0 +1,322 @@
/**
* Report Service
* Handles user reports of inappropriate content and automatic content takedown.
*
* Features:
* - Report submission with confidential reporter identity
* - Auto-takedown when 3+ different users report the same content
* - Content reset on takedown (nickname → "玩家+随机数", signature/description cleared)
* - Admin review confirmation
*/
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const AUTO_TAKEOWN_THRESHOLD = 3; // Number of reports to trigger auto-takedown
// Valid report reasons
const REPORT_REASONS = {
POLITICS: 'politics',
PORNOGRAPHY: 'pornography',
GAMBLING: 'gambling',
OTHER: 'other',
};
class ReportService {
/**
* @param {object} options
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
* @param {import('./violationService')} [options.violationService] - Violation service instance
*/
constructor(options = {}) {
this.logger = options.logger || new AuditLogger();
this.violationService = options.violationService || null;
/**
* In-memory report records.
* Key: contentId, Value: { targetUserId, contentType, contentSummary, reports: [{reporterId, reason, timestamp}], status, createdAt }
* @type {Map<string, object>}
*/
this._reports = new Map();
/**
* User content storage (for content reset on takedown).
* Key: userId, Value: { nickname, signature, description, avatarUrl }
* @type {Map<string, object>}
*/
this._userContent = new Map();
}
/**
* Submit a report for inappropriate content.
* @param {object} entry
* @param {string} entry.contentId - Unique identifier for the reported content
* @param {string} entry.targetUserId - User ID of the content author
* @param {string} entry.contentType - Type of content ('chat', 'nickname', 'signature', 'description', 'avatar')
* @param {string} entry.contentSummary - Desensitized content summary
* @param {string} entry.reporterId - User ID of the reporter
* @param {string} entry.reason - Report reason (politics/pornography/gambling/other)
* @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default')
* @returns {{ success: boolean, reportCount: number, autoTakenDown: boolean }}
*/
submitReport(entry) {
const {
contentId,
targetUserId,
contentType,
contentSummary,
reporterId,
reason,
} = entry;
const gameId = entry.gameId || 'default';
// Namespace contentId with gameId to avoid cross-game collisions
const namespacedContentId = `${gameId}:${contentId}`;
// Validate reason
if (!Object.values(REPORT_REASONS).includes(reason)) {
return { success: false, reportCount: 0, autoTakenDown: false };
}
// Get or create report record (using namespaced key)
let record = this._reports.get(namespacedContentId);
if (!record) {
record = {
contentId,
gameId,
namespacedContentId,
targetUserId,
contentType,
contentSummary,
reports: [],
status: 'active', // active | taken_down | reviewed
createdAt: Date.now(),
};
this._reports.set(namespacedContentId, record);
}
// Check if already reported by same user
const alreadyReported = record.reports.some(
(r) => r.reporterId === reporterId
);
if (alreadyReported) {
return {
success: false,
reportCount: record.reports.length,
autoTakenDown: false,
};
}
// Add the report
record.reports.push({
reporterId,
reason,
timestamp: Date.now(),
});
// Log the report (reporter identity kept confidential)
this.logger.logReport({
reporterId,
targetUserId,
contentSummary,
reason,
gameId,
});
console.log(
`[ReportService] Report submitted for content ${contentId} (game: ${gameId}) by user *** (${record.reports.length}/${AUTO_TAKEOWN_THRESHOLD})`
);
// Check for auto-takedown
let autoTakenDown = false;
if (
record.reports.length >= AUTO_TAKEOWN_THRESHOLD &&
record.status === 'active'
) {
autoTakenDown = this._autoTakedown(namespacedContentId, record);
}
return {
success: true,
reportCount: record.reports.length,
autoTakenDown,
};
}
/**
* Auto-takedown content when threshold is reached.
* @param {string} contentId
* @param {object} record
* @returns {boolean}
*/
_autoTakedown(contentId, record) {
record.status = 'taken_down';
record.takenDownAt = Date.now();
console.log(
`[ReportService] Auto-takedown triggered for content ${contentId} (${record.reports.length} reports)`
);
// Reset the user's content
this._resetUserContent(record.targetUserId, record.contentType);
// Record violation for the content author
if (this.violationService) {
this.violationService.recordViolation({
userId: record.targetUserId,
gameId: record.gameId,
violationType: 'report_takedown',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
return true;
}
/**
* Confirm a report as violation (admin action).
* @param {string} contentId - Original contentId (will be namespaced internally)
* @param {string} [gameId] - Game identifier (default: 'default')
* @returns {{ success: boolean }}
*/
confirmViolation(contentId, gameId) {
const gid = gameId || 'default';
const namespacedContentId = `${gid}:${contentId}`;
const record = this._reports.get(namespacedContentId);
if (!record) {
return { success: false };
}
record.status = 'reviewed';
record.reviewedAt = Date.now();
// Reset the user's content
this._resetUserContent(record.targetUserId, record.contentType);
// Record violation for the content author
if (this.violationService) {
this.violationService.recordViolation({
userId: record.targetUserId,
gameId: record.gameId,
violationType: 'admin_confirmed',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
console.log(
`[ReportService] Admin confirmed violation for content ${contentId} (game: ${gid})`
);
return { success: true };
}
/**
* Reset user content after takedown or deletion.
* Nickname → "玩家" + random 4-digit number
* Signature/description → cleared
* @param {string} userId
* @param {string} contentType
*/
_resetUserContent(userId, contentType) {
let userContent = this._userContent.get(userId);
if (!userContent) {
userContent = { nickname: '', signature: '', description: '', avatarUrl: '' };
this._userContent.set(userId, userContent);
}
switch (contentType) {
case 'nickname':
userContent.nickname = `玩家${Math.floor(1000 + Math.random() * 9000)}`;
break;
case 'signature':
userContent.signature = '';
break;
case 'description':
userContent.description = '';
break;
case 'avatar':
userContent.avatarUrl = '';
break;
case 'chat':
// Chat messages are just hidden, no user content reset needed
break;
}
console.log(
`[ReportService] Content reset for user ${userId}, type: ${contentType}`
);
}
/**
* Convert content type to scene number.
* @param {string} contentType
* @returns {number}
*/
_contentTypeToScene(contentType) {
const map = {
nickname: 1,
chat: 2,
signature: 3,
description: 4,
avatar: 5,
};
return map[contentType] || 0;
}
/**
* Get report status for a content item.
* @param {string} contentId - Original contentId
* @param {string} [gameId] - Game identifier (default: 'default')
* @returns {object|null}
*/
getReportStatus(contentId, gameId) {
const gid = gameId || 'default';
const namespacedContentId = `${gid}:${contentId}`;
const record = this._reports.get(namespacedContentId);
if (!record) return null;
return {
contentId: record.contentId,
gameId: record.gameId,
contentType: record.contentType,
reportCount: record.reports.length,
status: record.status,
// Do NOT expose reporter identities
};
}
/**
* Get all pending reports for admin review.
* @param {string} [gameId] - Filter by game identifier (optional, returns all if not specified)
* @returns {Array}
*/
getPendingReports(gameId) {
const pending = [];
for (const record of this._reports.values()) {
// Filter by gameId if specified
if (gameId && record.gameId !== gameId) continue;
if (record.status === 'taken_down') {
pending.push({
contentId: record.contentId,
gameId: record.gameId,
targetUserId: record.targetUserId,
contentType: record.contentType,
contentSummary: record.contentSummary,
reportCount: record.reports.length,
reasons: [...new Set(record.reports.map((r) => r.reason))],
takenDownAt: record.takenDownAt,
});
}
}
return pending;
}
}
// Export report reasons for external use
ReportService.REPORT_REASONS = REPORT_REASONS;
module.exports = ReportService;
@@ -0,0 +1,133 @@
/**
* Sensitive Words Dictionary
* Provides sensitive word lists for content security filtering.
* This is the server-side master word list that gets served to clients.
*
* Categories:
* - politics: Politically harmful content
* - pornography: Pornographic and obscene content
* - gambling: Gambling and illegal betting content
* - violence: Violent and threatening content
* - abuse: Abusive and insulting language
* - fraud: Fraud and scam content
* - other: Other regulated content
*/
const SENSITIVE_WORDS = {
politics: [
// Politically sensitive terms (representative samples)
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
'反动', '暴乱', '煽动颠覆', '分裂势力', '恐怖袭击',
'邪教组织', '法轮', '法轮功', '台独', '藏独', '疆独',
],
pornography: [
// Pornographic and obscene terms (representative samples)
'色情', '淫秽', '裸体', '性交', '卖淫',
'嫖娼', '成人电影', '情色', '黄色视频', '一夜情',
'援交', '约炮', '色诱', '露点', '性服务',
],
gambling: [
// Gambling related terms (representative samples)
'赌博', '赌场', '下注', '赌资', '博彩',
'六合彩', '时时彩', '赌球', '网络赌', '百家乐',
'老虎机', '扑克赌', '赌狗', '开盘下注', '庄家赔率',
],
violence: [
// Violence related terms (representative samples)
'杀人', '砍人', '捅死', '爆炸装置', '自制炸弹',
'灭门', '血腥屠杀', '残忍杀害', '暴力袭击', '砍杀',
],
abuse: [
// Abusive language (representative samples)
'傻逼', '操你', '妈的', '去死', '废物',
'滚蛋', '贱人', '狗日的', '草泥马', '脑残',
'白痴', '弱智', '猪头', '王八蛋', '混蛋',
],
fraud: [
// Fraud and scam terms (representative samples)
'代开发票', '虚假投资', '传销', '诈骗', '骗钱',
'刷单', '套现', '洗钱', '假币', '传销组织',
],
other: [
// Other regulated terms
'代孕', '买卖器官', '毒品', '吸毒', '走私',
'枪支', '管制刀具', '假药', '违禁品',
],
};
/**
* Get all sensitive words as a flat array.
* @returns {string[]}
*/
function getAllWords() {
return Object.values(SENSITIVE_WORDS).flat();
}
/**
* Get words grouped by category.
* @returns {object}
*/
function getWordsByCategory() {
return { ...SENSITIVE_WORDS };
}
/**
* Get the total count of words.
* @returns {number}
*/
function getWordCount() {
return getAllWords().length;
}
/**
* Check if a text contains any sensitive words.
* @param {string} text - Text to check
* @returns {{ hasViolation: boolean, matchedWords: string[], categories: string[] }}
*/
function checkText(text) {
if (!text || typeof text !== 'string') {
return { hasViolation: false, matchedWords: [], categories: [] };
}
const matchedWords = [];
const categories = new Set();
const lowerText = text.toLowerCase();
for (const [category, words] of Object.entries(SENSITIVE_WORDS)) {
for (const word of words) {
if (lowerText.includes(word.toLowerCase())) {
matchedWords.push(word);
categories.add(category);
}
}
}
return {
hasViolation: matchedWords.length > 0,
matchedWords: [...new Set(matchedWords)],
categories: [...categories],
};
}
/**
* Get the version/timestamp of the word list for cache validation.
* @returns {string}
*/
function getVersion() {
return '2026-05-11-v1';
}
module.exports = {
SENSITIVE_WORDS,
getAllWords,
getWordsByCategory,
getWordCount,
checkText,
getVersion,
};
@@ -0,0 +1,292 @@
/**
* Violation Service
* Manages user violation records and mute penalties.
*
* Penalty tiers:
* - 3 violations → 24-hour mute
* - 5 violations → 7-day mute
* - 10 violations → permanent mute
*
* When a mute period expires, the count for the current penalty tier is reset.
*/
const AuditLogger = require('./auditLogger');
// ============================================================
// Penalty Tiers
// ============================================================
const PENALTY_TIERS = [
{ threshold: 3, durationMs: 24 * 60 * 60 * 1000, label: '24小时禁言' },
{ threshold: 5, durationMs: 7 * 24 * 60 * 60 * 1000, label: '7天禁言' },
{ threshold: 10, durationMs: Infinity, label: '永久禁言' },
];
// Scene labels for logging
const SCENE_LABELS = {
1: 'nickname',
2: 'chat',
3: 'signature',
4: 'description',
5: 'image',
};
class ViolationService {
/**
* @param {object} options
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
*/
constructor(options = {}) {
this.logger = options.logger || new AuditLogger();
/**
* In-memory violation records.
* Key: "game_id:userId" (composite key for tenant isolation)
* Value: { count, violations[], currentTierStart, gameId, userId }
* In production, this should be backed by a database.
* @type {Map<string, { count: number, violations: Array, currentTierStart: number, gameId: string, userId: string }>}
*/
this._records = new Map();
/**
* In-memory mute records.
* Key: "game_id:userId" (composite key for tenant isolation)
* Value: { expiresAt: number|null, tier: number }
* @type {Map<string, { expiresAt: number|null, tier: number }>}
*/
this._mutes = new Map();
// Start periodic cleanup of expired mutes
this._cleanupInterval = setInterval(() => this._cleanupExpiredMutes(), 60000);
}
/**
* Build composite key for tenant isolation.
* @param {string} gameId
* @param {string} userId
* @returns {string}
*/
_compositeKey(gameId, userId) {
return `${gameId}:${userId}`;
}
/**
* Record a violation for a user.
* @param {object} entry
* @param {string} entry.userId - User's openid
* @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default')
* @param {string} entry.violationType - Type of violation (e.g., 'text_violation', 'image_violation')
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
recordViolation(entry) {
const { userId, violationType, contentSummary, scene } = entry;
const gameId = entry.gameId || 'default';
const key = this._compositeKey(gameId, userId);
// Get or create record
let record = this._records.get(key);
if (!record) {
record = { count: 0, violations: [], currentTierStart: 0, gameId, userId };
this._records.set(key, record);
}
// Increment violation count
record.count++;
record.violations.push({
type: violationType,
contentSummary,
scene,
timestamp: Date.now(),
});
// Log the violation
this.logger.logViolation({
userId,
gameId,
violationType,
contentSummary,
action: `violation_count_${record.count}`,
});
// Check if a penalty should be applied
const penalty = this._checkPenalty(key, record.count);
return penalty;
}
/**
* Check if a user is currently muted.
* @param {string} userId - User's openid
* @param {string} [gameId] - Game identifier for tenant isolation (default: 'default')
* @returns {{ isMuted: boolean, remainingMs: number, remainingText: string, tier: number }}
*/
getMuteStatus(userId, gameId) {
const gid = gameId || 'default';
const key = this._compositeKey(gid, userId);
const mute = this._mutes.get(key);
if (!mute) {
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
// Permanent mute
if (mute.expiresAt === null) {
return { isMuted: true, remainingMs: Infinity, remainingText: '永久', tier: mute.tier };
}
const remaining = mute.expiresAt - Date.now();
if (remaining <= 0) {
// Mute expired, clean up
this._removeMute(key);
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
return {
isMuted: true,
remainingMs: remaining,
remainingText: this._formatDuration(remaining),
tier: mute.tier,
};
}
/**
* Remove a mute (used when mute expires or is manually lifted).
* Resets the violation count for the current penalty tier.
* @param {string} key - Composite key "gameId:userId"
*/
_removeMute(key) {
const record = this._records.get(key);
if (record) {
// Find which tier they were at and reset count to just below that tier
const mute = this._mutes.get(key);
if (mute && mute.tier > 0) {
const tierIndex = PENALTY_TIERS.findIndex(t => t.threshold === mute.tier);
if (tierIndex > 0) {
record.count = PENALTY_TIERS[tierIndex - 1].threshold;
} else {
record.count = 0;
}
record.currentTierStart = record.count;
}
}
this._mutes.delete(key);
console.log(`[ViolationService] Mute removed for key ${key}`);
}
/**
* Check if a penalty should be applied based on violation count.
* @param {string} key - Composite key "gameId:userId"
* @param {number} count
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
_checkPenalty(key, count) {
// Check penalty tiers in reverse order (highest first)
for (let i = PENALTY_TIERS.length - 1; i >= 0; i--) {
const tier = PENALTY_TIERS[i];
if (count >= tier.threshold) {
// Only apply if not already muted at this or higher tier
const currentMute = this._mutes.get(key);
if (currentMute && currentMute.tier >= tier.threshold) {
return { penalty: null, isMuted: true, muteDuration: null };
}
// Apply mute
const expiresAt = tier.durationMs === Infinity ? null : Date.now() + tier.durationMs;
this._mutes.set(key, {
expiresAt,
tier: tier.threshold,
});
console.log(`[ViolationService] User ${key} muted: ${tier.label} (violations: ${count})`);
// Extract userId and gameId from key for logging
const colonIdx = key.indexOf(':');
const gameId = key.substring(0, colonIdx);
const userId = key.substring(colonIdx + 1);
this.logger.logViolation({
userId,
gameId,
violationType: 'penalty_applied',
contentSummary: '',
action: `mute_${tier.label}`,
});
return {
penalty: tier.label,
isMuted: true,
muteDuration: tier.durationMs === Infinity ? '永久' : this._formatDuration(tier.durationMs),
};
}
}
return { penalty: null, isMuted: false, muteDuration: null };
}
/**
* Clean up expired mutes periodically.
*/
_cleanupExpiredMutes() {
const now = Date.now();
for (const [userId, mute] of this._mutes) {
if (mute.expiresAt !== null && mute.expiresAt <= now) {
this._removeMute(userId);
}
}
}
/**
* Format a duration in milliseconds to a human-readable string.
* @param {number} ms
* @returns {string}
*/
_formatDuration(ms) {
if (ms === Infinity) return '永久';
const totalMinutes = Math.floor(ms / (60 * 1000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (days > 0) {
return `${days}${remainingHours}小时`;
}
return `${hours}小时${minutes}分钟`;
}
/**
* Get violation summary for a user.
* @param {string} userId
* @param {string} [gameId] - Game identifier for tenant isolation (default: 'default')
* @returns {{ count: number, isMuted: boolean, recentViolations: Array, gameId: string }}
*/
getViolationSummary(userId, gameId) {
const gid = gameId || 'default';
const key = this._compositeKey(gid, userId);
const record = this._records.get(key);
const muteStatus = this.getMuteStatus(userId, gid);
return {
count: record ? record.count : 0,
isMuted: muteStatus.isMuted,
muteRemainingText: muteStatus.remainingText,
recentViolations: record ? record.violations.slice(-5) : [],
gameId: gid,
};
}
/**
* Clean up resources.
*/
destroy() {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
}
console.log('[ViolationService] Destroyed');
}
}
module.exports = ViolationService;
@@ -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;