522 lines
16 KiB
JavaScript
522 lines
16 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
|
||
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
|
||
'法轮', '法轮功', '台独', '藏独', '疆独',
|
||
'习近平', '刁近平', '习大大', '习主席', '习总',
|
||
'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; |