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:
jakciehan
2026-05-12 07:05:20 +08:00
parent 38294c040c
commit d263c7bf48
48 changed files with 10480 additions and 25 deletions
+33
View File
@@ -0,0 +1,33 @@
FROM node:18-alpine
LABEL maintainer="tankwar-team"
LABEL description="Content Security Microservice for UGC moderation"
WORKDIR /app
# Copy package files and install dependencies
COPY package.json package-lock.json* ./
RUN npm install --omit=dev --no-audit --no-fund && \
npm cache clean --force
# Copy service code
COPY index.js ./
COPY services/ ./services/
# Expose HTTP port
EXPOSE 3000
# Environment defaults
ENV NODE_ENV=production \
HOST=0.0.0.0 \
PORT=3000
# Graceful shutdown & init for PID 1
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
CMD ["node", "index.js"]
+140
View File
@@ -0,0 +1,140 @@
/**
* Content Security Microservice - Standalone Entry Point
*
* Independent content security service for UGC moderation.
* Shared by multiple mini-games via game_id tenant isolation.
*
* API Endpoints:
* POST /api/content/check-text - Text content security check
* POST /api/content/check-image - Image content security check
* GET /api/content/sensitive-words - Get sensitive word list
* GET /api/user/mute-status - Check if a user is muted
* GET /api/user/violation-summary - Get violation summary
* POST /api/content/report - Submit a content report
* GET /api/health - Health check
* GET /api/metrics - Service metrics
*/
const express = require('express');
const http = require('http');
const WechatTokenManager = require('./services/wechatTokenManager');
const AuditLogger = require('./services/auditLogger');
const { createContentSecurityRouter } = require('./services/contentSecurityRoutes');
const ViolationService = require('./services/violationService');
const ReportService = require('./services/reportService');
// ============================================================
// Configuration
// ============================================================
const PORT = process.env.PORT || 3000;
const HOST = process.env.HOST || '0.0.0.0';
const DEFAULT_GAME_ID = process.env.DEFAULT_GAME_ID || 'tankwar';
// ============================================================
// Express App
// ============================================================
const app = express();
// Parse JSON bodies
app.use(express.json({ limit: '2mb' }));
// Request logging middleware
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
const game_id = req.headers['x-game-id'] || req.body?.game_id || DEFAULT_GAME_ID;
console.log(`[HTTP] ${req.method} ${req.url} ${res.statusCode} ${duration}ms game_id=${game_id}`);
});
next();
});
// ============================================================
// Health Check
// ============================================================
app.get('/api/health', (req, res) => {
res.json({
status: 'ok',
service: 'content-security',
version: '1.0.0',
timestamp: Date.now(),
tokenManagerAvailable: tokenManager ? tokenManager.isAvailable() : false,
});
});
// ============================================================
// Metrics Endpoint
// ============================================================
app.get('/api/metrics', (req, res) => {
res.json({
uptime: process.uptime(),
memory: process.memoryUsage(),
tokenManagerAvailable: tokenManager ? tokenManager.isAvailable() : false,
activeViolations: violationService ? violationService._records.size : 0,
activeMutes: violationService ? violationService._mutes.size : 0,
activeReports: reportService ? reportService._reports.size : 0,
timestamp: Date.now(),
});
});
// ============================================================
// Initialize Services
// ============================================================
const auditLogger = new AuditLogger();
const tokenManager = new WechatTokenManager();
const violationService = new ViolationService({ logger: auditLogger });
const reportService = new ReportService({ logger: auditLogger, violationService });
// Mount content security routes
const contentSecurityRouter = createContentSecurityRouter({
tokenManager,
logger: auditLogger,
violationService,
reportService,
});
app.use('/api/content', contentSecurityRouter);
// ============================================================
// Start Server
// ============================================================
const server = http.createServer(app);
server.listen(PORT, HOST, async () => {
console.log(`[Content Security Service] Running on ${HOST}:${PORT}`);
console.log(`[Content Security Service] Default game_id: ${DEFAULT_GAME_ID}`);
console.log(`[Content Security Service] API URL: http://${HOST}:${PORT}`);
// Initialize token manager
const success = await tokenManager.init();
if (success) {
console.log('[Content Security Service] WeChat token manager initialized successfully');
} else {
console.warn('[Content Security Service] WeChat token manager unavailable (token fetch failed)');
console.warn('[Content Security Service] Content checks will be rejected until token is obtained');
}
});
// Graceful shutdown
process.on('SIGTERM', () => {
console.log('[Content Security Service] SIGTERM received, shutting down gracefully...');
tokenManager.destroy();
auditLogger.destroy();
violationService.destroy();
server.close(() => {
console.log('[Content Security Service] Server closed');
process.exit(0);
});
});
process.on('SIGINT', () => {
console.log('[Content Security Service] SIGINT received, shutting down gracefully...');
tokenManager.destroy();
auditLogger.destroy();
violationService.destroy();
server.close(() => {
console.log('[Content Security Service] Server closed');
process.exit(0);
});
});
module.exports = { app, server };
+971
View File
@@ -0,0 +1,971 @@
{
"name": "content-security-service",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "content-security-service",
"version": "1.0.0",
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1"
}
},
"node_modules/accepts": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/accepts/-/accepts-2.0.0.tgz",
"integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==",
"dependencies": {
"mime-types": "^3.0.0",
"negotiator": "^1.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/body-parser": {
"version": "2.2.2",
"resolved": "https://mirrors.tencent.com/npm/body-parser/-/body-parser-2.2.2.tgz",
"integrity": "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==",
"license": "MIT",
"dependencies": {
"bytes": "^3.1.2",
"content-type": "^1.0.5",
"debug": "^4.4.3",
"http-errors": "^2.0.0",
"iconv-lite": "^0.7.0",
"on-finished": "^2.4.1",
"qs": "^6.14.1",
"raw-body": "^3.0.1",
"type-is": "^2.0.1"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/buffer-from": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/buffer-from/-/buffer-from-1.1.2.tgz",
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"license": "MIT"
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://mirrors.tencent.com/npm/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://mirrors.tencent.com/npm/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/call-bind-apply-helpers": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
"dependencies": {
"es-errors": "^1.3.0",
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/call-bound": {
"version": "1.0.4",
"resolved": "https://mirrors.tencent.com/npm/call-bound/-/call-bound-1.0.4.tgz",
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
"license": "MIT",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"get-intrinsic": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/concat-stream": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/concat-stream/-/concat-stream-2.0.0.tgz",
"integrity": "sha512-MWufYdFw53ccGjCA+Ol7XJYpAlW6/prSMzuPOTRnJGcGzuhLn4Scrz7qf6o8bROZ514ltazcIFJZevcfbo0x7A==",
"engines": [
"node >= 6.0"
],
"license": "MIT",
"dependencies": {
"buffer-from": "^1.0.0",
"inherits": "^2.0.3",
"readable-stream": "^3.0.2",
"typedarray": "^0.0.6"
}
},
"node_modules/content-disposition": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/content-disposition/-/content-disposition-1.1.0.tgz",
"integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/content-type": {
"version": "1.0.5",
"resolved": "https://mirrors.tencent.com/npm/content-type/-/content-type-1.0.5.tgz",
"integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/cookie/-/cookie-0.7.2.tgz",
"integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/cookie-signature": {
"version": "1.2.2",
"resolved": "https://mirrors.tencent.com/npm/cookie-signature/-/cookie-signature-1.2.2.tgz",
"integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==",
"license": "MIT",
"engines": {
"node": ">=6.6.0"
}
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://mirrors.tencent.com/npm/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/depd/-/depd-2.0.0.tgz",
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/dunder-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/dunder-proto/-/dunder-proto-1.0.1.tgz",
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.1",
"es-errors": "^1.3.0",
"gopd": "^1.2.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/ee-first": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/ee-first/-/ee-first-1.1.1.tgz",
"integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
"license": "MIT"
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/es-define-property/-/es-define-property-1.0.1.tgz",
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-errors": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/es-errors/-/es-errors-1.3.0.tgz",
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/es-object-atoms": {
"version": "1.1.1",
"resolved": "https://mirrors.tencent.com/npm/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
"dependencies": {
"es-errors": "^1.3.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/escape-html": {
"version": "1.0.3",
"resolved": "https://mirrors.tencent.com/npm/escape-html/-/escape-html-1.0.3.tgz",
"integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
"license": "MIT"
},
"node_modules/etag": {
"version": "1.8.1",
"resolved": "https://mirrors.tencent.com/npm/etag/-/etag-1.8.1.tgz",
"integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/express": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/express/-/express-5.2.1.tgz",
"integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==",
"license": "MIT",
"dependencies": {
"accepts": "^2.0.0",
"body-parser": "^2.2.1",
"content-disposition": "^1.0.0",
"content-type": "^1.0.5",
"cookie": "^0.7.1",
"cookie-signature": "^1.2.1",
"debug": "^4.4.0",
"depd": "^2.0.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"finalhandler": "^2.1.0",
"fresh": "^2.0.0",
"http-errors": "^2.0.0",
"merge-descriptors": "^2.0.0",
"mime-types": "^3.0.0",
"on-finished": "^2.4.1",
"once": "^1.4.0",
"parseurl": "^1.3.3",
"proxy-addr": "^2.0.7",
"qs": "^6.14.0",
"range-parser": "^1.2.1",
"router": "^2.2.0",
"send": "^1.1.0",
"serve-static": "^2.2.0",
"statuses": "^2.0.1",
"type-is": "^2.0.1",
"vary": "^1.1.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/finalhandler": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/finalhandler/-/finalhandler-2.1.1.tgz",
"integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"on-finished": "^2.4.1",
"parseurl": "^1.3.3",
"statuses": "^2.0.1"
},
"engines": {
"node": ">= 18.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/forwarded": {
"version": "0.2.0",
"resolved": "https://mirrors.tencent.com/npm/forwarded/-/forwarded-0.2.0.tgz",
"integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/fresh": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/fresh/-/fresh-2.0.0.tgz",
"integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/function-bind/-/function-bind-1.1.2.tgz",
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-intrinsic": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
"dependencies": {
"call-bind-apply-helpers": "^1.0.2",
"es-define-property": "^1.0.1",
"es-errors": "^1.3.0",
"es-object-atoms": "^1.1.1",
"function-bind": "^1.1.2",
"get-proto": "^1.0.1",
"gopd": "^1.2.0",
"has-symbols": "^1.1.0",
"hasown": "^2.0.2",
"math-intrinsics": "^1.1.0"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/get-proto": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/get-proto/-/get-proto-1.0.1.tgz",
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
"dependencies": {
"dunder-proto": "^1.0.1",
"es-object-atoms": "^1.0.0"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/gopd/-/gopd-1.2.0.tgz",
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/has-symbols": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/has-symbols/-/has-symbols-1.1.0.tgz",
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/hasown": {
"version": "2.0.3",
"resolved": "https://mirrors.tencent.com/npm/hasown/-/hasown-2.0.3.tgz",
"integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
"license": "MIT",
"dependencies": {
"function-bind": "^1.1.2"
},
"engines": {
"node": ">= 0.4"
}
},
"node_modules/http-errors": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/http-errors/-/http-errors-2.0.1.tgz",
"integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
"dependencies": {
"depd": "~2.0.0",
"inherits": "~2.0.4",
"setprototypeof": "~1.2.0",
"statuses": "~2.0.2",
"toidentifier": "~1.0.1"
},
"engines": {
"node": ">= 0.8"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/iconv-lite": {
"version": "0.7.2",
"resolved": "https://mirrors.tencent.com/npm/iconv-lite/-/iconv-lite-0.7.2.tgz",
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
"license": "MIT",
"dependencies": {
"safer-buffer": ">= 2.1.2 < 3.0.0"
},
"engines": {
"node": ">=0.10.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://mirrors.tencent.com/npm/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
},
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://mirrors.tencent.com/npm/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
"integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
"license": "MIT",
"engines": {
"node": ">= 0.10"
}
},
"node_modules/is-promise": {
"version": "4.0.0",
"resolved": "https://mirrors.tencent.com/npm/is-promise/-/is-promise-4.0.0.tgz",
"integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==",
"license": "MIT"
},
"node_modules/math-intrinsics": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
}
},
"node_modules/media-typer": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-1.1.0.tgz",
"integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/merge-descriptors": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/merge-descriptors/-/merge-descriptors-2.0.0.tgz",
"integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mime-db": {
"version": "1.54.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.54.0.tgz",
"integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/mime-types": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-3.0.2.tgz",
"integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==",
"dependencies": {
"mime-db": "^1.54.0"
},
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://mirrors.tencent.com/npm/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.1.1",
"resolved": "https://mirrors.tencent.com/npm/multer/-/multer-2.1.1.tgz",
"integrity": "sha512-mo+QTzKlx8R7E5ylSXxWzGoXoZbOsRMpyitcht8By2KHvMbf3tjwosZ/Mu/XYU6UuJ3VZnODIrak5ZrPiPyB6A==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"concat-stream": "^2.0.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/multer/node_modules/media-typer": {
"version": "0.3.0",
"resolved": "https://mirrors.tencent.com/npm/media-typer/-/media-typer-0.3.0.tgz",
"integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-db": {
"version": "1.52.0",
"resolved": "https://mirrors.tencent.com/npm/mime-db/-/mime-db-1.52.0.tgz",
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/mime-types": {
"version": "2.1.35",
"resolved": "https://mirrors.tencent.com/npm/mime-types/-/mime-types-2.1.35.tgz",
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
"dependencies": {
"mime-db": "1.52.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/multer/node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-1.6.18.tgz",
"integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
"license": "MIT",
"dependencies": {
"media-typer": "0.3.0",
"mime-types": "~2.1.24"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/negotiator": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/negotiator/-/negotiator-1.0.0.tgz",
"integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://mirrors.tencent.com/npm/object-inspect/-/object-inspect-1.13.4.tgz",
"integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
"license": "MIT",
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/on-finished": {
"version": "2.4.1",
"resolved": "https://mirrors.tencent.com/npm/on-finished/-/on-finished-2.4.1.tgz",
"integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
"license": "MIT",
"dependencies": {
"ee-first": "1.1.1"
},
"engines": {
"node": ">= 0.8"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://mirrors.tencent.com/npm/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/parseurl": {
"version": "1.3.3",
"resolved": "https://mirrors.tencent.com/npm/parseurl/-/parseurl-1.3.3.tgz",
"integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/path-to-regexp": {
"version": "8.4.2",
"resolved": "https://mirrors.tencent.com/npm/path-to-regexp/-/path-to-regexp-8.4.2.tgz",
"integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==",
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/proxy-addr": {
"version": "2.0.7",
"resolved": "https://mirrors.tencent.com/npm/proxy-addr/-/proxy-addr-2.0.7.tgz",
"integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
"license": "MIT",
"dependencies": {
"forwarded": "0.2.0",
"ipaddr.js": "1.9.1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/qs": {
"version": "6.15.1",
"resolved": "https://mirrors.tencent.com/npm/qs/-/qs-6.15.1.tgz",
"integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
"license": "BSD-3-Clause",
"dependencies": {
"side-channel": "^1.1.0"
},
"engines": {
"node": ">=0.6"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/range-parser": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/range-parser/-/range-parser-1.2.1.tgz",
"integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/raw-body": {
"version": "3.0.2",
"resolved": "https://mirrors.tencent.com/npm/raw-body/-/raw-body-3.0.2.tgz",
"integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==",
"license": "MIT",
"dependencies": {
"bytes": "~3.1.2",
"http-errors": "~2.0.1",
"iconv-lite": "~0.7.0",
"unpipe": "~1.0.0"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://mirrors.tencent.com/npm/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/router": {
"version": "2.2.0",
"resolved": "https://mirrors.tencent.com/npm/router/-/router-2.2.0.tgz",
"integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.0",
"depd": "^2.0.0",
"is-promise": "^4.0.0",
"parseurl": "^1.3.3",
"path-to-regexp": "^8.0.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://mirrors.tencent.com/npm/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/safer-buffer": {
"version": "2.1.2",
"resolved": "https://mirrors.tencent.com/npm/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
"license": "MIT"
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.4.3",
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"etag": "^1.8.1",
"fresh": "^2.0.0",
"http-errors": "^2.0.1",
"mime-types": "^3.0.2",
"ms": "^2.1.3",
"on-finished": "^2.4.1",
"range-parser": "^1.2.1",
"statuses": "^2.0.2"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/serve-static": {
"version": "2.2.1",
"resolved": "https://mirrors.tencent.com/npm/serve-static/-/serve-static-2.2.1.tgz",
"integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==",
"license": "MIT",
"dependencies": {
"encodeurl": "^2.0.0",
"escape-html": "^1.0.3",
"parseurl": "^1.3.3",
"send": "^1.2.0"
},
"engines": {
"node": ">= 18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://mirrors.tencent.com/npm/setprototypeof/-/setprototypeof-1.2.0.tgz",
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="
},
"node_modules/side-channel": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/side-channel/-/side-channel-1.1.0.tgz",
"integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.3",
"side-channel-list": "^1.0.0",
"side-channel-map": "^1.0.1",
"side-channel-weakmap": "^1.0.2"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-list": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-list/-/side-channel-list-1.0.1.tgz",
"integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
"license": "MIT",
"dependencies": {
"es-errors": "^1.3.0",
"object-inspect": "^1.13.4"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-map": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/side-channel-map/-/side-channel-map-1.0.1.tgz",
"integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/side-channel-weakmap": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
"integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
"license": "MIT",
"dependencies": {
"call-bound": "^1.0.2",
"es-errors": "^1.3.0",
"get-intrinsic": "^1.2.5",
"object-inspect": "^1.13.3",
"side-channel-map": "^1.0.1"
},
"engines": {
"node": ">= 0.4"
},
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://mirrors.tencent.com/npm/statuses/-/statuses-2.0.2.tgz",
"integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://mirrors.tencent.com/npm/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://mirrors.tencent.com/npm/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://mirrors.tencent.com/npm/toidentifier/-/toidentifier-1.0.1.tgz",
"integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
"license": "MIT",
"engines": {
"node": ">=0.6"
}
},
"node_modules/type-is": {
"version": "2.0.1",
"resolved": "https://mirrors.tencent.com/npm/type-is/-/type-is-2.0.1.tgz",
"integrity": "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==",
"license": "MIT",
"dependencies": {
"content-type": "^1.0.5",
"media-typer": "^1.1.0",
"mime-types": "^3.0.0"
},
"engines": {
"node": ">= 0.6"
}
},
"node_modules/typedarray": {
"version": "0.0.6",
"resolved": "https://mirrors.tencent.com/npm/typedarray/-/typedarray-0.0.6.tgz",
"integrity": "sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA==",
"license": "MIT"
},
"node_modules/unpipe": {
"version": "1.0.0",
"resolved": "https://mirrors.tencent.com/npm/unpipe/-/unpipe-1.0.0.tgz",
"integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vary": {
"version": "1.1.2",
"resolved": "https://mirrors.tencent.com/npm/vary/-/vary-1.1.2.tgz",
"integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
"license": "MIT",
"engines": {
"node": ">= 0.8"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://mirrors.tencent.com/npm/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
}
}
}
+15
View File
@@ -0,0 +1,15 @@
{
"name": "content-security-service",
"version": "1.0.0",
"description": "Content security microservice for UGC moderation (WeChat msgSecCheck/imgSecCheck)",
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js",
"test": "node --test test/"
},
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1"
}
}
+103
View File
@@ -0,0 +1,103 @@
#!/bin/bash
# ============================================================
# Content Security Service K8s Deploy Script
# Syncs source -> Master node -> builds Docker image ->
# distributes image to Worker nodes via ctr -> applies K8s resources
# -> rolls out the content-security-service deployment.
# ============================================================
set -e
LOG="/tmp/content-security-k8s-deploy.log"
> "$LOG"
exec > >(tee -a "$LOG") 2>&1
SERVICE_DIR="/Users/hanchengxi/workspace/tankwar_proj/content-security-service"
DEPLOY_DIR="/Users/hanchengxi/workspace/tankwar_proj/deploy/content-security"
MASTER="root@host_172.16.16.16"
WORKERS_IP=("172.16.16.17" "172.16.16.8")
REMOTE_BUILD_DIR="/tmp/content-security-build"
IMAGE_NAME="content-security-service:latest"
ts() { echo "[$(date '+%H:%M:%S')]"; }
# ------------------------------------------------------------
# Step 0: Sync service source to master node
# ------------------------------------------------------------
echo "$(ts) ===== Syncing content-security-service source to master node ====="
ssh -o StrictHostKeyChecking=no "$MASTER" "mkdir -p $REMOTE_BUILD_DIR"
rsync -az --delete --exclude='.git' --exclude='node_modules' \
-e "ssh -o StrictHostKeyChecking=no" \
"$SERVICE_DIR/" "${MASTER}:${REMOTE_BUILD_DIR}/"
echo "$(ts) ✓ Source synced"
# ------------------------------------------------------------
# Step 1: Ensure docker is available on master
# ------------------------------------------------------------
echo "$(ts) ===== Checking docker on master ====="
if ! ssh -o StrictHostKeyChecking=no "$MASTER" "which docker >/dev/null 2>&1"; then
echo "$(ts) Docker not found on master. Installing..."
ssh -o StrictHostKeyChecking=no "$MASTER" "curl -fsSL https://get.docker.com | sh"
fi
ssh -o StrictHostKeyChecking=no "$MASTER" "docker version --format '{{.Server.Version}}' 2>/dev/null || systemctl start docker"
echo "$(ts) ✓ Docker ready on master"
# ------------------------------------------------------------
# Step 2: Build image on master
# ------------------------------------------------------------
echo "$(ts) ===== Building $IMAGE_NAME on master ====="
ssh -o StrictHostKeyChecking=no "$MASTER" \
"cd $REMOTE_BUILD_DIR && docker build -t $IMAGE_NAME -f Dockerfile ."
echo "$(ts) ✓ Image built"
# ------------------------------------------------------------
# Step 3: Distribute image to workers (containerd / ctr)
# ------------------------------------------------------------
echo "$(ts) ===== Distributing $IMAGE_NAME to workers ====="
# Master itself may also be a worker; import locally first
ssh -o StrictHostKeyChecking=no "$MASTER" \
"docker save $IMAGE_NAME | ctr -n k8s.io images import -"
for w in "${WORKERS_IP[@]}"; do
echo "$(ts) -> $w"
ssh -o StrictHostKeyChecking=no "$MASTER" \
"docker save $IMAGE_NAME | ssh -o StrictHostKeyChecking=no root@$w 'ctr -n k8s.io images import -'"
done
echo "$(ts) ✓ Image distributed"
# ------------------------------------------------------------
# Step 4: Apply K8s manifests
# ------------------------------------------------------------
echo "$(ts) ===== Applying K8s manifests ====="
# Apply in order: namespace first, then configmap/secret, then deployment/service
for manifest in namespace.yaml configmap.yaml secret.yaml deployment.yaml service.yaml networkpolicy.yaml; do
if [ -f "$DEPLOY_DIR/$manifest" ]; then
echo "$(ts) Applying $manifest"
cat "$DEPLOY_DIR/$manifest" | \
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl apply -f -"
fi
done
echo "$(ts) ✓ Manifests applied"
# ------------------------------------------------------------
# Step 5: Restart deployment to pick up the new image
# ------------------------------------------------------------
echo "$(ts) ===== Restarting content-security-service deployment ====="
ssh -o StrictHostKeyChecking=no "$MASTER" \
"kubectl -n content-security rollout restart deployment/content-security-service" || true
ssh -o StrictHostKeyChecking=no "$MASTER" \
"kubectl -n content-security rollout status deployment/content-security-service --timeout=120s" || true
# ------------------------------------------------------------
# Step 6: Show final status
# ------------------------------------------------------------
echo "$(ts) ===== Final Status ====="
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n content-security get pods -o wide"
echo ""
ssh -o StrictHostKeyChecking=no "$MASTER" "kubectl -n content-security get svc"
echo ""
echo "$(ts) ===== ALL DONE ====="
echo "$(ts) Internal endpoint: content-security-service.content-security.svc.cluster.local:3000"
echo "$(ts) Game services should call: http://content-security-service.content-security.svc.cluster.local:3000/api/content/..."
# Cleanup
ssh -o StrictHostKeyChecking=no "$MASTER" "rm -rf $REMOTE_BUILD_DIR" 2>/dev/null || true
@@ -0,0 +1,199 @@
/**
* Audit Logger
* Logs content security audit events with content desensitization.
* Logs are retained for at least 180 days.
*
* Features:
* - Content desensitization: only first 3 and last 3 chars kept, middle replaced with ***
* - Access Token never logged in plaintext
* - Structured JSON log format
* - Automatic log rotation
*/
const fs = require('fs');
const path = require('path');
// ============================================================
// Configuration
// ============================================================
const LOG_DIR = path.join(__dirname, '..', 'logs', 'audit');
const LOG_RETENTION_DAYS = 180;
const LOG_ROTATION_INTERVAL_MS = 24 * 60 * 60 * 1000; // Rotate daily
class AuditLogger {
constructor() {
this._ensureLogDir();
this._currentLogFile = this._getLogFilePath(new Date());
this._rotationTimer = null;
this._startRotation();
}
/**
* Log an audit event.
* @param {object} entry
* @param {string} entry.userId - User identifier (openid)
* @param {string} entry.contentType - 'text' or 'image'
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @param {string} entry.result - 'pass', 'reject', 'error', 'rejected'
* @param {string} entry.reason - Reason for the result
* @param {number} [entry.duration] - Duration of the check in ms
*/
logAudit(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
userId: entry.userId || 'unknown',
contentType: entry.contentType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
scene: entry.scene || 0,
result: entry.result || 'unknown',
reason: entry.reason || '',
duration: entry.duration || 0,
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write log:', err.message);
}
}
/**
* Log a violation event.
* @param {object} entry
* @param {string} entry.userId - User identifier
* @param {string} entry.violationType - Type of violation
* @param {string} entry.contentSummary - Desensitized content
* @param {string} entry.action - Action taken (e.g., 'mute_24h')
*/
logViolation(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'violation',
userId: entry.userId || 'unknown',
violationType: entry.violationType || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
action: entry.action || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write violation log:', err.message);
}
}
/**
* Log a report event.
* @param {object} entry
* @param {string} entry.reporterId - Reporter's user ID (kept confidential)
* @param {string} entry.targetUserId - Reported user's ID
* @param {string} entry.contentSummary - Desensitized reported content
* @param {string} entry.reason - Report reason
*/
logReport(entry) {
const logEntry = {
timestamp: new Date().toISOString(),
type: 'report',
reporterId: entry.reporterId ? '***' : 'unknown', // Keep reporter confidential
targetUserId: entry.targetUserId || 'unknown',
contentSummary: this._sanitizeContent(entry.contentSummary),
reason: entry.reason || '',
};
const logLine = JSON.stringify(logEntry) + '\n';
try {
fs.appendFileSync(this._currentLogFile, logLine, 'utf8');
} catch (err) {
console.error('[AuditLogger] Failed to write report log:', err.message);
}
}
/**
* Ensure the log directory exists.
*/
_ensureLogDir() {
try {
if (!fs.existsSync(LOG_DIR)) {
fs.mkdirSync(LOG_DIR, { recursive: true });
}
} catch (err) {
console.error('[AuditLogger] Failed to create log directory:', err.message);
}
}
/**
* Get the log file path for a given date.
* @param {Date} date
* @returns {string}
*/
_getLogFilePath(date) {
const dateStr = date.toISOString().split('T')[0]; // YYYY-MM-DD
return path.join(LOG_DIR, `audit-${dateStr}.log`);
}
/**
* Start daily log rotation.
*/
_startRotation() {
this._rotationTimer = setInterval(() => {
const newLogFile = this._getLogFilePath(new Date());
if (newLogFile !== this._currentLogFile) {
this._currentLogFile = newLogFile;
console.log('[AuditLogger] Rotated to new log file:', newLogFile);
}
// Clean up old logs
this._cleanOldLogs();
}, LOG_ROTATION_INTERVAL_MS);
}
/**
* Remove log files older than the retention period.
*/
_cleanOldLogs() {
try {
const files = fs.readdirSync(LOG_DIR);
const cutoff = Date.now() - LOG_RETENTION_DAYS * 24 * 60 * 60 * 1000;
for (const file of files) {
const filePath = path.join(LOG_DIR, file);
const stat = fs.statSync(filePath);
if (stat.birthtimeMs < cutoff) {
fs.unlinkSync(filePath);
console.log('[AuditLogger] Deleted old log file:', file);
}
}
} catch (err) {
console.error('[AuditLogger] Failed to clean old logs:', err.message);
}
}
/**
* Sanitize content: keep only first 3 and last 3 chars, replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
/**
* Clean up resources.
*/
destroy() {
if (this._rotationTimer) {
clearInterval(this._rotationTimer);
this._rotationTimer = null;
}
console.log('[AuditLogger] Destroyed');
}
}
module.exports = AuditLogger;
@@ -0,0 +1,302 @@
/**
* 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,
});
// ============================================================
// Middleware: Extract game_id from request
// Priority: X-Game-Id header > game_id body/query param > DEFAULT_GAME_ID
// ============================================================
const DEFAULT_GAME_ID = process.env.DEFAULT_GAME_ID || 'tankwar';
function extractGameId(req) {
return req.headers['x-game-id'] || req.body?.game_id || req.query?.game_id || DEFAULT_GAME_ID;
}
// ============================================================
// POST /api/content/check-text - Text content security check
// ============================================================
router.post('/check-text', express.json(), async (req, res) => {
const { openid, content, scene } = req.body;
const gameId = extractGameId(req);
// 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,
gameId,
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,
game_id: gameId,
});
}
// 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,
gameId,
violationType: `wechat_label_${result.label}`,
contentSummary: content.substring(0, 20),
scene,
});
}
return res.json({ ...result, game_id: gameId });
} 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) => {
const gameId = extractGameId(req);
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,
gameId,
violationType: 'image_violation',
contentSummary: `image_${req.file.size}bytes`,
scene: 5,
});
}
return res.json({ ...result, game_id: gameId });
} 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;
const gameId = extractGameId(req);
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const status = violationService.getMuteStatus(openid, gameId);
return res.json({ ...status, game_id: gameId });
});
// ============================================================
// GET /api/user/violation-summary - Get violation summary for a user
// ============================================================
router.get('/user/violation-summary', (req, res) => {
const openid = req.query.openid;
const gameId = extractGameId(req);
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const summary = violationService.getViolationSummary(openid, gameId);
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;
const gameId = extractGameId(req);
// 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,
gameId,
});
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,
game_id: gameId,
});
}
return res.json({
success: true,
message: '举报已提交',
reportCount: result.reportCount,
autoTakenDown: result.autoTakenDown,
game_id: gameId,
});
});
// ============================================================
// 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 };
@@ -0,0 +1,475 @@
/**
* Content Security Service
* Provides unified methods for text and image content security checking
* via WeChat's msgSecCheck and imgSecCheck APIs.
*
* Features:
* - checkTextContent(openid, content, scene): Text content audit
* - checkImageContent(imageBuffer): Image content audit
* - 3-second timeout for each API call
* - Rate limiting queue (5000 requests/minute for msgSecCheck)
*/
const https = require('https');
const AuditLogger = require('./auditLogger');
// ============================================================
// Configuration
// ============================================================
const MSG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/msg_sec_check';
const IMG_SEC_CHECK_URL = 'https://api.weixin.qq.com/wxa/img_sec_check';
const API_TIMEOUT_MS = 3000; // 3 seconds timeout
const RATE_LIMIT_PER_MINUTE = 5000;
const RATE_LIMIT_WINDOW_MS = 60000; // 1 minute
// Scene mapping for msgSecCheck
const SCENE_MAP = {
NICKNAME: 1,
CHAT: 2,
SIGNATURE: 3,
DESCRIPTION: 4,
};
class ContentSecurityService {
/**
* @param {object} options
* @param {import('./wechatTokenManager')} options.tokenManager - Token manager instance
* @param {object} [options.logger] - Optional custom logger
*/
constructor(options = {}) {
this.tokenManager = options.tokenManager;
this.logger = options.logger || new AuditLogger();
// Rate limiting
this._requestTimestamps = [];
this._requestQueue = [];
this._isProcessingQueue = false;
}
/**
* Check text content for security violations using msgSecCheck.
* @param {string} openid - User's openid
* @param {string} content - Text content to check
* @param {number} scene - Scene value (1=nickname, 2=chat, 3=signature, 4=description)
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string, suggest: string, label: number}>}
*/
async checkTextContent(openid, content, scene) {
const startTime = Date.now();
// Validate scene value
if (!Object.values(SCENE_MAP).includes(scene)) {
const result = {
pass: false,
errcode: -1,
errmsg: 'Invalid scene value',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'invalid_scene',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${MSG_SEC_CHECK_URL}?access_token=${token}`;
const postData = JSON.stringify({
openid,
scene,
version: 2,
content,
});
const apiResult = await this._callWechatAPI(url, postData, 'msgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0 && apiResult.result && apiResult.result.suggest === 'pass';
const result = {
pass,
errcode: apiResult.errcode || 0,
errmsg: apiResult.errmsg || '',
suggest: apiResult.result ? apiResult.result.suggest : 'risky',
label: apiResult.result ? apiResult.result.label : 100,
};
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: pass ? 'pass' : 'reject',
reason: pass ? 'content_safe' : `label_${result.label}`,
duration,
});
return result;
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] msgSecCheck error:', err.message);
this.logger.logAudit({
userId: openid,
contentType: 'text',
contentSummary: this._sanitizeContent(content),
scene,
result: 'error',
reason: err.message,
duration,
});
// On error, reject the content (fail-closed: safety over availability)
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
}
}
/**
* Check image content for security violations using imgSecCheck.
* @param {Buffer} imageBuffer - Image data buffer
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
*/
async checkImageContent(imageBuffer) {
const startTime = Date.now();
// Check image size (max 1MB)
if (imageBuffer.length > 1024 * 1024) {
const result = {
pass: false,
errcode: -1,
errmsg: '图片大小不能超过1MB',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'image_too_large',
duration: Date.now() - startTime,
});
return result;
}
// Check if token manager is available
if (!this.tokenManager || !this.tokenManager.isAvailable()) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'service_unavailable',
duration: Date.now() - startTime,
});
return result;
}
// Enforce rate limiting
await this._enforceRateLimit();
try {
const token = await this.tokenManager.getAccessToken();
if (!token) {
const result = {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'rejected',
reason: 'token_unavailable',
duration: Date.now() - startTime,
});
return result;
}
const url = `${IMG_SEC_CHECK_URL}?access_token=${token}`;
// Build multipart form data for imgSecCheck
const boundary = '----WebKitFormBoundary7MA4YWxkTrZu0gW';
const prefix = `--${boundary}\r\nContent-Disposition: form-data; name="media"; filename="image.png"\r\nContent-Type: image/png\r\n\r\n`;
const suffix = `\r\n--${boundary}--\r\n`;
const buffer = Buffer.concat([
Buffer.from(prefix),
imageBuffer,
Buffer.from(suffix),
]);
const apiResult = await this._callWechatAPIWithBuffer(url, buffer, boundary, 'imgSecCheck');
const duration = Date.now() - startTime;
const pass = apiResult.errcode === 0;
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: pass ? 'pass' : 'reject',
reason: pass ? 'image_safe' : `errcode_${apiResult.errcode}`,
duration,
});
return {
pass,
errcode: apiResult.errcode || 0,
errmsg: pass ? 'ok' : '图片内容违规,请更换',
};
} catch (err) {
const duration = Date.now() - startTime;
console.error('[ContentSecurity] imgSecCheck error:', err.message);
this.logger.logAudit({
userId: 'system',
contentType: 'image',
contentSummary: `image_${imageBuffer.length}bytes`,
scene: 0,
result: 'error',
reason: err.message,
duration,
});
// Fail-closed
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
}
}
/**
* Call WeChat API with JSON POST data.
* @param {string} url
* @param {string} postData
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPI(url, postData, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(postData),
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(postData);
req.end();
});
}
/**
* Call WeChat API with multipart form data (for image upload).
* @param {string} url
* @param {Buffer} buffer
* @param {string} boundary
* @param {string} apiName - For logging
* @returns {Promise<object>}
*/
_callWechatAPIWithBuffer(url, buffer, boundary, apiName) {
return new Promise((resolve, reject) => {
const parsedUrl = new URL(url);
const options = {
hostname: parsedUrl.hostname,
path: parsedUrl.pathname + parsedUrl.search,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': buffer.length,
},
timeout: API_TIMEOUT_MS,
};
const req = https.request(options, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
resolve(result);
} catch (e) {
reject(new Error(`Invalid JSON from ${apiName}: ${data.substring(0, 100)}`));
}
});
});
req.on('timeout', () => {
req.destroy();
reject(new Error(`${apiName} request timeout (${API_TIMEOUT_MS}ms)`));
});
req.on('error', (err) => {
reject(err);
});
req.write(buffer);
req.end();
});
}
/**
* Enforce rate limiting: queue requests if exceeding 5000/min.
* @returns {Promise<void>}
*/
async _enforceRateLimit() {
const now = Date.now();
// Clean old timestamps outside the current window
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE) {
this._requestTimestamps.push(now);
return;
}
// Rate limited - wait until a slot opens
return new Promise((resolve) => {
this._requestQueue.push(resolve);
this._processQueue();
});
}
/**
* Process queued requests as rate limit slots become available.
*/
_processQueue() {
if (this._isProcessingQueue || this._requestQueue.length === 0) return;
this._isProcessingQueue = true;
const processNext = () => {
const now = Date.now();
this._requestTimestamps = this._requestTimestamps.filter(
(ts) => now - ts < RATE_LIMIT_WINDOW_MS
);
if (this._requestTimestamps.length < RATE_LIMIT_PER_MINUTE && this._requestQueue.length > 0) {
this._requestTimestamps.push(now);
const nextResolve = this._requestQueue.shift();
nextResolve();
processNext();
} else if (this._requestQueue.length > 0) {
// Wait for the oldest timestamp to expire
const oldestTs = this._requestTimestamps[0];
const waitTime = RATE_LIMIT_WINDOW_MS - (now - oldestTs) + 100;
setTimeout(processNext, waitTime);
} else {
this._isProcessingQueue = false;
}
};
processNext();
}
/**
* Sanitize content for logging: keep only first 3 and last 3 chars,
* replace middle with ***.
* @param {string} content
* @returns {string}
*/
_sanitizeContent(content) {
if (!content || typeof content !== 'string') return '';
if (content.length <= 6) return content;
return content.substring(0, 3) + '***' + content.substring(content.length - 3);
}
}
// Export scene mapping for external use
ContentSecurityService.SCENE_MAP = SCENE_MAP;
module.exports = ContentSecurityService;
@@ -0,0 +1,322 @@
/**
* 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)
* @param {string} [entry.gameId] - Game identifier for tenant isolation (default: 'default')
* @returns {{ success: boolean, reportCount: number, autoTakenDown: boolean }}
*/
submitReport(entry) {
const {
contentId,
targetUserId,
contentType,
contentSummary,
reporterId,
reason,
} = entry;
const gameId = entry.gameId || 'default';
// Namespace contentId with gameId to avoid cross-game collisions
const namespacedContentId = `${gameId}:${contentId}`;
// Validate reason
if (!Object.values(REPORT_REASONS).includes(reason)) {
return { success: false, reportCount: 0, autoTakenDown: false };
}
// Get or create report record (using namespaced key)
let record = this._reports.get(namespacedContentId);
if (!record) {
record = {
contentId,
gameId,
namespacedContentId,
targetUserId,
contentType,
contentSummary,
reports: [],
status: 'active', // active | taken_down | reviewed
createdAt: Date.now(),
};
this._reports.set(namespacedContentId, 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,
gameId,
});
console.log(
`[ReportService] Report submitted for content ${contentId} (game: ${gameId}) 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(namespacedContentId, 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,
gameId: record.gameId,
violationType: 'report_takedown',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
return true;
}
/**
* Confirm a report as violation (admin action).
* @param {string} contentId - Original contentId (will be namespaced internally)
* @param {string} [gameId] - Game identifier (default: 'default')
* @returns {{ success: boolean }}
*/
confirmViolation(contentId, gameId) {
const gid = gameId || 'default';
const namespacedContentId = `${gid}:${contentId}`;
const record = this._reports.get(namespacedContentId);
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,
gameId: record.gameId,
violationType: 'admin_confirmed',
contentSummary: record.contentSummary,
scene: this._contentTypeToScene(record.contentType),
});
}
console.log(
`[ReportService] Admin confirmed violation for content ${contentId} (game: ${gid})`
);
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 - Original contentId
* @param {string} [gameId] - Game identifier (default: 'default')
* @returns {object|null}
*/
getReportStatus(contentId, gameId) {
const gid = gameId || 'default';
const namespacedContentId = `${gid}:${contentId}`;
const record = this._reports.get(namespacedContentId);
if (!record) return null;
return {
contentId: record.contentId,
gameId: record.gameId,
contentType: record.contentType,
reportCount: record.reports.length,
status: record.status,
// Do NOT expose reporter identities
};
}
/**
* Get all pending reports for admin review.
* @param {string} [gameId] - Filter by game identifier (optional, returns all if not specified)
* @returns {Array}
*/
getPendingReports(gameId) {
const pending = [];
for (const record of this._reports.values()) {
// Filter by gameId if specified
if (gameId && record.gameId !== gameId) continue;
if (record.status === 'taken_down') {
pending.push({
contentId: record.contentId,
gameId: record.gameId,
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;
@@ -0,0 +1,133 @@
/**
* Sensitive Words Dictionary
* Provides sensitive word lists for content security filtering.
* This is the server-side master word list that gets served to clients.
*
* Categories:
* - politics: Politically harmful content
* - pornography: Pornographic and obscene content
* - gambling: Gambling and illegal betting content
* - violence: Violent and threatening content
* - abuse: Abusive and insulting language
* - fraud: Fraud and scam content
* - other: Other regulated content
*/
const SENSITIVE_WORDS = {
politics: [
// Politically sensitive terms (representative samples)
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
'反动', '暴乱', '煽动颠覆', '分裂势力', '恐怖袭击',
'邪教组织', '法轮', '法轮功', '台独', '藏独', '疆独',
],
pornography: [
// Pornographic and obscene terms (representative samples)
'色情', '淫秽', '裸体', '性交', '卖淫',
'嫖娼', '成人电影', '情色', '黄色视频', '一夜情',
'援交', '约炮', '色诱', '露点', '性服务',
],
gambling: [
// Gambling related terms (representative samples)
'赌博', '赌场', '下注', '赌资', '博彩',
'六合彩', '时时彩', '赌球', '网络赌', '百家乐',
'老虎机', '扑克赌', '赌狗', '开盘下注', '庄家赔率',
],
violence: [
// Violence related terms (representative samples)
'杀人', '砍人', '捅死', '爆炸装置', '自制炸弹',
'灭门', '血腥屠杀', '残忍杀害', '暴力袭击', '砍杀',
],
abuse: [
// Abusive language (representative samples)
'傻逼', '操你', '妈的', '去死', '废物',
'滚蛋', '贱人', '狗日的', '草泥马', '脑残',
'白痴', '弱智', '猪头', '王八蛋', '混蛋',
],
fraud: [
// Fraud and scam terms (representative samples)
'代开发票', '虚假投资', '传销', '诈骗', '骗钱',
'刷单', '套现', '洗钱', '假币', '传销组织',
],
other: [
// Other regulated terms
'代孕', '买卖器官', '毒品', '吸毒', '走私',
'枪支', '管制刀具', '假药', '违禁品',
],
};
/**
* Get all sensitive words as a flat array.
* @returns {string[]}
*/
function getAllWords() {
return Object.values(SENSITIVE_WORDS).flat();
}
/**
* Get words grouped by category.
* @returns {object}
*/
function getWordsByCategory() {
return { ...SENSITIVE_WORDS };
}
/**
* Get the total count of words.
* @returns {number}
*/
function getWordCount() {
return getAllWords().length;
}
/**
* Check if a text contains any sensitive words.
* @param {string} text - Text to check
* @returns {{ hasViolation: boolean, matchedWords: string[], categories: string[] }}
*/
function checkText(text) {
if (!text || typeof text !== 'string') {
return { hasViolation: false, matchedWords: [], categories: [] };
}
const matchedWords = [];
const categories = new Set();
const lowerText = text.toLowerCase();
for (const [category, words] of Object.entries(SENSITIVE_WORDS)) {
for (const word of words) {
if (lowerText.includes(word.toLowerCase())) {
matchedWords.push(word);
categories.add(category);
}
}
}
return {
hasViolation: matchedWords.length > 0,
matchedWords: [...new Set(matchedWords)],
categories: [...categories],
};
}
/**
* Get the version/timestamp of the word list for cache validation.
* @returns {string}
*/
function getVersion() {
return '2026-05-11-v1';
}
module.exports = {
SENSITIVE_WORDS,
getAllWords,
getWordsByCategory,
getWordCount,
checkText,
getVersion,
};
@@ -0,0 +1,292 @@
/**
* 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<string, { count: number, violations: Array, currentTierStart: number, gameId: string, userId: string }>}
*/
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<string, { expiresAt: number|null, tier: number }>}
*/
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;
@@ -0,0 +1,223 @@
/**
* WeChat Access Token Manager
* Manages the lifecycle of WeChat access tokens for content security APIs.
* - Auto-refreshes tokens before expiry (2-hour validity, refresh 5 minutes early)
* - Retry with exponential backoff on failure (2s, 4s, 8s)
* - Marks service as unavailable after all retries exhausted
*/
const https = require('https');
// ============================================================
// Configuration
// ============================================================
const TOKEN_VALIDITY_MS = 2 * 60 * 60 * 1000; // 2 hours
const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000; // Refresh 5 minutes before expiry
const RETRY_DELAYS = [2000, 4000, 8000]; // Exponential backoff delays in ms
const WECHAT_TOKEN_URL = 'https://api.weixin.qq.com/cgi-bin/token';
class WechatTokenManager {
/**
* @param {object} options
* @param {string} options.appId - WeChat Mini Program App ID
* @param {string} options.appSecret - WeChat Mini Program App Secret
*/
constructor(options = {}) {
this.appId = options.appId || process.env.WX_APPID || '';
this.appSecret = options.appSecret || process.env.WX_APPSECRET || '';
this._accessToken = null;
this._expiresAt = 0;
this._refreshTimer = null;
this._isAvailable = false;
this._isRefreshing = false;
this._retryCount = 0;
}
/**
* Initialize the token manager and fetch the first token.
* @returns {Promise<boolean>} true if token obtained successfully
*/
async init() {
console.log('[TokenManager] Initializing...');
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
return success;
}
/**
* Get the current valid access token.
* If the token is expired or about to expire, refresh it first.
* @returns {Promise<string|null>} The access token, or null if unavailable
*/
async getAccessToken() {
// If token is still valid (with margin), return it directly
if (this._accessToken && Date.now() < this._expiresAt - TOKEN_REFRESH_MARGIN_MS) {
return this._accessToken;
}
// Token expired or about to expire, try to refresh
if (!this._isRefreshing) {
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
}
return this._isAvailable ? this._accessToken : null;
}
/**
* Check if the token manager is currently available.
* @returns {boolean}
*/
isAvailable() {
return this._isAvailable;
}
/**
* Refresh the access token by calling WeChat API.
* Implements retry with exponential backoff.
* @returns {Promise<boolean>}
*/
async _refreshToken() {
if (this._isRefreshing) {
// Wait for the ongoing refresh to complete
return new Promise((resolve) => {
const checkInterval = setInterval(() => {
if (!this._isRefreshing) {
clearInterval(checkInterval);
resolve(this._isAvailable);
}
}, 100);
});
}
this._isRefreshing = true;
for (let attempt = 0; attempt <= RETRY_DELAYS.length; attempt++) {
try {
const token = await this._fetchTokenFromWechat();
if (token) {
this._accessToken = token;
this._expiresAt = Date.now() + TOKEN_VALIDITY_MS;
this._isAvailable = true;
this._retryCount = 0;
this._isRefreshing = false;
console.log('[TokenManager] Token refreshed successfully, expires at:',
new Date(this._expiresAt).toISOString());
return true;
}
} catch (err) {
console.error(`[TokenManager] Fetch token failed (attempt ${attempt + 1}):`, err.message);
}
// Wait before retry (if there are more retries)
if (attempt < RETRY_DELAYS.length) {
const delay = RETRY_DELAYS[attempt];
console.log(`[TokenManager] Retrying in ${delay}ms...`);
await this._sleep(delay);
}
}
// All retries exhausted
this._isAvailable = false;
this._isRefreshing = false;
this._accessToken = null;
console.error('[TokenManager] All retry attempts exhausted. Service marked as unavailable.');
// Schedule a recovery attempt after 10 minutes
setTimeout(() => {
console.log('[TokenManager] Attempting recovery refresh...');
this._refreshToken().then((success) => {
if (success) {
this._scheduleRefresh();
}
});
}, 10 * 60 * 1000);
return false;
}
/**
* Fetch access token from WeChat API.
* @returns {Promise<string|null>}
*/
_fetchTokenFromWechat() {
return new Promise((resolve, reject) => {
if (!this.appId || !this.appSecret) {
reject(new Error('AppId or AppSecret not configured'));
return;
}
const url = `${WECHAT_TOKEN_URL}?grant_type=client_credential&appid=${this.appId}&secret=${this.appSecret}`;
https.get(url, (res) => {
let data = '';
res.on('data', (chunk) => { data += chunk; });
res.on('end', () => {
try {
const result = JSON.parse(data);
if (result.access_token) {
resolve(result.access_token);
} else {
reject(new Error(`WeChat API error: ${result.errcode} - ${result.errmsg}`));
}
} catch (e) {
reject(new Error(`Invalid JSON response: ${data.substring(0, 100)}`));
}
});
}).on('error', (err) => {
reject(err);
});
});
}
/**
* Schedule the next token refresh before expiry.
*/
_scheduleRefresh() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
}
const refreshAt = this._expiresAt - TOKEN_REFRESH_MARGIN_MS;
const delay = Math.max(refreshAt - Date.now(), 60000); // At least 1 minute
console.log(`[TokenManager] Next refresh scheduled in ${Math.round(delay / 1000)}s`);
this._refreshTimer = setTimeout(async () => {
console.log('[TokenManager] Scheduled refresh triggered');
const success = await this._refreshToken();
if (success) {
this._scheduleRefresh();
}
}, delay);
}
/**
* Utility: sleep for a given number of milliseconds.
* @param {number} ms
* @returns {Promise<void>}
*/
_sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
/**
* Clean up resources.
*/
destroy() {
if (this._refreshTimer) {
clearTimeout(this._refreshTimer);
this._refreshTimer = null;
}
this._accessToken = null;
this._isAvailable = false;
console.log('[TokenManager] Destroyed');
}
}
module.exports = WechatTokenManager;