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
@@ -0,0 +1,166 @@
# 需求文档 — 用户生成内容(UGC)安全审核系统
## 引言
坦克大战小游戏当前在用户自定义昵称、聊天消息、个人资料签名、个人空间描述等场景中,未具备过滤政治有害、色情、赌博违法等不当信息的机制。根据微信平台规范及中国相关法律法规要求,小游戏必须对用户产生内容(UGC)进行内容安全审核,确保上线内容合法合规。
本需求旨在接入微信公众平台内容安全 API(`msgSecCheck``imgSecCheck`),并结合客户端本地敏感词过滤、服务器端二次校验、违规记录与处罚机制,构建一套完整的内容安全审核体系,覆盖所有用户可输入文本和上传图片的场景。
### 适用场景
| 场景 | 内容类型 | 审核方式 |
|------|---------|---------|
| 用户自定义昵称 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 聊天室消息 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 个人资料签名 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 个人空间描述 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
| 用户头像上传 | 图片 | 服务器端 imgSecCheck |
| 用户分享卡片文案 | 文本 | 本地过滤 + 服务器端 msgSecCheck |
---
## 需求
### 需求 1:客户端本地敏感词过滤
**用户故事:** 作为一名玩家,我希望在输入内容时能够即时收到不当内容的提示,以便我能够及时修改而不必等待服务器审核结果。
#### 验收标准
1. WHEN 用户在任意文本输入框中输入内容 AND 输入内容包含本地敏感词库中的违规词汇 THEN 系统 SHALL 立即在输入框下方显示"内容包含违规信息,请修改"的提示信息
2. WHEN 用户输入的内容通过本地敏感词过滤 AND 用户提交内容 THEN 系统 SHALL 将内容发送至服务器端进行二次审核
3. IF 本地敏感词库匹配到违规内容 THEN 系统 SHALL 阻止该内容的提交,提交按钮置灰或点击后提示违规
4. WHEN 系统初始化时 THEN 系统 SHALL 从服务器拉取最新的敏感词库并缓存到本地,缓存有效期不超过 24 小时
5. WHEN 本地敏感词库缓存过期或拉取失败 THEN 系统 SHALL 使用内置的兜底敏感词列表(硬编码的基础违规词库)继续工作
### 需求 2:服务器端文本内容安全审核(msgSecCheck
**用户故事:** 作为一名游戏运营者,我希望所有用户提交的文本内容都经过微信官方内容安全 API 审核,以便确保内容审核的准确性和合规性。
#### 验收标准
1. WHEN 服务器接收到用户提交的文本内容(昵称、聊天消息、签名、描述等) THEN 系统 SHALL 在将内容存储或转发之前,调用微信 `msgSecCheck` API 进行审核
2. IF `msgSecCheck` 返回结果为内容违规 THEN 系统 SHALL 拒绝该内容的存储/转发,并向客户端返回错误码和"内容违规,请修改"的提示
3. IF `msgSecCheck` 返回结果为内容安全 THEN 系统 SHALL 允许该内容正常存储/转发
4. IF `msgSecCheck` API 调用失败(网络超时、服务不可用等) THEN 系统 SHALL 拒绝该内容的提交,返回"审核服务暂时不可用,请稍后再试",并记录错误日志
5. WHEN 调用 `msgSecCheck` API THEN 系统 SHALL 传递用户的 `openid``scene` 值(昵称场景=1,聊天场景=2,签名场景=3,描述场景=4),以便微信进行精准审核
6. WHEN 服务器调用 `msgSecCheck` THEN 系统 SHALL 在 3 秒内完成审核并返回结果,超时则按审核失败处理
### 需求 3:服务器端图片内容安全审核(imgSecCheck
**用户故事:** 作为一名游戏运营者,我希望用户上传的图片(如头像)经过微信官方图片安全 API 审核,以便防止违规图片传播。
#### 验收标准
1. WHEN 服务器接收到用户上传的图片(头像等) THEN 系统 SHALL 在将图片存储或展示之前,调用微信 `imgSecCheck` API 进行审核
2. IF `imgSecCheck` 返回结果为图片违规 THEN 系统 SHALL 拒绝该图片的存储/展示,并向客户端返回"图片内容违规,请更换"的提示
3. IF `imgSecCheck` 返回结果为图片安全 THEN 系统 SHALL 允许该图片正常存储/展示
4. IF `imgSecCheck` API 调用失败 THEN 系统 SHALL 拒绝该图片的上传,返回"审核服务暂时不可用,请稍后再试"
5. IF 用户上传的图片大小超过 1MB THEN 系统 SHALL 在调用审核前拒绝并提示"图片大小不能超过1MB"
### 需求 4:聊天室消息实时审核
**用户故事:** 作为一名玩家,我希望聊天室中的消息都是安全的,以便我能在良好的环境中与其他玩家交流。
#### 验收标准
1. WHEN 用户在聊天室发送消息 THEN 客户端 SHALL 先进行本地敏感词过滤,通过后再发送至服务器
2. WHEN 服务器接收到聊天消息 THEN 系统 SHALL 先调用 `msgSecCheck` 审核,审核通过后再将消息广播给同房间/队伍的其他玩家
3. IF 聊天消息审核未通过 THEN 系统 SHALL 仅向发送者返回"消息发送失败:内容违规"提示,不广播该消息
4. WHEN 聊天消息正在服务器审核中 THEN 系统 SHALL 在客户端显示"发送中..."状态,审核通过后显示为已发送
5. IF 聊天消息审核耗时超过 3 秒 THEN 系统 SHALL 显示"发送超时"并允许用户重试
### 需求 5:昵称设置与修改审核
**用户故事:** 作为一名玩家,我希望设置一个合规的昵称,以便其他玩家能看到我的身份标识。
#### 验收标准
1. WHEN 用户首次设置或修改昵称 THEN 客户端 SHALL 先进行本地敏感词过滤,通过后提交至服务器
2. WHEN 服务器接收到昵称修改请求 THEN 系统 SHALL 调用 `msgSecCheck`scene=1)进行审核
3. IF 昵称审核通过 THEN 系统 SHALL 更新用户昵称并返回成功
4. IF 昵称审核未通过 THEN 系统 SHALL 返回错误,客户端提示"昵称包含违规内容,请重新输入"
5. IF 用户昵称长度超过 20 个字符 THEN 系统 SHALL 在客户端直接拒绝并提示"昵称长度不能超过20个字符"
6. IF 用户昵称长度少于 2 个字符 THEN 系统 SHALL 在客户端直接拒绝并提示"昵称长度不能少于2个字符"
### 需求 6:个人资料签名与空间描述审核
**用户故事:** 作为一名玩家,我希望在个人资料中表达自己,同时确保内容合规,以便不影响其他玩家的体验。
#### 验收标准
1. WHEN 用户设置或修改个人资料签名 THEN 系统 SHALL 经本地过滤 + `msgSecCheck`scene=3)双重审核
2. WHEN 用户设置或修改个人空间描述 THEN 系统 SHALL 经本地过滤 + `msgSecCheck`scene=4)双重审核
3. IF 签名或描述审核未通过 THEN 系统 SHALL 返回错误并提示"内容包含违规信息,请修改"
4. IF 个人资料签名长度超过 50 个字符 THEN 系统 SHALL 在客户端直接拒绝
5. IF 个人空间描述长度超过 200 个字符 THEN 系统 SHALL 在客户端直接拒绝
### 需求 7:违规记录与处罚机制
**用户故事:** 作为一名游戏运营者,我希望对多次违规的用户进行处罚,以便维护游戏环境的健康。
#### 验收标准
1. WHEN 用户的提交内容被审核判定为违规 THEN 系统 SHALL 记录违规日志,包含用户ID、违规内容、违规类型、时间戳
2. IF 用户累计违规次数达到 3 次 THEN 系统 SHALL 对该用户实施 24 小时禁言处罚
3. IF 用户累计违规次数达到 5 次 THEN 系统 SHALL 对该用户实施 7 天禁言处罚
4. IF 用户累计违规次数达到 10 次 THEN 系统 SHALL 对该用户实施永久禁言处罚
5. WHEN 用户处于禁言状态 AND 尝试发送聊天消息 THEN 系统 SHALL 提示"您已被禁言,剩余时间:X小时X分钟"
6. WHEN 用户处于禁言状态 AND 尝试修改昵称/签名/描述 THEN 系统 SHALL 提示"由于违规行为,您暂无法修改个人信息"
7. WHEN 禁言期满 THEN 系统 SHALL 自动解除禁言状态,并重置当前处罚级别的计数
### 需求 8:已有违规内容的清理与举报
**用户故事:** 作为一名玩家,我希望能够举报看到的不当内容,以便运营团队及时处理。
#### 验收标准
1. WHEN 玩家点击某条聊天消息或个人资料旁的举报按钮 THEN 系统 SHALL 弹出举报原因选择界面(政治有害/色情低俗/赌博诈骗/其他)
2. WHEN 玩家提交举报 THEN 系统 SHALL 将举报信息发送至服务器,包含被举报内容、举报原因、举报人信息
3. IF 同一条内容被 3 名以上不同用户举报 THEN 系统 SHALL 自动对该内容进行下线处理(隐藏),并标记待人工复核
4. WHEN 运营人员在后台对举报内容确认违规 THEN 系统 SHALL 对内容发布者按需求 7 的规则累计违规次数
5. WHEN 内容被自动下线或人工删除 THEN 系统 SHALL 将该用户的该字段内容重置为默认值(昵称恢复为"玩家+随机数",签名/描述清空)
### 需求 9:服务器端审核服务架构
**用户故事:** 作为一名开发者,我希望服务器端的内容审核服务有清晰的架构和可靠的接口,以便各业务模块能够方便地接入审核能力。
#### 验收标准
1. WHEN 服务器启动时 THEN 系统 SHALL 初始化微信 Access Token 管理器,自动获取和刷新 token(有效期 2 小时,提前 5 分钟刷新)
2. WHEN 业务模块需要审核文本内容 THEN 系统 SHALL 提供统一的 `checkTextContent(openid, content, scene)` 异步方法
3. WHEN 业务模块需要审核图片内容 THEN 系统 SHALL 提供统一的 `checkImageContent(imageBuffer)` 异步方法
4. IF 微信 API 调用频率超过限制(msgSecCheck: 5000次/分钟) THEN 系统 SHALL 实施请求排队机制,避免超出频率限制
5. WHEN 服务器接收到审核请求 THEN 系统 SHALL 记录审核日志(请求时间、用户ID、内容摘要、审核结果),日志保留至少 180 天
6. IF Access Token 获取失败 THEN 系统 SHALL 重试最多 3 次,每次间隔递增(2s, 4s, 8s),全部失败后标记审核服务不可用
### 需求 10:客户端 ContentSecurityManager 集成
**用户故事:** 作为一名开发者,我希望客户端有一个统一的内容安全管理器,以便各场景能方便地调用内容审核功能。
#### 验收标准
1. WHEN 游戏初始化时 THEN 系统 SHALL 创建 `ContentSecurityManager` 实例并挂载到 `GameGlobal`
2. WHEN 任意场景需要验证用户文本输入 THEN 该场景 SHALL 调用 `GameGlobal.contentSecurityManager.checkLocalText(content)` 进行本地校验
3. IF 本地校验通过 THEN 该场景 SHALL 将内容提交至服务器进行二次审核
4. IF 本地校验未通过 THEN 该场景 SHALL 显示具体的违规提示,不向服务器发送请求
5. WHEN `ContentSecurityManager` 初始化时 THEN 系统 SHALL 从服务器拉取最新敏感词库,拉取失败时使用内置兜底词库
6. WHEN 敏感词库更新接口被调用 THEN 系统 SHALL 增量更新本地缓存,不影响正在进行的审核操作
---
## 非功能性需求
### 性能要求
- 本地敏感词过滤应在 50ms 内完成
- 服务器端审核接口响应时间应 ≤ 3 秒(含微信 API 调用时间)
- 敏感词库拉取不应阻塞游戏主线程
### 安全要求
- 违规日志中的用户内容应脱敏存储(仅保留前3个和后3个字符,中间用***替代)
- Access Token 不得在日志中明文记录
- 举报人信息应对被举报者保密
### 可用性要求
- 审核服务不可用时,系统应拒绝所有UGC提交(安全优先于可用性)
- 本地敏感词库应内置至少 500 个基础违规词汇作为兜底
@@ -0,0 +1,67 @@
# 实施计划 — 用户生成内容(UGC)安全审核系统
- [ ] 1. 搭建服务器端审核服务核心架构
- 创建 `server/services/wechatTokenManager.js`:实现微信 Access Token 自动获取与刷新(2小时有效期,提前5分钟刷新),获取失败时指数退避重试(2s/4s/8s),全部失败标记服务不可用
- 创建 `server/services/contentSecurityService.js`:实现统一审核方法 `checkTextContent(openid, content, scene)``checkImageContent(imageBuffer)`,封装 `msgSecCheck``imgSecCheck` API 调用,3秒超时处理,频率限制排队(5000次/分钟)
- 创建 `server/services/auditLogger.js`:实现审核日志记录(请求时间、用户ID、内容脱敏摘要、审核结果),日志保留180天,Access Token 不得明文记录
- _需求:9.1、9.2、9.3、9.4、9.5、9.6_
- [ ] 2. 实现服务器端 HTTP API 与敏感词库接口
-`server/index.js` 中新增 HTTP 服务(Express),提供内容审核相关 API 路由:`POST /api/content/check-text``POST /api/content/check-image``GET /api/content/sensitive-words`
- 实现敏感词库拉取接口,从服务端返回最新敏感词列表供客户端缓存
- 各审核 API 内部调用 `contentSecurityService` 进行审核,审核不可用时拒绝请求并返回明确错误码
- _需求:2.1、2.4、3.1、3.4、1.4_
- [ ] 3. 实现违规记录与处罚机制
- 创建 `server/services/violationService.js`:实现违规日志记录(用户ID、违规内容、违规类型、时间戳),累计违规次数统计
- 实现禁言处罚逻辑:3次→24小时禁言,5次→7天禁言,10次→永久禁言;禁言期满自动解除并重置当前处罚级别计数
- 在审核 API 中集成违规记录:`msgSecCheck`/`imgSecCheck` 违规时自动调用 violationService 记录并触发处罚
- 添加禁言状态查询接口 `GET /api/user/mute-status`,返回是否禁言及剩余时间
- _需求:7.1、7.2、7.3、7.4、7.5、7.6、7.7_
- [ ] 4. 实现举报功能与违规内容自动下线
- 创建 `server/services/reportService.js`:实现举报提交(被举报内容、举报原因、举报人信息),举报人信息保密存储
- 实现自动下线逻辑:同一条内容被3名以上不同用户举报时自动隐藏,标记待人工复核
- 实现内容重置逻辑:内容被下线或删除时,昵称恢复为"玩家+随机数",签名/描述清空
- 添加举报 API`POST /api/content/report`,举报原因枚举(政治有害/色情低俗/赌博诈骗/其他)
- _需求:8.1、8.2、8.3、8.4、8.5_
- [ ] 5. 实现客户端 ContentSecurityManager
- 创建 `js/managers/ContentSecurityManager.js`:实现本地敏感词过滤方法 `checkLocalText(content)`50ms 内完成匹配
- 实现敏感词库远程拉取与本地缓存(24小时有效期),拉取失败使用内置兜底词库(硬编码基础违规词汇)
- 实现增量词库更新方法,不阻塞正在进行的审核操作
-`GameGlobal.js` 中初始化并挂载 `contentSecurityManager` 实例
- _需求:1.1、1.2、1.3、1.4、1.5、10.1、10.2、10.3、10.4、10.5、10.6_
- [ ] 6. 集成昵称设置与修改审核流程
- 在昵称设置/修改 UI 中添加本地敏感词实时校验,违规时输入框下方提示"内容包含违规信息,请修改",提交按钮置灰
- 添加昵称长度前端校验:2~20字符,不满足时即时提示
- 昵称提交时调用服务器 `POST /api/content/check-text`(scene=1),审核未通过提示"昵称包含违规内容,请重新输入"
- 禁言状态下尝试修改昵称时提示"由于违规行为,您暂无法修改个人信息"
- _需求:5.1、5.2、5.3、5.4、5.5、5.6、7.6_
- [ ] 7. 集成聊天室消息实时审核流程
- 在聊天发送流程中添加本地敏感词校验,违规时阻止发送并提示
- 聊天消息提交至服务器后,服务器先调用 `msgSecCheck`(scene=2)审核,通过后再广播;未通过仅向发送者返回"消息发送失败:内容违规"
- 客户端实现"发送中..."状态显示,审核通过后切换为已发送;超时3秒显示"发送超时"并允许重试
- 禁言用户发送消息时提示"您已被禁言,剩余时间:X小时X分钟"
- _需求:4.1、4.2、4.3、4.4、4.5、7.5_
- [ ] 8. 集成个人资料签名与空间描述审核流程
- 在签名/描述编辑 UI 中添加本地敏感词实时校验与长度限制(签名≤50字符,描述≤200字符)
- 签名提交调用 `msgSecCheck`scene=3),描述提交调用 `msgSecCheck`(scene=4),审核未通过提示"内容包含违规信息,请修改"
- 禁言状态下尝试修改签名/描述时提示"由于违规行为,您暂无法修改个人信息"
- _需求:6.1、6.2、6.3、6.4、6.5、7.6_
- [ ] 9. 集成头像上传图片审核与举报 UI
- 实现头像上传流程:客户端选择图片后先校验大小(≤1MB),上传至服务器调用 `imgSecCheck` 审核,违规提示"图片内容违规,请更换"
- 实现举报 UI:聊天消息和个人资料旁添加举报按钮,点击弹出举报原因选择界面(政治有害/色情低俗/赌博诈骗/其他)
- 举报提交调用 `POST /api/content/report`,成功后提示"举报已提交"
- _需求:3.1、3.2、3.3、3.4、3.5、8.1、8.2_
- [ ] 10. 端到端集成测试与日志验证
- 编写服务器端审核服务单元测试:Token管理、msgSecCheck/imgSecCheck 调用与超时、频率限制、违规记录与禁言处罚
- 编写客户端 ContentSecurityManager 单元测试:本地敏感词过滤、词库缓存与更新、兜底词库降级
- 端到端测试:昵称修改→聊天消息→签名修改→头像上传→举报→自动下线→禁言处罚全流程验证
- 验证审核日志脱敏存储(仅保留前3后3字符)和日志保留策略
- _需求:9.5、7.1、1.1、2.1、3.1、8.3_
@@ -0,0 +1,177 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
@@ -0,0 +1,42 @@
---
name: frontend-design
description: Create distinctive, production-grade frontend interfaces with high design quality. Use this skill when the user asks to build web components, pages, artifacts, posters, or applications (examples include websites, landing pages, dashboards, React components, HTML/CSS layouts, or when styling/beautifying any web UI). Generates creative, polished code and UI design that avoids generic AI aesthetics.
license: Complete terms in LICENSE.txt
---
This skill guides creation of distinctive, production-grade frontend interfaces that avoid generic "AI slop" aesthetics. Implement real working code with exceptional attention to aesthetic details and creative choices.
The user provides frontend requirements: a component, page, application, or interface to build. They may include context about the purpose, audience, or technical constraints.
## Design Thinking
Before coding, understand the context and commit to a BOLD aesthetic direction:
- **Purpose**: What problem does this interface solve? Who uses it?
- **Tone**: Pick an extreme: brutally minimal, maximalist chaos, retro-futuristic, organic/natural, luxury/refined, playful/toy-like, editorial/magazine, brutalist/raw, art deco/geometric, soft/pastel, industrial/utilitarian, etc. There are so many flavors to choose from. Use these for inspiration but design one that is true to the aesthetic direction.
- **Constraints**: Technical requirements (framework, performance, accessibility).
- **Differentiation**: What makes this UNFORGETTABLE? What's the one thing someone will remember?
**CRITICAL**: Choose a clear conceptual direction and execute it with precision. Bold maximalism and refined minimalism both work - the key is intentionality, not intensity.
Then implement working code (HTML/CSS/JS, React, Vue, etc.) that is:
- Production-grade and functional
- Visually striking and memorable
- Cohesive with a clear aesthetic point-of-view
- Meticulously refined in every detail
## Frontend Aesthetics Guidelines
Focus on:
- **Typography**: Choose fonts that are beautiful, unique, and interesting. Avoid generic fonts like Arial and Inter; opt instead for distinctive choices that elevate the frontend's aesthetics; unexpected, characterful font choices. Pair a distinctive display font with a refined body font.
- **Color & Theme**: Commit to a cohesive aesthetic. Use CSS variables for consistency. Dominant colors with sharp accents outperform timid, evenly-distributed palettes.
- **Motion**: Use animations for effects and micro-interactions. Prioritize CSS-only solutions for HTML. Use Motion library for React when available. Focus on high-impact moments: one well-orchestrated page load with staggered reveals (animation-delay) creates more delight than scattered micro-interactions. Use scroll-triggering and hover states that surprise.
- **Spatial Composition**: Unexpected layouts. Asymmetry. Overlap. Diagonal flow. Grid-breaking elements. Generous negative space OR controlled density.
- **Backgrounds & Visual Details**: Create atmosphere and depth rather than defaulting to solid colors. Add contextual effects and textures that match the overall aesthetic. Apply creative forms like gradient meshes, noise textures, geometric patterns, layered transparencies, dramatic shadows, decorative borders, custom cursors, and grain overlays.
NEVER use generic AI-generated aesthetics like overused font families (Inter, Roboto, Arial, system fonts), cliched color schemes (particularly purple gradients on white backgrounds), predictable layouts and component patterns, and cookie-cutter design that lacks context-specific character.
Interpret creatively and make unexpected choices that feel genuinely designed for the context. No design should be the same. Vary between light and dark themes, different fonts, different aesthetics. NEVER converge on common choices (Space Grotesk, for example) across generations.
**IMPORTANT**: Match implementation complexity to the aesthetic vision. Maximalist designs need elaborate code with extensive animations and effects. Minimalist or refined designs need restraint, precision, and careful attention to spacing, typography, and subtle details. Elegance comes from executing the vision well.
Remember: Claude is capable of extraordinary creative work. Don't hold back, show what can truly be created when thinking outside the box and committing fully to a distinctive vision.
+2
View File
@@ -0,0 +1,2 @@
node_modules/
.codebuddy/
+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;
+25
View File
@@ -0,0 +1,25 @@
# ============================================================
# ConfigMap: content-security-config
# Centralized configuration for content security service.
# WX_APPID and WX_APPSECRET should be overridden via SealedSecret
# or external secret management in production.
# ============================================================
apiVersion: v1
kind: ConfigMap
metadata:
name: content-security-config
namespace: content-security
labels:
app: content-security-service
data:
NODE_ENV: "production"
HOST: "0.0.0.0"
PORT: "3000"
# Default game_id for requests without explicit game_id
DEFAULT_GAME_ID: "tankwar"
# Audit log retention in days
AUDIT_LOG_RETENTION_DAYS: "180"
# Rate limit: max requests per minute to WeChat API
RATE_LIMIT_PER_MINUTE: "5000"
# API timeout in milliseconds
API_TIMEOUT_MS: "3000"
+75
View File
@@ -0,0 +1,75 @@
# ============================================================
# Deployment: content-security-service
# Content security microservice for UGC moderation.
# Shared by multiple mini-games via game_id tenant isolation.
# ============================================================
apiVersion: apps/v1
kind: Deployment
metadata:
name: content-security-service
namespace: content-security
labels:
app: content-security-service
app.kubernetes.io/part-of: content-security
spec:
replicas: 2
selector:
matchLabels:
app: content-security-service
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: content-security-service
annotations:
prometheus.io/scrape: "true"
prometheus.io/port: "3000"
prometheus.io/path: "/metrics"
spec:
containers:
- name: content-security-service
image: content-security-service:latest
imagePullPolicy: Never
ports:
- name: http
containerPort: 3000
protocol: TCP
envFrom:
- configMapRef:
name: content-security-config
- secretRef:
name: content-security-secrets
livenessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 15
periodSeconds: 15
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /api/health
port: 3000
initialDelaySeconds: 5
periodSeconds: 5
timeoutSeconds: 2
failureThreshold: 3
resources:
requests:
cpu: 100m
memory: 128Mi
limits:
cpu: 500m
memory: 256Mi
volumeMounts:
- name: audit-logs
mountPath: /app/logs/audit
volumes:
- name: audit-logs
emptyDir: {}
terminationGracePeriodSeconds: 15
@@ -0,0 +1,8 @@
# ============================================================
# Namespace label patch for tankwar
# This adds the kubernetes.io/metadata.name label to the tankwar
# namespace so that NetworkPolicy can reference it.
# Apply: kubectl label namespace tankwar kubernetes.io/metadata.name=tankwar --overwrite
# ============================================================
# Note: Kubernetes 1.21+ automatically adds this label to namespaces.
# Verify with: kubectl get namespace tankwar --show-labels
+12
View File
@@ -0,0 +1,12 @@
# ============================================================
# Namespace: content-security
# Independent namespace for content security service,
# shared by multiple mini-games.
# ============================================================
apiVersion: v1
kind: Namespace
metadata:
name: content-security
labels:
app.kubernetes.io/part-of: content-security
app.kubernetes.io/managed-by: kubectl
@@ -0,0 +1,40 @@
# ============================================================
# NetworkPolicy: content-security-policy
# Restrict access to content security service:
# - Only allow ingress from game namespaces (tankwar, etc.)
# - Allow egress to WeChat APIs and DNS
# ============================================================
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: content-security-ingress-policy
namespace: content-security
spec:
podSelector:
matchLabels:
app: content-security-service
policyTypes:
- Ingress
ingress:
# Allow from tankwar namespace
- from:
- namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: tankwar
ports:
- protocol: TCP
port: 3000
# Allow from any namespace with the game-client label
- from:
- podSelector:
matchLabels:
content-security-client: "true"
ports:
- protocol: TCP
port: 3000
# Allow health checks from within same namespace
- from:
- podSelector: {}
ports:
- protocol: TCP
port: 3000
+19
View File
@@ -0,0 +1,19 @@
# ============================================================
# Secret: content-security-secrets
# WeChat Mini Program credentials for content security APIs.
# IMPORTANT: In production, use SealedSecret or external secret
# management (e.g., HashiCorp Vault) instead of plain Secrets.
# ============================================================
apiVersion: v1
kind: Secret
metadata:
name: content-security-secrets
namespace: content-security
labels:
app: content-security-service
type: Opaque
stringData:
# WeChat Mini Program App ID (replace with actual value)
WX_APPID: "wx3527fe2fd49db523"
# WeChat Mini Program App Secret (replace with actual value)
WX_APPSECRET: "a8e92749ccf2f4bc2667833812a7bf4e"
+43
View File
@@ -0,0 +1,43 @@
# ============================================================
# Service: content-security-service
# ClusterIP service for internal access from game namespaces.
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: content-security-service
namespace: content-security
labels:
app: content-security-service
spec:
type: ClusterIP
ports:
- name: http
port: 3000
protocol: TCP
targetPort: 3000
selector:
app: content-security-service
---
# ============================================================
# Service: content-security-nodeport
# NodePort service for external access (development/debug only).
# Should be removed or restricted in production.
# ============================================================
apiVersion: v1
kind: Service
metadata:
name: content-security-nodeport
namespace: content-security
labels:
app: content-security-service
spec:
type: NodePort
ports:
- name: http
port: 3000
protocol: TCP
targetPort: 3000
nodePort: 30082
selector:
app: content-security-service
+11
View File
@@ -27,6 +27,7 @@ const ShareManager = require('./js/managers/ShareManager');
const CurrencyManager = require('./js/managers/CurrencyManager');
const PaymentManager = require('./js/managers/PaymentManager');
const ComplianceManager = require('./js/managers/ComplianceManager');
const ContentSecurityManager = require('./js/managers/ContentSecurityManager');
const BuffManager = require('./js/managers/BuffManager');
const SkinManager = require('./js/managers/SkinManager');
const PlayerProfile = require('./js/managers/PlayerProfile');
@@ -76,6 +77,7 @@ const shareManager = new ShareManager();
const currencyManager = new CurrencyManager();
const paymentManager = new PaymentManager();
const complianceManager = new ComplianceManager();
const contentSecurityManager = new ContentSecurityManager();
const buffManager = new BuffManager();
const skinManager = new SkinManager();
const playerProfile = new PlayerProfile();
@@ -84,6 +86,7 @@ GameGlobal.shareManager = shareManager;
GameGlobal.currencyManager = currencyManager;
GameGlobal.paymentManager = paymentManager;
GameGlobal.complianceManager = complianceManager;
GameGlobal.contentSecurityManager = contentSecurityManager;
GameGlobal.buffManager = buffManager;
GameGlobal.skinManager = skinManager;
GameGlobal.playerProfile = playerProfile;
@@ -271,6 +274,14 @@ const LoadingScene = {
// Initialize audio system (programmatic synthesis, no files needed)
audioManager.init();
// Initialize content security manager (local word list + remote sync)
// Derive HTTP base URL from WebSocket server URL for content security API calls
const wsUrl = networkManager._serverUrl || '';
const httpServerUrl = wsUrl.replace(/^wss?/, 'https');
contentSecurityManager.init({
serverUrl: httpServerUrl,
});
// Define all image assets to preload
// For now we use procedural drawing, so asset list is empty.
// Assets can be added later as the game grows.
+2
View File
@@ -172,6 +172,8 @@ const SCENE = {
TEAM_ROOM: 'team_room',
TEAM_GAME: 'team_game',
TEAM_RESULT: 'team_result',
PROFILE: 'profile',
CHAT_ROOM: 'chat_room',
};
// ============================================================
+41
View File
@@ -30,6 +30,8 @@ module.exports = {
'menu.skin': 'Skins',
'menu.ranking': 'Ranking',
'menu.settings': 'Settings',
'menu.profile': 'Profile',
'menu.chat': 'Chat',
// ============================================================
// Room Scene (PVP)
@@ -207,6 +209,7 @@ module.exports = {
'settings.music': 'Music',
'settings.vibration': 'Vibration',
'settings.nickname': 'Display Name',
'settings.profile': 'Profile',
// ============================================================
// Shop Scene (Simplified)
@@ -226,6 +229,44 @@ module.exports = {
// ============================================================
// Profile Scene
// ============================================================
'profile.title': 'Profile',
'profile.nickname': 'Nickname',
'profile.signature': 'Signature',
'profile.description': 'Space Description',
'profile.changeAvatar': 'Change Avatar',
'profile.tapToEdit': 'Tap to edit',
'profile.save': 'Save',
// ============================================================
// Chat Room Scene
// ============================================================
'chat.title': 'Chat Room',
'chat.inputPlaceholder': 'Type a message...',
'chat.send': 'Send',
'chat.reportTitle': 'Report',
'chat.reportPolitics': 'Harmful politics',
'chat.reportPornography': 'Pornography',
'chat.reportGambling': 'Gambling & fraud',
'chat.reportOther': 'Other',
'chat.reportCancel': 'Cancel',
// ============================================================
// Content Security
// ============================================================
'contentSecurity.violation': 'Content contains prohibited information',
'contentSecurity.nicknameViolation': 'Nickname contains prohibited content',
'contentSecurity.chatViolation': 'Message failed: content violation',
'contentSecurity.muted': 'You cannot modify profile due to violation',
'contentSecurity.mutedChat': 'You are muted, remaining: {time}',
'contentSecurity.imageViolation': 'Image contains prohibited content',
'contentSecurity.checking': 'Checking...',
'contentSecurity.timeout': 'Check timeout, please retry',
'contentSecurity.reportSuccess': 'Report submitted',
'contentSecurity.reportFail': 'Report failed',
// ============================================================
// Ad System
// ============================================================
+41
View File
@@ -30,6 +30,8 @@ module.exports = {
'menu.skin': '皮肤',
'menu.ranking': '排行榜',
'menu.settings': '设置',
'menu.profile': '个人资料',
'menu.chat': '聊天室',
// ============================================================
// Room Scene (PVP)
@@ -207,6 +209,7 @@ module.exports = {
'settings.music': '音乐',
'settings.vibration': '振动',
'settings.nickname': '显示名字',
'settings.profile': '个人资料',
// ============================================================
// Shop Scene (Simplified)
@@ -226,6 +229,44 @@ module.exports = {
// ============================================================
// Profile Scene
// ============================================================
'profile.title': '个人资料',
'profile.nickname': '昵称',
'profile.signature': '个性签名',
'profile.description': '个人空间描述',
'profile.changeAvatar': '更换头像',
'profile.tapToEdit': '点击编辑',
'profile.save': '保存',
// ============================================================
// Chat Room Scene
// ============================================================
'chat.title': '聊天室',
'chat.inputPlaceholder': '输入消息...',
'chat.send': '发送',
'chat.reportTitle': '举报',
'chat.reportPolitics': '政治有害',
'chat.reportPornography': '色情低俗',
'chat.reportGambling': '赌博诈骗',
'chat.reportOther': '其他',
'chat.reportCancel': '取消',
// ============================================================
// Content Security
// ============================================================
'contentSecurity.violation': '内容包含违规信息,请修改',
'contentSecurity.nicknameViolation': '昵称包含违规内容,请重新输入',
'contentSecurity.chatViolation': '消息发送失败:内容违规',
'contentSecurity.muted': '由于违规行为,您暂无法修改个人信息',
'contentSecurity.mutedChat': '您已被禁言,剩余时间:{time}',
'contentSecurity.imageViolation': '图片内容违规,请更换',
'contentSecurity.checking': '审核中...',
'contentSecurity.timeout': '审核超时,请重试',
'contentSecurity.reportSuccess': '举报已提交',
'contentSecurity.reportFail': '举报失败',
// ============================================================
// Ad System
// ============================================================
+504
View File
@@ -0,0 +1,504 @@
/**
* ContentSecurityManager
* Client-side content security filtering for WeChat mini game.
*
* Features:
* - Local sensitive word filtering (50ms target)
* - Remote word list fetching with 24-hour local cache
* - Built-in fallback word list when remote fetch fails
* - Incremental word list updates without blocking ongoing checks
* - Integration with server-side msgSecCheck/imgSecCheck APIs
*/
// ============================================================
// Configuration
// ============================================================
const CACHE_KEY = 'content_security_word_cache';
const CACHE_VERSION_KEY = 'content_security_word_version';
const CACHE_TIMESTAMP_KEY = 'content_security_word_timestamp';
const CACHE_VALIDITY_MS = 24 * 60 * 60 * 1000; // 24 hours
const SERVER_BASE_URL = ''; // Will use relative URL or configured server URL
const DEFAULT_GAME_ID = 'tankwar'; // Default game identifier for multi-tenant isolation
// Scene mapping (must match server-side)
const SCENE = {
NICKNAME: 1,
CHAT: 2,
SIGNATURE: 3,
DESCRIPTION: 4,
};
// Report reasons
const REPORT_REASONS = {
POLITICS: 'politics',
PORNOGRAPHY: 'pornography',
GAMBLING: 'gambling',
OTHER: 'other',
};
// Report reason display labels
const REPORT_REASON_LABELS = {
politics: '政治有害',
pornography: '色情低俗',
gambling: '赌博诈骗',
other: '其他',
};
// ============================================================
// Built-in Fallback Sensitive Word List
// This is used when the remote word list is unavailable.
// ============================================================
const FALLBACK_WORDS = [
// Politics
'颠覆国家', '推翻政权', '分裂国家', '恐怖组织', '极端主义',
'法轮', '法轮功', '台独', '藏独', '疆独',
// Pornography
'色情', '淫秽', '裸体', '卖淫', '嫖娼',
'约炮', '援交', '一夜情', '黄色视频',
// Gambling
'赌博', '赌场', '下注', '博彩', '六合彩',
'时时彩', '赌球', '百家乐', '老虎机',
// Violence
'杀人', '砍人', '捅死', '自制炸弹', '血腥屠杀',
// Abuse
'傻逼', '操你', '妈的', '草泥马', '脑残',
'贱人', '狗日的', '废物', '滚蛋', '王八蛋',
// Fraud
'代开发票', '传销', '诈骗', '洗钱',
// Other
'代孕', '毒品', '吸毒', '走私', '枪支',
];
class ContentSecurityManager {
constructor() {
/** @type {string[]} Active sensitive word list */
this._words = [];
/** @type {string} Current word list version */
this._version = '';
/** @type {boolean} Whether the manager is initialized */
this._initialized = false;
/** @type {boolean} Whether an update is in progress */
this._updating = false;
/** @type {string} Server base URL for API calls */
this._serverUrl = '';
/** @type {string} Game identifier for multi-tenant isolation */
this._gameId = DEFAULT_GAME_ID;
}
/**
* Initialize the manager: load cached words, then fetch latest.
* @param {object} [options]
* @param {string} [options.serverUrl] - Server base URL
* @returns {Promise<void>}
*/
async init(options = {}) {
this._serverUrl = options.serverUrl || '';
this._gameId = options.gameId || DEFAULT_GAME_ID;
// Try to load from cache first
this._loadFromCache();
// If no cached words, use fallback
if (this._words.length === 0) {
this._words = [...FALLBACK_WORDS];
console.log('[ContentSecurity] Using fallback word list (' + this._words.length + ' words)');
}
this._initialized = true;
// Fetch latest words from server (non-blocking)
this.fetchWordList();
console.log('[ContentSecurity] Initialized with ' + this._words.length + ' words');
}
/**
* Check text content against local sensitive words.
* Must complete within 50ms.
* @param {string} content - Text to check
* @returns {{ hasViolation: boolean, matchedWords: string[] }}
*/
checkLocalText(content) {
if (!content || typeof content !== 'string') {
return { hasViolation: false, matchedWords: [] };
}
const startTime = Date.now();
const lowerContent = content.toLowerCase();
const matchedWords = [];
for (let i = 0; i < this._words.length; i++) {
const word = this._words[i];
if (word && lowerContent.includes(word.toLowerCase())) {
matchedWords.push(word);
// Early exit if we find violations (performance optimization)
if (matchedWords.length >= 3) break;
}
}
const duration = Date.now() - startTime;
if (duration > 50) {
console.warn('[ContentSecurity] Local check took ' + duration + 'ms (target: 50ms)');
}
return {
hasViolation: matchedWords.length > 0,
matchedWords,
};
}
/**
* Check text content via server-side API (msgSecCheck).
* @param {string} openid - User's openid
* @param {string} content - Text 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) {
if (!this._serverUrl) {
return {
pass: true,
errcode: 0,
errmsg: 'ok',
suggest: 'pass',
label: 100,
};
}
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/check-text`,
method: 'POST',
data: { openid, content, scene },
});
return res;
} catch (err) {
console.error('[ContentSecurity] checkTextContent error:', err && (err.message || err.errMsg) || 'unknown');
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
suggest: 'risky',
label: 100,
};
}
}
/**
* Check image content via server-side API (imgSecCheck).
* @param {string} filePath - Local temporary file path of the image
* @param {string} openid - User's openid
* @returns {Promise<{pass: boolean, errcode: number, errmsg: string}>}
*/
async checkImageContent(filePath, openid) {
if (!this._serverUrl) {
return { pass: true, errcode: 0, errmsg: 'ok' };
}
try {
const res = await this._uploadImage(filePath, openid);
return res;
} catch (err) {
console.error('[ContentSecurity] checkImageContent error:', err && (err.message || err.errMsg) || 'unknown');
return {
pass: false,
errcode: -1,
errmsg: '审核服务暂时不可用,请稍后再试',
};
}
}
/**
* Fetch sensitive word list from server with cache validation.
* Non-blocking: does not interfere with ongoing checks.
* @returns {Promise<boolean>}
*/
async fetchWordList() {
if (this._updating) return false;
if (!this._serverUrl) return false;
this._updating = true;
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/sensitive-words`,
method: 'GET',
data: { version: this._version },
});
if (res.updated && res.words) {
// Incremental update: replace word list atomically
this._words = res.words;
this._version = res.version;
// Cache locally
this._saveToCache();
console.log('[ContentSecurity] Word list updated: ' + this._words.length + ' words, version: ' + this._version);
}
this._updating = false;
return true;
} catch (err) {
console.error('[ContentSecurity] fetchWordList error:', err && (err.message || err.errMsg) || 'unknown');
this._updating = false;
return false;
}
}
/**
* Check if user is muted.
* @param {string} openid
* @returns {Promise<{isMuted: boolean, remainingMs: number, remainingText: string}>}
*/
async getMuteStatus(openid) {
if (!this._serverUrl) {
return { isMuted: false, remainingMs: 0, remainingText: '' };
}
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/user/mute-status`,
method: 'GET',
data: { openid },
});
return res;
} catch (err) {
console.error('[ContentSecurity] getMuteStatus error:', err && (err.message || err.errMsg) || 'unknown');
return { isMuted: false, remainingMs: 0, remainingText: '' };
}
}
/**
* Submit a content report.
* @param {object} entry
* @param {string} entry.contentId - Unique content identifier
* @param {string} entry.targetUserId - Content author's user ID
* @param {string} entry.contentType - Content type ('chat', 'nickname', 'signature', 'description', 'avatar')
* @param {string} entry.contentSummary - Content summary
* @param {string} entry.reporterId - Reporter's user ID
* @param {string} entry.reason - Report reason
* @returns {Promise<{success: boolean, message: string}>}
*/
async submitReport(entry) {
if (!this._serverUrl) {
return { success: false, message: '举报功能暂不可用' };
}
try {
const res = await this._request({
url: `${this._serverUrl}/api/content/report`,
method: 'POST',
data: entry,
});
return res;
} catch (err) {
console.error('[ContentSecurity] submitReport error:', err && (err.message || err.errMsg) || 'unknown');
return { success: false, message: '举报提交失败,请稍后再试' };
}
}
/**
* Full text check: local check first, then server-side if local passes.
* @param {string} openid
* @param {string} content
* @param {number} scene
* @returns {Promise<{pass: boolean, localViolation: boolean, serverResult: object|null, errorMessage: string}>}
*/
async fullTextCheck(openid, content, scene) {
// Step 1: Local sensitive word check (fast)
const localResult = this.checkLocalText(content);
if (localResult.hasViolation) {
return {
pass: false,
localViolation: true,
serverResult: null,
errorMessage: this._getErrorMessage({ suggest: 'risky' }, scene),
};
}
// Step 2: Check mute status (for nickname/signature/description scenes)
if (scene === SCENE.NICKNAME || scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
const muteStatus = await this.getMuteStatus(openid);
if (muteStatus.isMuted) {
return {
pass: false,
localViolation: false,
serverResult: null,
errorMessage: '由于违规行为,您暂无法修改个人信息',
};
}
}
// Step 3: Server-side msgSecCheck
const serverResult = await this.checkTextContent(openid, content, scene);
if (!serverResult.pass) {
return {
pass: false,
localViolation: false,
serverResult,
errorMessage: this._getErrorMessage(serverResult, scene),
};
}
return {
pass: true,
localViolation: false,
serverResult,
errorMessage: '',
};
}
/**
* Get appropriate error message based on scene and result.
* @param {object} result
* @param {number} scene
* @returns {string}
*/
_getErrorMessage(result, scene) {
if (scene === SCENE.NICKNAME) {
return '昵称包含违规内容,请重新输入';
}
if (scene === SCENE.CHAT) {
return '消息发送失败:内容违规';
}
if (scene === SCENE.SIGNATURE || scene === SCENE.DESCRIPTION) {
return '内容包含违规信息,请修改';
}
return '内容违规,请修改';
}
/**
* Make an HTTP request using wx.request.
* @param {object} options
* @returns {Promise<object>}
*/
_request(options) {
return new Promise((resolve, reject) => {
const header = Object.assign({}, options.header || { 'content-type': 'application/json' });
// Add X-Game-Id header for multi-tenant isolation
if (this._gameId) {
header['X-Game-Id'] = this._gameId;
}
wx.request({
url: options.url,
method: options.method || 'GET',
data: options.data || {},
header,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(res.data);
} else {
reject(new Error(`HTTP ${res.statusCode}`));
}
},
fail: (err) => {
reject(err);
},
});
});
}
/**
* Upload image for security check using wx.uploadFile.
* @param {string} filePath - Local file path
* @param {string} openid - User openid
* @returns {Promise<object>}
*/
_uploadImage(filePath, openid) {
return new Promise((resolve, reject) => {
const header = {};
if (this._gameId) {
header['X-Game-Id'] = this._gameId;
}
wx.uploadFile({
url: `${this._serverUrl}/api/content/check-image`,
filePath,
name: 'image',
formData: { openid },
header,
success: (res) => {
if (res.statusCode >= 200 && res.statusCode < 300) {
try {
const data = JSON.parse(res.data);
resolve(data);
} catch (e) {
reject(new Error('Invalid response'));
}
} else {
reject(new Error(`HTTP ${res.statusCode}`));
}
},
fail: (err) => {
reject(err);
},
});
});
}
/**
* Load word list from local cache.
*/
_loadFromCache() {
try {
const timestamp = wx.getStorageSync(CACHE_TIMESTAMP_KEY);
const version = wx.getStorageSync(CACHE_VERSION_KEY);
const words = wx.getStorageSync(CACHE_KEY);
if (timestamp && version && words && Array.isArray(words)) {
// Check if cache is still valid
if (Date.now() - timestamp < CACHE_VALIDITY_MS) {
this._words = words;
this._version = version;
console.log('[ContentSecurity] Loaded ' + words.length + ' words from cache (version: ' + version + ')');
}
}
} catch (err) {
console.error('[ContentSecurity] Failed to load cache:', err.message);
}
}
/**
* Save word list to local cache.
*/
_saveToCache() {
try {
wx.setStorageSync(CACHE_KEY, this._words);
wx.setStorageSync(CACHE_VERSION_KEY, this._version);
wx.setStorageSync(CACHE_TIMESTAMP_KEY, Date.now());
} catch (err) {
console.error('[ContentSecurity] Failed to save cache:', err.message);
}
}
/**
* Get current word count.
* @returns {number}
*/
getWordCount() {
return this._words.length;
}
/**
* Get current version.
* @returns {string}
*/
getVersion() {
return this._version;
}
/**
* Check if initialized.
* @returns {boolean}
*/
isInitialized() {
return this._initialized;
}
}
// Export scene and report reason constants
ContentSecurityManager.SCENE = SCENE;
ContentSecurityManager.REPORT_REASONS = REPORT_REASONS;
ContentSecurityManager.REPORT_REASON_LABELS = REPORT_REASON_LABELS;
module.exports = ContentSecurityManager;
+548
View File
@@ -0,0 +1,548 @@
/**
* ChatRoomScene.js
* Chat room scene for in-game communication.
* All messages are checked against content security before sending.
* Muted users cannot send messages.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const ContentSecurityManager = require('../managers/ContentSecurityManager');
// ============================================================
// Layout Constants
// ============================================================
const CENTER_X = SCREEN_WIDTH / 2;
const INPUT_HEIGHT = 36;
const SEND_BTN_WIDTH = 60;
const MAX_VISIBLE_MESSAGES = 50;
const MESSAGE_AREA_TOP = 50;
const MESSAGE_AREA_BOTTOM = SCREEN_HEIGHT - 60;
// ============================================================
// Chat Room Scene
// ============================================================
const ChatRoomScene = {
// Chat state
_messages: [],
_inputText: '',
_errorMessage: '',
_isSending: false,
_localViolation: false,
_isMuted: false,
_muteRemainingText: '',
_scrollOffset: 0,
// User info
_openid: '',
_nickname: '',
// Touch rects
_backBtnRect: null,
_inputRect: null,
_sendBtnRect: null,
_reportTargetMsgIdx: -1,
// Report overlay state
_showReportOverlay: false,
_reportContentId: '',
_reportTargetUserId: '',
_reportContentSummary: '',
enter() {
this._messages = [];
this._inputText = '';
this._errorMessage = '';
this._isSending = false;
this._localViolation = false;
this._isMuted = false;
this._muteRemainingText = '';
this._scrollOffset = 0;
this._showReportOverlay = false;
// Load user info
try {
this._openid = wx.getStorageSync('player_openid') || '';
const profile = wx.getStorageSync('player_profile');
if (profile) {
const parsed = JSON.parse(profile);
this._nickname = parsed.nickname || '玩家';
}
} catch (e) {
this._nickname = '玩家';
}
// Calculate layouts
this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 };
this._inputRect = {
x: 10,
y: SCREEN_HEIGHT - INPUT_HEIGHT - 10,
w: SCREEN_WIDTH - SEND_BTN_WIDTH - 30,
h: INPUT_HEIGHT,
};
this._sendBtnRect = {
x: SCREEN_WIDTH - SEND_BTN_WIDTH - 10,
y: SCREEN_HEIGHT - INPUT_HEIGHT - 10,
w: SEND_BTN_WIDTH,
h: INPUT_HEIGHT,
};
// Check mute status
this._checkMuteStatus();
},
exit() {
wx.hideKeyboard();
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Title bar
ctx.fillStyle = 'rgba(0, 0, 0, 0.3)';
ctx.fillRect(0, 0, SCREEN_WIDTH, 45);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('chat.title'), CENTER_X, 22);
// Back button
ctx.fillStyle = '#AAAAAA';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.fillText('← ' + t('common.back'), 15, 22);
// Messages area
this._renderMessages(ctx);
// Input area
this._renderInputArea(ctx);
// Error message
if (this._errorMessage) {
ctx.fillStyle = '#FF4444';
ctx.font = '11px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMessage, CENTER_X, SCREEN_HEIGHT - INPUT_HEIGHT - 25);
}
// Report overlay
if (this._showReportOverlay) {
this._renderReportOverlay(ctx);
}
},
handleTouch(type, e) {
if (type !== 'touchstart') return;
const touch = e.touches[0];
const x = touch.clientX;
const y = touch.clientY;
// Report overlay handling
if (this._showReportOverlay) {
this._handleReportOverlayTouch(x, y);
return;
}
// Back button
if (this._hitTest(x, y, this._backBtnRect)) {
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
return;
}
// Input field - show keyboard
if (this._hitTest(x, y, this._inputRect)) {
if (this._isMuted) {
this._errorMessage = `您已被禁言,剩余时间:${this._muteRemainingText}`;
return;
}
this._showKeyboard();
return;
}
// Send button
if (this._hitTest(x, y, this._sendBtnRect)) {
this._handleSend();
return;
}
// Long press on message for reporting
// (Simplified: double-tap to report for now)
},
// ============================================================
// Message Sending
// ============================================================
_showKeyboard() {
wx.showKeyboard({
defaultValue: this._inputText,
maxLength: 200,
multiple: false,
confirmHold: false,
confirmType: 'send',
});
this._onKeyboardInput = (res) => {
this._inputText = res.value;
// Real-time local sensitive word check
if (this._inputText) {
const csm = GameGlobal.contentSecurityManager;
if (csm && csm.isInitialized()) {
const localCheck = csm.checkLocalText(this._inputText);
if (localCheck.hasViolation) {
this._localViolation = true;
this._errorMessage = '内容包含违规信息,请修改';
} else {
this._localViolation = false;
this._errorMessage = '';
}
}
}
};
this._onKeyboardConfirm = () => {
this._handleSend();
};
wx.onKeyboardInput(this._onKeyboardInput);
wx.onKeyboardConfirm(this._onKeyboardConfirm);
},
_hideKeyboard() {
if (this._onKeyboardInput) {
wx.offKeyboardInput(this._onKeyboardInput);
this._onKeyboardInput = null;
}
if (this._onKeyboardConfirm) {
wx.offKeyboardConfirm(this._onKeyboardConfirm);
this._onKeyboardConfirm = null;
}
wx.hideKeyboard();
},
async _handleSend() {
if (this._isSending || this._localViolation) return;
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
const content = this._inputText.trim();
if (!content) return;
// Check mute status first
if (this._isMuted) {
this._errorMessage = `您已被禁言,剩余时间:${this._muteRemainingText}`;
return;
}
// Local check
const localCheck = csm.checkLocalText(content);
if (localCheck.hasViolation) {
this._errorMessage = '内容包含违规信息,请修改';
return;
}
this._isSending = true;
this._errorMessage = '';
// Server-side check
const result = await csm.fullTextCheck(this._openid, content, ContentSecurityManager.SCENE.CHAT);
this._isSending = false;
if (result.pass) {
// Add message to list
this._messages.push({
id: `msg_${Date.now()}`,
userId: this._openid,
nickname: this._nickname,
content,
timestamp: Date.now(),
isLocal: true,
});
// Keep only last MAX_VISIBLE_MESSAGES
if (this._messages.length > MAX_VISIBLE_MESSAGES) {
this._messages = this._messages.slice(-MAX_VISIBLE_MESSAGES);
}
this._inputText = '';
this._errorMessage = '';
this._hideKeyboard();
// Scroll to bottom
this._scrollOffset = 0;
} else {
this._errorMessage = result.errorMessage;
}
},
// ============================================================
// Mute Status Check
// ============================================================
async _checkMuteStatus() {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized() || !this._openid) return;
try {
const status = await csm.getMuteStatus(this._openid);
this._isMuted = status.isMuted;
this._muteRemainingText = status.remainingText || '';
} catch (e) {
// Assume not muted on error
this._isMuted = false;
}
},
// ============================================================
// Reporting
// ============================================================
_showReportForMessage(msgIdx) {
if (msgIdx < 0 || msgIdx >= this._messages.length) return;
const msg = this._messages[msgIdx];
if (msg.isLocal) return; // Can't report own messages
this._reportContentId = msg.id;
this._reportTargetUserId = msg.userId;
this._reportContentSummary = msg.content.substring(0, 20);
this._showReportOverlay = true;
},
async _submitReport(reason) {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
const result = await csm.submitReport({
contentId: this._reportContentId,
targetUserId: this._reportTargetUserId,
contentType: 'chat',
contentSummary: this._reportContentSummary,
reporterId: this._openid,
reason,
});
this._showReportOverlay = false;
if (result.success) {
wx.showToast({ title: '举报已提交', icon: 'success' });
} else {
wx.showToast({ title: result.message || '举报失败', icon: 'none' });
}
},
// ============================================================
// Render Helpers
// ============================================================
_renderMessages(ctx) {
const areaTop = MESSAGE_AREA_TOP;
const areaBottom = MESSAGE_AREA_BOTTOM;
const padding = 10;
const msgHeight = 40;
const startY = areaBottom - msgHeight;
// Clip to message area
ctx.save();
ctx.beginPath();
ctx.rect(0, areaTop, SCREEN_WIDTH, areaBottom - areaTop);
ctx.clip();
// Render messages from bottom to top
let y = startY - this._scrollOffset;
for (let i = this._messages.length - 1; i >= 0; i--) {
const msg = this._messages[i];
if (y < areaTop - msgHeight || y > areaBottom) {
y -= msgHeight;
continue;
}
const isLocal = msg.isLocal;
const msgX = isLocal ? SCREEN_WIDTH - padding - 200 : padding;
// Message bubble
ctx.fillStyle = isLocal ? 'rgba(74, 144, 226, 0.3)' : 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(msgX, y, 200, msgHeight - 4);
// Border
ctx.strokeStyle = isLocal ? '#4a90d9' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(msgX, y, 200, msgHeight - 4);
// Nickname
ctx.fillStyle = isLocal ? '#FFD700' : '#CCCCCC';
ctx.font = 'bold 10px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(msg.nickname, msgX + 6, y + 3);
// Content
ctx.fillStyle = '#FFFFFF';
ctx.font = '11px Arial';
const contentPreview = msg.content.length > 25 ? msg.content.substring(0, 25) + '...' : msg.content;
ctx.fillText(contentPreview, msgX + 6, y + 18);
// Report button for non-local messages
if (!isLocal) {
ctx.fillStyle = '#FF6347';
ctx.font = '9px Arial';
ctx.textAlign = 'right';
ctx.fillText('⚠举报', msgX + 196, y + 3);
}
y -= msgHeight;
}
ctx.restore();
// Scroll hint if there are more messages
if (this._messages.length > 0 && this._scrollOffset > 0) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.3)';
ctx.font = '10px Arial';
ctx.textAlign = 'center';
ctx.fillText('↑ 向上滚动查看更多', CENTER_X, areaTop + 10);
}
},
_renderInputArea(ctx) {
// Input box background
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(this._inputRect.x, this._inputRect.y, this._inputRect.w, this._inputRect.h);
ctx.strokeStyle = this._localViolation ? '#FF4444' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(this._inputRect.x, this._inputRect.y, this._inputRect.w, this._inputRect.h);
// Input text or placeholder
ctx.fillStyle = this._inputText ? '#FFFFFF' : '#666666';
ctx.font = '13px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const displayText = this._inputText || (this._isMuted ? '您已被禁言' : t('chat.inputPlaceholder'));
const truncated = displayText.length > 25 ? displayText.substring(0, 25) + '...' : displayText;
ctx.fillText(truncated, this._inputRect.x + 8, this._inputRect.y + this._inputRect.h / 2);
// Send button
const canSend = this._inputText.trim() && !this._localViolation && !this._isSending && !this._isMuted;
ctx.fillStyle = canSend ? '#4a90d9' : '#555555';
ctx.fillRect(this._sendBtnRect.x, this._sendBtnRect.y, this._sendBtnRect.w, this._sendBtnRect.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 13px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this._isSending ? '...' : t('chat.send'), this._sendBtnRect.x + this._sendBtnRect.w / 2, this._sendBtnRect.y + this._sendBtnRect.h / 2);
},
_renderReportOverlay(ctx) {
// Semi-transparent overlay
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// Report dialog
const dialogW = Math.min(SCREEN_WIDTH * 0.8, 300);
const dialogH = 220;
const dialogX = CENTER_X - dialogW / 2;
const dialogY = SCREEN_HEIGHT / 2 - dialogH / 2;
ctx.fillStyle = '#2a2a3e';
ctx.fillRect(dialogX, dialogY, dialogW, dialogH);
ctx.strokeStyle = '#4a90d9';
ctx.lineWidth = 2;
ctx.strokeRect(dialogX, dialogY, dialogW, dialogH);
// Title
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 16px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('chat.reportTitle'), CENTER_X, dialogY + 25);
// Report reason buttons
const reasons = [
{ key: 'politics', label: t('chat.reportPolitics') },
{ key: 'pornography', label: t('chat.reportPornography') },
{ key: 'gambling', label: t('chat.reportGambling') },
{ key: 'other', label: t('chat.reportOther') },
];
const btnW = dialogW - 40;
const btnH = 30;
let btnY = dialogY + 55;
this._reportBtnRects = [];
for (const reason of reasons) {
const rect = { x: dialogX + 20, y: btnY, w: btnW, h: btnH };
this._reportBtnRects.push({ rect, key: reason.key });
ctx.fillStyle = 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeStyle = '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = '13px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(reason.label, rect.x + rect.w / 2, rect.y + rect.h / 2);
btnY += btnH + 8;
}
// Cancel button
const cancelRect = { x: CENTER_X - 50, y: dialogY + dialogH - 35, w: 100, h: 25 };
this._reportCancelRect = cancelRect;
ctx.fillStyle = '#666666';
ctx.fillRect(cancelRect.x, cancelRect.y, cancelRect.w, cancelRect.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('chat.reportCancel'), cancelRect.x + cancelRect.w / 2, cancelRect.y + cancelRect.h / 2);
},
_handleReportOverlayTouch(x, y) {
// Check report reason buttons
if (this._reportBtnRects) {
for (const btn of this._reportBtnRects) {
if (this._hitTest(x, y, btn.rect)) {
this._submitReport(btn.key);
return;
}
}
}
// Cancel button
if (this._reportCancelRect && this._hitTest(x, y, this._reportCancelRect)) {
this._showReportOverlay = false;
return;
}
},
// ============================================================
// Utility
// ============================================================
_hitTest(x, y, rect) {
return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
},
};
module.exports = ChatRoomScene;
+597
View File
@@ -0,0 +1,597 @@
/**
* ProfileScene.js
* Player profile editing scene.
* Supports nickname, signature, description editing and avatar upload.
* All user-generated content is checked against content security before saving.
*/
const {
SCREEN_WIDTH,
SCREEN_HEIGHT,
COLORS,
SCENE,
} = require('../base/GameGlobal');
const { t } = require('../i18n/I18n');
const ContentSecurityManager = require('../managers/ContentSecurityManager');
// ============================================================
// Layout Constants
// ============================================================
const CENTER_X = SCREEN_WIDTH / 2;
const INPUT_WIDTH = Math.min(SCREEN_WIDTH * 0.7, 280);
const INPUT_HEIGHT = 36;
const BTN_WIDTH = Math.min(SCREEN_WIDTH * 0.5, 200);
const BTN_HEIGHT = 36;
// Field constraints
const NICKNAME_MIN = 2;
const NICKNAME_MAX = 20;
const SIGNATURE_MAX = 50;
const DESCRIPTION_MAX = 200;
// Profile field IDs
const FIELD = {
NICKNAME: 'nickname',
SIGNATURE: 'signature',
DESCRIPTION: 'description',
AVATAR: 'avatar',
};
// ============================================================
// Profile Scene
// ============================================================
const ProfileScene = {
// Current editing state
_editingField: null, // Which field is being edited
_inputText: '', // Current input text
_errorMessage: '', // Error message to display
_isSubmitting: false, // Whether a submission is in progress
_localViolation: false, // Whether local check found violation
// User profile data
_profile: {
nickname: '',
signature: '',
description: '',
avatarUrl: '',
},
// Open ID for API calls
_openid: '',
// Button rects for touch handling
_backBtnRect: null,
_nicknameRect: null,
_signatureRect: null,
_descriptionRect: null,
_avatarRect: null,
_saveBtnRect: null,
enter() {
this._editingField = null;
this._inputText = '';
this._errorMessage = '';
this._isSubmitting = false;
this._localViolation = false;
// Load profile from storage
this._loadProfile();
// Get openid
try {
this._openid = wx.getStorageSync('player_openid') || '';
} catch (e) {
this._openid = '';
}
// Calculate button positions
this._backBtnRect = { x: 10, y: 10, w: 60, h: 30 };
this._avatarRect = {
x: CENTER_X - 40,
y: 70,
w: 80,
h: 80,
};
this._nicknameRect = {
x: CENTER_X - INPUT_WIDTH / 2,
y: 175,
w: INPUT_WIDTH,
h: INPUT_HEIGHT,
};
this._signatureRect = {
x: CENTER_X - INPUT_WIDTH / 2,
y: 240,
w: INPUT_WIDTH,
h: INPUT_HEIGHT,
};
this._descriptionRect = {
x: CENTER_X - INPUT_WIDTH / 2,
y: 305,
w: INPUT_WIDTH,
h: 80,
};
this._saveBtnRect = {
x: CENTER_X - BTN_WIDTH / 2,
y: SCREEN_HEIGHT - 100,
w: BTN_WIDTH,
h: BTN_HEIGHT,
};
},
exit() {
// Save profile to storage
this._saveProfile();
},
update(dt) {},
render(ctx) {
// Background
ctx.fillStyle = COLORS.MENU_BG;
ctx.fillRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
const cx = CENTER_X;
// Back button
this._renderBackButton(ctx);
// Title
ctx.fillStyle = COLORS.MENU_TITLE;
ctx.font = 'bold 24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(t('profile.title'), cx, 50);
// Avatar
this._renderAvatar(ctx);
// Nickname field
this._renderField(ctx, this._nicknameRect, t('profile.nickname'), this._profile.nickname, FIELD.NICKNAME);
// Signature field
this._renderField(ctx, this._signatureRect, t('profile.signature'), this._profile.signature, FIELD.SIGNATURE);
// Description field
this._renderDescriptionField(ctx);
// Error message
if (this._errorMessage) {
ctx.fillStyle = '#FF4444';
ctx.font = '12px Arial';
ctx.textAlign = 'center';
ctx.fillText(this._errorMessage, cx, SCREEN_HEIGHT - 140);
}
// Save button
this._renderSaveButton(ctx);
// Editing overlay
if (this._editingField) {
this._renderEditOverlay(ctx);
}
},
handleTouch(type, e) {
if (type !== 'touchstart') return;
const touch = e.touches[0];
const x = touch.clientX;
const y = touch.clientY;
// If editing, handle save/cancel in overlay
if (this._editingField) {
this._handleEditOverlayTouch(x, y);
return;
}
// Back button
if (this._hitTest(x, y, this._backBtnRect)) {
const sm = GameGlobal.sceneManager;
sm.switchTo(SCENE.MENU);
return;
}
// Avatar click
if (this._hitTest(x, y, this._avatarRect)) {
this._handleChangeAvatar();
return;
}
// Nickname click
if (this._hitTest(x, y, this._nicknameRect)) {
this._startEditing(FIELD.NICKNAME, this._profile.nickname);
return;
}
// Signature click
if (this._hitTest(x, y, this._signatureRect)) {
this._startEditing(FIELD.SIGNATURE, this._profile.signature);
return;
}
// Description click
if (this._hitTest(x, y, this._descriptionRect)) {
this._startEditing(FIELD.DESCRIPTION, this._profile.description);
return;
}
},
// ============================================================
// Editing
// ============================================================
_startEditing(field, currentValue) {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
this._editingField = field;
this._inputText = currentValue || '';
this._errorMessage = '';
this._localViolation = false;
// Determine input constraints
let maxLength = 200;
if (field === FIELD.NICKNAME) maxLength = NICKNAME_MAX;
if (field === FIELD.SIGNATURE) maxLength = SIGNATURE_MAX;
if (field === FIELD.DESCRIPTION) maxLength = DESCRIPTION_MAX;
// Show keyboard
wx.showKeyboard({
defaultValue: this._inputText,
maxLength,
multiple: field === FIELD.DESCRIPTION,
confirmHold: false,
confirmType: 'done',
});
// Listen for keyboard input
this._onKeyboardInput = (res) => {
this._inputText = res.value;
// Real-time local sensitive word check
if (this._inputText) {
const localCheck = csm.checkLocalText(this._inputText);
if (localCheck.hasViolation) {
this._localViolation = true;
this._errorMessage = '内容包含违规信息,请修改';
} else {
this._localViolation = false;
this._errorMessage = '';
}
// Length validation
if (field === FIELD.NICKNAME) {
if (this._inputText.length < NICKNAME_MIN) {
this._errorMessage = `昵称至少需要${NICKNAME_MIN}个字符`;
this._localViolation = true;
} else if (this._inputText.length > NICKNAME_MAX) {
this._errorMessage = `昵称不能超过${NICKNAME_MAX}个字符`;
this._localViolation = true;
}
} else if (field === FIELD.SIGNATURE && this._inputText.length > SIGNATURE_MAX) {
this._errorMessage = `签名不能超过${SIGNATURE_MAX}个字符`;
this._localViolation = true;
} else if (field === FIELD.DESCRIPTION && this._inputText.length > DESCRIPTION_MAX) {
this._errorMessage = `描述不能超过${DESCRIPTION_MAX}个字符`;
this._localViolation = true;
}
}
};
this._onKeyboardConfirm = () => {
this._handleSubmit();
};
wx.onKeyboardInput(this._onKeyboardInput);
wx.onKeyboardConfirm(this._onKeyboardConfirm);
},
_stopEditing() {
if (this._onKeyboardInput) {
wx.offKeyboardInput(this._onKeyboardInput);
this._onKeyboardInput = null;
}
if (this._onKeyboardConfirm) {
wx.offKeyboardConfirm(this._onKeyboardConfirm);
this._onKeyboardConfirm = null;
}
wx.hideKeyboard();
this._editingField = null;
},
async _handleSubmit() {
if (this._isSubmitting || this._localViolation) return;
const csm = GameGlobal.contentSecurityManager;
if (!csm || !this._editingField) return;
const content = this._inputText.trim();
if (!content) {
this._errorMessage = '内容不能为空';
return;
}
this._isSubmitting = true;
this._errorMessage = '审核中...';
// Determine scene
const sceneMap = {
[FIELD.NICKNAME]: ContentSecurityManager.SCENE.NICKNAME,
[FIELD.SIGNATURE]: ContentSecurityManager.SCENE.SIGNATURE,
[FIELD.DESCRIPTION]: ContentSecurityManager.SCENE.DESCRIPTION,
};
const scene = sceneMap[this._editingField];
// Full text check (local + server)
const result = await csm.fullTextCheck(this._openid, content, scene);
this._isSubmitting = false;
if (result.pass) {
// Save the content
this._profile[this._editingField] = content;
this._saveProfile();
this._errorMessage = '';
this._stopEditing();
} else {
this._errorMessage = result.errorMessage;
}
},
// ============================================================
// Avatar Upload
// ============================================================
_handleChangeAvatar() {
const csm = GameGlobal.contentSecurityManager;
if (!csm || !csm.isInitialized()) return;
wx.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: async (res) => {
const filePath = res.tempFilePaths[0];
// Check file size
const fileInfo = wx.getFileInfo({ filePath });
if (fileInfo && fileInfo.size > 1024 * 1024) {
this._errorMessage = '图片大小不能超过1MB';
return;
}
this._isSubmitting = true;
this._errorMessage = '审核中...';
// Check image content
const result = await csm.checkImageContent(filePath, this._openid);
this._isSubmitting = false;
if (result.pass) {
this._profile.avatarUrl = filePath;
this._saveProfile();
this._errorMessage = '';
} else {
this._errorMessage = result.errmsg || '图片内容违规,请更换';
}
},
fail: () => {
// User cancelled or error
},
});
},
// ============================================================
// Render Helpers
// ============================================================
_renderBackButton(ctx) {
ctx.fillStyle = '#AAAAAA';
ctx.font = '14px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText('← ' + t('common.back'), 15, 25);
},
_renderAvatar(ctx) {
const rect = this._avatarRect;
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
const r = rect.w / 2;
// Circle background
ctx.fillStyle = '#4a90d9';
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fill();
// Camera icon
ctx.fillStyle = '#FFFFFF';
ctx.font = '24px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('📷', cx, cy);
// Hint text
ctx.fillStyle = '#888888';
ctx.font = '10px Arial';
ctx.fillText(t('profile.changeAvatar'), cx, rect.y + rect.h + 14);
},
_renderField(ctx, rect, label, value, field) {
const isEditing = this._editingField === field;
// Label
ctx.fillStyle = '#CCCCCC';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillText(label, rect.x, rect.y - 4);
// Input box
ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
// Border
ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
// Value
ctx.fillStyle = value ? '#FFFFFF' : '#666666';
ctx.font = '13px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
const displayText = value || t('profile.tapToEdit');
const truncated = displayText.length > 20 ? displayText.substring(0, 20) + '...' : displayText;
ctx.fillText(truncated, rect.x + 8, rect.y + rect.h / 2);
},
_renderDescriptionField(ctx) {
const rect = this._descriptionRect;
const isEditing = this._editingField === FIELD.DESCRIPTION;
// Label
ctx.fillStyle = '#CCCCCC';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'bottom';
ctx.fillText(t('profile.description'), rect.x, rect.y - 4);
// Input box (taller for description)
ctx.fillStyle = isEditing ? 'rgba(74, 144, 226, 0.2)' : 'rgba(255, 255, 255, 0.1)';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.strokeStyle = isEditing ? '#4a90d9' : '#555555';
ctx.lineWidth = 1;
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h);
// Value (multi-line)
ctx.fillStyle = this._profile.description ? '#FFFFFF' : '#666666';
ctx.font = '12px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
const displayText = this._profile.description || t('profile.tapToEdit');
const lines = this._wrapText(ctx, displayText, rect.w - 16, 4);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], rect.x + 8, rect.y + 6 + i * 16);
}
},
_renderSaveButton(ctx) {
const rect = this._saveBtnRect;
const isActive = this._editingField && !this._localViolation && !this._isSubmitting;
ctx.fillStyle = isActive ? '#4a90d9' : '#555555';
ctx.fillRect(rect.x, rect.y, rect.w, rect.h);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(this._isSubmitting ? '审核中...' : t('profile.save'), rect.x + rect.w / 2, rect.y + rect.h / 2);
},
_renderEditOverlay(ctx) {
// Semi-transparent overlay at bottom
const overlayY = SCREEN_HEIGHT * 0.6;
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(0, overlayY, SCREEN_WIDTH, SCREEN_HEIGHT - overlayY);
// Current input text preview
ctx.fillStyle = '#FFFFFF';
ctx.font = '14px Arial';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const previewText = this._inputText || '';
const lines = this._wrapText(ctx, previewText, INPUT_WIDTH, 3);
for (let i = 0; i < lines.length; i++) {
ctx.fillText(lines[i], CENTER_X, overlayY + 30 + i * 20);
}
// Character count
let maxLen = 200;
if (this._editingField === FIELD.NICKNAME) maxLen = NICKNAME_MAX;
if (this._editingField === FIELD.SIGNATURE) maxLen = SIGNATURE_MAX;
ctx.fillStyle = '#888888';
ctx.font = '11px Arial';
ctx.fillText(`${this._inputText.length}/${maxLen}`, CENTER_X, overlayY + 100);
// Violation warning
if (this._localViolation) {
ctx.fillStyle = '#FF4444';
ctx.font = 'bold 12px Arial';
ctx.fillText('⚠ ' + this._errorMessage, CENTER_X, overlayY + 120);
}
},
_handleEditOverlayTouch(x, y) {
// Save button
if (this._hitTest(x, y, this._saveBtnRect)) {
if (!this._localViolation && !this._isSubmitting) {
this._handleSubmit();
}
return;
}
// Tap outside to cancel
this._stopEditing();
},
// ============================================================
// Utility
// ============================================================
_hitTest(x, y, rect) {
return rect && x >= rect.x && x <= rect.x + rect.w && y >= rect.y && y <= rect.y + rect.h;
},
_wrapText(ctx, text, maxWidth, maxLines) {
const words = text.split('');
const lines = [];
let currentLine = '';
for (const char of words) {
const testLine = currentLine + char;
const metrics = ctx.measureText(testLine);
if (metrics.width > maxWidth && currentLine.length > 0) {
lines.push(currentLine);
currentLine = char;
if (lines.length >= maxLines) {
lines[lines.length - 1] += '...';
break;
}
} else {
currentLine = testLine;
}
}
if (currentLine && lines.length < maxLines) {
lines.push(currentLine);
}
return lines;
},
_loadProfile() {
try {
const saved = wx.getStorageSync('player_profile');
if (saved) {
this._profile = { ...this._profile, ...JSON.parse(saved) };
}
} catch (e) {
console.warn('[ProfileScene] Failed to load profile:', e);
}
},
_saveProfile() {
try {
wx.setStorageSync('player_profile', JSON.stringify(this._profile));
} catch (e) {
console.warn('[ProfileScene] Failed to save profile:', e);
}
},
};
module.exports = ProfileScene;
+39
View File
@@ -90,6 +90,10 @@ const SettingsScene = {
}
}
// Profile entry button (below the last toggle row)
const profileY = firstCenterY + rows.length * step;
this._renderProfileButton(ctx, cx, profileY);
// Back button
this._renderBackButton(ctx, cx, backCenterY);
},
@@ -180,6 +184,34 @@ const SettingsScene = {
ctx.fill();
},
_renderProfileButton(ctx, cx, y) {
const w = SCREEN_WIDTH * 0.7;
const h = 50;
const x = cx - w / 2;
this._buttons['profile'] = { x, y: y - h / 2, w, h };
// Background
ctx.fillStyle = '#1e1e3a';
ctx.fillRect(x, y - h / 2, w, h);
ctx.strokeStyle = '#333366';
ctx.lineWidth = 1;
ctx.strokeRect(x, y - h / 2, w, h);
// Icon and label
ctx.fillStyle = COLORS.HUD_TEXT;
ctx.font = '16px Arial';
ctx.textAlign = 'left';
ctx.textBaseline = 'middle';
ctx.fillText(`👤 ${t('settings.profile')}`, x + 15, y);
// Arrow indicator
ctx.fillStyle = '#888888';
ctx.font = '14px Arial';
ctx.textAlign = 'right';
ctx.fillText('', x + w - 15, y);
},
_renderBackButton(ctx, cx, y) {
const w = SCREEN_WIDTH * 0.4;
const h = 42;
@@ -215,6 +247,13 @@ const SettingsScene = {
// IMPORTANT: wx.getUserProfile must be called synchronously from a
// user tap handler; invoking it here is fine (touchstart is a tap).
this._requestNicknameAuth();
} else if (key === 'profile') {
const sm = GameGlobal.sceneManager;
if (!sm._scenes.has(SCENE.PROFILE)) {
const ProfileScene = require('./ProfileScene');
sm.register(SCENE.PROFILE, ProfileScene);
}
sm.switchTo(SCENE.PROFILE);
} else if (this._settings.hasOwnProperty(key)) {
this._settings[key] = !this._settings[key];
// Notify audio system
+89 -24
View File
@@ -10,8 +10,17 @@
*/
const { WebSocketServer } = require('ws');
const express = require('express');
const http = require('http');
// ============================================================
// Content Security Proxy Configuration// The content security service is now deployed as an independent
// microservice in the "content-security" K8s namespace.
// This proxy forwards /api/content/* requests to that service.
// ============================================================
const CONTENT_SECURITY_SERVICE_URL = process.env.CONTENT_SECURITY_SERVICE_URL
|| 'http://content-security-service.content-security.svc.cluster.local:3000';
const GAME_ID = process.env.GAME_ID || 'tankwar';
// ============================================================
// Configuration
// ============================================================
@@ -22,26 +31,80 @@ const HEARTBEAT_INTERVAL = 10000; // ms
const ROOM_TIMEOUT = 300000; // 5 minutes room idle timeout
// ============================================================
// HTTP Health Check Server
// Express HTTP Server + Content Security API
// ============================================================
const healthServer = http.createServer((req, res) => {
if (req.url === '/health' || req.url === '/tankwar/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
}));
} else {
res.writeHead(404, { 'Content-Type': 'text/plain' });
res.end('Not Found');
}
const app = express();
const server = http.createServer(app);
// Parse JSON bodies
app.use(express.json({ limit: '2mb' }));
// Health check endpoints (for K8s livenessProbe/readinessProbe)
app.get('/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
});
});
app.get('/tankwar/health', (req, res) => {
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
activeConnections: players.size,
activeRooms: rooms.size,
activeTeamRooms: teamRooms.size
});
});
app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: Date.now() });
});
healthServer.listen(PORT, HOST, () => {
console.log(`[Health Server] Running on ${HOST}:${PORT}`);
// ============================================================
// Content Security Proxy
// Forward /api/content/* requests to the independent
// content-security-service in the content-security namespace.
// This allows the tankwar-server to delegate all content
// moderation to the shared microservice.
// ============================================================
app.use('/api/content', (req, res, next) => {
// Parse target host and port
const urlObj = new URL(CONTENT_SECURITY_SERVICE_URL);
const targetPath = `/api/content${req.path}`;
const qs = req.url.includes('?') ? req.url.substring(req.url.indexOf('?')) : '';
const finalPath = qs ? `${targetPath}${qs}` : targetPath;
const proxyReq = http.request({
hostname: urlObj.hostname,
port: urlObj.port || 80,
path: finalPath,
method: req.method,
headers: {
...req.headers,
host: `${urlObj.hostname}:${urlObj.port || 80}`,
'X-Game-Id': GAME_ID,
'X-Forwarded-For': req.ip || req.socket.remoteAddress,
},
}, (proxyRes) => {
res.writeHead(proxyRes.statusCode, proxyRes.headers);
proxyRes.pipe(res, { end: true });
});
proxyReq.on('error', (err) => {
console.error('[TankWar Proxy] Failed to forward to content-security-service:', err.message);
if (!res.headersSent) {
res.status(502).json({
errcode: -1,
errmsg: '内容安全服务暂时不可用,请稍后再试',
});
}
});
// Pipe request body to proxy
req.pipe(proxyReq, { end: true });
});
// ============================================================
@@ -1594,15 +1657,14 @@ setInterval(() => {
}, 300000); // Every 5 minutes
// ============================================================
// WebSocket Server (noServer mode, shares HTTP server with health check)
// ============================================================
// WebSocket Server (noServer mode, shares HTTP server with express)
// ============================================================
// Use noServer mode so the WS upgrade only fires on the configured path.
// This lets /health stay as plain HTTP on the same port.
const wss = new WebSocketServer({ noServer: true });
healthServer.on('upgrade', (req, socket, head) => {
server.on('upgrade', (req, socket, head) => {
// Only upgrade on the configured WebSocket path; reject any other path.
// We compare by pathname so query strings are tolerated.
const pathname = (req.url || '').split('?')[0];
if (pathname !== WS_PATH) {
console.warn(`[Server] Rejected WebSocket upgrade on unexpected path: ${req.url}`);
@@ -1676,6 +1738,9 @@ setInterval(() => {
// ============================================================
// Startup
// ============================================================
console.log(`[Tank War Server] Running on ${HOST}:${PORT}`);
console.log(`[Tank War Server] WebSocket path: ${WS_PATH}`);
console.log(`[Tank War Server] Health check paths: /health, /tankwar/health`);
server.listen(PORT, HOST, () => {
console.log(`[Tank War Server] Running on ${HOST}:${PORT}`);
console.log(`[Tank War Server] WebSocket path: ${WS_PATH}`);
console.log(`[Tank War Server] Health check paths: /health, /tankwar/health`);
console.log(`[Tank War Server] HTTP API URL: http://${HOST}:${PORT}`);
});
+126
View File
@@ -0,0 +1,126 @@
{"timestamp":"2026-05-11T15:29:48.530Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:29:48.533Z","type":"violation","userId":"test_user_1778513388533","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.534Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.534Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"mute_test_1778513388534","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"mute_test_1778513388534","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:48.535Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:29:48.536Z","type":"violation","userId":"escalate_test_1778513388535","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:29:48.538Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:29:57.828Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:29:57.831Z","type":"violation","userId":"test_user_1778513397831","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:57.832Z","type":"violation","userId":"mute_test_1778513397832","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"mute_test_1778513397832","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:29:57.833Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:29:57.834Z","type":"violation","userId":"escalate_test_1778513397833","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:29:57.836Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:33:57.494Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"test_user_1778513637497","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:33:57.497Z","type":"violation","userId":"mute_test_1778513637497","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"mute_test_1778513637497","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:33:57.498Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:33:57.499Z","type":"violation","userId":"escalate_test_1778513637498","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:33:57.501Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:34:09.377Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:34:09.380Z","type":"violation","userId":"test_user_1778513649380","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:34:09.381Z","type":"violation","userId":"mute_test_1778513649381","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"mute_test_1778513649381","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:34:09.382Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:34:09.383Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:34:09.383Z","type":"violation","userId":"escalate_test_1778513649382","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:34:09.385Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:38:42.680Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"test_user_1778513922683","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.683Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:38:42.684Z","type":"violation","userId":"mute_test_1778513922683","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:38:42.684Z","type":"violation","userId":"mute_test_1778513922683","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:38:42.685Z","type":"violation","userId":"escalate_test_1778513922685","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:38:42.687Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:42:44.943Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:42:44.946Z","type":"violation","userId":"test_user_1778514164946","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:42:44.947Z","type":"violation","userId":"mute_test_1778514164947","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"mute_test_1778514164947","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:42:44.948Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:42:44.949Z","type":"violation","userId":"escalate_test_1778514164948","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:42:44.950Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:45:46.119Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:45:46.122Z","type":"violation","userId":"test_user_1778514346122","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.122Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:45:46.123Z","type":"violation","userId":"mute_test_1778514346122","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:45:46.124Z","type":"violation","userId":"escalate_test_1778514346123","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:45:46.126Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:46:37.383Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"test_user_1778514397392","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.392Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:46:37.393Z","type":"violation","userId":"mute_test_1778514397392","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:46:37.394Z","type":"violation","userId":"mute_test_1778514397392","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:46:37.395Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:46:37.397Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:46:37.398Z","type":"violation","userId":"escalate_test_1778514397395","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:46:37.400Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
{"timestamp":"2026-05-11T15:54:21.418Z","userId":"test_openid","contentType":"text","contentSummary":"hello","scene":1,"result":"rejected","reason":"service_unavailable","duration":0}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"test_user_1778514861422","violationType":"text_violation","contentSummary":"测试违规内容","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:54:21.422Z","type":"violation","userId":"mute_test_1778514861422","violationType":"pornography","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:54:21.423Z","type":"violation","userId":"mute_test_1778514861422","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 0","action":"violation_count_1"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 1","action":"violation_count_2"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 2","action":"violation_count_3"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"penalty_applied","contentSummary":"","action":"mute_24小时禁言"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 3","action":"violation_count_4"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"gambling","contentSummary":"vio***n 4","action":"violation_count_5"}
{"timestamp":"2026-05-11T15:54:21.424Z","type":"violation","userId":"escalate_test_1778514861423","violationType":"penalty_applied","contentSummary":"","action":"mute_7天禁言"}
{"timestamp":"2026-05-11T15:54:21.426Z","type":"report","reporterId":"***","targetUserId":"target_user_123","contentSummary":"tes***ent","reason":"politics"}
+942
View File
@@ -4,6 +4,948 @@
"lockfileVersion": 3,
"requires": true,
"packages": {
"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==",
"license": "MIT",
"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"
],
"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==",
"license": "MIT",
"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==",
"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==",
"license": "MIT",
"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==",
"license": "MIT",
"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==",
"license": "MIT",
"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=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"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==",
"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=="
},
"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==",
"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==",
"license": "MIT",
"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==",
"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==",
"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==",
"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==",
"license": "ISC"
},
"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==",
"license": "MIT",
"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=="
},
"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==",
"license": "MIT",
"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==",
"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==",
"license": "MIT",
"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==",
"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==",
"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==",
"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==",
"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==",
"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"
}
]
},
"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=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"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==",
"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==",
"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==",
"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=="
},
"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==",
"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"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+944
View File
@@ -8,9 +8,953 @@
"name": "tankwar-server",
"version": "1.0.0",
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1",
"ws": "^8.16.0"
}
},
"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==",
"license": "MIT",
"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"
],
"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==",
"license": "MIT",
"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==",
"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==",
"license": "MIT",
"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==",
"license": "MIT",
"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==",
"license": "MIT",
"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=="
},
"node_modules/encodeurl": {
"version": "2.0.0",
"resolved": "https://mirrors.tencent.com/npm/encodeurl/-/encodeurl-2.0.0.tgz",
"integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
"license": "MIT",
"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==",
"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=="
},
"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==",
"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==",
"license": "MIT",
"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==",
"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==",
"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==",
"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==",
"license": "ISC"
},
"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==",
"license": "MIT",
"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=="
},
"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==",
"license": "MIT",
"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==",
"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==",
"license": "MIT",
"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==",
"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==",
"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==",
"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==",
"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==",
"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"
}
]
},
"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=="
},
"node_modules/send": {
"version": "1.2.1",
"resolved": "https://mirrors.tencent.com/npm/send/-/send-1.2.1.tgz",
"integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==",
"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==",
"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==",
"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==",
"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=="
},
"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==",
"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"
},
"node_modules/ws": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.0.tgz",
+4 -1
View File
@@ -5,9 +5,12 @@
"main": "index.js",
"scripts": {
"start": "node index.js",
"dev": "node index.js"
"dev": "node index.js",
"test": "node --test test/"
},
"dependencies": {
"express": "^5.2.1",
"multer": "^2.1.1",
"ws": "^8.16.0"
}
}
+199
View File
@@ -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;
+279
View File
@@ -0,0 +1,279 @@
/**
* 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,
});
// ============================================================
// POST /api/content/check-text - Text content security check
// ============================================================
router.post('/check-text', express.json(), async (req, res) => {
const { openid, content, scene } = req.body;
// 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,
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,
});
}
// 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,
violationType: `wechat_label_${result.label}`,
contentSummary: content.substring(0, 20),
scene,
});
}
return res.json(result);
} 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) => {
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,
violationType: 'image_violation',
contentSummary: `image_${req.file.size}bytes`,
scene: 5,
});
}
return res.json(result);
} 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;
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const status = violationService.getMuteStatus(openid);
return res.json(status);
});
// ============================================================
// GET /api/user/violation-summary - Get violation summary for a user
// ============================================================
router.get('/user/violation-summary', (req, res) => {
const openid = req.query.openid;
if (!openid || typeof openid !== 'string') {
return res.status(400).json({
errcode: 40006,
errmsg: 'openid is required',
});
}
const summary = violationService.getViolationSummary(openid);
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;
// 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,
});
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,
});
}
return res.json({
success: true,
message: '举报已提交',
reportCount: result.reportCount,
autoTakenDown: result.autoTakenDown,
});
});
// ============================================================
// 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 };
+475
View File
@@ -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;
+301
View File
@@ -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;
+133
View File
@@ -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,
};
+265
View File
@@ -0,0 +1,265 @@
/**
* Violation Service
* Manages user violation records and mute penalties.
*
* Penalty tiers:
* - 3 violations → 24-hour mute
* - 5 violations → 7-day mute
* - 10 violations → permanent mute
*
* When a mute period expires, the count for the current penalty tier is reset.
*/
const AuditLogger = require('./auditLogger');
// ============================================================
// Penalty Tiers
// ============================================================
const PENALTY_TIERS = [
{ threshold: 3, durationMs: 24 * 60 * 60 * 1000, label: '24小时禁言' },
{ threshold: 5, durationMs: 7 * 24 * 60 * 60 * 1000, label: '7天禁言' },
{ threshold: 10, durationMs: Infinity, label: '永久禁言' },
];
// Scene labels for logging
const SCENE_LABELS = {
1: 'nickname',
2: 'chat',
3: 'signature',
4: 'description',
5: 'image',
};
class ViolationService {
/**
* @param {object} options
* @param {import('./auditLogger')} [options.logger] - Audit logger instance
*/
constructor(options = {}) {
this.logger = options.logger || new AuditLogger();
/**
* In-memory violation records.
* Key: userId (openid), Value: { count, violations[], currentTierStart }
* In production, this should be backed by a database.
* @type {Map<string, { count: number, violations: Array, currentTierStart: number }>}
*/
this._records = new Map();
/**
* In-memory mute records.
* Key: userId (openid), Value: { expiresAt: number|null, tier: number }
* @type {Map<string, { expiresAt: number|null, tier: number }>}
*/
this._mutes = new Map();
// Start periodic cleanup of expired mutes
this._cleanupInterval = setInterval(() => this._cleanupExpiredMutes(), 60000);
}
/**
* Record a violation for a user.
* @param {object} entry
* @param {string} entry.userId - User's openid
* @param {string} entry.violationType - Type of violation (e.g., 'text_violation', 'image_violation')
* @param {string} entry.contentSummary - Desensitized content summary
* @param {number} entry.scene - Scene value
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
recordViolation(entry) {
const { userId, violationType, contentSummary, scene } = entry;
// Get or create record
let record = this._records.get(userId);
if (!record) {
record = { count: 0, violations: [], currentTierStart: 0 };
this._records.set(userId, record);
}
// Increment violation count
record.count++;
record.violations.push({
type: violationType,
contentSummary,
scene,
timestamp: Date.now(),
});
// Log the violation
this.logger.logViolation({
userId,
violationType,
contentSummary,
action: `violation_count_${record.count}`,
});
// Check if a penalty should be applied
const penalty = this._checkPenalty(userId, record.count);
return penalty;
}
/**
* Check if a user is currently muted.
* @param {string} userId - User's openid
* @returns {{ isMuted: boolean, remainingMs: number, remainingText: string, tier: number }}
*/
getMuteStatus(userId) {
const mute = this._mutes.get(userId);
if (!mute) {
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
// Permanent mute
if (mute.expiresAt === null) {
return { isMuted: true, remainingMs: Infinity, remainingText: '永久', tier: mute.tier };
}
const remaining = mute.expiresAt - Date.now();
if (remaining <= 0) {
// Mute expired, clean up
this._removeMute(userId);
return { isMuted: false, remainingMs: 0, remainingText: '', tier: 0 };
}
return {
isMuted: true,
remainingMs: remaining,
remainingText: this._formatDuration(remaining),
tier: mute.tier,
};
}
/**
* Remove a mute (used when mute expires or is manually lifted).
* Resets the violation count for the current penalty tier.
* @param {string} userId
*/
_removeMute(userId) {
const record = this._records.get(userId);
if (record) {
// Find which tier they were at and reset count to just below that tier
const mute = this._mutes.get(userId);
if (mute && mute.tier > 0) {
// Reset to just below the current tier threshold
// so they need a full set of new violations to reach the next tier
const tierIndex = PENALTY_TIERS.findIndex(t => t.threshold === mute.tier);
if (tierIndex > 0) {
record.count = PENALTY_TIERS[tierIndex - 1].threshold;
} else {
record.count = 0;
}
record.currentTierStart = record.count;
}
}
this._mutes.delete(userId);
console.log(`[ViolationService] Mute removed for user ${userId}`);
}
/**
* Check if a penalty should be applied based on violation count.
* @param {string} userId
* @param {number} count
* @returns {{ penalty: string|null, isMuted: boolean, muteDuration: string|null }}
*/
_checkPenalty(userId, count) {
// Check penalty tiers in reverse order (highest first)
for (let i = PENALTY_TIERS.length - 1; i >= 0; i--) {
const tier = PENALTY_TIERS[i];
if (count >= tier.threshold) {
// Only apply if not already muted at this or higher tier
const currentMute = this._mutes.get(userId);
if (currentMute && currentMute.tier >= tier.threshold) {
return { penalty: null, isMuted: true, muteDuration: null };
}
// Apply mute
const expiresAt = tier.durationMs === Infinity ? null : Date.now() + tier.durationMs;
this._mutes.set(userId, {
expiresAt,
tier: tier.threshold,
});
console.log(`[ViolationService] User ${userId} muted: ${tier.label} (violations: ${count})`);
this.logger.logViolation({
userId,
violationType: 'penalty_applied',
contentSummary: '',
action: `mute_${tier.label}`,
});
return {
penalty: tier.label,
isMuted: true,
muteDuration: tier.durationMs === Infinity ? '永久' : this._formatDuration(tier.durationMs),
};
}
}
return { penalty: null, isMuted: false, muteDuration: null };
}
/**
* Clean up expired mutes periodically.
*/
_cleanupExpiredMutes() {
const now = Date.now();
for (const [userId, mute] of this._mutes) {
if (mute.expiresAt !== null && mute.expiresAt <= now) {
this._removeMute(userId);
}
}
}
/**
* Format a duration in milliseconds to a human-readable string.
* @param {number} ms
* @returns {string}
*/
_formatDuration(ms) {
if (ms === Infinity) return '永久';
const totalMinutes = Math.floor(ms / (60 * 1000));
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
if (days > 0) {
return `${days}${remainingHours}小时`;
}
return `${hours}小时${minutes}分钟`;
}
/**
* Get violation summary for a user.
* @param {string} userId
* @returns {{ count: number, isMuted: boolean, recentViolations: Array }}
*/
getViolationSummary(userId) {
const record = this._records.get(userId);
const muteStatus = this.getMuteStatus(userId);
return {
count: record ? record.count : 0,
isMuted: muteStatus.isMuted,
muteRemainingText: muteStatus.remainingText,
recentViolations: record ? record.violations.slice(-5) : [],
};
}
/**
* Clean up resources.
*/
destroy() {
if (this._cleanupInterval) {
clearInterval(this._cleanupInterval);
}
console.log('[ViolationService] Destroyed');
}
}
module.exports = ViolationService;
+223
View File
@@ -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;
+197
View File
@@ -0,0 +1,197 @@
/**
* contentSecurityService.test.js
* Unit tests for server-side content security service.
*/
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
const ContentSecurityService = require('../services/contentSecurityService');
describe('ContentSecurityService', () => {
let service;
before(() => {
service = new ContentSecurityService();
});
after(() => {
// Clean up rate limiter state
service._requestTimestamps = [];
service._requestQueue.forEach(resolve => resolve());
service._requestQueue = [];
service._isProcessingQueue = false;
});
it('should initialize with rate limiter', () => {
assert.ok(service._requestTimestamps);
assert.ok(Array.isArray(service._requestTimestamps));
assert.ok(service._requestQueue);
assert.ok(Array.isArray(service._requestQueue));
});
it('should have checkTextContent method', () => {
assert.strictEqual(typeof service.checkTextContent, 'function');
});
it('should have checkImageContent method', () => {
assert.strictEqual(typeof service.checkImageContent, 'function');
});
it('should reject checkTextContent when token manager is unavailable', async () => {
// Override token manager to be unavailable
if (service.tokenManager) {
service.tokenManager._accessToken = '';
service.tokenManager._isAvailable = false;
}
try {
const result = await service.checkTextContent('test_openid', 'hello', 1);
// When token is unavailable, should return error or risky
assert.ok(result);
assert.strictEqual(result.pass, false);
} catch (e) {
// Also acceptable - throws when service unavailable
assert.ok(e);
}
});
it('should respect rate limiting', async () => {
// Test rate limiter concept
const now = Date.now();
service._requestTimestamps = [];
// Should allow first request
await service._enforceRateLimit();
assert.ok(service._requestTimestamps.length > 0);
// Fill up rate limit
for (let i = 0; i < 5000; i++) {
service._requestTimestamps.push(now);
}
// Should queue when limit reached (returns a pending Promise)
const limited = service._enforceRateLimit();
assert.ok(limited instanceof Promise);
// Clean up: clear the queue to avoid hanging
service._requestQueue.forEach(resolve => resolve());
service._requestQueue = [];
service._requestTimestamps = [];
service._isProcessingQueue = false;
});
});
describe('ViolationService', () => {
let violationService;
before(() => {
const ViolationService = require('../services/violationService');
const AuditLogger = require('../services/auditLogger');
violationService = new ViolationService({ logger: new AuditLogger() });
});
after(() => {
// Clean up any timers using destroy method
if (violationService.destroy) {
violationService.destroy();
}
});
it('should initialize without errors', () => {
assert.ok(violationService);
});
it('should have recordViolation method', () => {
assert.strictEqual(typeof violationService.recordViolation, 'function');
});
it('should have getMuteStatus method', () => {
assert.strictEqual(typeof violationService.getMuteStatus, 'function');
});
it('should record a violation and increment count', () => {
const testUserId = 'test_user_' + Date.now();
violationService.recordViolation({
userId: testUserId,
violationType: 'text_violation',
contentSummary: '测试违规内容',
scene: 2,
});
const status = violationService.getMuteStatus(testUserId);
assert.ok(status);
assert.strictEqual(typeof status.isMuted, 'boolean');
// Check violation count via internal records
const record = violationService._records.get(testUserId);
assert.ok(record);
assert.strictEqual(typeof record.count, 'number');
assert.strictEqual(record.count, 1);
});
it('should apply mute penalty at 3 violations', () => {
const testUserId = 'mute_test_' + Date.now();
// Record 3 violations
for (let i = 0; i < 3; i++) {
violationService.recordViolation({
userId: testUserId,
violationType: 'pornography',
contentSummary: `violation ${i}`,
scene: 2,
});
}
const status = violationService.getMuteStatus(testUserId);
assert.strictEqual(status.isMuted, true);
assert.ok(status.remainingMs > 0);
});
it('should escalate mute at 5 violations', () => {
const testUserId = 'escalate_test_' + Date.now();
// Record 5 violations
for (let i = 0; i < 5; i++) {
violationService.recordViolation({
userId: testUserId,
violationType: 'gambling',
contentSummary: `violation ${i}`,
scene: 2,
});
}
const status = violationService.getMuteStatus(testUserId);
assert.strictEqual(status.isMuted, true);
});
});
describe('ReportService', () => {
let reportService;
before(() => {
const ReportService = require('../services/reportService');
const AuditLogger = require('../services/auditLogger');
reportService = new ReportService({ logger: new AuditLogger() });
});
it('should initialize without errors', () => {
assert.ok(reportService);
});
it('should have submitReport method', () => {
assert.strictEqual(typeof reportService.submitReport, 'function');
});
it('should accept a report submission', () => {
const result = reportService.submitReport({
contentId: 'test_content_' + Date.now(),
targetUserId: 'target_user_123',
contentType: 'chat',
contentSummary: 'test content',
reporterId: 'reporter_user_456',
reason: 'politics',
});
assert.ok(result);
assert.strictEqual(typeof result.success, 'boolean');
});
});
+259
View File
@@ -0,0 +1,259 @@
/**
* contentSecurityManager.test.js
* Unit tests for client-side ContentSecurityManager.
*
* Since ContentSecurityManager depends on wx APIs (wx.request, wx.getStorageSync, etc.),
* we mock these APIs before requiring the module.
*/
const { describe, it, before, after, beforeEach } = require('node:test');
const assert = require('node:assert/strict');
// ============================================================
// Mock wx APIs
// ============================================================
const mockStorage = {};
const mockRequests = {};
global.wx = {
getStorageSync: (key) => mockStorage[key] || '',
setStorageSync: (key, value) => { mockStorage[key] = value; },
request: (options) => {
const key = `${options.method}:${options.url}`;
if (mockRequests[key]) {
const handler = mockRequests[key];
setTimeout(() => {
if (handler.success) {
options.success(handler.success);
} else if (handler.fail) {
options.fail(handler.fail);
}
}, 0);
} else {
// Default: 404
setTimeout(() => {
options.fail({ errMsg: 'request:fail' });
}, 0);
}
},
uploadFile: (options) => {
setTimeout(() => {
options.fail({ errMsg: 'uploadFile:fail' });
}, 0);
},
showToast: () => {},
showKeyboard: () => {},
hideKeyboard: () => {},
onKeyboardInput: () => {},
offKeyboardInput: () => {},
onKeyboardConfirm: () => {},
offKeyboardConfirm: () => {},
chooseImage: () => {},
getFileInfo: () => ({ size: 0 }),
};
// Now require the module after mocking wx
const ContentSecurityManager = require('../../js/managers/ContentSecurityManager');
// ============================================================
// Tests
// ============================================================
describe('ContentSecurityManager - Local Check', () => {
let manager;
before(async () => {
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should initialize with fallback word list', () => {
assert.ok(manager.isInitialized());
assert.ok(manager.getWordCount() > 0);
});
it('should detect sensitive words in content', () => {
const result = manager.checkLocalText('这是一个赌博网站');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.length > 0);
assert.ok(result.matchedWords.includes('赌博'));
});
it('should detect political content', () => {
const result = manager.checkLocalText('法轮功是邪教');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.includes('法轮功'));
});
it('should detect pornographic content', () => {
const result = manager.checkLocalText('色情视频');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.includes('色情'));
});
it('should detect abusive language', () => {
const result = manager.checkLocalText('你是个傻逼');
assert.strictEqual(result.hasViolation, true);
assert.ok(result.matchedWords.includes('傻逼'));
});
it('should pass clean content', () => {
const result = manager.checkLocalText('今天天气真好,适合出去玩');
assert.strictEqual(result.hasViolation, false);
assert.strictEqual(result.matchedWords.length, 0);
});
it('should handle empty content', () => {
const result = manager.checkLocalText('');
assert.strictEqual(result.hasViolation, false);
});
it('should handle null content', () => {
const result = manager.checkLocalText(null);
assert.strictEqual(result.hasViolation, false);
});
it('should complete within 50ms', () => {
const start = Date.now();
for (let i = 0; i < 100; i++) {
manager.checkLocalText('这是一个普通的测试内容,不包含任何敏感词汇');
}
const duration = Date.now() - start;
const avgMs = duration / 100;
assert.ok(avgMs < 50, `Average check time ${avgMs}ms exceeds 50ms target`);
});
it('should be case-insensitive', () => {
const result = manager.checkLocalText('赌博');
assert.strictEqual(result.hasViolation, true);
});
});
describe('ContentSecurityManager - Word List Cache', () => {
let manager;
before(async () => {
// Clear cache
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should cache word list locally', () => {
// After init, cache should be populated
assert.ok(mockStorage['content_security_word_cache'] || manager.getWordCount() > 0);
});
it('should load from cache on subsequent init', async () => {
// Pre-populate cache
mockStorage['content_security_word_timestamp'] = Date.now();
mockStorage['content_security_word_version'] = 'test_v1';
mockStorage['content_security_word_cache'] = ['测试词1', '测试词2'];
const manager2 = new ContentSecurityManager();
await manager2.init({ serverUrl: 'http://localhost:3000' });
assert.strictEqual(manager2.getWordCount(), 2);
});
it('should ignore expired cache', async () => {
// Set expired cache
mockStorage['content_security_word_timestamp'] = Date.now() - 25 * 60 * 60 * 1000; // 25 hours ago
mockStorage['content_security_word_version'] = 'expired_v1';
mockStorage['content_security_word_cache'] = ['过期词1'];
const manager3 = new ContentSecurityManager();
await manager3.init({ serverUrl: 'http://localhost:3000' });
// Should use fallback, not expired cache
assert.ok(manager3.getWordCount() > 1);
});
});
describe('ContentSecurityManager - Full Text Check', () => {
let manager;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should reject content with local violations', async () => {
const result = await manager.fullTextCheck('test_openid', '赌博网站', ContentSecurityManager.SCENE.CHAT);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
assert.ok(result.errorMessage.length > 0);
});
it('should provide appropriate error message for nickname scene', async () => {
const result = await manager.fullTextCheck('test_openid', '赌博', ContentSecurityManager.SCENE.NICKNAME);
assert.ok(result.errorMessage.includes('昵称'));
});
it('should provide appropriate error message for chat scene', async () => {
const result = await manager.fullTextCheck('test_openid', '色情内容', ContentSecurityManager.SCENE.CHAT);
assert.ok(result.errorMessage.includes('违规'));
});
it('should provide appropriate error message for signature scene', async () => {
const result = await manager.fullTextCheck('test_openid', '色情', ContentSecurityManager.SCENE.SIGNATURE);
assert.ok(result.errorMessage.includes('违规'));
});
});
describe('ContentSecurityManager - SCENE Constants', () => {
it('should export SCENE constants', () => {
assert.strictEqual(ContentSecurityManager.SCENE.NICKNAME, 1);
assert.strictEqual(ContentSecurityManager.SCENE.CHAT, 2);
assert.strictEqual(ContentSecurityManager.SCENE.SIGNATURE, 3);
assert.strictEqual(ContentSecurityManager.SCENE.DESCRIPTION, 4);
});
it('should export REPORT_REASONS constants', () => {
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.POLITICS, 'politics');
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.PORNOGRAPHY, 'pornography');
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.GAMBLING, 'gambling');
assert.strictEqual(ContentSecurityManager.REPORT_REASONS.OTHER, 'other');
});
});
describe('ContentSecurityManager - Mute Status', () => {
let manager;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should return not muted when server is unreachable', async () => {
const status = await manager.getMuteStatus('nonexistent_user');
assert.strictEqual(status.isMuted, false);
assert.strictEqual(status.remainingMs, 0);
});
});
describe('ContentSecurityManager - Submit Report', () => {
let manager;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
manager = new ContentSecurityManager();
await manager.init({ serverUrl: 'http://localhost:3000' });
});
it('should handle report submission failure gracefully', async () => {
const result = await manager.submitReport({
contentId: 'test_123',
targetUserId: 'target_456',
contentType: 'chat',
contentSummary: 'test',
reporterId: 'reporter_789',
reason: 'politics',
});
// Should not throw, returns failure object
assert.strictEqual(result.success, false);
});
});
+289
View File
@@ -0,0 +1,289 @@
/**
* e2e-content-security.test.js
* End-to-end integration test for content security system.
*
* Tests the full flow:
* 1. Nickname modification with content security check
* 2. Chat message with real-time check
* 3. Signature/description modification with check
* 4. Avatar upload with image check
* 5. Report submission
* 6. Auto-takedown after 3 reports
* 7. Mute penalty after violations
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
// ============================================================
// Mock wx APIs for client-side modules
// ============================================================
const mockStorage = {};
global.wx = {
getStorageSync: (key) => mockStorage[key] || '',
setStorageSync: (key, value) => { mockStorage[key] = value; },
request: (options) => {
// Simulate server responses
const url = options.url;
if (url.includes('/api/content/check-text')) {
// Simulate msgSecCheck: reject known bad content
const content = options.data?.content || '';
const badWords = ['赌博', '色情', '法轮功', '杀人', '傻逼'];
const hasBad = badWords.some(w => content.includes(w));
setTimeout(() => {
options.success({
statusCode: 200,
data: hasBad
? { pass: false, errcode: 87014, errmsg: 'risky content', suggest: 'risky', label: 20001 }
: { pass: true, errcode: 0, errmsg: 'ok', suggest: 'pass', label: 100 },
});
}, 10);
} else if (url.includes('/api/content/user/mute-status')) {
setTimeout(() => {
options.success({
statusCode: 200,
data: { isMuted: false, remainingMs: 0, remainingText: '', violationCount: 0 },
});
}, 10);
} else if (url.includes('/api/content/report')) {
setTimeout(() => {
options.success({
statusCode: 200,
data: { success: true, message: '举报已提交' },
});
}, 10);
} else if (url.includes('/api/content/sensitive-words')) {
setTimeout(() => {
options.success({
statusCode: 200,
data: { updated: false, version: '1' },
});
}, 10);
} else {
setTimeout(() => {
options.fail({ errMsg: 'request:fail unknown url' });
}, 10);
}
},
uploadFile: (options) => {
setTimeout(() => {
options.success({
statusCode: 200,
data: JSON.stringify({ pass: true, errcode: 0, errmsg: 'ok' }),
});
}, 10);
},
showToast: () => {},
showKeyboard: () => {},
hideKeyboard: () => {},
onKeyboardInput: () => {},
offKeyboardInput: () => {},
onKeyboardConfirm: () => {},
offKeyboardConfirm: () => {},
chooseImage: () => {},
getFileInfo: () => ({ size: 500 * 1024 }), // 500KB
};
const ContentSecurityManager = require('../../js/managers/ContentSecurityManager');
// ============================================================
// E2E Tests
// ============================================================
describe('E2E: Nickname Modification Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean nickname through local + server check', async () => {
const result = await csm.fullTextCheck('user_001', '小明同学', ContentSecurityManager.SCENE.NICKNAME);
assert.strictEqual(result.pass, true);
assert.strictEqual(result.localViolation, false);
});
it('should reject nickname with local violation', async () => {
const result = await csm.fullTextCheck('user_001', '赌博大王', ContentSecurityManager.SCENE.NICKNAME);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
assert.ok(result.errorMessage.includes('昵称'));
});
it('should reject nickname with server violation (not in local list)', async () => {
// "杀人" is in the fallback list, so this will be caught locally
// For a true server-only test, we'd need a word not in the local list
// but the server would flag. Here we verify the flow works.
const result = await csm.fullTextCheck('user_001', '杀人狂魔', ContentSecurityManager.SCENE.NICKNAME);
assert.strictEqual(result.pass, false);
});
it('should reject empty nickname at UI level', () => {
const localCheck = csm.checkLocalText('');
assert.strictEqual(localCheck.hasViolation, false);
// Empty content is handled at UI level, not by content security
});
});
describe('E2E: Chat Message Real-time Check Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean chat message', async () => {
const result = await csm.fullTextCheck('user_001', '大家好,来一起玩坦克大战吧!', ContentSecurityManager.SCENE.CHAT);
assert.strictEqual(result.pass, true);
});
it('should reject chat with violation', async () => {
const result = await csm.fullTextCheck('user_001', '快来看色情视频', ContentSecurityManager.SCENE.CHAT);
assert.strictEqual(result.pass, false);
assert.ok(result.errorMessage.includes('违规'));
});
it('should show mute status for muted user', async () => {
const status = await csm.getMuteStatus('user_001');
// Our mock returns not muted
assert.strictEqual(status.isMuted, false);
});
});
describe('E2E: Signature and Description Check Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean signature', async () => {
const result = await csm.fullTextCheck('user_001', '热爱生活,天天向上', ContentSecurityManager.SCENE.SIGNATURE);
assert.strictEqual(result.pass, true);
});
it('should reject signature with violation', async () => {
const result = await csm.fullTextCheck('user_001', '加入法轮功', ContentSecurityManager.SCENE.SIGNATURE);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
});
it('should pass clean description', async () => {
const result = await csm.fullTextCheck('user_001', '这是一个游戏爱好者的个人空间', ContentSecurityManager.SCENE.DESCRIPTION);
assert.strictEqual(result.pass, true);
});
it('should reject description with violation', async () => {
const result = await csm.fullTextCheck('user_001', '代开发票请联系', ContentSecurityManager.SCENE.DESCRIPTION);
assert.strictEqual(result.pass, false);
assert.strictEqual(result.localViolation, true);
});
});
describe('E2E: Avatar Upload Check Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should pass clean avatar upload', async () => {
const result = await csm.checkImageContent('/tmp/test_avatar.png', 'user_001');
assert.strictEqual(result.pass, true);
});
});
describe('E2E: Report Submission Flow', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should submit report successfully', async () => {
const result = await csm.submitReport({
contentId: 'chat_msg_123',
targetUserId: 'bad_user_456',
contentType: 'chat',
contentSummary: '违规内容摘要',
reporterId: 'user_001',
reason: 'politics',
});
assert.strictEqual(result.success, true);
});
it('should submit report with different reasons', async () => {
const reasons = ['politics', 'pornography', 'gambling', 'other'];
for (const reason of reasons) {
const result = await csm.submitReport({
contentId: `msg_${reason}_${Date.now()}`,
targetUserId: 'bad_user',
contentType: 'chat',
contentSummary: 'test',
reporterId: 'user_001',
reason,
});
assert.strictEqual(result.success, true);
}
});
});
describe('E2E: Audit Log Verification', () => {
it('should verify audit logger exists and has correct methods', () => {
const AuditLogger = require('../../server/services/auditLogger');
const logger = new AuditLogger();
assert.strictEqual(typeof logger.logAudit, 'function');
assert.strictEqual(typeof logger.logViolation, 'function');
assert.strictEqual(typeof logger.logReport, 'function');
});
it('should log content check result with desensitized content', () => {
const AuditLogger = require('../../server/services/auditLogger');
const logger = new AuditLogger();
// Test desensitization
const original = '这是一个很长的内容用于测试脱敏';
const desensitized = logger._sanitizeContent(original);
// _sanitizeContent keeps first 3 and last 3 chars, replaces middle with ***
assert.ok(desensitized.length < original.length || desensitized.includes('***'));
});
});
describe('E2E: Sensitive Word List Verification', () => {
let csm;
before(async () => {
Object.keys(mockStorage).forEach(k => delete mockStorage[k]);
csm = new ContentSecurityManager();
await csm.init({ serverUrl: 'http://localhost:3000' });
});
it('should have fallback word list covering major categories', () => {
const wordCount = csm.getWordCount();
assert.ok(wordCount >= 30, `Fallback word list should have at least 30 words, got ${wordCount}`);
// Verify key categories are covered
const politicsCheck = csm.checkLocalText('颠覆国家');
assert.strictEqual(politicsCheck.hasViolation, true);
const pornCheck = csm.checkLocalText('色情');
assert.strictEqual(pornCheck.hasViolation, true);
const gamblingCheck = csm.checkLocalText('赌博');
assert.strictEqual(gamblingCheck.hasViolation, true);
const abuseCheck = csm.checkLocalText('傻逼');
assert.strictEqual(abuseCheck.hasViolation, true);
});
});
+88
View File
@@ -0,0 +1,88 @@
/**
* wechatTokenManager.test.js
* Unit tests for WeChat Access Token management.
*/
const { describe, it, before, after } = require('node:test');
const assert = require('node:assert/strict');
// Mock wx environment for WeChat mini game server
// The wechatTokenManager uses wx APIs on client, but on server it uses
// HTTPS requests to WeChat API. We test the server-side module.
const WechatTokenManager = require('../services/wechatTokenManager');
describe('WechatTokenManager', () => {
let manager;
before(() => {
manager = new WechatTokenManager();
});
after(() => {
// Clean up any timers
if (manager._refreshTimer) {
clearInterval(manager._refreshTimer);
}
});
it('should initialize with no token', () => {
assert.strictEqual(manager._accessToken, '');
assert.strictEqual(manager._expiresAt, 0);
assert.strictEqual(manager._isAvailable, false);
});
it('should report unavailable when no token is set', () => {
const available = manager.isAvailable();
assert.strictEqual(available, false);
});
it('should report available when valid token is set', () => {
manager._accessToken = 'test_token_123';
manager._expiresAt = Date.now() + 7000 * 1000; // 7000 seconds from now
manager._isAvailable = true;
assert.strictEqual(manager.isAvailable(), true);
});
it('should return token when available', () => {
manager._accessToken = 'test_token_456';
manager._expiresAt = Date.now() + 7000 * 1000;
manager._isAvailable = true;
const token = manager.getAccessToken();
assert.strictEqual(token, 'test_token_456');
});
it('should return empty string when token is expired', () => {
manager._accessToken = 'expired_token';
manager._expiresAt = Date.now() - 1000; // expired
manager._isAvailable = false;
const token = manager.getAccessToken();
assert.strictEqual(token, '');
});
it('should handle token refresh failure gracefully', async () => {
// Create a manager with invalid appid/secret to test failure
const failManager = new WechatTokenManager();
failManager.appId = 'invalid_appid';
failManager.appSecret = 'invalid_secret';
// Override retry delays to speed up test
failManager._isRefreshing = false;
// The _refreshToken will attempt with retries, but should not throw.
// Use a short timeout to avoid long waits.
const result = await Promise.race([
failManager._refreshToken(),
new Promise((resolve) => setTimeout(() => resolve(false), 5000)),
]);
assert.strictEqual(failManager.isAvailable(), false);
// Clean up any pending timers
failManager.destroy();
});
it('should not leak token in toString', () => {
manager._accessToken = 'sensitive_token_xyz';
const str = manager.toString();
assert.strictEqual(str.includes('sensitive_token_xyz'), false);
});
});