Files
tankwar_proj/js/managers/ContentSecurityManager.js
jakciehan c4bd390478 commit
2026-05-12 08:03:21 +08:00

522 lines
16 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
'法轮', '法轮功', '台独', '藏独', '疆独',
'习近平', '刁近平', '习大大', '习主席', '习总',
'XiJinping', 'xijinping', '习近', '近平',
'李强', '王岐山', '栗战书', '汪洋', '韩正',
'李克强', '胡锦涛', '江泽民', '温家宝', '朱镕基',
'邓小平', '毛泽东', '周恩来', '刘少奇', '彭德怀',
'薄熙来', '周永康', '徐才厚', '郭伯雄', '令计划',
'孙政才', '赵乐际', '王沪宁', '丁薛祥', '蔡奇',
// 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();
// Strip common evasion characters (punctuation, spaces, zero-width chars) for split-char detection
const strippedContent = content.replace(/[\s\u3000.,;:!?·…—\-_\|\\/~`@#$%^&*+=<>()\[\]{}""''「」『』【】()〈〕\u200b\u200c\u200d\ufeff]/g, '').toLowerCase();
const matchedWords = [];
for (let i = 0; i < this._words.length; i++) {
const word = this._words[i];
if (!word) continue;
const lowerWord = word.toLowerCase();
// Direct match
if (lowerContent.includes(lowerWord)) {
matchedWords.push(word);
if (matchedWords.length >= 3) break;
continue;
}
// Split-char evasion match: check if word chars appear in order with evasion chars between
// Only for multi-char words (length >= 2)
if (word.length >= 2 && strippedContent.includes(lowerWord)) {
matchedWords.push(word);
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;