/** * 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 };