/** * Violation Service * Manages user violation records and mute penalties. * * Penalty tiers: * - 3 violations → 24-hour mute * - 5 violations → 7-day mute * - 10 violations → permanent mute * * When a mute period expires, the count for the current penalty tier is reset. */ const AuditLogger = require('./auditLogger'); // ============================================================ // Penalty Tiers // ============================================================ const PENALTY_TIERS = [ { threshold: 3, durationMs: 24 * 60 * 60 * 1000, label: '24小时禁言' }, { threshold: 5, durationMs: 7 * 24 * 60 * 60 * 1000, label: '7天禁言' }, { threshold: 10, durationMs: Infinity, label: '永久禁言' }, ]; // Scene labels for logging const SCENE_LABELS = { 1: 'nickname', 2: 'chat', 3: 'signature', 4: 'description', 5: 'image', }; class ViolationService { /** * @param {object} options * @param {import('./auditLogger')} [options.logger] - Audit logger instance */ constructor(options = {}) { this.logger = options.logger || new AuditLogger(); /** * In-memory violation records. * Key: "game_id:userId" (composite key for tenant isolation) * Value: { count, violations[], currentTierStart, gameId, userId } * In production, this should be backed by a database. * @type {Map} */ this._records = new Map(); /** * In-memory mute records. * Key: "game_id:userId" (composite key for tenant isolation) * Value: { expiresAt: number|null, tier: number } * @type {Map} */ this._mutes = new Map(); // Start periodic cleanup of expired mutes this._cleanupInterval = setInterval(() => this._cleanupExpiredMutes(), 60000); } /** * Build composite key for tenant isolation. * @param {string} gameId * @param {string} userId * @returns {string} */ _compositeKey(gameId, userId) { return `${gameId}:${userId}`; } /** * Record a violation for a user. * @param {object} entry * @param {string} entry.userId - User's openid * @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default') * @param {string} entry.violationType - Type of violation (e.g., 'text_violation', 'image_violation') * @param {string} entry.contentSummary - Desensitized content summary * @param {number} entry.scene - Scene value * @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }} */ recordViolation(entry) { const { userId, violationType, contentSummary, scene } = entry; const gameId = entry.gameId || 'default'; const key = this._compositeKey(gameId, userId); // Get or create record let record = this._records.get(key); if (!record) { record = { count: 0, violations: [], currentTierStart: 0, gameId, userId }; this._records.set(key, record); } // Increment violation count record.count++; record.violations.push({ type: violationType, contentSummary, scene, timestamp: Date.now(), }); // Log the violation this.logger.logViolation({ userId, gameId, violationType, contentSummary, action: `violation_count_${record.count}`, }); // Check if a penalty should be applied const penalty = this._checkPenalty(key, record.count); return penalty; } /** * Check if a user is currently muted. * @param {string} userId - User's openid * @param {string} [gameId] - Game identifier for tenant isolation (default: 'default') * @returns {{ isMuted: boolean, remainingMs: number, remainingText: string, tier: number }} */ getMuteStatus(userId, gameId) { const gid = gameId || 'default'; const key = this._compositeKey(gid, userId); const mute = this._mutes.get(key); if (!mute) { return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 }; } // Permanent mute if (mute.expiresAt === null) { return { isMuted: true, remainingMs: Infinity, remainingText: '永久', tier: mute.tier }; } const remaining = mute.expiresAt - Date.now(); if (remaining <= 0) { // Mute expired, clean up this._removeMute(key); return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 }; } return { isMuted: true, remainingMs: remaining, remainingText: this._formatDuration(remaining), tier: mute.tier, }; } /** * Remove a mute (used when mute expires or is manually lifted). * Resets the violation count for the current penalty tier. * @param {string} key - Composite key "gameId:userId" */ _removeMute(key) { const record = this._records.get(key); if (record) { // Find which tier they were at and reset count to just below that tier const mute = this._mutes.get(key); if (mute && mute.tier > 0) { const tierIndex = PENALTY_TIERS.findIndex(t => t.threshold === mute.tier); if (tierIndex > 0) { record.count = PENALTY_TIERS[tierIndex - 1].threshold; } else { record.count = 0; } record.currentTierStart = record.count; } } this._mutes.delete(key); console.log(`[ViolationService] Mute removed for key ${key}`); } /** * Check if a penalty should be applied based on violation count. * @param {string} key - Composite key "gameId:userId" * @param {number} count * @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }} */ _checkPenalty(key, count) { // Check penalty tiers in reverse order (highest first) for (let i = PENALTY_TIERS.length - 1; i >= 0; i--) { const tier = PENALTY_TIERS[i]; if (count >= tier.threshold) { // Only apply if not already muted at this or higher tier const currentMute = this._mutes.get(key); if (currentMute && currentMute.tier >= tier.threshold) { return { penalty: null, isMuted: true, muteDuration: null }; } // Apply mute const expiresAt = tier.durationMs === Infinity ? null : Date.now() + tier.durationMs; this._mutes.set(key, { expiresAt, tier: tier.threshold, }); console.log(`[ViolationService] User ${key} muted: ${tier.label} (violations: ${count})`); // Extract userId and gameId from key for logging const colonIdx = key.indexOf(':'); const gameId = key.substring(0, colonIdx); const userId = key.substring(colonIdx + 1); this.logger.logViolation({ userId, gameId, violationType: 'penalty_applied', contentSummary: '', action: `mute_${tier.label}`, }); return { penalty: tier.label, isMuted: true, muteDuration: tier.durationMs === Infinity ? '永久' : this._formatDuration(tier.durationMs), }; } } return { penalty: null, isMuted: false, muteDuration: null }; } /** * Clean up expired mutes periodically. */ _cleanupExpiredMutes() { const now = Date.now(); for (const [userId, mute] of this._mutes) { if (mute.expiresAt !== null && mute.expiresAt <= now) { this._removeMute(userId); } } } /** * Format a duration in milliseconds to a human-readable string. * @param {number} ms * @returns {string} */ _formatDuration(ms) { if (ms === Infinity) return '永久'; const totalMinutes = Math.floor(ms / (60 * 1000)); const hours = Math.floor(totalMinutes / 60); const minutes = totalMinutes % 60; const days = Math.floor(hours / 24); const remainingHours = hours % 24; if (days > 0) { return `${days}天${remainingHours}小时`; } return `${hours}小时${minutes}分钟`; } /** * Get violation summary for a user. * @param {string} userId * @param {string} [gameId] - Game identifier for tenant isolation (default: 'default') * @returns {{ count: number, isMuted: boolean, recentViolations: Array, gameId: string }} */ getViolationSummary(userId, gameId) { const gid = gameId || 'default'; const key = this._compositeKey(gid, userId); const record = this._records.get(key); const muteStatus = this.getMuteStatus(userId, gid); return { count: record ? record.count : 0, isMuted: muteStatus.isMuted, muteRemainingText: muteStatus.remainingText, recentViolations: record ? record.violations.slice(-5) : [], gameId: gid, }; } /** * Clean up resources. */ destroy() { if (this._cleanupInterval) { clearInterval(this._cleanupInterval); } console.log('[ViolationService] Destroyed'); } } module.exports = ViolationService;