/** * ContentSecurityManager * Client-side content security filtering for WeChat mini game. * * Features: * - Local sensitive word filtering (50ms target) * - Remote word list fetching with 24-hour local cache * - Built-in fallback word list when remote fetch fails * - Incremental word list updates without blocking ongoing checks * - Integration with server-side msgSecCheck/imgSecCheck APIs */ // ============================================================ // Configuration // ============================================================ const CACHE_KEY = 'content_security_word_cache'; const CACHE_VERSION_KEY = 'content_security_word_version'; const CACHE_TIMESTAMP_KEY = 'content_security_word_timestamp'; const CACHE_VALIDITY_MS = 24 * 60 * 60 * 1000; // 24 hours const SERVER_BASE_URL = ''; // Will use relative URL or configured server URL const DEFAULT_GAME_ID = 'tankwar'; // Default game identifier for multi-tenant isolation // Scene mapping (must match server-side) const SCENE = { NICKNAME: 1, CHAT: 2, SIGNATURE: 3, DESCRIPTION: 4, }; // Report reasons const REPORT_REASONS = { POLITICS: 'politics', PORNOGRAPHY: 'pornography', GAMBLING: 'gambling', OTHER: 'other', }; // Report reason display labels const REPORT_REASON_LABELS = { politics: '政治有害', pornography: '色情低俗', gambling: '赌博诈骗', other: '其他', }; // ============================================================ // Built-in Fallback Sensitive Word List // This is used when the remote word list is unavailable. // ============================================================ const FALLBACK_WORDS = [ // Politics '颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义', '法轮', '法轮功', '台独', '藏独', '疆独', // Pornography '色情', '淫秽', '裸体', '卖淫', '嫖娼', '约炮', '援交', '一夜情', '黄色视频', // Gambling '赌博', '赌场', '下注', '博彩', '六合彩', '时时彩', '赌球', '百家乐', '老虎机', // Violence '杀人', '砍人', '捅死', '自制炸弹', '血腥屠杀', // Abuse '傻逼', '操你', '妈的', '草泥马', '脑残', '贱人', '狗日的', '废物', '滚蛋', '王八蛋', // Fraud '代开发票', '传销', '诈骗', '洗钱', // Other '代孕', '毒品', '吸毒', '走私', '枪支', ]; class ContentSecurityManager { constructor() { /** @type {string[]} Active sensitive word list */ this._words = []; /** @type {string} Current word list version */ this._version = ''; /** @type {boolean} Whether the manager is initialized */ this._initialized = false; /** @type {boolean} Whether an update is in progress */ this._updating = false; /** @type {string} Server base URL for API calls */ this._serverUrl = ''; /** @type {string} Game identifier for multi-tenant isolation */ this._gameId = DEFAULT_GAME_ID; } /** * Initialize the manager: load cached words, then fetch latest. * @param {object} [options] * @param {string} [options.serverUrl] - Server base URL * @returns {Promise} */ async init(options = {}) { this._serverUrl = options.serverUrl || ''; this._gameId = options.gameId || DEFAULT_GAME_ID; // Try to load from cache first this._loadFromCache(); // If no cached words, use fallback if (this._words.length === 0) { this._words = [...FALLBACK_WORDS]; console.log('[ContentSecurity] Using fallback word list (' + this._words.length + ' words)'); } this._initialized = true; // Fetch latest words from server (non-blocking) this.fetchWordList(); console.log('[ContentSecurity] Initialized with ' + this._words.length + ' words'); } /** * Check text content against local sensitive words. * Must complete within 50ms. * @param {string} content - Text to check * @returns {{ hasViolation: boolean, matchedWords: string[] }} */ checkLocalText(content) { if (!content || typeof content !== 'string') { return { hasViolation: false, matchedWords: [] }; } const startTime = Date.now(); const lowerContent = content.toLowerCase(); const matchedWords = []; for (let i = 0; i < this._words.length; i++) { const word = this._words[i]; if (word && lowerContent.includes(word.toLowerCase())) { matchedWords.push(word); // Early exit if we find violations (performance optimization) if (matchedWords.length >= 3) break; } } const duration = Date.now() - startTime; if (duration > 50) { console.warn('[ContentSecurity] Local check took ' + duration + 'ms (target: 50ms)'); } return { hasViolation: matchedWords.length > 0, matchedWords, }; } /** * Check text content via server-side API (msgSecCheck). * @param {string} openid - User's openid * @param {string} content - Text 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) { if (!this._serverUrl) { return { pass: true, errcode: 0, errmsg: 'ok', suggest: 'pass', label: 100, }; } try { const res = await this._request({ url: `${this._serverUrl}/api/content/check-text`, method: 'POST', data: { openid, content, scene }, }); return res; } catch (err) { console.error('[ContentSecurity] checkTextContent error:', err && (err.message || err.errMsg) || 'unknown'); return { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', suggest: 'risky', label: 100, }; } } /** * Check image content via server-side API (imgSecCheck). * @param {string} filePath - Local temporary file path of the image * @param {string} openid - User's openid * @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>} */ async checkImageContent(filePath, openid) { if (!this._serverUrl) { return { pass: true, errcode: 0, errmsg: 'ok' }; } try { const res = await this._uploadImage(filePath, openid); return res; } catch (err) { console.error('[ContentSecurity] checkImageContent error:', err && (err.message || err.errMsg) || 'unknown'); return { pass: false, errcode: -1, errmsg: '审核服务暂时不可用,请稍后再试', }; } } /** * Fetch sensitive word list from server with cache validation. * Non-blocking: does not interfere with ongoing checks. * @returns {Promise} */ async fetchWordList() { if (this._updating) return false; if (!this._serverUrl) return false; this._updating = true; try { const res = await this._request({ url: `${this._serverUrl}/api/content/sensitive-words`, method: 'GET', data: { version: this._version }, }); if (res.updated && res.words) { // Incremental update: replace word list atomically this._words = res.words; this._version = res.version; // Cache locally this._saveToCache(); console.log('[ContentSecurity] Word list updated: ' + this._words.length + ' words, version: ' + this._version); } this._updating = false; return true; } catch (err) { console.error('[ContentSecurity] fetchWordList error:', err && (err.message || err.errMsg) || 'unknown'); this._updating = false; return false; } } /** * Check if user is muted. * @param {string} openid * @returns {Promise<{isMuted: boolean, remainingMs: number, remainingText: string}>} */ async getMuteStatus(openid) { if (!this._serverUrl) { return { isMuted: false, remainingMs: 0, remainingText: '' }; } try { const res = await this._request({ url: `${this._serverUrl}/api/content/user/mute-status`, method: 'GET', data: { openid }, }); return res; } catch (err) { console.error('[ContentSecurity] getMuteStatus error:', err && (err.message || err.errMsg) || 'unknown'); return { isMuted: false, remainingMs: 0, remainingText: '' }; } } /** * Submit a content report. * @param {object} entry * @param {string} entry.contentId - Unique content identifier * @param {string} entry.targetUserId - Content author's user ID * @param {string} entry.contentType - Content type ('chat', 'nickname', 'signature', 'description', 'avatar') * @param {string} entry.contentSummary - Content summary * @param {string} entry.reporterId - Reporter's user ID * @param {string} entry.reason - Report reason * @returns {Promise<{success: boolean, message: string}>} */ async submitReport(entry) { if (!this._serverUrl) { return { success: false, message: '举报功能暂不可用' }; } try { const res = await this._request({ url: `${this._serverUrl}/api/content/report`, method: 'POST', data: entry, }); return res; } catch (err) { console.error('[ContentSecurity] submitReport error:', err && (err.message || err.errMsg) || 'unknown'); return { success: false, message: '举报提交失败,请稍后再试' }; } } /** * Full text check: local check first, then server-side if local passes. * @param {string} openid * @param {string} content * @param {number} scene * @returns {Promise<{pass: boolean, localViolation: boolean, serverResult: object|null, errorMessage: string}>} */ async fullTextCheck(openid, content, scene) { // Step 1: Local sensitive word check (fast) const localResult = this.checkLocalText(content); if (localResult.hasViolation) { return { pass: false, localViolation: true, serverResult: null, errorMessage: this._getErrorMessage({ suggest: 'risky' }, scene), }; } // Step 2: Check mute status (for nickname/signature/description scenes) if (scene === SCENE.NICKNAME || scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) { const muteStatus = await this.getMuteStatus(openid); if (muteStatus.isMuted) { return { pass: false, localViolation: false, serverResult: null, errorMessage: '由于违规行为,您暂无法修改个人信息', }; } } // Step 3: Server-side msgSecCheck const serverResult = await this.checkTextContent(openid, content, scene); if (!serverResult.pass) { return { pass: false, localViolation: false, serverResult, errorMessage: this._getErrorMessage(serverResult, scene), }; } return { pass: true, localViolation: false, serverResult, errorMessage: '', }; } /** * Get appropriate error message based on scene and result. * @param {object} result * @param {number} scene * @returns {string} */ _getErrorMessage(result, scene) { if (scene === SCENE.NICKNAME) { return '昵称包含违规内容,请重新输入'; } if (scene === SCENE.CHAT) { return '消息发送失败:内容违规'; } if (scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) { return '内容包含违规信息,请修改'; } return '内容违规,请修改'; } /** * Make an HTTP request using wx.request. * @param {object} options * @returns {Promise} */ _request(options) { return new Promise((resolve, reject) => { const header = Object.assign({}, options.header || { 'content-type': 'application/json' }); // Add X-Game-Id header for multi-tenant isolation if (this._gameId) { header['X-Game-Id'] = this._gameId; } wx.request({ url: options.url, method: options.method || 'GET', data: options.data || {}, header, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve(res.data); } else { reject(new Error(`HTTP ${res.statusCode}`)); } }, fail: (err) => { reject(err); }, }); }); } /** * Upload image for security check using wx.uploadFile. * @param {string} filePath - Local file path * @param {string} openid - User openid * @returns {Promise} */ _uploadImage(filePath, openid) { return new Promise((resolve, reject) => { const header = {}; if (this._gameId) { header['X-Game-Id'] = this._gameId; } wx.uploadFile({ url: `${this._serverUrl}/api/content/check-image`, filePath, name: 'image', formData: { openid }, header, success: (res) => { if (res.statusCode >= 200 && res.statusCode < 300) { try { const data = JSON.parse(res.data); resolve(data); } catch (e) { reject(new Error('Invalid response')); } } else { reject(new Error(`HTTP ${res.statusCode}`)); } }, fail: (err) => { reject(err); }, }); }); } /** * Load word list from local cache. */ _loadFromCache() { try { const timestamp = wx.getStorageSync(CACHE_TIMESTAMP_KEY); const version = wx.getStorageSync(CACHE_VERSION_KEY); const words = wx.getStorageSync(CACHE_KEY); if (timestamp && version && words && Array.isArray(words)) { // Check if cache is still valid if (Date.now() - timestamp < CACHE_VALIDITY_MS) { this._words = words; this._version = version; console.log('[ContentSecurity] Loaded ' + words.length + ' words from cache (version: ' + version + ')'); } } } catch (err) { console.error('[ContentSecurity] Failed to load cache:', err.message); } } /** * Save word list to local cache. */ _saveToCache() { try { wx.setStorageSync(CACHE_KEY, this._words); wx.setStorageSync(CACHE_VERSION_KEY, this._version); wx.setStorageSync(CACHE_TIMESTAMP_KEY, Date.now()); } catch (err) { console.error('[ContentSecurity] Failed to save cache:', err.message); } } /** * Get current word count. * @returns {number} */ getWordCount() { return this._words.length; } /** * Get current version. * @returns {string} */ getVersion() { return this._version; } /** * Check if initialized. * @returns {boolean} */ isInitialized() { return this._initialized; } } // Export scene and report reason constants ContentSecurityManager.SCENE = SCENE; ContentSecurityManager.REPORT_REASONS = REPORT_REASONS; ContentSecurityManager.REPORT_REASON_LABELS = REPORT_REASON_LABELS; module.exports = ContentSecurityManager;