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
This commit is contained in:
@@ -0,0 +1,279 @@
|
||||
/**
|
||||
* 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,
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// POST /api/content/check-text - Text content security check
|
||||
// ============================================================
|
||||
router.post('/check-text', express.json(), async (req, res) => {
|
||||
const { openid, content, scene } = req.body;
|
||||
|
||||
// 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,
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
// 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,
|
||||
violationType: `wechat_label_${result.label}`,
|
||||
contentSummary: content.substring(0, 20),
|
||||
scene,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} 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) => {
|
||||
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,
|
||||
violationType: 'image_violation',
|
||||
contentSummary: `image_${req.file.size}bytes`,
|
||||
scene: 5,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json(result);
|
||||
} 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;
|
||||
|
||||
if (!openid || typeof openid !== 'string') {
|
||||
return res.status(400).json({
|
||||
errcode: 40006,
|
||||
errmsg: 'openid is required',
|
||||
});
|
||||
}
|
||||
|
||||
const status = violationService.getMuteStatus(openid);
|
||||
return res.json(status);
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// GET /api/user/violation-summary - Get violation summary for a user
|
||||
// ============================================================
|
||||
router.get('/user/violation-summary', (req, res) => {
|
||||
const openid = req.query.openid;
|
||||
|
||||
if (!openid || typeof openid !== 'string') {
|
||||
return res.status(400).json({
|
||||
errcode: 40006,
|
||||
errmsg: 'openid is required',
|
||||
});
|
||||
}
|
||||
|
||||
const summary = violationService.getViolationSummary(openid);
|
||||
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;
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
return res.json({
|
||||
success: true,
|
||||
message: '举报已提交',
|
||||
reportCount: result.reportCount,
|
||||
autoTakenDown: result.autoTakenDown,
|
||||
});
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
// 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 };
|
||||
Reference in New Issue
Block a user