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,265 @@
|
||||
/**
|
||||
* 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: userId (openid), Value: { count, violations[], currentTierStart }
|
||||
* In production, this should be backed by a database.
|
||||
* @type {Map<string, { count: number, violations: Array, currentTierStart: number }>}
|
||||
*/
|
||||
this._records = new Map();
|
||||
|
||||
/**
|
||||
* In-memory mute records.
|
||||
* Key: userId (openid), Value: { expiresAt: number|null, tier: number }
|
||||
* @type {Map<string, { expiresAt: number|null, tier: number }>}
|
||||
*/
|
||||
this._mutes = new Map();
|
||||
|
||||
// Start periodic cleanup of expired mutes
|
||||
this._cleanupInterval = setInterval(() => this._cleanupExpiredMutes(), 60000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a violation for a user.
|
||||
* @param {object} entry
|
||||
* @param {string} entry.userId - User's openid
|
||||
* @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;
|
||||
|
||||
// Get or create record
|
||||
let record = this._records.get(userId);
|
||||
if (!record) {
|
||||
record = { count: 0, violations: [], currentTierStart: 0 };
|
||||
this._records.set(userId, record);
|
||||
}
|
||||
|
||||
// Increment violation count
|
||||
record.count++;
|
||||
record.violations.push({
|
||||
type: violationType,
|
||||
contentSummary,
|
||||
scene,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// Log the violation
|
||||
this.logger.logViolation({
|
||||
userId,
|
||||
violationType,
|
||||
contentSummary,
|
||||
action: `violation_count_${record.count}`,
|
||||
});
|
||||
|
||||
// Check if a penalty should be applied
|
||||
const penalty = this._checkPenalty(userId, record.count);
|
||||
|
||||
return penalty;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a user is currently muted.
|
||||
* @param {string} userId - User's openid
|
||||
* @returns {{ isMuted: boolean, remainingMs: number, remainingText: string, tier: number }}
|
||||
*/
|
||||
getMuteStatus(userId) {
|
||||
const mute = this._mutes.get(userId);
|
||||
|
||||
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(userId);
|
||||
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} userId
|
||||
*/
|
||||
_removeMute(userId) {
|
||||
const record = this._records.get(userId);
|
||||
if (record) {
|
||||
// Find which tier they were at and reset count to just below that tier
|
||||
const mute = this._mutes.get(userId);
|
||||
if (mute && mute.tier > 0) {
|
||||
// Reset to just below the current tier threshold
|
||||
// so they need a full set of new violations to reach the next tier
|
||||
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(userId);
|
||||
console.log(`[ViolationService] Mute removed for user ${userId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a penalty should be applied based on violation count.
|
||||
* @param {string} userId
|
||||
* @param {number} count
|
||||
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
|
||||
*/
|
||||
_checkPenalty(userId, 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(userId);
|
||||
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(userId, {
|
||||
expiresAt,
|
||||
tier: tier.threshold,
|
||||
});
|
||||
|
||||
console.log(`[ViolationService] User ${userId} muted: ${tier.label} (violations: ${count})`);
|
||||
|
||||
this.logger.logViolation({
|
||||
userId,
|
||||
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
|
||||
* @returns {{ count: number, isMuted: boolean, recentViolations: Array }}
|
||||
*/
|
||||
getViolationSummary(userId) {
|
||||
const record = this._records.get(userId);
|
||||
const muteStatus = this.getMuteStatus(userId);
|
||||
|
||||
return {
|
||||
count: record ? record.count : 0,
|
||||
isMuted: muteStatus.isMuted,
|
||||
muteRemainingText: muteStatus.remainingText,
|
||||
recentViolations: record ? record.violations.slice(-5) : [],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up resources.
|
||||
*/
|
||||
destroy() {
|
||||
if (this._cleanupInterval) {
|
||||
clearInterval(this._cleanupInterval);
|
||||
}
|
||||
console.log('[ViolationService] Destroyed');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ViolationService;
|
||||
Reference in New Issue
Block a user