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,301 @@
|
||||
/**
|
||||
* 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<string, object>}
|
||||
*/
|
||||
this._reports = new Map();
|
||||
|
||||
/**
|
||||
* User content storage (for content reset on takedown).
|
||||
* Key: userId, Value: { nickname, signature, description, avatarUrl }
|
||||
* @type {Map<string, object>}
|
||||
*/
|
||||
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;
|
||||
Reference in New Issue
Block a user