Files
tankwar_proj/js/managers/ContentSecurityManager.js
T
jakciehan d263c7bf48 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
2026-05-12 07:05:20 +08:00

504 lines
15 KiB
JavaScript

/**
* 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<void>}
*/
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<boolean>}
*/
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<object>}
*/
_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<object>}
*/
_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;