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,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;
|
||||
Reference in New Issue
Block a user