/** * Report Service * Handles user reports of inappropriate content and automatic content takedown. * * Features: * - Report submission with confidential reporter identity * - Auto-takedown when 3+ different users report the same content * - Content reset on takedown (nickname → "玩家+随机数", signature/description cleared) * - Admin review confirmation */ const AuditLogger = require('./auditLogger'); // ============================================================ // Configuration // ============================================================ const AUTO_TAKEOWN_THRESHOLD = 3; // Number of reports to trigger auto-takedown // Valid report reasons const REPORT_REASONS = { POLITICS: 'politics', PORNOGRAPHY: 'pornography', GAMBLING: 'gambling', OTHER: 'other', }; class ReportService { /** * @param {object} options * @param {import('./auditLogger')} [options.logger] - Audit logger instance * @param {import('./violationService')} [options.violationService] - Violation service instance */ constructor(options = {}) { this.logger = options.logger || new AuditLogger(); this.violationService = options.violationService || null; /** * In-memory report records. * Key: contentId, Value: { targetUserId, contentType, contentSummary, reports: [{reporterId, reason, timestamp}], status, createdAt } * @type {Map} */ this._reports = new Map(); /** * User content storage (for content reset on takedown). * Key: userId, Value: { nickname, signature, description, avatarUrl } * @type {Map} */ this._userContent = new Map(); } /** * Submit a report for inappropriate content. * @param {object} entry * @param {string} entry.contentId - Unique identifier for the reported content * @param {string} entry.targetUserId - User ID of the content author * @param {string} entry.contentType - Type of content ('chat', 'nickname', 'signature', 'description', 'avatar') * @param {string} entry.contentSummary - Desensitized content summary * @param {string} entry.reporterId - User ID of the reporter * @param {string} entry.reason - Report reason (politics/pornography/gambling/other) * @returns {{ success: boolean, reportCount: number, autoTakenDown: boolean }} */ submitReport(entry) { const { contentId, targetUserId, contentType, contentSummary, reporterId, reason, } = entry; // Validate reason if (!Object.values(REPORT_REASONS).includes(reason)) { return { success: false, reportCount: 0, autoTakenDown: false }; } // Get or create report record let record = this._reports.get(contentId); if (!record) { record = { contentId, targetUserId, contentType, contentSummary, reports: [], status: 'active', // active | taken_down | reviewed createdAt: Date.now(), }; this._reports.set(contentId, record); } // Check if already reported by same user const alreadyReported = record.reports.some( (r) => r.reporterId === reporterId ); if (alreadyReported) { return { success: false, reportCount: record.reports.length, autoTakenDown: false, }; } // Add the report record.reports.push({ reporterId, reason, timestamp: Date.now(), }); // Log the report (reporter identity kept confidential) this.logger.logReport({ reporterId, targetUserId, contentSummary, reason, }); console.log( `[ReportService] Report submitted for content ${contentId} by user *** (${record.reports.length}/${AUTO_TAKEOWN_THRESHOLD})` ); // Check for auto-takedown let autoTakenDown = false; if ( record.reports.length >= AUTO_TAKEOWN_THRESHOLD && record.status === 'active' ) { autoTakenDown = this._autoTakedown(contentId, record); } return { success: true, reportCount: record.reports.length, autoTakenDown, }; } /** * Auto-takedown content when threshold is reached. * @param {string} contentId * @param {object} record * @returns {boolean} */ _autoTakedown(contentId, record) { record.status = 'taken_down'; record.takenDownAt = Date.now(); console.log( `[ReportService] Auto-takedown triggered for content ${contentId} (${record.reports.length} reports)` ); // Reset the user's content this._resetUserContent(record.targetUserId, record.contentType); // Record violation for the content author if (this.violationService) { this.violationService.recordViolation({ userId: record.targetUserId, violationType: 'report_takedown', contentSummary: record.contentSummary, scene: this._contentTypeToScene(record.contentType), }); } return true; } /** * Confirm a report as violation (admin action). * @param {string} contentId * @returns {{ success: boolean }} */ confirmViolation(contentId) { const record = this._reports.get(contentId); if (!record) { return { success: false }; } record.status = 'reviewed'; record.reviewedAt = Date.now(); // Reset the user's content this._resetUserContent(record.targetUserId, record.contentType); // Record violation for the content author if (this.violationService) { this.violationService.recordViolation({ userId: record.targetUserId, violationType: 'admin_confirmed', contentSummary: record.contentSummary, scene: this._contentTypeToScene(record.contentType), }); } console.log( `[ReportService] Admin confirmed violation for content ${contentId}` ); return { success: true }; } /** * Reset user content after takedown or deletion. * Nickname → "玩家" + random 4-digit number * Signature/description → cleared * @param {string} userId * @param {string} contentType */ _resetUserContent(userId, contentType) { let userContent = this._userContent.get(userId); if (!userContent) { userContent = { nickname: '', signature: '', description: '', avatarUrl: '' }; this._userContent.set(userId, userContent); } switch (contentType) { case 'nickname': userContent.nickname = `玩家${Math.floor(1000 + Math.random() * 9000)}`; break; case 'signature': userContent.signature = ''; break; case 'description': userContent.description = ''; break; case 'avatar': userContent.avatarUrl = ''; break; case 'chat': // Chat messages are just hidden, no user content reset needed break; } console.log( `[ReportService] Content reset for user ${userId}, type: ${contentType}` ); } /** * Convert content type to scene number. * @param {string} contentType * @returns {number} */ _contentTypeToScene(contentType) { const map = { nickname: 1, chat: 2, signature: 3, description: 4, avatar: 5, }; return map[contentType] || 0; } /** * Get report status for a content item. * @param {string} contentId * @returns {object|null} */ getReportStatus(contentId) { const record = this._reports.get(contentId); if (!record) return null; return { contentId: record.contentId, contentType: record.contentType, reportCount: record.reports.length, status: record.status, // Do NOT expose reporter identities }; } /** * Get all pending reports for admin review. * @returns {Array} */ getPendingReports() { const pending = []; for (const record of this._reports.values()) { if (record.status === 'taken_down') { pending.push({ contentId: record.contentId, targetUserId: record.targetUserId, contentType: record.contentType, contentSummary: record.contentSummary, reportCount: record.reports.length, reasons: [...new Set(record.reports.map((r) => r.reason))], takenDownAt: record.takenDownAt, }); } } return pending; } } // Export report reasons for external use ReportService.REPORT_REASONS = REPORT_REASONS; module.exports = ReportService;