ice-static hace 2 meses
commit
93b813daa9
Se han modificado 100 ficheros con 17318 adiciones y 0 borrados
  1. 17 0
      .editorconfig
  2. 60 0
      .gitignore
  3. 5 0
      .postcssrc.json
  4. 4 0
      .vscode/extensions.json
  5. 20 0
      .vscode/launch.json
  6. 42 0
      .vscode/tasks.json
  7. 76 0
      README.md
  8. 112 0
      angular.json
  9. 16 0
      backend/.env
  10. 15 0
      backend/.env.example
  11. 221 0
      backend/docs/backend-design.md
  12. 222 0
      backend/docs/database-design.md
  13. 302 0
      backend/docs/deployment-baota.md
  14. 220 0
      backend/docs/group-organization-schema.md
  15. 295 0
      backend/docs/后端目录规范.md
  16. 1634 0
      backend/package-lock.json
  17. 23 0
      backend/package.json
  18. 1081 0
      backend/pnpm-lock.yaml
  19. 42 0
      backend/scripts/add-community-isdeleted.ts
  20. 41 0
      backend/scripts/add-riskevent-discoveredat.ts
  21. 43 0
      backend/scripts/add-store-district-isdeleted.ts
  22. 42 0
      backend/scripts/add-user-isdeleted.ts
  23. 250 0
      backend/scripts/assign-store-ids.ts
  24. 990 0
      backend/scripts/audit-risk-2026-06-15-22.html
  25. 599 0
      backend/scripts/audit-risk-2026-06-15-22.md
  26. 297 0
      backend/scripts/audit-risk-README.md
  27. 751 0
      backend/scripts/audit-risk-coverage.ts
  28. 303 0
      backend/scripts/audit-risk-deep-2026-06-15-22.md
  29. 678 0
      backend/scripts/audit-risk-deep-analysis.ts
  30. 115 0
      backend/scripts/backfill-member-nicknames.ts
  31. 74 0
      backend/scripts/cleanup-dead-fields.ts
  32. 70 0
      backend/scripts/cleanup-groupchat-dead-fields.ts
  33. 76 0
      backend/scripts/cleanup-permission-data.ts
  34. 123 0
      backend/scripts/cleanup-redundant-fields.ts
  35. 181 0
      backend/scripts/create-contact-webhooklog-schema.ts
  36. 134 0
      backend/scripts/discover-db.ts
  37. 113 0
      backend/scripts/dump-qiwe-schema.ts
  38. 819 0
      backend/scripts/group-daily-report.ts
  39. 94 0
      backend/scripts/list-schemas.ts
  40. 238 0
      backend/scripts/migrate-risk-notification-schema.ts
  41. 77 0
      backend/scripts/restore-guid-field.ts
  42. 416 0
      backend/scripts/seed-data.ts
  43. 205 0
      backend/scripts/seed-test-users.ts
  44. 30 0
      backend/src/app.ts
  45. 68 0
      backend/src/auth/auth.service.ts
  46. 2 0
      backend/src/auth/constants.ts
  47. 7 0
      backend/src/auth/request.util.ts
  48. 16 0
      backend/src/auth/require-user.ts
  49. 13 0
      backend/src/config/env.ts
  50. 13 0
      backend/src/controllers/account.controller.ts
  51. 17 0
      backend/src/controllers/aggregate.controller.ts
  52. 124 0
      backend/src/controllers/compliance.controller.ts
  53. 53 0
      backend/src/controllers/groups.controller.ts
  54. 26 0
      backend/src/controllers/health.controller.ts
  55. 43 0
      backend/src/controllers/messages.controller.ts
  56. 15 0
      backend/src/controllers/risk-event.controller.ts
  57. 26 0
      backend/src/controllers/sync.controller.ts
  58. 60 0
      backend/src/controllers/users.controller.ts
  59. 23 0
      backend/src/controllers/webhook.controller.ts
  60. 8 0
      backend/src/db/parse-client.ts
  61. 44 0
      backend/src/db/parse-health.service.ts
  62. 14 0
      backend/src/errors/app-error.ts
  63. 9 0
      backend/src/http/async-handler.ts
  64. 17 0
      backend/src/http/error-handler.ts
  65. 6 0
      backend/src/http/not-found.middleware.ts
  66. 37 0
      backend/src/http/response.ts
  67. 14 0
      backend/src/index.ts
  68. 283 0
      backend/src/qiwi/qiwe-api.service.ts
  69. 83 0
      backend/src/qiwi/qiwe-room.api.ts
  70. 7 0
      backend/src/routes/aggregate.routes.ts
  71. 9 0
      backend/src/routes/compliance.routes.ts
  72. 10 0
      backend/src/routes/health.routes.ts
  73. 30 0
      backend/src/routes/qiwei.routes.ts
  74. 23 0
      backend/src/routes/users.routes.ts
  75. 10 0
      backend/src/server.ts
  76. 25 0
      backend/src/services/account.service.ts
  77. 78 0
      backend/src/services/aggregation.service.ts
  78. 101 0
      backend/src/services/ai-analysis.service.ts
  79. 21 0
      backend/src/services/compliance-check.service.ts
  80. 126 0
      backend/src/services/group-infra.service.ts
  81. 121 0
      backend/src/services/keyword-matcher.ts
  82. 149 0
      backend/src/services/message-analyzer.service.ts
  83. 291 0
      backend/src/services/message-persist.service.ts
  84. 421 0
      backend/src/services/message-sync.service.ts
  85. 89 0
      backend/src/services/responsibility.service.ts
  86. 282 0
      backend/src/services/risk-event.service.ts
  87. 133 0
      backend/src/services/room-operation.service.ts
  88. 137 0
      backend/src/services/seed-responsibility-assignment.ts
  89. 456 0
      backend/src/services/sync.service.ts
  90. 139 0
      backend/src/services/users.service.ts
  91. 396 0
      backend/src/services/webhook.service.ts
  92. 5 0
      backend/src/types/parse-node.d.ts
  93. 22 0
      backend/src/utils/with-timeout.ts
  94. 30 0
      backend/test-webhook.bat
  95. 17 0
      backend/tsconfig.json
  96. 1374 0
      doc/Angular项目开发规范.md
  97. 302 0
      doc/M1-M2功能模块开发计划.md
  98. 111 0
      doc/QiWe开放平台文档/README.md
  99. 210 0
      doc/QiWe开放平台文档/md/OpenID 2.md
  100. 6 0
      doc/QiWe开放平台文档/md/Openid.md

+ 17 - 0
.editorconfig

@@ -0,0 +1,17 @@
+# Editor configuration, see https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.ts]
+quote_type = single
+ij_typescript_use_double_quotes = false
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false

+ 60 - 0
.gitignore

@@ -0,0 +1,60 @@
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node (all levels)
+node_modules/
+npm-debug.log
+yarn-error.log
+
+# IDEs and editors
+.idea/
+.project
+.classpath
+.c9/
+*.launch
+.settings/
+*.sublime-workspace
+
+# Visual Studio Code
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+.history/*
+
+# Miscellaneous
+/.angular/cache
+.sass-cache/
+/connect.lock
+/coverage
+/libpeerconnection.log
+testem.log
+/typings
+__screenshots__/
+
+# System files
+.DS_Store
+Thumbs.db
+
+# Environment & secrets
+!.env
+!.env.*
+!.env.example
+
+# Legacy / backup
+backend_Gyd8G/
+
+# Temp / local artifacts
+lami-base-v1/.venv/
+
+# Local docs (not for version control)
+backend/docs/P0-功能改造说明.md
+
+# Generated reports
+backend/scripts/group-report-*.html
+
+/qiwe-common

+ 5 - 0
.postcssrc.json

@@ -0,0 +1,5 @@
+{
+  "plugins": {
+    "@tailwindcss/postcss": {}
+  }
+}

+ 4 - 0
.vscode/extensions.json

@@ -0,0 +1,4 @@
+{
+  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=827846
+  "recommendations": ["angular.ng-template"]
+}

+ 20 - 0
.vscode/launch.json

@@ -0,0 +1,20 @@
+{
+  // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
+  "version": "0.2.0",
+  "configurations": [
+    {
+      "name": "ng serve",
+      "type": "chrome",
+      "request": "launch",
+      "preLaunchTask": "npm: start",
+      "url": "http://localhost:4200/"
+    },
+    {
+      "name": "ng test",
+      "type": "chrome",
+      "request": "launch",
+      "preLaunchTask": "npm: test",
+      "url": "http://localhost:9876/debug.html"
+    }
+  ]
+}

+ 42 - 0
.vscode/tasks.json

@@ -0,0 +1,42 @@
+{
+  // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
+  "version": "2.0.0",
+  "tasks": [
+    {
+      "type": "npm",
+      "script": "start",
+      "isBackground": true,
+      "problemMatcher": {
+        "owner": "typescript",
+        "pattern": "$tsc",
+        "background": {
+          "activeOnStart": true,
+          "beginsPattern": {
+            "regexp": "(.*?)"
+          },
+          "endsPattern": {
+            "regexp": "bundle generation complete"
+          }
+        }
+      }
+    },
+    {
+      "type": "npm",
+      "script": "test",
+      "isBackground": true,
+      "problemMatcher": {
+        "owner": "typescript",
+        "pattern": "$tsc",
+        "background": {
+          "activeOnStart": true,
+          "beginsPattern": {
+            "regexp": "(.*?)"
+          },
+          "endsPattern": {
+            "regexp": "bundle generation complete"
+          }
+        }
+      }
+    }
+  ]
+}

+ 76 - 0
README.md

@@ -0,0 +1,76 @@
+# 拉迷企微客户服务系统(PC 社群运营端)
+
+## 项目结构
+
+| 目录 | 说明 |
+|------|------|
+| `src/` | **PC 前端**(Angular 20,本仓库主应用) |
+| `backend/` | **后端 API**(Express,默认端口 `:3101`) |
+| `doc/` | 产品与设计文档 |
+| `backend/docs/` | 后端设计、部署与数据库文档 |
+
+> `lami-base-v1/` 是早期演示/参考项目,接口路径为 `/api/qiwei/*`,**不是**本前端对接的后端。请勿与 `backend/` 同时占用 `3101` 端口。
+
+## 本地开发
+
+### 1. 启动后端
+
+```bash
+cd backend
+npm install   # 或 pnpm install
+npm run dev
+```
+
+PC API:`http://localhost:3101/api`  
+健康检查:`http://localhost:3101/api/health`
+
+### 2. 启动 PC 前端
+
+```bash
+# 仓库根目录
+pnpm install
+pnpm start
+```
+
+前端:`http://localhost:4200`(通过 `proxy.conf.json` 代理 `/api` → `3101`)
+
+### 演示账号
+
+默认密码均为 `123456`(可在个人设置中修改):
+
+| 角色 | 邮箱 |
+|------|------|
+| 系统管理员 | admin@lami.com |
+| 店长 | lidian@lami.com |
+| 社群运营 | wangying@lami.com |
+
+## 环境变量(后端)
+
+见 `backend/.env.example`。生产环境务必配置:
+
+- `PARSE_SERVER_URL` / `PARSE_APP_ID` / `PARSE_MASTER_KEY` — Parse 数据库
+- `QIWE_TOKEN` / `QIWE_GUID` — 企微 QiWe 平台(群同步、消息同步)
+- `PARSE_TIMEOUT_MS` — Parse 请求超时(默认 `8000`,避免数据库不可达时接口卡死)
+
+## 常见问题
+
+### 后端报 `EADDRINUSE :::3101`
+
+端口已被占用。先结束旧进程再启动:
+
+```powershell
+netstat -ano | findstr ":3101"
+taskkill /PID <PID> /F
+```
+
+### 前端提示「网络错误,请确认后端服务已启动」
+
+1. 确认启动的是 `backend/`,不是 `lami-base-v1/backend/`
+2. 访问 `http://localhost:3101/api/health`,若 `database.connected` 为 `false`,说明 Parse 数据库不可达,需检查 `PARSE_SERVER_URL` 对应服务是否正常运行
+
+## 构建
+
+```bash
+pnpm build          # 前端生产构建
+pnpm test           # 前端单元测试
+```

+ 112 - 0
angular.json

@@ -0,0 +1,112 @@
+{
+  "$schema": "./node_modules/@angular/cli/lib/config/schema.json",
+  "version": 1,
+  "cli": {
+    "packageManager": "pnpm"
+  },
+  "newProjectRoot": "projects",
+  "projects": {
+    "laim-test": {
+      "projectType": "application",
+      "schematics": {
+        "@schematics/angular:component": {
+          "style": "scss"
+        }
+      },
+      "root": "",
+      "sourceRoot": "src",
+      "prefix": "app",
+      "architect": {
+        "build": {
+          "builder": "@angular/build:application",
+          "options": {
+            "browser": "src/main.ts",
+            "polyfills": [
+              "zone.js"
+            ],
+            "tsConfig": "tsconfig.app.json",
+            "inlineStyleLanguage": "scss",
+            "assets": [
+              {
+                "glob": "**/*",
+                "input": "public"
+              }
+            ],
+            "styles": [
+              "src/tailwind.css",
+              "src/styles.scss"
+            ]
+          },
+          "configurations": {
+            "production": {
+              "budgets": [
+                {
+                  "type": "initial",
+                  "maximumWarning": "2MB",
+                  "maximumError": "4MB"
+                },
+                {
+                  "type": "anyComponentStyle",
+                  "maximumWarning": "4kB",
+                  "maximumError": "8kB"
+                }
+              ],
+              "outputHashing": "all"
+            },
+            "development": {
+              "optimization": false,
+              "extractLicenses": false,
+              "sourceMap": true,
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.development.ts"
+                }
+              ]
+            }
+          },
+          "defaultConfiguration": "production"
+        },
+        "serve": {
+          "builder": "@angular/build:dev-server",
+          "options": {
+            "proxyConfig": "proxy.conf.json"
+          },
+          "configurations": {
+            "production": {
+              "buildTarget": "laim-test:build:production"
+            },
+            "development": {
+              "buildTarget": "laim-test:build:development"
+            }
+          },
+          "defaultConfiguration": "development"
+        },
+        "extract-i18n": {
+          "builder": "@angular/build:extract-i18n"
+        },
+        "test": {
+          "builder": "@angular/build:karma",
+          "options": {
+            "polyfills": [
+              "zone.js",
+              "zone.js/testing"
+            ],
+            "tsConfig": "tsconfig.spec.json",
+            "inlineStyleLanguage": "scss",
+            "assets": [
+              {
+                "glob": "**/*",
+                "input": "public"
+              }
+            ],
+            "styles": [
+              "src/tailwind.css",
+              "src/styles.scss"
+            ]
+          }
+        }
+      }
+    }
+  }
+}

+ 16 - 0
backend/.env

@@ -0,0 +1,16 @@
+NODE_ENV=development
+PC_PORT=3101
+PARSE_APP_ID=lami-ai
+PARSE_MASTER_KEY=5s1gfOasPqx9JKsA
+PARSE_SERVER_URL=https://server.sh-lami.com/parse
+
+# QiWe 平台配置
+QIWE_API_BASE=https://manager.qiweapi.com/qiwe
+QIWE_TOKEN=146708f9-1c49-4cf0-aad9-0b7a36834b0b
+QIWE_GUID=B8591A9D-B540-44F9-9D74-B2EF5B419EAA
+QIWE_USER_ID=1688857912458394
+
+# DeepSeek AI 配置(合规检测)
+DEEPSEEK_API_KEY=sk-cb4e988b869e4c2392e061cfec2daf9c
+DEEPSEEK_API_URL=https://api.deepseek.com/v1/chat/completions
+DEEPSEEK_MODEL=deepseek-chat

+ 15 - 0
backend/.env.example

@@ -0,0 +1,15 @@
+NODE_ENV=development
+PC_PORT=3101
+
+# Parse 数据库(与 Parse Dashboard 的 --serverURL 保持一致)
+PARSE_APP_ID=lami-ai
+PARSE_MASTER_KEY=your-master-key
+PARSE_SERVER_URL=https://server.sh-lami.com/parse
+# Parse 请求超时(毫秒),避免数据库不可达时健康检查卡死
+PARSE_TIMEOUT_MS=8000
+
+# 企微 QiWe 平台(sync-groups 拉群列表需要)
+QIWE_API_BASE=https://manager.qiweapi.com/qiwe
+QIWE_TOKEN=146708f9-1c49-4cf0-aad9-0b7a36834b0b
+QIWE_GUID=B8591A9D-B540-44F9-9D74-B2EF5B419EAA
+QIWE_USER_ID=1688857912458394

+ 221 - 0
backend/docs/backend-design.md

@@ -0,0 +1,221 @@
+# 企微 Webhook 接收服务 — 后端设计文档
+
+## 一、项目概述
+
+本服务是社群运营系统的企业微信回调数据接收层。企微平台在群事件发生时通过 HTTP POST 推送 JSON 数据到本服务,服务解析后存入 Parse Server(底层为 PostgreSQL)。
+
+**核心职责**:接收回调 → 解析事件 → 存储群数据 + 文本消息。
+
+---
+
+## 二、技术选型
+
+| 组件 | 选型 | 原因 |
+|------|------|------|
+| 运行时 | Node.js + TypeScript | 前后端统一语言栈,类型安全 |
+| HTTP 框架 | Express 4.x | 轻量,中间件生态成熟 |
+| 数据库 | PostgreSQL | 通过 Parse Server 托管 |
+| 数据库访问 | Parse SDK (`parse/node`) | 直接操作 Parse Object,无需手写 SQL |
+| 包管理器 | pnpm | 节省磁盘空间,安装速度快 |
+| 开发运行 | `tsx` | 直接运行 TypeScript,无需编译步骤 |
+
+---
+
+## 三、项目目录结构
+
+```
+backend/
+├── .env                              # 环境变量
+├── package.json                      # 依赖声明 (pnpm)
+├── tsconfig.json                     # TypeScript 配置 (ESNext module)
+├── docs/                             # 设计文档(本目录)
+└── src/
+    ├── index.ts                      # 总启动器
+    └── apps/
+        └── pc/
+            ├── app.ts                # PC 端 Express 装配层
+            ├── health/
+            │   └── server.ts         # 服务启动入口 + Schema 初始化
+            └── qiwe/                 # 企微 Webhook 模块
+                ├── routes/
+                │   └── webhook.routes.ts       # 路由定义
+                ├── controllers/
+                │   └── webhook.controller.ts   # 请求处理(解析 → 响应 → 异步处理)
+                ├── services/
+                │   └── webhook.service.ts      # 业务逻辑(事件分发 + Parse CRUD)
+                └── models/
+                    ├── parse-client.ts         # Parse SDK 初始化
+                    └── schema-setup.ts         # 建表脚本(启动时自动执行)
+```
+
+### 分层职责
+
+```
+请求 → routes(路由映射)
+       → controllers(参数校验、响应、异步调度)
+         → services(业务编排、Parse 读写)
+           → models/parse-client(数据库连接)
+```
+
+- **routes**: 只做 URL → controller 的映射,不写任何逻辑
+- **controllers**: 校验请求体、立即返回 HTTP 响应、调 service 异步处理
+- **services**: 根据 `cmd + msgType` 分发事件、执行 upsert、处理 base64 解码等业务逻辑
+- **models**: Parse SDK 初始化、Schema 管理,不含业务逻辑
+
+---
+
+## 四、启动流程
+
+```
+src/index.ts
+  │
+  ├─ 1. import 'dotenv/config'        → 加载 .env 到 process.env
+  ├─ 2. import parse-client.ts        → Parse.initialize()(副作用执行)
+  └─ 3. import health/server.ts       → 启动 Express
+         │
+         ├─ ensureSchemas()           → 幂等建表(GroupChat / GroupMember / Message)
+         └─ app.listen(3101)          → 监听端口
+```
+
+---
+
+## 五、Webhook 接口
+
+### 端点
+
+```
+POST /api/qiwe/webhook
+Content-Type: application/json
+```
+
+### 请求体格式
+
+```json
+{
+  "code": 0,
+  "msg": "成功",
+  "data": [
+    {
+      "guid": "设备标识",
+      "userId": "企微userId",
+      "cmd": 15000,
+      "msgType": 0,
+      "msgServerId": 1002001,
+      "msgUniqueIdentifier": "唯一消息ID",
+      "senderId": 168885000001,
+      "fromRoomId": 123456789,
+      "timestamp": 1759064100,
+      "msgData": { "content": "消息内容", "atList": [] },
+      "base64RawData": "base64编码的原始数据"
+    }
+  ]
+}
+```
+
+### 响应
+
+无论处理结果如何,**均在收到请求后立即返回 HTTP 200**:
+
+```json
+{ "success": true, "data": null, "error": null }
+```
+
+这是**强制性设计**:企微平台要求回调接口在 3 秒内返回 200,否则判定超时并丢弃消息。实际数据处理在响应之后异步执行。
+
+---
+
+## 六、事件处理分发逻辑
+
+`processWebhookEvent(event)` 根据 `cmd` 和 `msgType` 分发:
+
+| cmd | msgType | 处理器 | 行为 |
+|-----|---------|--------|------|
+| **15000** | 0 / 2 | `handleTextMessage()` | 文本消息入库(去重) |
+| 15000 | 1001 | `handleGroupNameChange()` | 更新群名称 |
+| 15000 | 1002 | `handleMemberAdd()` | 新增群成员 |
+| 15000 | 1003 | `handleMemberRemove()` | 移除群成员(标记 left) |
+| 15000 | 1005 | `handleMemberQuit()` | 成员主动退群(标记 left) |
+| 15000 | **1006** | `handleGroupCreate()` | **创建群 + 初始化成员列表** |
+| 15000 | 1022 | 日志 | 群主转让(仅记录) |
+| 15000 | **1023** | `handleGroupDismiss()` | **群解散(标记 dismissed)** |
+| 15000 | 1043 | 日志 | 管理员变动(仅记录) |
+| 15000 | 2002 | 日志 | 删除聊天(仅记录) |
+| 15000 | 2055 | 日志 | 清空聊天(仅记录) |
+| **15500** | 任意 | 日志 | 系统消息(联系人/标签变动) |
+| **11016** | — | 日志 | 账号状态变化(登录/离线/顶号) |
+| **20000** | — | 日志 | API 异步消息 |
+
+### 设计原则
+
+- **cmd=15000** 是核心入口:普通消息 + 群事件共用
+- **仅文本入消息库**:`msgType=0 或 2` 才写入 Message 表,图片/视频/文件等暂不存储
+- **群事件写相关表**:1001~1043 写 GroupChat / GroupMember
+- **15500 系统消息暂不入库**:联系人变动、标签变动等需求后续按需开启
+- **未匹配事件只记日志**:便于排查,不丢数据
+
+---
+
+## 七、关键设计决策
+
+### 7.1 为什么 controller 先响应再处理?
+
+因为企微平台硬性要求 3 秒内返回 200。如果在 controller 里 `await` 所有数据库操作完成才响应,网络抖动或数据库慢查询可能导致超时,消息被平台丢弃。
+
+采用"先响应,后处理":收到 body 后立刻 `res.json(200)`,然后 `for` 循环异步处理每条事件。这样即使某条数据处理耗时较长,也不影响响应速度。
+
+### 7.2 为什么使用 Upsert 而不是 Insert?
+
+`GroupChat` 和 `GroupMember` 使用 upsert 模式(查→在则更新/不在则创建):
+
+- 群创建(1006)事件可能重复推送
+- 群名变更(1001)需要在已有记录上更新 roomName
+- 成员可能退出后重新加入,需要恢复 status=active
+
+直接 insert 会导致主键冲突或重复数据。upsert 保证了数据的一致性和幂等性。
+
+### 7.3 为什么用 Parse SDK 而不是直接 SQL?
+
+- Parse 自动管理 `objectId`、`createdAt`、`updatedAt`
+- 无需手写建表语句、迁移脚本
+- 无需管理数据库连接池
+- Parse Dashboard 可直接查看/编辑数据
+- 后续需要复杂查询时,可通过云函数写 SQL
+
+### 7.4 为什么要解码 base64?
+
+企微平台将成员列表等数据用 base64 编码传输,原因有三:
+
+1. **JSON 安全**:避免原始数据中的特殊字符破坏 JSON 结构
+2. **兼容 Protobuf**:企微底层使用 protobuf 序列化,base64 是标准传输格式
+3. **数据完整性**:防止 HTTP 传输过程中因字符集转换导致数据损坏
+
+解码后的格式是分号分隔的 userId 列表,如 `"168885000001;168885000002"`。
+
+---
+
+## 八、环境变量
+
+| 变量 | 默认值 | 说明 |
+|------|--------|------|
+| `NODE_ENV` | `development` | 运行环境 |
+| `PC_PORT` | `3101` | PC 端服务端口 |
+| `PARSE_APP_ID` | `lami-ai` | Parse 应用 ID |
+| `PARSE_MASTER_KEY` | `5s1gfOasPqx9JKsA` | Parse Master Key(服务端使用) |
+| `PARSE_SERVER_URL` | `https://server.sh-lami.com/parse` | Parse Server 地址 |
+
+---
+
+## 九、启动命令
+
+```bash
+pnpm dev      # 开发模式(tsx watch,文件变更自动重启)
+pnpm start    # 生产模式
+```
+
+---
+
+## 十、相关文档
+
+- [数据库设计文档](./database-design.md)
+- [企微回调结构说明](../../21935940-3255-4bca-8e0b-984f80cdc934/QiWe开放平台文档/md/回调结构说明.md)
+- [后端目录规范](../后端目录规范.md)

+ 222 - 0
backend/docs/database-design.md

@@ -0,0 +1,222 @@
+# 企微 Webhook 接收服务 — 数据库设计文档
+
+## 一、数据库概述
+
+- **数据库类型**:PostgreSQL(通过 Parse Server 管理)
+- **Parse App ID**:`lami-ai`
+- **Schema 管理**:启动时通过 Parse Schema API 自动建表,幂等执行
+- **数据访问**:全部通过 Parse SDK(`parse/node`),使用 masterKey 绕过 ACL 权限
+
+---
+
+## 二、核心设计原则
+
+### 2.1 数据隔离:`roomId + guid` 联合唯一
+
+每条记录都携带 `guid`(设备/账号标识),与业务主键(如 `roomId`)组成联合去重键。
+
+**为什么需要 guid?**
+
+企业微信平台中,一个公司可能有多个员工的企微账号接入。每个账号登录后对应一个独立的设备节点(`guid`)。回调数据中同时包含:
+- `guid` — 来自哪个设备/账号
+- `fromRoomId` — 发生在哪个群
+
+同一个 roomId 可能被多个设备看到(比如同一个群里有多个公司的员工)。如果只按 roomId 去重,不同设备的数据会互相覆盖。
+
+**隔离策略**:所有表的查询/写入都以 `(roomId, guid)` 或 `(userId, guid)` 作为唯一性约束。查询时按 `guid` 过滤,确保只返回属于该设备的数据。
+
+### 2.2 Upsert 模式
+
+群表和成员表不直接 insert,而是先查询是否存在,存在则更新,不存在则创建。原因:
+- 回调事件可能重复推送
+- 群信息可能发生变更(改名、人数增减)
+- 成员可能退出后重新加入
+
+### 2.3 Base64 成员列表解析
+
+群事件中 `changedMemberList` 和 `base64RawData` 字段是 base64 编码的字符串。解码后为分号分隔的 userId 列表。
+
+例如:
+```
+原始 base64:MTY4ODg1MDAwMDAxOzE2ODg4NTAwMDAwMjsxNjg4ODUwMDAwMDM=
+解码后:   "168885000001;168885000002;168885000003"
+解析结果: ["168885000001", "168885000002", "168885000003"]
+```
+
+---
+
+## 三、数据表设计
+
+### 3.1 GroupChat(群聊表)
+
+存储企业微信群聊的基本信息。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `objectId` | String | Parse 自动生成的主键 |
+| `roomId` | String | 企微群 ID(来自回调 `fromRoomId`) |
+| `roomName` | String | 群名称(来自群名变更事件 base64 解码) |
+| `ownerId` | String | 群主企微 userId |
+| `memberCount` | Number | 当前成员数 |
+| `status` | String | `active`(活跃)\| `dismissed`(已解散) |
+| `guid` | String | 所属设备/账号标识 |
+| `createdAt` | Date | Parse 自动,记录创建时间 |
+| `updatedAt` | Date | Parse 自动,记录最后更新时间 |
+
+**索引**:
+- `roomId_guid`:联合索引 `(roomId, guid)` — 用于 upsert 查询
+
+**数据来源**:
+- `msgType=1006`(群创建):写入新记录,含群主和初始成员
+- `msgType=1001`(群名变更):更新 `roomName`
+- `msgType=1023`(群解散):更新 `status=dismissed`
+
+**去重逻辑**:按 `(roomId, guid)` 联合查询,存在则更新已有字段,不存在则创建。
+
+---
+
+### 3.2 GroupMember(群成员表)
+
+记录每个群内成员的加入/退出状态。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `objectId` | String | Parse 自动生成的主键 |
+| `roomId` | String | 所属群 ID |
+| `userId` | String | 成员企微 userId |
+| `nickname` | String | 群内昵称 |
+| `status` | String | `active`(在群)\| `left`(已退出) |
+| `joinedAt` | Date | 入群时间 |
+| `leftAt` | Date | 退群时间(status=left 时记录) |
+| `guid` | String | 所属设备/账号标识 |
+| `createdAt` | Date | Parse 自动 |
+| `updatedAt` | Date | Parse 自动 |
+
+**索引**:
+- `roomId_userId_guid`:联合索引 `(roomId, userId, guid)` — 用于 upsert 查询
+
+**数据来源**:
+- `msgType=1006`(群创建):`changedMemberList` 解码后的所有成员,状态 `active`
+- `msgType=1002`(新增成员):解码后的成员,状态 `active`
+- `msgType=1003`(移除成员):解码后的成员,状态 `left`
+- `msgType=1005`(退群):事件 `senderId`,状态 `left`
+
+**重入群处理**:如果成员之前标记为 `left`,收到再次入群事件时,状态恢复为 `active`,`leftAt` 清空,`joinedAt` 更新为新时间。
+
+---
+
+### 3.3 Message(消息表)
+
+存储群内的文本消息。当前仅存储 `msgType=0` 和 `msgType=2`(纯文本)。
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `objectId` | String | Parse 自动生成的主键 |
+| `msgUniqueIdentifier` | String | 消息唯一标识(来自回调,去重键) |
+| `roomId` | String | 所属群 ID |
+| `senderId` | String | 发送者企微 userId |
+| `content` | String | 文本内容(来自 `msgData.content`) |
+| `atList` | Array | @提及的用户列表(来自 `msgData.atList`) |
+| `msgType` | Number | 消息类型(0 或 2) |
+| `timestamp` | Date | 消息时间戳(Unix timestamp 转换) |
+| `guid` | String | 来源设备 |
+| `createdAt` | Date | Parse 自动 |
+| `updatedAt` | Date | Parse 自动 |
+
+**索引**:
+- `msgUniqueIdentifier`:唯一索引 — 用于消息去重
+
+**存储策略**:
+- **仅存文本**:`msgType=0` 和 `msgType=2` 写入,其他类型(图片/视频/文件/链接等)忽略
+- **去重**:按 `msgUniqueIdentifier` 查重,已存在则跳过
+- **完整存储**:`content` 存文本原文,`atList` 保留 @提及信息
+
+**为什么暂不存非文本消息?**
+
+图片/视频/文件消息的 `msgData` 结构各异(图片有 `fileId`+`fileAeskey`,视频有 `coverImageId`+`duration`),且媒体文件需要通过额外 API 下载。当前阶段业务优先需要文本数据,非文本消息后续按需扩展。
+
+---
+
+## 四、数据流向图
+
+```
+企微平台 POST
+  │
+  ├─ cmd=15000, msgType=1006(群创建)
+  │   ├─ GroupChat.upsert(roomId, guid) → 写入/更新群信息
+  │   └─ base64 解码 changedMemberList
+  │       └─ 逐个 GroupMember.upsert(roomId, userId, guid) → 写入成员
+  │
+  ├─ cmd=15000, msgType=1001(群名变更)
+  │   ├─ base64 解码 rawData → 群名称
+  │   └─ GroupChat.upsert(roomId, guid, { roomName }) → 更新群名
+  │
+  ├─ cmd=15000, msgType=1002(成员加入)
+  │   └─ base64 解码 → 逐个 GroupMember.upsert(status=active)
+  │
+  ├─ cmd=15000, msgType=1003(成员移除)
+  │   └─ base64 解码 → 逐个 GroupMember.upsert(status=left)
+  │
+  ├─ cmd=15000, msgType=1005(成员退群)
+  │   └─ GroupMember.upsert(senderId, status=left)
+  │
+  ├─ cmd=15000, msgType=1023(群解散)
+  │   └─ GroupChat.upsert(roomId, guid, { status: 'dismissed' })
+  │
+  ├─ cmd=15000, msgType=0/2(文本消息)
+  │   ├─ Message.query(msgUniqueIdentifier) → 去重检查
+  │   └─ 不存在 → Message.save()
+  │
+  └─ cmd=15500 / 11016 / 20000
+      └─ 日志记录(暂不入库)
+```
+
+---
+
+## 五、Upsert 逻辑伪代码
+
+```typescript
+async function upsertGroupChat(roomId: string, guid: string, extra: Record<string, any>) {
+  // 1. 查询是否存在
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const existing = await query.first();
+
+  // 2. 存在 → 更新
+  if (existing) {
+    for (const [key, value] of Object.entries(extra)) {
+      if (value !== undefined) existing.set(key, value);
+    }
+    return existing.save(null, { useMasterKey: true });
+  }
+
+  // 3. 不存在 → 创建
+  const obj = new Parse.Object('GroupChat');
+  obj.set('roomId', roomId);
+  obj.set('guid', guid);
+  obj.set('status', 'active');
+  for (const [key, value] of Object.entries(extra)) {
+    if (value !== undefined) obj.set(key, value);
+  }
+  return obj.save(null, { useMasterKey: true });
+}
+```
+
+`GroupMember` 的 upsert 逻辑相同,只是在更新时会额外处理"重入群"场景:如果 `existing.status === 'left'` 且新数据 `status === 'active'`,表示成员重新入群,需要恢复状态和清空退群时间。
+
+---
+
+## 六、扩展指南
+
+### 后续需要新增消息类型时
+
+在 `webhook.service.ts` 中新增 handler(如 `handleImageMessage`),并在 `processWebhookEvent()` 的 `msgType` 分支中添加调用。图片/视频/文件消息需要额外处理媒体文件下载(通过企微 API)。
+
+### 后续需要新增系统消息入库时
+
+`cmd=15500` 的系统消息当前仅记录日志。如需入库(如联系人变动、标签操作),在 15500 分支下按 `msgType` 分发写入对应的新增表。
+
+### 后续需要新增数据表时
+
+在 `schema-setup.ts` 的 `schemas` 数组中新增条目,启动时自动建表。无需手动操作数据库。

+ 302 - 0
backend/docs/deployment-baota.md

@@ -0,0 +1,302 @@
+# 宝塔面板部署指南
+
+## 一、服务器环境要求
+
+| 组件 | 版本要求 | 说明 |
+|------|----------|------|
+| Node.js | >= 18.x | 推荐 20.x LTS |
+| pnpm | >= 8.x | 包管理器(可选,也可用 npm) |
+| PM2 | 最新版 | 进程守护 |
+| 宝塔面板 | 最新版 | 服务器管理面板 |
+
+---
+
+## 二、宝塔面板安装 Node.js 环境
+
+### 2.1 安装 Node.js 版本管理器
+
+1. 登录宝塔面板 → 左侧菜单「软件商店」
+2. 搜索「Node.js 版本管理器」→ 点击安装
+3. 安装完成后,在 Node.js 版本管理器中安装 **v20.x LTS**
+
+### 2.2 安装 PM2
+
+在宝塔终端中执行:
+
+```bash
+npm install -g pm2
+```
+
+### 2.3 安装 pnpm(推荐)
+
+```bash
+npm install -g pnpm
+```
+
+---
+
+## 三、项目部署
+
+### 3.1 上传项目文件
+
+将整个 `qiwe/backend/` 目录上传到服务器,推荐路径:
+
+```
+/www/wwwroot/qiwe/backend/
+```
+
+可以用宝塔「文件」界面直接拖拽上传,或使用 git:
+
+```bash
+cd /www/wwwroot
+git clone <your-repo-url> qiwe
+cd qiwe/backend
+```
+
+### 3.2 上传的文件清单
+
+确保以下关键文件都在服务器上:
+
+```
+backend/
+├── .env                    # 环境变量配置(需要根据服务器调整)
+├── package.json
+├── pnpm-lock.yaml
+├── tsconfig.json
+└── src/
+    ├── index.ts
+    └── apps/
+        └── pc/
+            ├── app.ts
+            ├── health/server.ts
+            └── qiwe/
+                ├── models/
+                │   ├── parse-client.ts
+                │   └── schema-setup.ts
+                ├── routes/webhook.routes.ts
+                ├── controllers/
+                │   ├── webhook.controller.ts
+                │   └── sync.controller.ts
+                └── services/
+                    ├── webhook.service.ts
+                    ├── sync.service.ts
+                    └── qiwe-api.service.ts
+```
+
+### 3.3 安装依赖
+
+```bash
+cd /www/wwwroot/qiwe/backend
+pnpm install
+# 如果使用 npm: npm install
+```
+
+### 3.4 配置环境变量
+
+编辑 `.env` 文件,确认以下配置正确:
+
+```env
+NODE_ENV=production
+PC_PORT=3101
+
+# Parse Server(保持不变)
+PARSE_APP_ID=lami-ai
+PARSE_MASTER_KEY=5s1gfOasPqx9JKsA
+PARSE_SERVER_URL=https://server.sh-lami.com/parse
+
+# QiWe 平台配置(保持不变)
+QIWE_API_BASE=https://manager.qiweapi.com/qiwe
+QIWE_TOKEN=146708f9-1c49-4cf0-aad9-0b7a36834b0b
+QIWE_GUID=810456E2-B009-4915-917F-085EC800AA16
+QIWE_USER_ID=1688857385538777
+```
+
+> **注意**:`NODE_ENV` 改为 `production`。
+
+---
+
+## 四、PM2 进程管理
+
+### 4.1 启动服务
+
+```bash
+cd /www/wwwroot/qiwe/backend
+pm2 start src/index.ts --name qiwe-backend --interpreter npx -- tsx
+```
+
+等价于执行 `npx tsx src/index.ts`,服务监听在 `3101` 端口。
+
+### 4.2 PM2 常用命令
+
+```bash
+pm2 list                  # 查看所有进程
+pm2 logs qiwe-backend     # 查看日志
+pm2 stop qiwe-backend     # 停止
+pm2 restart qiwe-backend  # 重启
+pm2 delete qiwe-backend   # 删除
+pm2 monit                 # 实时监控
+```
+
+### 4.3 设置开机自启
+
+```bash
+pm2 startup
+pm2 save
+```
+
+---
+
+## 五、Nginx 反向代理(宝塔)
+
+### 5.1 添加站点
+
+1. 宝塔面板 → 左侧「网站」→「添加站点」
+2. 域名填写:`47.96.148.66`(或你解析的域名)
+3. PHP 版本选择「纯静态」
+
+### 5.2 配置反向代理
+
+点击站点右侧「设置」→「反向代理」→「添加反向代理」:
+
+| 字段 | 值 |
+|------|-----|
+| 代理名称 | `qiwe-backend` |
+| 目标URL | `http://127.0.0.1:3101` |
+| 发送域名 | `$host` |
+
+或者直接编辑站点配置文件,在 `server` 块中添加:
+
+```nginx
+location /api/qiwe/ {
+    proxy_pass http://127.0.0.1:3101;
+    proxy_http_version 1.1;
+    proxy_set_header Host $host;
+    proxy_set_header X-Real-IP $remote_addr;
+    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+    proxy_set_header X-Forwarded-Proto $scheme;
+    proxy_read_timeout 10s;
+    proxy_connect_timeout 5s;
+}
+```
+
+### 5.3 配置防火墙/安全组
+
+确保服务器安全组开放以下端口:
+
+| 端口 | 协议 | 用途 |
+|------|------|------|
+| 80 | TCP | HTTP(回调接收) |
+| 443 | TCP | HTTPS(如使用) |
+
+宝塔面板默认已开放 80 端口,如果有云服务器安全组,需要在云控制台也放行。
+
+---
+
+## 六、企微平台回调配置
+
+### 6.1 配置回调 URL
+
+登录企微管理平台,在相应的应用/群配置中,将回调 URL 设置为:
+
+```
+http://47.96.148.66/api/qiwe/webhook
+```
+
+### 6.2 验证回调是否生效
+
+在服务器终端查看日志:
+
+```bash
+pm2 logs qiwe-backend
+```
+
+当日志中出现 `[文本消息]`、`[群创建]`、`[成员加入]` 等输出时,说明回调已正常接收。
+
+也可以用 curl 从外部测试:
+
+```bash
+curl -X POST http://47.96.148.66/api/qiwe/webhook -H "Content-Type: application/json" -d '{"code":0,"data":[{"guid":"test","cmd":15000,"msgType":0,"fromRoomId":0,"senderId":123,"msgUniqueIdentifier":"deploy-test","timestamp":1716883200,"msgData":{"content":"deploy test"}}],"msg":"success"}'
+```
+
+---
+
+## 七、更新与维护
+
+### 7.1 更新代码
+
+```bash
+cd /www/wwwroot/qiwe/backend
+git pull                    # 拉取最新代码
+pnpm install                # 安装可能的新依赖
+pm2 restart qiwe-backend    # 重启服务
+```
+
+### 7.2 查看运行状态
+
+```bash
+pm2 status                  # 进程状态
+curl http://127.0.0.1:3101/api/health   # 健康检查
+```
+
+### 7.3 查看数据库
+
+Parse Dashboard 可在线查看数据库:
+
+```bash
+npx parse-dashboard --appId lami-ai --masterKey 5s1gfOasPqx9JKsA --serverURL https://server.sh-lami.com/parse --appName lami-dev --port 4041
+```
+
+然后访问 `http://47.96.148.66:4041`(需要开放 4041 端口或通过 SSH 隧道访问)。
+
+---
+
+## 八、常见问题
+
+### Q1: 端口被占用
+
+```bash
+lsof -i :3101          # 查看占用端口的进程
+kill -9 <PID>          # 结束进程
+```
+
+### Q2: PM2 进程异常退出
+
+```bash
+pm2 logs qiwe-backend --lines 50 --err   # 查看错误日志
+```
+
+常见原因:
+- `.env` 配置缺失
+- Parse Server 连接失败(检查 `PARSE_SERVER_URL` 是否可达)
+- 依赖未安装(运行 `pnpm install`)
+
+### Q3: 反向代理返回 502
+
+检查:
+1. PM2 进程是否运行:`pm2 status`
+2. 端口是否正确:`curl http://127.0.0.1:3101/api/health`
+3. Nginx 配置中 proxy_pass 是否指向正确端口
+
+### Q4: 回调收不到数据
+
+检查:
+1. Nginx 访问日志:`/www/wwwlogs/` 目录下对应站点的 `.log` 文件
+2. 服务器防火墙是否开放 80 端口
+3. 云服务商安全组是否放行 80 端口
+4. 企微平台回调 URL 是否正确配置
+
+---
+
+## 九、快速部署检查清单
+
+- [ ] Node.js v20.x 已安装
+- [ ] PM2 已全局安装
+- [ ] 项目文件已上传到 `/www/wwwroot/qiwe/backend/`
+- [ ] `pnpm install` 执行成功
+- [ ] `.env` 中 `NODE_ENV=production`
+- [ ] PM2 进程正常运行(`pm2 status`)
+- [ ] `curl http://127.0.0.1:3101/api/health` 返回 `{"status":"ok"}`
+- [ ] 宝塔站点已创建,反向代理已配置
+- [ ] `curl http://47.96.148.66/api/qiwe/webhook` 返回正常
+- [ ] 企微平台回调 URL 已配置为 `http://47.96.148.66/api/qiwe/webhook`
+- [ ] PM2 已设置开机自启(`pm2 save`)

+ 220 - 0
backend/docs/group-organization-schema.md

@@ -0,0 +1,220 @@
+# 客户群组织资产 — 数据库扩展设计说明
+
+> 文档版本:2026-05-29  
+> 适用范围:`backend/backend` Parse 数据库 + 客户群管理页筛选功能
+
+---
+
+## 一、设计背景
+
+### 1.1 原有问题
+
+`GroupChat` 表最初只为**企微 Webhook 同步**设计,仅保存:
+
+- 群 ID、群名、群主、成员数、状态、设备 guid
+
+客户群管理页需要按**门店、活跃度、健康状态、文档状态**筛选,但这些字段在数据库中不存在,导致:
+
+1. 筛选项写死在前端(上海总部 / 北京旗舰店 / 深圳体验店)
+2. 列表中的门店、活跃度、健康分由前端临时计算
+3. 筛选只在浏览器本地执行,无法按真实数据过滤
+
+### 1.2 设计目标
+
+在**不破坏企微同步逻辑**的前提下:
+
+1. 扩展 `GroupChat` 业务字段,支持筛选与展示
+2. 新增 `Store` 表,门店下拉从数据库读取
+3. 预留 `Community` 表,支持后续「小区—群—门店」关系维护
+4. 启动时自动建表、补字段、种子数据迁移
+
+---
+
+## 二、设计原则
+
+| 原则 | 说明 |
+|------|------|
+| **分层存储** | 企微原始字段与业务扩展字段共存于 `GroupChat`,同步时只更新企微字段,不覆盖门店等业务字段 |
+| **默认归属** | 新同步的群默认归属第一家门店(上海总部),后续由运营手工调整 |
+| **指标可计算** | 活跃度、健康分根据消息数、成员数实时计算后写回数据库,供筛选使用 |
+| **幂等启动** | Schema、种子门店、历史数据补全均在服务启动时自动执行,可重复运行 |
+
+---
+
+## 三、表结构说明
+
+### 3.1 GroupChat(群聊表 — 扩展字段)
+
+> 原有字段见 [database-design.md](./database-design.md),此处仅列**新增业务字段**。
+
+| 字段 | 类型 | 说明 | 数据来源 |
+|------|------|------|----------|
+| `ownerName` | String | 负责人显示名 | 暂用企微 `ownerId`,后续可关联用户表 |
+| `storeId` | String | 归属门店 ID(Parse objectId) | 默认第一家门店;运营维护 |
+| `storeName` | String | 归属门店名称(冗余,便于展示) | 同上 |
+| `communityId` | String | 归属小区 ID | 运营维护,暂为空 |
+| `communityName` | String | 归属小区名称 | 运营维护,暂为空 |
+| `activityLevel` | String | 活跃度:`high` / `medium` / `low` / `inactive` | 根据今日消息数计算 |
+| `healthScore` | Number | 健康分 0–100 | 根据成员数、消息数、文档状态计算 |
+| `healthStatus` | String | 健康状态:`healthy` / `warning` / `critical` | 由健康分推导 |
+| `hasDocument` | Boolean | 是否已登记沟通记录文档 | 默认 `false`,合规模块后续写入 |
+| `documentPinned` | Boolean | 文档是否置顶 | 默认 `false` |
+| `documentInNotice` | Boolean | 公告是否含文档链接 | 默认 `false` |
+| `messageCountToday` | Number | 今日消息数 | 从 `Message` 表统计 |
+| `memberChange24h` | Number | 24 小时成员变化 | 默认 `0`,后续由成员事件计算 |
+
+**为什么门店信息冗余存储 `storeName`?**  
+列表页高频展示,避免每次查询再关联 `Store` 表;`storeId` 仍作为筛选与关联主键。
+
+**为什么活跃度/健康分写入数据库而不是只在前端算?**  
+筛选条件需要后端 `Parse.Query.equalTo('activityLevel', ...)` 直接过滤,必须持久化。
+
+---
+
+### 3.2 Store(门店表)— 新建
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `code` | String | 门店编码,如 `SH001` |
+| `name` | String | 门店名称,如「上海总部」 |
+| `region` | String | 所属区域,如「华东」 |
+| `status` | String | `active` / `inactive` |
+
+**种子数据(启动时自动写入):**
+
+| code | name | region |
+|------|------|--------|
+| SH001 | 上海总部 | 华东 |
+| BJ001 | 北京旗舰店 | 华北 |
+| SZ001 | 深圳体验店 | 华南 |
+
+**用途:** 客户群管理页「门店」下拉选项的数据来源。
+
+---
+
+### 3.3 Community(小区表)— 新建(预留)
+
+| 字段 | 类型 | 说明 |
+|------|------|------|
+| `name` | String | 小区名称 |
+| `storeId` | String | 归属门店 ID |
+| `storeName` | String | 归属门店名称 |
+| `address` | String | 地址 |
+| `status` | String | `active` / `inactive` |
+
+**当前状态:** 仅建表,暂无种子数据。  
+**后续用途:** 维护「小区—群—门店」对应关系(见功能清单模块 2)。
+
+---
+
+## 四、指标计算规则
+
+### 4.1 活跃度 `activityLevel`
+
+| 条件 | 结果 |
+|------|------|
+| 今日消息 ≥ 20 | `high` |
+| 今日消息 ≥ 5 | `medium` |
+| 今日消息 ≥ 1 或成员数 > 0 | `low` |
+| 其他 | `inactive` |
+
+### 4.2 健康分 `healthScore`
+
+```
+基础分 55
++ 成员数 ≥ 50 → +15;≥ 10 → +8
++ min(今日消息数, 15)
++ 有沟通记录文档 → +10
+群已解散 → 0
+最终限制在 0–100
+```
+
+**注意**:门店、小区、负责人姓名等字段**不会自动填充占位值**。数据库中无真实归属时保持为空,前端显示「—」。
+
+### 4.3 健康状态 `healthStatus`
+
+| 条件 | 结果 |
+|------|------|
+| 群已解散 或 健康分 < 50 | `critical` |
+| 健康分 < 70 | `warning` |
+| 其他 | `healthy` |
+
+---
+
+## 五、API 变更
+
+### 5.1 群列表(支持筛选)
+
+```
+GET /api/qiwe/groups?storeId=&activityLevel=&healthStatus=&hasDocument=&q=
+```
+
+| 参数 | 说明 |
+|------|------|
+| `storeId` | 门店 Parse objectId |
+| `activityLevel` | high / medium / low / inactive |
+| `healthStatus` | healthy / warning / critical |
+| `hasDocument` | true / false |
+| `q` | 关键词(群名、负责人、小区、门店) |
+
+**响应新增:** `stores` 数组,供前端渲染门店下拉。
+
+### 5.2 门店列表
+
+```
+GET /api/qiwe/stores
+```
+
+返回所有 `status=active` 的门店。
+
+---
+
+## 六、启动时自动执行
+
+服务启动顺序(`src/index.ts`):
+
+1. `ensureSchemas()` — 建表/补字段(幂等)
+2. `bootstrapAuth()` — 用户与角色
+3. `bootstrapOrganization()` — 门店种子 + 历史群业务字段补全
+
+日志示例:
+
+```
+[Schema] GroupChat — updated
+[Schema] Store — created
+[Organization] 门店数据已同步:新增 3,更新 0
+[Organization] 已补全 5 个群的业务字段默认值
+```
+
+---
+
+## 七、与企微同步的兼容性
+
+| 操作 | 是否覆盖业务字段 |
+|------|------------------|
+| 从企微同步(`sync-groups`) | 否,仅更新群名/群主/成员数/头像/状态;**不写入门店** |
+| Webhook 群事件 | 否,新建群时不写入门店/小区占位值 |
+| 列表查询(`listGroupChats`) | 会重算并写回活跃度/健康分/今日消息数 |
+| 启动迁移 | 一次性清除历史占位门店/小区数据 |
+
+---
+
+## 八、后续扩展建议
+
+1. **小区关联**:在 `Community` 表录入数据后,提供接口维护 `GroupChat.communityId`
+2. **负责人姓名**:在 `AppUser` 增加 `wecomUserId` 字段,与 `ownerId` 关联
+3. **文档状态**:合规模块识别到沟通记录后,更新 `hasDocument` / `documentPinned`
+4. **成员变化**:根据 `GroupMember` 入退群事件计算 `memberChange24h`
+
+---
+
+## 九、相关代码文件
+
+| 文件 | 职责 |
+|------|------|
+| `src/shared/db/schema-setup.ts` | Schema 定义 |
+| `src/shared/db/organization-bootstrap.ts` | 组织数据启动入口 |
+| `src/apps/pc/qiwe/services/organization.service.ts` | 门店 CRUD |
+| `src/apps/pc/qiwe/services/groups.service.ts` | 群列表、指标计算、字段迁移 |
+| `src/apps/pc/qiwe/utils/group-metrics.util.ts` | 活跃度/健康分算法 |
+| `src/app/features/group-management/group-list/` | 前端筛选接入 |

+ 295 - 0
backend/docs/后端目录规范.md

@@ -0,0 +1,295 @@
+# 📐 Express 后端项目架构与开发规范文档(强隔离 · 多端口 · 分模块)
+
+## 一、架构设计理念
+
+本项目后端采用 **“强隔离 + 多端口 + 分模块”** 的 BFF(Backend For Frontend)风格架构。
+
+**核心思想:**
+- 同一个 `backend` 仓库中,PC 端与移动端的接口实现彼此隔离(避免互相影响)。
+- PC 端再按业务模块拆分为多个独立的 Express App,每个模块 **独立端口**(满足你“分模块、分端口”的诉求)。
+- 通用能力(环境变量解析、通用中间件、错误模型、工具函数)集中在 `shared/`,作为唯一允许跨模块复用的区域。
+
+**为什么要分端口?**
+- 模块级独立发布/重启:某个模块改动或异常时不影响其他模块。
+- 权限与流量隔离:可以在网关或反向代理层按端口做更明确的访问控制/限流。
+- 未来可拆仓库:把某个模块目录剪切出去即可形成独立服务,迁移成本更低。
+
+---
+
+## 二、目录结构概览
+
+后端代码根目录:`backend/src`
+
+```text
+backend/
+└── src/
+    ├── index.ts                          # [总启动器] 启动 PC 和 Mobile 两个 Express 实例
+    │
+    ├── apps/                             # [应用隔离区] 端 + 模块 的物理隔离
+    │   ├── pc/                           # [💻 PC 端隔离区] 所有模块共用一个端口
+    │   │   ├── app.ts                    # [统一装配] 创建 Express app,注册所有模块路由
+    │   │   ├── health/                   # 健康检查
+    │   │   │   ├── server.ts             # PC 端入口(监听 PC_PORT)
+    │   │   │   ├── routes/
+    │   │   │   └── controllers/
+    │   │   │
+    │   │   ├── qiwei/                    # PC - 企微API代理 [模块1]
+    │   │   │   ├── routes/
+    │   │   │   ├── controllers/
+    │   │   │   ├── services/
+    │   │   │   └── models/
+    │   │   │
+    │   │   ├── staff/                    # PC - 人员管理 [模块1]
+    │   │   ├── community/                # PC - 小区管理 [模块2]
+    │   │   ├── room/                     # PC - 群管理 [模块2]
+    │   │   ├── compliance/               # PC - 合规检查 [模块4]
+    │   │   ├── risk/                     # PC - 群风控 [模块5]
+    │   │   ├── content/                  # PC - 内容运营 [模块6]
+    │   │   ├── koc/                      # PC - KOC与意向 [模块7]
+    │   │   ├── dashboard/                # PC - 数据看板 [模块8]
+    │   │   ├── sales/                    # PC - 经营数据 [模块9]
+    │   │   └── workbench/                # PC - 工作台 [模块10]
+    │   │
+    │   └── mobile/                       # [📱 移动端隔离区]
+    │       └── chat/                     # Mobile - AI聊天(独立端口)
+    │           ├── app.ts
+    │           ├── server.ts
+    │           ├── routes/
+    │           └── controllers/
+    │
+    └── shared/                           # [共享区] 唯一允许跨模块复用的区域
+        ├── config/                       # 环境变量读取
+        ├── http/                         # 通用 HTTP 能力(响应封装/错误处理)
+        ├── errors/                       # 通用错误类型(AppError)
+        ├── qiwei/                        # QiWe API 客户端
+        ├── types/                        # 全局类型(仅 truly-shared)
+        └── utils/                        # 工具函数
+```
+
+---
+
+## 三、核心开发纪律(必须遵守)
+
+### 🚫 1. 绝对禁止跨模块导入(防越界法则)
+
+为了保证“模块独立端口”真正成立:
+- `apps/pc/<module>/` **禁止**导入 `apps/pc/<other-module>/` 的任何文件。
+- `apps/mobile/<module>/` **禁止**导入 `apps/mobile/<other-module>/` 的任何文件。
+- `apps/pc/**` 与 `apps/mobile/**` 之间也 **禁止互相导入**。
+- **唯一例外**:允许导入 `shared/**`。
+
+如果双端/多模块存在相似实现:
+- 业务逻辑允许重复(解耦优先)。
+- 只有“纯工具 / 纯协议 / 纯错误模型”才放进 `shared/`。
+
+### 🚫 2. 禁止在 `src/` 根目录堆业务代码
+
+`src/` 根目录只允许:
+- `index.ts`(总启动器)
+- `apps/`(隔离区)
+- `shared/`(共享区)
+
+除此之外不应出现具体业务 controller/service/model。
+
+---
+
+## 四、端口与服务拆分规范
+
+### 1. 端口规划
+
+采用**「一端一端口」**策略,同一端下的所有模块共用一个 Express App 和端口。
+
+当前规划:
+
+| 端 | 环境变量 | 默认端口 | 包含模块 |
+|---|---|---|---|
+| PC | `PC_PORT` | 3101 | health / qiwei / staff / community / room / compliance / risk / content / koc / dashboard / sales / workbench |
+| Mobile | `MOBILE_CHAT_PORT` | 3201 | chat 等 |
+
+**为什么不用模块级端口?**
+
+- 当前阶段各模块无独立发布、独立扩容的强需求
+- 模块级端口意味着前端需要管理 12 个 baseURL,开发和部署成本高
+- 模块目录结构和分层(models/services/controllers/routes)已保证逻辑隔离
+- 未来某模块需要独立部署时,给它加一个 `server.ts` 即可拆分出去
+
+### 2. URL 规范
+
+所有 PC 模块路由统一挂载在 `/api` 前缀下:
+- `http://localhost:3101/api/health` — 健康检查
+- `http://localhost:3101/api/staff` — 人员管理
+- `http://localhost:3101/api/rooms` — 群管理
+
+前端只需配置一个 baseURL:`http://localhost:3101/api`
+
+### 3. 当前是否引入 API Gateway
+
+不需要。PC 端已有一个统一的 `apps/pc/app.ts` 充当”装配层”:
+- 创建唯一的 Express 实例
+- 注册所有模块路由
+- 共享同一套中间件(cors、json 解析、日志、错误处理)
+
+这本质上就是 Gateway 和模块路由的合并实现,等未来有模块需要独立端口时再做拆分。
+
+---
+
+## 五、模块内部代码分层规范
+
+每个模块建议遵循固定分层,以降低心智负担:
+
+### 1. `routes/`(路由层)
+
+职责:
+- 定义 URL 与 controller 映射
+- 只做”路由组织”,不写业务逻辑
+
+### 2. `controllers/`(控制器层)
+
+职责:
+- 解析请求参数(path/query/body)
+- 调用 service
+- 统一响应格式
+
+要求:
+- controller 内不直接写复杂业务逻辑
+- 外部依赖(数据库/第三方接口)应在 service 或 client 中处理
+
+### 3. `services/`(业务层)
+
+职责:
+- 编排业务流程
+- 聚合多个数据源
+- 产出业务结果或抛出业务错误(AppError)
+
+### 4. `models/` 与 `validators/`
+
+建议:
+- `models/` 存 DTO、业务对象、接口响应结构类型
+- `validators/` 存入参校验逻辑
+
+### 5. `app.ts` 与 `server.ts`(端级入口)
+
+`apps/pc/app.ts` 是 PC 端唯一的 Express 装配层:创建 app、注册所有模块路由、挂载中间件和错误处理。
+`apps/pc/health/server.ts` 是 PC 端的监听入口:读取 `PC_PORT`,调用 `app.listen()`。
+
+**当前阶段模块不需要自己的 app.ts 和 server.ts**。它们只需要 routes → controllers → services → models 四层。
+未来某模块需要独立部署时,再给它加 `server.ts` 并单独监听端口即可。
+
+---
+
+## 六、`shared/` 共享区规范
+
+`shared/` 的存在是为了减少“不可避免的重复”,但必须克制使用。
+
+允许放入 `shared/` 的内容:
+- 环境变量读取与校验(例如 `shared/config/env.ts`)
+- 通用错误类型(例如 `shared/errors/app-error.ts`)
+- 通用 HTTP 中间件(例如统一错误处理、统一响应包装)
+- 与业务无关的工具函数(例如日期格式化、字符串处理)
+- 与端无关的协议类型(例如分页参数 `PageQuery`)
+
+禁止放入 `shared/` 的内容:
+- 任何“某个模块独有”的业务规则
+- 与某个端强绑定的鉴权逻辑(PC / Mobile 通常不同)
+- 依赖某个模块模型的工具(会引入反向耦合)
+
+---
+
+## 七、环境变量与配置规范
+
+### 1. `.env` 示例(建议)
+
+后端已使用 `dotenv`,建议将端口集中到环境变量中管理:
+
+```ini
+NODE_ENV=development
+
+PC_USER_PORT=3101
+PC_ORDER_PORT=3102
+
+MOBILE_USER_PORT=3201
+MOBILE_CONTENT_PORT=3202
+```
+
+### 2. 端口读取规范
+
+约定:每个模块只读取属于自己的端口变量,避免“误用其他模块端口”。
+
+---
+
+## 八、总启动器 `src/index.ts` 的职责边界
+
+`src/index.ts` 作为“进程内多端口启动器”,职责应保持克制:
+- 加载环境变量(`dotenv.config()`)
+- 启动各模块 server(例如 import 并调用 `startPcUserServer()`)
+- 处理进程级事件(例如 `SIGINT` 优雅退出,按需)
+
+它不应该:
+- 直接注册路由
+- 承载业务逻辑
+- 写模块内部的中间件细节
+
+---
+
+## 九、命名规范
+
+| 层级/类型 | 命名约定 | 示例 |
+|---|---|---|
+| 模块目录 | kebab 或 plain(统一即可) | `apps/pc/user` |
+| 路由文件 | `[resource].routes.ts` | `routes/user.routes.ts` |
+| 控制器 | `[resource].controller.ts` | `controllers/user.controller.ts` |
+| 服务 | `[domain].service.ts` | `services/user.service.ts` |
+| 模型/DTO | `[entity].model.ts` / `[name].dto.ts` | `models/user.model.ts` |
+| 校验 | `[name].validator.ts` | `validators/create-user.validator.ts` |
+
+---
+
+## 十、接口实现建议(最小可落地模板)
+
+每个模块建议至少包含:
+- 健康检查:`GET /api/health`
+- 版本信息:`GET /api/version`(可选)
+- 统一错误返回格式(来自 `shared/http`)
+
+统一响应格式(建议):
+
+```json
+{
+  "success": true,
+  "data": {},
+  "error": null
+}
+```
+
+错误响应(建议):
+
+```json
+{
+  "success": false,
+  "data": null,
+  "error": {
+    "message": "xxx",
+    "code": "SOME_ERROR_CODE"
+  }
+}
+```
+
+---
+
+## 十一、迁移当前 `backend/src/index.ts` 的建议落点
+
+你当前 `src/index.ts` 里包含了 `/api/chat` 与 `/api/health`,并且是单端口单应用形态。
+
+按本规范迁移时(建议,不要求一次做完):
+- 如果 `/api/chat` 属于 PC 或 Mobile 的某个模块:移动到对应的 `apps/<端>/<模块>/` 下
+- `src/index.ts` 只保留“启动多个模块 server”逻辑
+
+---
+
+## 十二、维护指南(接手/扩展时怎么做)
+
+1. 新增接口:先明确属于哪个端(pc/mobile),再明确属于哪个模块目录。
+2. 新增模块:在 `apps/<端>/<模块>/` 建目录,并新增端口环境变量。
+3. 改动共享能力:优先评估是否会影响所有模块;共享区变更需要更严格的自测。
+4. 需要复用但又不适合进 `shared/`:宁可复制一份(保持隔离),不要为了复用而耦合。
+

+ 1634 - 0
backend/package-lock.json

@@ -0,0 +1,1634 @@
+{
+  "name": "qiwe-backend",
+  "version": "1.0.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "qiwe-backend",
+      "version": "1.0.0",
+      "dependencies": {
+        "cors": "^2.8.5",
+        "dotenv": "^16.4.0",
+        "express": "^4.21.0",
+        "parse": "^5.3.0"
+      },
+      "devDependencies": {
+        "@types/cors": "^2.8.17",
+        "@types/express": "^5.0.0",
+        "@types/node": "^22.0.0",
+        "tsx": "^4.19.0",
+        "typescript": "^5.5.0"
+      }
+    },
+    "node_modules/@babel/runtime-corejs3": {
+      "version": "7.24.7",
+      "resolved": "https://registry.npmmirror.com/@babel/runtime-corejs3/-/runtime-corejs3-7.24.7.tgz",
+      "integrity": "sha512-eytSX6JLBY6PVAeQa2bFlDx/7Mmln/gaEpsit5a3WEvjGfiIytEsgAwuIXCPM0xvw0v0cJn3ilq0/TvXrW0kgA==",
+      "license": "MIT",
+      "dependencies": {
+        "core-js-pure": "^3.30.2",
+        "regenerator-runtime": "^0.14.0"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.0.tgz",
+      "integrity": "sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.28.0.tgz",
+      "integrity": "sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.28.0.tgz",
+      "integrity": "sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.28.0.tgz",
+      "integrity": "sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.0.tgz",
+      "integrity": "sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.28.0.tgz",
+      "integrity": "sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.0.tgz",
+      "integrity": "sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.28.0.tgz",
+      "integrity": "sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.28.0.tgz",
+      "integrity": "sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.28.0.tgz",
+      "integrity": "sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.28.0.tgz",
+      "integrity": "sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.0.tgz",
+      "integrity": "sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.0.tgz",
+      "integrity": "sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.0.tgz",
+      "integrity": "sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.28.0.tgz",
+      "integrity": "sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.28.0.tgz",
+      "integrity": "sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.0.tgz",
+      "integrity": "sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.0.tgz",
+      "integrity": "sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.0.tgz",
+      "integrity": "sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/openharmony-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.0.tgz",
+      "integrity": "sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.28.0.tgz",
+      "integrity": "sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.28.0.tgz",
+      "integrity": "sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.28.0.tgz",
+      "integrity": "sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.28.0.tgz",
+      "integrity": "sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/@types/body-parser": {
+      "version": "1.19.6",
+      "resolved": "https://registry.npmmirror.com/@types/body-parser/-/body-parser-1.19.6.tgz",
+      "integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/connect": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/connect": {
+      "version": "3.4.38",
+      "resolved": "https://registry.npmmirror.com/@types/connect/-/connect-3.4.38.tgz",
+      "integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/cors": {
+      "version": "2.8.19",
+      "resolved": "https://registry.npmmirror.com/@types/cors/-/cors-2.8.19.tgz",
+      "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/express": {
+      "version": "5.0.6",
+      "resolved": "https://registry.npmmirror.com/@types/express/-/express-5.0.6.tgz",
+      "integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/body-parser": "*",
+        "@types/express-serve-static-core": "^5.0.0",
+        "@types/serve-static": "^2"
+      }
+    },
+    "node_modules/@types/express-serve-static-core": {
+      "version": "5.1.1",
+      "resolved": "https://registry.npmmirror.com/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
+      "integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "@types/qs": "*",
+        "@types/range-parser": "*",
+        "@types/send": "*"
+      }
+    },
+    "node_modules/@types/http-errors": {
+      "version": "2.0.5",
+      "resolved": "https://registry.npmmirror.com/@types/http-errors/-/http-errors-2.0.5.tgz",
+      "integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/node": {
+      "version": "22.19.20",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.19.20.tgz",
+      "integrity": "sha512-6tELRwSDYWW9EdZhbeZmYGZ1/7Djkt+Ah3/ScEYT9cDord7UJzasR/4D3VONg9tQI5CDp+/CZC1AXj2pCFOvpw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmmirror.com/@types/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/range-parser": {
+      "version": "1.2.7",
+      "resolved": "https://registry.npmmirror.com/@types/range-parser/-/range-parser-1.2.7.tgz",
+      "integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/send": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/@types/send/-/send-1.2.1.tgz",
+      "integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*"
+      }
+    },
+    "node_modules/@types/serve-static": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmmirror.com/@types/serve-static/-/serve-static-2.2.0.tgz",
+      "integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/http-errors": "*",
+        "@types/node": "*"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmmirror.com/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmmirror.com/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmmirror.com/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://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "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://registry.npmmirror.com/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/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmmirror.com/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmmirror.com/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://registry.npmmirror.com/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmmirror.com/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/core-js-pure": {
+      "version": "3.49.0",
+      "resolved": "https://registry.npmmirror.com/core-js-pure/-/core-js-pure-3.49.0.tgz",
+      "integrity": "sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/core-js"
+      }
+    },
+    "node_modules/cors": {
+      "version": "2.8.6",
+      "resolved": "https://registry.npmmirror.com/cors/-/cors-2.8.6.tgz",
+      "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==",
+      "license": "MIT",
+      "dependencies": {
+        "object-assign": "^4",
+        "vary": "^1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/crypto-js": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmmirror.com/crypto-js/-/crypto-js-4.2.0.tgz",
+      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmmirror.com/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmmirror.com/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/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://registry.npmmirror.com/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://registry.npmmirror.com/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://registry.npmmirror.com/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://registry.npmmirror.com/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.2",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+      "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.28.0",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.28.0.tgz",
+      "integrity": "sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.28.0",
+        "@esbuild/android-arm": "0.28.0",
+        "@esbuild/android-arm64": "0.28.0",
+        "@esbuild/android-x64": "0.28.0",
+        "@esbuild/darwin-arm64": "0.28.0",
+        "@esbuild/darwin-x64": "0.28.0",
+        "@esbuild/freebsd-arm64": "0.28.0",
+        "@esbuild/freebsd-x64": "0.28.0",
+        "@esbuild/linux-arm": "0.28.0",
+        "@esbuild/linux-arm64": "0.28.0",
+        "@esbuild/linux-ia32": "0.28.0",
+        "@esbuild/linux-loong64": "0.28.0",
+        "@esbuild/linux-mips64el": "0.28.0",
+        "@esbuild/linux-ppc64": "0.28.0",
+        "@esbuild/linux-riscv64": "0.28.0",
+        "@esbuild/linux-s390x": "0.28.0",
+        "@esbuild/linux-x64": "0.28.0",
+        "@esbuild/netbsd-arm64": "0.28.0",
+        "@esbuild/netbsd-x64": "0.28.0",
+        "@esbuild/openbsd-arm64": "0.28.0",
+        "@esbuild/openbsd-x64": "0.28.0",
+        "@esbuild/openharmony-arm64": "0.28.0",
+        "@esbuild/sunos-x64": "0.28.0",
+        "@esbuild/win32-arm64": "0.28.0",
+        "@esbuild/win32-ia32": "0.28.0",
+        "@esbuild/win32-x64": "0.28.0"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/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://registry.npmmirror.com/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.2",
+      "resolved": "https://registry.npmmirror.com/express/-/express-4.22.2.tgz",
+      "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.5",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.15.1",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmmirror.com/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmmirror.com/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmmirror.com/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/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://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "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://registry.npmmirror.com/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://registry.npmmirror.com/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://registry.npmmirror.com/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.4",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.4.tgz",
+      "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmmirror.com/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "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.4.24",
+      "resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/idb-keyval": {
+      "version": "6.2.1",
+      "resolved": "https://registry.npmmirror.com/idb-keyval/-/idb-keyval-6.2.1.tgz",
+      "integrity": "sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==",
+      "license": "Apache-2.0"
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmmirror.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmmirror.com/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmmirror.com/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmmirror.com/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/object-assign": {
+      "version": "4.1.1",
+      "resolved": "https://registry.npmmirror.com/object-assign/-/object-assign-4.1.1.tgz",
+      "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmmirror.com/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://registry.npmmirror.com/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/parse": {
+      "version": "5.3.0",
+      "resolved": "https://registry.npmmirror.com/parse/-/parse-5.3.0.tgz",
+      "integrity": "sha512-mWBnE6hHJhdvlx5KPQcYgCGRdgqKhPw+5fSC0j7vOfse3Lkh3xtDwOfmDpvv2LXZVBj72G/mgVKMRmbAICRzkQ==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "@babel/runtime-corejs3": "7.24.7",
+        "idb-keyval": "6.2.1",
+        "react-native-crypto-js": "1.0.0",
+        "uuid": "10.0.0",
+        "ws": "8.17.1",
+        "xmlhttprequest": "1.8.0"
+      },
+      "engines": {
+        "node": "18 || 19 || 20 || 22"
+      },
+      "optionalDependencies": {
+        "crypto-js": "4.2.0"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmmirror.com/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmmirror.com/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmmirror.com/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.2",
+      "resolved": "https://registry.npmmirror.com/qs/-/qs-6.15.2.tgz",
+      "integrity": "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==",
+      "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://registry.npmmirror.com/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": "2.5.3",
+      "resolved": "https://registry.npmmirror.com/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/react-native-crypto-js": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/react-native-crypto-js/-/react-native-crypto-js-1.0.0.tgz",
+      "integrity": "sha512-FNbLuG/HAdapQoybeZSoes1PWdOj0w242gb+e1R0hicf3Gyj/Mf8M9NaED2AnXVOX01b2FXomwUiw1xP1K+8sA==",
+      "license": "MIT"
+    },
+    "node_modules/regenerator-runtime": {
+      "version": "0.14.1",
+      "resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz",
+      "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==",
+      "license": "MIT"
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmmirror.com/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://registry.npmmirror.com/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmmirror.com/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmmirror.com/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmmirror.com/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/side-channel/-/side-channel-1.1.1.tgz",
+      "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4",
+        "side-channel-list": "^1.0.1",
+        "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://registry.npmmirror.com/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://registry.npmmirror.com/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://registry.npmmirror.com/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://registry.npmmirror.com/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tsx": {
+      "version": "4.22.4",
+      "resolved": "https://registry.npmmirror.com/tsx/-/tsx-4.22.4.tgz",
+      "integrity": "sha512-X8EX+XV4QR5xCsrgxaED954zTDfY8KqlDtskKEL0cHhyS/P8b4IFOvGDQpsC9Q1XnLq915wEfwwY/zzskCtmhg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "~0.28.0"
+      },
+      "bin": {
+        "tsx": "dist/cli.mjs"
+      },
+      "engines": {
+        "node": ">=18.0.0"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      }
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmmirror.com/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/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/uuid": {
+      "version": "10.0.0",
+      "resolved": "https://registry.npmmirror.com/uuid/-/uuid-10.0.0.tgz",
+      "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
+      "funding": [
+        "https://github.com/sponsors/broofa",
+        "https://github.com/sponsors/ctavan"
+      ],
+      "license": "MIT",
+      "bin": {
+        "uuid": "dist/bin/uuid"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/ws": {
+      "version": "8.17.1",
+      "resolved": "https://registry.npmmirror.com/ws/-/ws-8.17.1.tgz",
+      "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.0.0"
+      },
+      "peerDependencies": {
+        "bufferutil": "^4.0.1",
+        "utf-8-validate": ">=5.0.2"
+      },
+      "peerDependenciesMeta": {
+        "bufferutil": {
+          "optional": true
+        },
+        "utf-8-validate": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/xmlhttprequest": {
+      "version": "1.8.0",
+      "resolved": "https://registry.npmmirror.com/xmlhttprequest/-/xmlhttprequest-1.8.0.tgz",
+      "integrity": "sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    }
+  }
+}

+ 23 - 0
backend/package.json

@@ -0,0 +1,23 @@
+{
+  "name": "qiwe-backend",
+  "version": "1.0.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "tsx watch src/index.ts",
+    "start": "tsx src/index.ts"
+  },
+  "dependencies": {
+    "cors": "^2.8.5",
+    "dotenv": "^16.4.0",
+    "express": "^4.21.0",
+    "parse": "^5.3.0"
+  },
+  "devDependencies": {
+    "@types/cors": "^2.8.17",
+    "@types/express": "^5.0.0",
+    "@types/node": "^22.0.0",
+    "tsx": "^4.19.0",
+    "typescript": "^5.5.0"
+  }
+}

+ 1081 - 0
backend/pnpm-lock.yaml

@@ -0,0 +1,1081 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      cors:
+        specifier: ^2.8.5
+        version: 2.8.6
+      dotenv:
+        specifier: ^16.4.0
+        version: 16.6.1
+      express:
+        specifier: ^4.21.0
+        version: 4.22.2
+      parse:
+        specifier: ^5.3.0
+        version: 5.3.0
+    devDependencies:
+      '@types/cors':
+        specifier: ^2.8.17
+        version: 2.8.19
+      '@types/express':
+        specifier: ^5.0.0
+        version: 5.0.6
+      '@types/node':
+        specifier: ^22.0.0
+        version: 22.19.19
+      tsx:
+        specifier: ^4.19.0
+        version: 4.22.3
+      typescript:
+        specifier: ^5.5.0
+        version: 5.9.3
+
+packages:
+
+  '@babel/runtime-corejs3@7.24.7':
+    resolution: {integrity: sha512-eytSX6JLBY6PVAeQa2bFlDx/7Mmln/gaEpsit5a3WEvjGfiIytEsgAwuIXCPM0xvw0v0cJn3ilq0/TvXrW0kgA==}
+    engines: {node: '>=6.9.0'}
+
+  '@esbuild/aix-ppc64@0.28.0':
+    resolution: {integrity: sha512-lhRUCeuOyJQURhTxl4WkpFTjIsbDayJHih5kZC1giwE+MhIzAb7mEsQMqMf18rHLsrb5qI1tafG20mLxEWcWlA==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.28.0':
+    resolution: {integrity: sha512-+WzIXQOSaGs33tLEgYPYe/yQHf0WTU0X42Jca3y8NWMbUVhp7rUnw+vAsRC/QiDrdD31IszMrZy+qwPOPjd+rw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.28.0':
+    resolution: {integrity: sha512-wqh0ByljabXLKHeWXYLqoJ5jKC4XBaw6Hk08OfMrCRd2nP2ZQ5eleDZC41XHyCNgktBGYMbqnrJKq/K/lzPMSQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.28.0':
+    resolution: {integrity: sha512-+VJggoaKhk2VNNqVL7f6S189UzShHC/mR9EE8rDdSkdpN0KflSwWY/gWjDrNxxisg8Fp1ZCD9jLMo4m0OUfeUA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.28.0':
+    resolution: {integrity: sha512-0T+A9WZm+bZ84nZBtk1ckYsOvyA3x7e2Acj1KdVfV4/2tdG4fzUp91YHx+GArWLtwqp77pBXVCPn2We7Letr0Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.28.0':
+    resolution: {integrity: sha512-fyzLm/DLDl/84OCfp2f/XQ4flmORsjU7VKt8HLjvIXChJoFFOIL6pLJPH4Yhd1n1gGFF9mPwtlN5Wf82DZs+LQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.28.0':
+    resolution: {integrity: sha512-l9GeW5UZBT9k9brBYI+0WDffcRxgHQD8ShN2Ur4xWq/NFzUKm3k5lsH4PdaRgb2w7mI9u61nr2gI2mLI27Nh3Q==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.28.0':
+    resolution: {integrity: sha512-BXoQai/A0wPO6Es3yFJ7APCiKGc1tdAEOgeTNy3SsB491S3aHn4S4r3e976eUnPdU+NbdtmBuLncYir2tMU9Nw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.28.0':
+    resolution: {integrity: sha512-RVyzfb3FWsGA55n6WY0MEIEPURL1FcbhFE6BffZEMEekfCzCIMtB5yyDcFnVbTnwk+CLAgTujmV/Lgvih56W+A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.28.0':
+    resolution: {integrity: sha512-CjaaREJagqJp7iTaNQjjidaNbCKYcd4IDkzbwwxtSvjI7NZm79qiHc8HqciMddQ6CKvJT6aBd8lO9kN/ZudLlw==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.28.0':
+    resolution: {integrity: sha512-KBnSTt1kxl9x70q+ydterVdl+Cn0H18ngRMRCEQfrbqdUuntQQ0LoMZv47uB97NljZFzY6HcfqEZ2SAyIUTQBQ==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.28.0':
+    resolution: {integrity: sha512-zpSlUce1mnxzgBADvxKXX5sl8aYQHo2ezvMNI8I0lbblJtp8V4odlm3Yzlj7gPyt3T8ReksE6bK+pT3WD+aJRg==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.28.0':
+    resolution: {integrity: sha512-2jIfP6mmjkdmeTlsX/9vmdmhBmKADrWqN7zcdtHIeNSCH1SqIoNI63cYsjQR8J+wGa4Y5izRcSHSm8K3QWmk3w==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.28.0':
+    resolution: {integrity: sha512-bc0FE9wWeC0WBm49IQMPSPILRocGTQt3j5KPCA8os6VprfuJ7KD+5PzESSrJ6GmPIPJK965ZJHTUlSA6GNYEhg==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.28.0':
+    resolution: {integrity: sha512-SQPZOwoTTT/HXFXQJG/vBX8sOFagGqvZyXcgLA3NhIqcBv1BJU1d46c0rGcrij2B56Z2rNiSLaZOYW5cUk7yLQ==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.28.0':
+    resolution: {integrity: sha512-SCfR0HN8CEEjnYnySJTd2cw0k9OHB/YFzt5zgJEwa+wL/T/raGWYMBqwDNAC6dqFKmJYZoQBRfHjgwLHGSrn3Q==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.28.0':
+    resolution: {integrity: sha512-us0dSb9iFxIi8srnpl931Nvs65it/Jd2a2K3qs7fz2WfGPHqzfzZTfec7oxZJRNPXPnNYZtanmRc4AL/JwVzHQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.28.0':
+    resolution: {integrity: sha512-CR/RYotgtCKwtftMwJlUU7xCVNg3lMYZ0RzTmAHSfLCXw3NtZtNpswLEj/Kkf6kEL3Gw+BpOekRX0BYCtklhUw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.28.0':
+    resolution: {integrity: sha512-nU1yhmYutL+fQ71Kxnhg8uEOdC0pwEW9entHykTgEbna2pw2dkbFSMeqjjyHZoCmt8SBkOSvV+yNmm94aUrrqw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.28.0':
+    resolution: {integrity: sha512-cXb5vApOsRsxsEl4mcZ1XY3D4DzcoMxR/nnc4IyqYs0rTI8ZKmW6kyyg+11Z8yvgMfAEldKzP7AdP64HnSC/6g==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.28.0':
+    resolution: {integrity: sha512-8wZM2qqtv9UP3mzy7HiGYNH/zjTA355mpeuA+859TyR+e+Tc08IHYpLJuMsfpDJwoLo1ikIJI8jC3GFjnRClzA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.28.0':
+    resolution: {integrity: sha512-FLGfyizszcef5C3YtoyQDACyg95+dndv79i2EekILBofh5wpCa1KuBqOWKrEHZg3zrL3t5ouE5jgr94vA+Wb2w==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.28.0':
+    resolution: {integrity: sha512-1ZgjUoEdHZZl/YlV76TSCz9Hqj9h9YmMGAgAPYd+q4SicWNX3G5GCyx9uhQWSLcbvPW8Ni7lj4gDa1T40akdlw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.28.0':
+    resolution: {integrity: sha512-Q9StnDmQ/enxnpxCCLSg0oo4+34B9TdXpuyPeTedN/6+iXBJ4J+zwfQI28u/Jl40nOYAxGoNi7mFP40RUtkmUA==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.28.0':
+    resolution: {integrity: sha512-zF3ag/gfiCe6U2iczcRzSYJKH1DCI+ByzSENHlM2FcDbEeo5Zd2C86Aq0tKUYAJJ1obRP84ymxIAksZUcdztHA==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.28.0':
+    resolution: {integrity: sha512-pEl1bO9mfAmIC+tW5btTmrKaujg3zGtUmWNdCw/xs70FBjwAL3o9OEKNHvNmnyylD6ubxUERiEhdsL0xBQ9efw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@types/body-parser@1.19.6':
+    resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==}
+
+  '@types/connect@3.4.38':
+    resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==}
+
+  '@types/cors@2.8.19':
+    resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==}
+
+  '@types/express-serve-static-core@5.1.1':
+    resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==}
+
+  '@types/express@5.0.6':
+    resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==}
+
+  '@types/http-errors@2.0.5':
+    resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==}
+
+  '@types/node@22.19.19':
+    resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
+
+  '@types/qs@6.15.1':
+    resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
+
+  '@types/range-parser@1.2.7':
+    resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
+
+  '@types/send@1.2.1':
+    resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
+
+  '@types/serve-static@2.2.0':
+    resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==}
+
+  accepts@1.3.8:
+    resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==}
+    engines: {node: '>= 0.6'}
+
+  array-flatten@1.1.1:
+    resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==}
+
+  body-parser@1.20.5:
+    resolution: {integrity: sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
+  bytes@3.1.2:
+    resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==}
+    engines: {node: '>= 0.8'}
+
+  call-bind-apply-helpers@1.0.2:
+    resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==}
+    engines: {node: '>= 0.4'}
+
+  call-bound@1.0.4:
+    resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==}
+    engines: {node: '>= 0.4'}
+
+  content-disposition@0.5.4:
+    resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==}
+    engines: {node: '>= 0.6'}
+
+  content-type@1.0.5:
+    resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+    engines: {node: '>= 0.6'}
+
+  cookie-signature@1.0.7:
+    resolution: {integrity: sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==}
+
+  cookie@0.7.2:
+    resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+    engines: {node: '>= 0.6'}
+
+  core-js-pure@3.49.0:
+    resolution: {integrity: sha512-XM4RFka59xATyJv/cS3O3Kml72hQXUeGRuuTmMYFxwzc9/7C8OYTaIR/Ji+Yt8DXzsFLNhat15cE/JP15HrCgw==}
+
+  cors@2.8.6:
+    resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+    engines: {node: '>= 0.10'}
+
+  crypto-js@4.2.0:
+    resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==}
+
+  debug@2.6.9:
+    resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
+  destroy@1.2.0:
+    resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
+    engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
+
+  dotenv@16.6.1:
+    resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==}
+    engines: {node: '>=12'}
+
+  dunder-proto@1.0.1:
+    resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
+    engines: {node: '>= 0.4'}
+
+  ee-first@1.1.1:
+    resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
+
+  encodeurl@2.0.0:
+    resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==}
+    engines: {node: '>= 0.8'}
+
+  es-define-property@1.0.1:
+    resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==}
+    engines: {node: '>= 0.4'}
+
+  es-errors@1.3.0:
+    resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==}
+    engines: {node: '>= 0.4'}
+
+  es-object-atoms@1.1.2:
+    resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==}
+    engines: {node: '>= 0.4'}
+
+  esbuild@0.28.0:
+    resolution: {integrity: sha512-sNR9MHpXSUV/XB4zmsFKN+QgVG82Cc7+/aaxJ8Adi8hyOac+EXptIp45QBPaVyX3N70664wRbTcLTOemCAnyqw==}
+    engines: {node: '>=18'}
+    hasBin: true
+
+  escape-html@1.0.3:
+    resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==}
+
+  etag@1.8.1:
+    resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==}
+    engines: {node: '>= 0.6'}
+
+  express@4.22.2:
+    resolution: {integrity: sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==}
+    engines: {node: '>= 0.10.0'}
+
+  finalhandler@1.3.2:
+    resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
+    engines: {node: '>= 0.8'}
+
+  forwarded@0.2.0:
+    resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+    engines: {node: '>= 0.6'}
+
+  fresh@0.5.2:
+    resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==}
+    engines: {node: '>= 0.6'}
+
+  fsevents@2.3.3:
+    resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
+    engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+    os: [darwin]
+
+  function-bind@1.1.2:
+    resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==}
+
+  get-intrinsic@1.3.0:
+    resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==}
+    engines: {node: '>= 0.4'}
+
+  get-proto@1.0.1:
+    resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
+    engines: {node: '>= 0.4'}
+
+  gopd@1.2.0:
+    resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==}
+    engines: {node: '>= 0.4'}
+
+  has-symbols@1.1.0:
+    resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==}
+    engines: {node: '>= 0.4'}
+
+  hasown@2.0.3:
+    resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==}
+    engines: {node: '>= 0.4'}
+
+  http-errors@2.0.1:
+    resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
+    engines: {node: '>= 0.8'}
+
+  iconv-lite@0.4.24:
+    resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==}
+    engines: {node: '>=0.10.0'}
+
+  idb-keyval@6.2.1:
+    resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==}
+
+  inherits@2.0.4:
+    resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
+
+  ipaddr.js@1.9.1:
+    resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
+    engines: {node: '>= 0.10'}
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  media-typer@0.3.0:
+    resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==}
+    engines: {node: '>= 0.6'}
+
+  merge-descriptors@1.0.3:
+    resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==}
+
+  methods@1.1.2:
+    resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==}
+    engines: {node: '>= 0.6'}
+
+  mime-db@1.52.0:
+    resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@2.1.35:
+    resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
+    engines: {node: '>= 0.6'}
+
+  mime@1.6.0:
+    resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==}
+    engines: {node: '>=4'}
+    hasBin: true
+
+  ms@2.0.0:
+    resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  negotiator@0.6.3:
+    resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==}
+    engines: {node: '>= 0.6'}
+
+  object-assign@4.1.1:
+    resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==}
+    engines: {node: '>=0.10.0'}
+
+  object-inspect@1.13.4:
+    resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==}
+    engines: {node: '>= 0.4'}
+
+  on-finished@2.4.1:
+    resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==}
+    engines: {node: '>= 0.8'}
+
+  parse@5.3.0:
+    resolution: {integrity: sha512-mWBnE6hHJhdvlx5KPQcYgCGRdgqKhPw+5fSC0j7vOfse3Lkh3xtDwOfmDpvv2LXZVBj72G/mgVKMRmbAICRzkQ==}
+    engines: {node: 18 || 19 || 20 || 22}
+
+  parseurl@1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+
+  path-to-regexp@0.1.13:
+    resolution: {integrity: sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==}
+
+  proxy-addr@2.0.7:
+    resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+    engines: {node: '>= 0.10'}
+
+  qs@6.15.2:
+    resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
+    engines: {node: '>=0.6'}
+
+  range-parser@1.2.1:
+    resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+    engines: {node: '>= 0.6'}
+
+  raw-body@2.5.3:
+    resolution: {integrity: sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==}
+    engines: {node: '>= 0.8'}
+
+  react-native-crypto-js@1.0.0:
+    resolution: {integrity: sha512-FNbLuG/HAdapQoybeZSoes1PWdOj0w242gb+e1R0hicf3Gyj/Mf8M9NaED2AnXVOX01b2FXomwUiw1xP1K+8sA==}
+
+  regenerator-runtime@0.14.1:
+    resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==}
+
+  safe-buffer@5.2.1:
+    resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  send@0.19.2:
+    resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==}
+    engines: {node: '>= 0.8.0'}
+
+  serve-static@1.16.3:
+    resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
+    engines: {node: '>= 0.8.0'}
+
+  setprototypeof@1.2.0:
+    resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
+
+  side-channel-list@1.0.1:
+    resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-map@1.0.1:
+    resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==}
+    engines: {node: '>= 0.4'}
+
+  side-channel-weakmap@1.0.2:
+    resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==}
+    engines: {node: '>= 0.4'}
+
+  side-channel@1.1.0:
+    resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==}
+    engines: {node: '>= 0.4'}
+
+  statuses@2.0.2:
+    resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
+    engines: {node: '>= 0.8'}
+
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
+  tsx@4.22.3:
+    resolution: {integrity: sha512-mdoNxBC/cSQObGGVQ5Bpn5i+yv7j68gk3Nfm3wFjcJg3Z0Mix9jzAFfP12prmm5eVGmDKtp0yyArrs0Q+8gZHg==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  type-is@1.6.18:
+    resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==}
+    engines: {node: '>= 0.6'}
+
+  typescript@5.9.3:
+    resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  undici-types@6.21.0:
+    resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==}
+
+  unpipe@1.0.0:
+    resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+    engines: {node: '>= 0.8'}
+
+  utils-merge@1.0.1:
+    resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
+    engines: {node: '>= 0.4.0'}
+
+  uuid@10.0.0:
+    resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
+    hasBin: true
+
+  vary@1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+
+  ws@8.17.1:
+    resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==}
+    engines: {node: '>=10.0.0'}
+    peerDependencies:
+      bufferutil: ^4.0.1
+      utf-8-validate: '>=5.0.2'
+    peerDependenciesMeta:
+      bufferutil:
+        optional: true
+      utf-8-validate:
+        optional: true
+
+  xmlhttprequest@1.8.0:
+    resolution: {integrity: sha512-58Im/U0mlVBLM38NdZjHyhuMtCqa61469k2YP/AaPbvCoV9aQGUpbJBj1QRm2ytRiVQBD/fsw7L2bJGDVQswBA==}
+    engines: {node: '>=0.4.0'}
+
+snapshots:
+
+  '@babel/runtime-corejs3@7.24.7':
+    dependencies:
+      core-js-pure: 3.49.0
+      regenerator-runtime: 0.14.1
+
+  '@esbuild/aix-ppc64@0.28.0':
+    optional: true
+
+  '@esbuild/android-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/android-arm@0.28.0':
+    optional: true
+
+  '@esbuild/android-x64@0.28.0':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/darwin-x64@0.28.0':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.28.0':
+    optional: true
+
+  '@esbuild/linux-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/linux-arm@0.28.0':
+    optional: true
+
+  '@esbuild/linux-ia32@0.28.0':
+    optional: true
+
+  '@esbuild/linux-loong64@0.28.0':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.28.0':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.28.0':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.28.0':
+    optional: true
+
+  '@esbuild/linux-s390x@0.28.0':
+    optional: true
+
+  '@esbuild/linux-x64@0.28.0':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.28.0':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.28.0':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/sunos-x64@0.28.0':
+    optional: true
+
+  '@esbuild/win32-arm64@0.28.0':
+    optional: true
+
+  '@esbuild/win32-ia32@0.28.0':
+    optional: true
+
+  '@esbuild/win32-x64@0.28.0':
+    optional: true
+
+  '@types/body-parser@1.19.6':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/node': 22.19.19
+
+  '@types/connect@3.4.38':
+    dependencies:
+      '@types/node': 22.19.19
+
+  '@types/cors@2.8.19':
+    dependencies:
+      '@types/node': 22.19.19
+
+  '@types/express-serve-static-core@5.1.1':
+    dependencies:
+      '@types/node': 22.19.19
+      '@types/qs': 6.15.1
+      '@types/range-parser': 1.2.7
+      '@types/send': 1.2.1
+
+  '@types/express@5.0.6':
+    dependencies:
+      '@types/body-parser': 1.19.6
+      '@types/express-serve-static-core': 5.1.1
+      '@types/serve-static': 2.2.0
+
+  '@types/http-errors@2.0.5': {}
+
+  '@types/node@22.19.19':
+    dependencies:
+      undici-types: 6.21.0
+
+  '@types/qs@6.15.1': {}
+
+  '@types/range-parser@1.2.7': {}
+
+  '@types/send@1.2.1':
+    dependencies:
+      '@types/node': 22.19.19
+
+  '@types/serve-static@2.2.0':
+    dependencies:
+      '@types/http-errors': 2.0.5
+      '@types/node': 22.19.19
+
+  accepts@1.3.8:
+    dependencies:
+      mime-types: 2.1.35
+      negotiator: 0.6.3
+
+  array-flatten@1.1.1: {}
+
+  body-parser@1.20.5:
+    dependencies:
+      bytes: 3.1.2
+      content-type: 1.0.5
+      debug: 2.6.9
+      depd: 2.0.0
+      destroy: 1.2.0
+      http-errors: 2.0.1
+      iconv-lite: 0.4.24
+      on-finished: 2.4.1
+      qs: 6.15.2
+      raw-body: 2.5.3
+      type-is: 1.6.18
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  bytes@3.1.2: {}
+
+  call-bind-apply-helpers@1.0.2:
+    dependencies:
+      es-errors: 1.3.0
+      function-bind: 1.1.2
+
+  call-bound@1.0.4:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      get-intrinsic: 1.3.0
+
+  content-disposition@0.5.4:
+    dependencies:
+      safe-buffer: 5.2.1
+
+  content-type@1.0.5: {}
+
+  cookie-signature@1.0.7: {}
+
+  cookie@0.7.2: {}
+
+  core-js-pure@3.49.0: {}
+
+  cors@2.8.6:
+    dependencies:
+      object-assign: 4.1.1
+      vary: 1.1.2
+
+  crypto-js@4.2.0:
+    optional: true
+
+  debug@2.6.9:
+    dependencies:
+      ms: 2.0.0
+
+  depd@2.0.0: {}
+
+  destroy@1.2.0: {}
+
+  dotenv@16.6.1: {}
+
+  dunder-proto@1.0.1:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-errors: 1.3.0
+      gopd: 1.2.0
+
+  ee-first@1.1.1: {}
+
+  encodeurl@2.0.0: {}
+
+  es-define-property@1.0.1: {}
+
+  es-errors@1.3.0: {}
+
+  es-object-atoms@1.1.2:
+    dependencies:
+      es-errors: 1.3.0
+
+  esbuild@0.28.0:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.28.0
+      '@esbuild/android-arm': 0.28.0
+      '@esbuild/android-arm64': 0.28.0
+      '@esbuild/android-x64': 0.28.0
+      '@esbuild/darwin-arm64': 0.28.0
+      '@esbuild/darwin-x64': 0.28.0
+      '@esbuild/freebsd-arm64': 0.28.0
+      '@esbuild/freebsd-x64': 0.28.0
+      '@esbuild/linux-arm': 0.28.0
+      '@esbuild/linux-arm64': 0.28.0
+      '@esbuild/linux-ia32': 0.28.0
+      '@esbuild/linux-loong64': 0.28.0
+      '@esbuild/linux-mips64el': 0.28.0
+      '@esbuild/linux-ppc64': 0.28.0
+      '@esbuild/linux-riscv64': 0.28.0
+      '@esbuild/linux-s390x': 0.28.0
+      '@esbuild/linux-x64': 0.28.0
+      '@esbuild/netbsd-arm64': 0.28.0
+      '@esbuild/netbsd-x64': 0.28.0
+      '@esbuild/openbsd-arm64': 0.28.0
+      '@esbuild/openbsd-x64': 0.28.0
+      '@esbuild/openharmony-arm64': 0.28.0
+      '@esbuild/sunos-x64': 0.28.0
+      '@esbuild/win32-arm64': 0.28.0
+      '@esbuild/win32-ia32': 0.28.0
+      '@esbuild/win32-x64': 0.28.0
+
+  escape-html@1.0.3: {}
+
+  etag@1.8.1: {}
+
+  express@4.22.2:
+    dependencies:
+      accepts: 1.3.8
+      array-flatten: 1.1.1
+      body-parser: 1.20.5
+      content-disposition: 0.5.4
+      content-type: 1.0.5
+      cookie: 0.7.2
+      cookie-signature: 1.0.7
+      debug: 2.6.9
+      depd: 2.0.0
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      etag: 1.8.1
+      finalhandler: 1.3.2
+      fresh: 0.5.2
+      http-errors: 2.0.1
+      merge-descriptors: 1.0.3
+      methods: 1.1.2
+      on-finished: 2.4.1
+      parseurl: 1.3.3
+      path-to-regexp: 0.1.13
+      proxy-addr: 2.0.7
+      qs: 6.15.2
+      range-parser: 1.2.1
+      safe-buffer: 5.2.1
+      send: 0.19.2
+      serve-static: 1.16.3
+      setprototypeof: 1.2.0
+      statuses: 2.0.2
+      type-is: 1.6.18
+      utils-merge: 1.0.1
+      vary: 1.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  finalhandler@1.3.2:
+    dependencies:
+      debug: 2.6.9
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      on-finished: 2.4.1
+      parseurl: 1.3.3
+      statuses: 2.0.2
+      unpipe: 1.0.0
+    transitivePeerDependencies:
+      - supports-color
+
+  forwarded@0.2.0: {}
+
+  fresh@0.5.2: {}
+
+  fsevents@2.3.3:
+    optional: true
+
+  function-bind@1.1.2: {}
+
+  get-intrinsic@1.3.0:
+    dependencies:
+      call-bind-apply-helpers: 1.0.2
+      es-define-property: 1.0.1
+      es-errors: 1.3.0
+      es-object-atoms: 1.1.2
+      function-bind: 1.1.2
+      get-proto: 1.0.1
+      gopd: 1.2.0
+      has-symbols: 1.1.0
+      hasown: 2.0.3
+      math-intrinsics: 1.1.0
+
+  get-proto@1.0.1:
+    dependencies:
+      dunder-proto: 1.0.1
+      es-object-atoms: 1.1.2
+
+  gopd@1.2.0: {}
+
+  has-symbols@1.1.0: {}
+
+  hasown@2.0.3:
+    dependencies:
+      function-bind: 1.1.2
+
+  http-errors@2.0.1:
+    dependencies:
+      depd: 2.0.0
+      inherits: 2.0.4
+      setprototypeof: 1.2.0
+      statuses: 2.0.2
+      toidentifier: 1.0.1
+
+  iconv-lite@0.4.24:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  idb-keyval@6.2.1: {}
+
+  inherits@2.0.4: {}
+
+  ipaddr.js@1.9.1: {}
+
+  math-intrinsics@1.1.0: {}
+
+  media-typer@0.3.0: {}
+
+  merge-descriptors@1.0.3: {}
+
+  methods@1.1.2: {}
+
+  mime-db@1.52.0: {}
+
+  mime-types@2.1.35:
+    dependencies:
+      mime-db: 1.52.0
+
+  mime@1.6.0: {}
+
+  ms@2.0.0: {}
+
+  ms@2.1.3: {}
+
+  negotiator@0.6.3: {}
+
+  object-assign@4.1.1: {}
+
+  object-inspect@1.13.4: {}
+
+  on-finished@2.4.1:
+    dependencies:
+      ee-first: 1.1.1
+
+  parse@5.3.0:
+    dependencies:
+      '@babel/runtime-corejs3': 7.24.7
+      idb-keyval: 6.2.1
+      react-native-crypto-js: 1.0.0
+      uuid: 10.0.0
+      ws: 8.17.1
+      xmlhttprequest: 1.8.0
+    optionalDependencies:
+      crypto-js: 4.2.0
+    transitivePeerDependencies:
+      - bufferutil
+      - utf-8-validate
+
+  parseurl@1.3.3: {}
+
+  path-to-regexp@0.1.13: {}
+
+  proxy-addr@2.0.7:
+    dependencies:
+      forwarded: 0.2.0
+      ipaddr.js: 1.9.1
+
+  qs@6.15.2:
+    dependencies:
+      side-channel: 1.1.0
+
+  range-parser@1.2.1: {}
+
+  raw-body@2.5.3:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.1
+      iconv-lite: 0.4.24
+      unpipe: 1.0.0
+
+  react-native-crypto-js@1.0.0: {}
+
+  regenerator-runtime@0.14.1: {}
+
+  safe-buffer@5.2.1: {}
+
+  safer-buffer@2.1.2: {}
+
+  send@0.19.2:
+    dependencies:
+      debug: 2.6.9
+      depd: 2.0.0
+      destroy: 1.2.0
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      etag: 1.8.1
+      fresh: 0.5.2
+      http-errors: 2.0.1
+      mime: 1.6.0
+      ms: 2.1.3
+      on-finished: 2.4.1
+      range-parser: 1.2.1
+      statuses: 2.0.2
+    transitivePeerDependencies:
+      - supports-color
+
+  serve-static@1.16.3:
+    dependencies:
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      parseurl: 1.3.3
+      send: 0.19.2
+    transitivePeerDependencies:
+      - supports-color
+
+  setprototypeof@1.2.0: {}
+
+  side-channel-list@1.0.1:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-map@1.0.1:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+
+  side-channel-weakmap@1.0.2:
+    dependencies:
+      call-bound: 1.0.4
+      es-errors: 1.3.0
+      get-intrinsic: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-map: 1.0.1
+
+  side-channel@1.1.0:
+    dependencies:
+      es-errors: 1.3.0
+      object-inspect: 1.13.4
+      side-channel-list: 1.0.1
+      side-channel-map: 1.0.1
+      side-channel-weakmap: 1.0.2
+
+  statuses@2.0.2: {}
+
+  toidentifier@1.0.1: {}
+
+  tsx@4.22.3:
+    dependencies:
+      esbuild: 0.28.0
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  type-is@1.6.18:
+    dependencies:
+      media-typer: 0.3.0
+      mime-types: 2.1.35
+
+  typescript@5.9.3: {}
+
+  undici-types@6.21.0: {}
+
+  unpipe@1.0.0: {}
+
+  utils-merge@1.0.1: {}
+
+  uuid@10.0.0: {}
+
+  vary@1.1.2: {}
+
+  ws@8.17.1: {}
+
+  xmlhttprequest@1.8.0: {}

+ 42 - 0
backend/scripts/add-community-isdeleted.ts

@@ -0,0 +1,42 @@
+/**
+ * 为 Community 表添加 isDeleted(Boolean) 字段
+ *
+ * 用法: cd backend && npx tsx scripts/add-community-isdeleted.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function main(): Promise<void> {
+  console.log('=== 为 Community 添加 isDeleted 字段 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+
+  const schema = new Parse.Schema('Community');
+
+  try {
+    await schema.addField('isDeleted', 'Boolean');
+    console.log('+ isDeleted (Boolean) 字段已添加');
+  } catch (err: any) {
+    if (err.message?.includes('already')) {
+      console.log('~ isDeleted 字段已存在,无需添加');
+    } else {
+      console.warn(`⚠ isDeleted 添加失败: ${err.message}`);
+    }
+  }
+
+  await schema.update({ useMasterKey: true });
+  console.log('\n=== 完成 ===');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 41 - 0
backend/scripts/add-riskevent-discoveredat.ts

@@ -0,0 +1,41 @@
+/**
+ * 为 RiskEvent 表添加 discoveredAt(Date) 字段
+ *
+ * 用法: cd backend && npx tsx scripts/add-riskevent-discoveredat.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function main(): Promise<void> {
+  console.log('=== 为 RiskEvent 添加 discoveredAt 字段 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+
+  const schema = new Parse.Schema('RiskEvent');
+  try {
+    await schema.addField('discoveredAt', 'Date');
+    await schema.update({ useMasterKey: true });
+    console.log('+ RiskEvent.discoveredAt (Date) 字段已添加');
+  } catch (err: any) {
+    if (err.message?.includes('already')) {
+      console.log('~ RiskEvent.discoveredAt 字段已存在,无需添加');
+    } else {
+      console.warn(`⚠ RiskEvent.discoveredAt 添加失败: ${err.message}`);
+    }
+  }
+
+  console.log('\n=== 完成 ===');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 43 - 0
backend/scripts/add-store-district-isdeleted.ts

@@ -0,0 +1,43 @@
+/**
+ * 为 Store 和 District 表添加 isDeleted(Boolean) 字段
+ *
+ * 用法: cd backend && npx tsx scripts/add-store-district-isdeleted.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function main(): Promise<void> {
+  console.log('=== 为 Store 和 District 添加 isDeleted 字段 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+
+  for (const tableName of ['Store', 'District']) {
+    const schema = new Parse.Schema(tableName);
+    try {
+      await schema.addField('isDeleted', 'Boolean');
+      await schema.update({ useMasterKey: true });
+      console.log(`+ ${tableName}.isDeleted (Boolean) 字段已添加`);
+    } catch (err: any) {
+      if (err.message?.includes('already')) {
+        console.log(`~ ${tableName}.isDeleted 字段已存在,无需添加`);
+      } else {
+        console.warn(`⚠ ${tableName} isDeleted 添加失败: ${err.message}`);
+      }
+    }
+  }
+
+  console.log('\n=== 完成 ===');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 42 - 0
backend/scripts/add-user-isdeleted.ts

@@ -0,0 +1,42 @@
+/**
+ * 为 _User 表添加 isDeleted(Boolean) 字段
+ *
+ * 用法: cd backend && npx tsx scripts/add-user-isdeleted.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function main(): Promise<void> {
+  console.log('=== 为 _User 添加 isDeleted 字段 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+
+  const schema = new Parse.Schema('_User');
+
+  try {
+    await schema.addField('isDeleted', 'Boolean');
+    console.log('+ isDeleted (Boolean) 字段已添加');
+  } catch (err: any) {
+    if (err.message?.includes('already')) {
+      console.log('~ isDeleted 字段已存在,无需添加');
+    } else {
+      console.warn(`⚠ isDeleted 添加失败: ${err.message}`);
+    }
+  }
+
+  await schema.update({ useMasterKey: true });
+  console.log('\n=== 完成 ===');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 250 - 0
backend/scripts/assign-store-ids.ts

@@ -0,0 +1,250 @@
+/**
+ * 完整归属关系回填脚本:
+ *   1. 给 GroupChat 分配 storeId + storeName + communityId + communityName
+ *   2. 同时设置 Pointer 字段 (store → Store, community → Community)
+ *   3. 给 Store 设置 Pointer 字段 (district → District)
+ *   4. 给 Community 设置 Pointer 字段 (store → Store)
+ *
+ * 分配逻辑:
+ *   1. 有 communityId → 从 Community 表查 storeId
+ *   2. 有 communityName → 匹配 Community.name 查 storeId
+ *   3. 兜底 → 随机分配到一个门店
+ *
+ * 用法: npx tsx scripts/assign-store-ids.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+function makePointer(className: string, objectId: string): Parse.Object | null {
+  if (!objectId) return null;
+  const ptr = new (Parse.Object as any)(className);
+  ptr.id = objectId;
+  return ptr;
+}
+
+async function main(): Promise<void> {
+  console.log('[AssignStoreIds] 开始完整归属关系回填…\n');
+
+  // 1. 获取所有门店
+  const storeQuery = new Parse.Query('Store');
+  storeQuery.limit(500);
+  const stores = await storeQuery.find({ useMasterKey: true });
+  console.log(`  门店总数: ${stores.length}`);
+
+  if (stores.length === 0) {
+    console.warn('[AssignStoreIds] 无门店数据,请先运行 seed-data.ts');
+    return;
+  }
+
+  // 门店 map: id → { name, districtId }
+  const storeMap = new Map<string, { name: string; districtId: string }>();
+  for (const s of stores) {
+    storeMap.set(s.id, {
+      name: (s.get('name') as string) || '',
+      districtId: (s.get('districtId') as string) || '',
+    });
+  }
+
+  // 2. 获取所有小区(用于 community → store 映射)
+  const communityQuery = new Parse.Query('Community');
+  communityQuery.limit(500);
+  const communities = await communityQuery.find({ useMasterKey: true });
+  const communityStoreMap = new Map<string, string>(); // communityId → storeId
+  const communityNameMap = new Map<string, string>();   // communityName → storeId
+  const communityNameToIdMap = new Map<string, string>(); // communityName → communityId
+  for (const c of communities) {
+    const sid = c.get('storeId') as string;
+    const name = c.get('name') as string;
+    if (sid) {
+      communityStoreMap.set(c.id, sid);
+      if (name) {
+        communityNameMap.set(name, sid);
+        communityNameToIdMap.set(name, c.id);
+      }
+    }
+  }
+  console.log(`  小区总数: ${communities.length}, 有 storeId 的: ${communityStoreMap.size}`);
+
+  // 3. 处理 GroupChat 归属
+  const groupQuery = new Parse.Query('GroupChat');
+  groupQuery.limit(1000);
+  const groups = await groupQuery.find({ useMasterKey: true });
+
+  let assigned = 0;
+  let fromCommunity = 0;
+  let fromName = 0;
+  let fromRandom = 0;
+  let pointerSet = 0;
+  const toSave: Parse.Object[] = [];
+
+  for (const g of groups) {
+    const existing = g.get('storeId') as string;
+    if (existing) continue;
+
+    const communityId = g.get('communityId') as string;
+    const communityName = g.get('communityName') as string;
+    let storeId: string | null = null;
+    let matchCommunityId: string | null = null;
+    let matchCommunityName: string | null = null;
+
+    // 策略 1: 通过 communityId 查
+    if (communityId && communityStoreMap.has(communityId)) {
+      storeId = communityStoreMap.get(communityId)!;
+      fromCommunity++;
+    }
+
+    // 策略 2: 通过 communityName 匹配
+    if (!storeId && communityName && communityNameMap.has(communityName)) {
+      storeId = communityNameMap.get(communityName)!;
+      matchCommunityId = communityNameToIdMap.get(communityName) || null;
+      matchCommunityName = communityName;
+      fromName++;
+    }
+
+    // 策略 3: 随机分配
+    if (!storeId) {
+      const randomStore = stores[Math.floor(Math.random() * stores.length)];
+      storeId = randomStore.id;
+      fromRandom++;
+    }
+
+    const storeInfo = storeMap.get(storeId!);
+    const storeName = storeInfo?.name || '';
+
+    g.set('storeId', storeId!);
+    g.set('storeName', storeName);
+
+    // 如果通过 communityName 匹配到了 communityId,也设置上
+    if (matchCommunityId) {
+      g.set('communityId', matchCommunityId);
+      g.set('communityName', matchCommunityName || '');
+    }
+
+    // 设置 Pointer 字段
+    const storePtr = makePointer('Store', storeId!);
+    if (storePtr) {
+      g.set('store', storePtr);
+      pointerSet++;
+    }
+    if (matchCommunityId) {
+      const commPtr = makePointer('Community', matchCommunityId);
+      if (commPtr) {
+        g.set('community', commPtr);
+        pointerSet++;
+      }
+    }
+
+    toSave.push(g);
+    assigned++;
+  }
+
+  // 4. 批量保存 GroupChat
+  if (toSave.length > 0) {
+    for (let i = 0; i < toSave.length; i += 100) {
+      await Parse.Object.saveAll(toSave.slice(i, i + 100), { useMasterKey: true });
+    }
+  }
+
+  console.log(`\n[AssignStoreIds] GroupChat 处理完成:`);
+  console.log(`  GroupChat 总数: ${groups.length}`);
+  console.log(`  已分配 storeId: ${assigned}`);
+  console.log(`    - 通过 communityId: ${fromCommunity}`);
+  console.log(`    - 通过 communityName: ${fromName}`);
+  console.log(`    - 随机分配: ${fromRandom}`);
+  console.log(`  Pointer 字段设置: ${pointerSet}`);
+
+  // 5. 给已有 String 值但缺 Pointer 的记录补 Pointer
+  console.log('\n[AssignStoreIds] 补充缺失的 Pointer 字段…');
+  const allGroups = await groupQuery.find({ useMasterKey: true });
+  let storePtrAdded = 0;
+  let commPtrAdded = 0;
+  const ptrToSave: Parse.Object[] = [];
+
+  for (const g of allGroups) {
+    let changed = false;
+    const sid = g.get('storeId') as string;
+    const cid = g.get('communityId') as string;
+
+    if (sid && !g.get('store')) {
+      const ptr = makePointer('Store', sid);
+      if (ptr) {
+        g.set('store', ptr);
+        storePtrAdded++;
+        changed = true;
+      }
+    }
+    if (cid && !g.get('community')) {
+      const ptr = makePointer('Community', cid);
+      if (ptr) {
+        g.set('community', ptr);
+        commPtrAdded++;
+        changed = true;
+      }
+    }
+    if (changed) ptrToSave.push(g);
+  }
+
+  if (ptrToSave.length > 0) {
+    for (let i = 0; i < ptrToSave.length; i += 100) {
+      await Parse.Object.saveAll(ptrToSave.slice(i, i + 100), { useMasterKey: true });
+    }
+  }
+  console.log(`  store Pointer 补充: ${storePtrAdded}, community Pointer 补充: ${commPtrAdded}`);
+
+  // 6. 处理 Store.district Pointer
+  console.log('\n[AssignStoreIds] 为 Store 补充 district Pointer…');
+  const allStores = await storeQuery.find({ useMasterKey: true });
+  let districtPtrAdded = 0;
+  const storeToSave: Parse.Object[] = [];
+
+  for (const s of allStores) {
+    const did = s.get('districtId') as string;
+    if (did && !s.get('district')) {
+      const ptr = makePointer('District', did);
+      if (ptr) {
+        s.set('district', ptr);
+        districtPtrAdded++;
+        storeToSave.push(s);
+      }
+    }
+  }
+
+  if (storeToSave.length > 0) {
+    for (let i = 0; i < storeToSave.length; i += 100) {
+      await Parse.Object.saveAll(storeToSave.slice(i, i + 100), { useMasterKey: true });
+    }
+  }
+  console.log(`  district Pointer 补充: ${districtPtrAdded}`);
+
+  // 7. 处理 Community.store Pointer
+  console.log('\n[AssignStoreIds] 为 Community 补充 store Pointer…');
+  const allCommunities = await communityQuery.find({ useMasterKey: true });
+  let commStorePtrAdded = 0;
+  const commToSave: Parse.Object[] = [];
+
+  for (const c of allCommunities) {
+    const sid = c.get('storeId') as string;
+    if (sid && !c.get('store')) {
+      const ptr = makePointer('Store', sid);
+      if (ptr) {
+        c.set('store', ptr);
+        commStorePtrAdded++;
+        commToSave.push(c);
+      }
+    }
+  }
+
+  if (commToSave.length > 0) {
+    for (let i = 0; i < commToSave.length; i += 100) {
+      await Parse.Object.saveAll(commToSave.slice(i, i + 100), { useMasterKey: true });
+    }
+  }
+  console.log(`  store Pointer 补充: ${commStorePtrAdded}`);
+
+  console.log('\n[AssignStoreIds] 全部完成!');
+}
+
+main().catch((err) => {
+  console.error('[AssignStoreIds] 失败:', err);
+  process.exit(1);
+});

+ 990 - 0
backend/scripts/audit-risk-2026-06-15-22.html

@@ -0,0 +1,990 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>拉迷风险预警覆盖审计 — 2026.06.15-22</title>
+<style>
+  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif;background:#f0f2f5;color:#1a1a2e;line-height:1.6}
+
+  /* Header */
+  .hero{background:linear-gradient(135deg,#0f0c29 0%,#1a1a2e 40%,#16213e 100%);color:#fff;padding:40px 48px;position:relative;overflow:hidden}
+  .hero::after{content:'';position:absolute;top:-50%;right:-10%;width:400px;height:400px;background:radial-gradient(circle,rgba(231,76,60,.15),transparent 70%);border-radius:50%}
+  .hero h1{font-size:26px;font-weight:700;position:relative;z-index:1}
+  .hero .subtitle{font-size:14px;color:#8892b0;margin-top:6px;position:relative;z-index:1}
+  .hero .meta{font-size:12px;color:#5a6785;margin-top:4px;position:relative;z-index:1}
+
+  /* Container */
+  .container{max-width:1100px;margin:0 auto;padding:28px 24px}
+
+  /* Overview Cards */
+  .overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px;margin-bottom:36px}
+  .card{background:#fff;border-radius:12px;padding:24px 20px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.06);transition:transform .15s}
+  .card:hover{transform:translateY(-2px)}
+  .card-num{font-size:36px;font-weight:800;line-height:1.2}
+  .card-label{font-size:13px;color:#7f8c8d;margin-top:6px}
+  .card-blue .card-num{color:#2980b9}
+  .card-purple .card-num{color:#8e44ad}
+  .card-teal .card-num{color:#16a085}
+  .card-orange .card-num{color:#e67e22}
+  .card-red .card-num{color:#e74c3c}
+  .card-green .card-num{color:#27ae60}
+
+  /* Sections */
+  .section{margin-bottom:36px}
+  .section h2{font-size:18px;font-weight:700;color:#1a1a2e;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #e8ecf4}
+  .sub{font-size:13px;color:#95a5a6;font-weight:400}
+  .empty{padding:40px;text-align:center;color:#95a5a6;background:#fff;border-radius:10px}
+  .empty.success{color:#27ae60;background:#eafaf1}
+  .hint{font-size:13px;color:#95a5a6;margin-top:12px;line-height:1.6}
+
+  /* Badge */
+  .badge{font-size:11px;font-weight:700;padding:3px 10px;border-radius:12px;color:#fff;white-space:nowrap}
+  .badge-high{background:#e74c3c}
+  .badge-medium{background:#f39c12}
+  .badge-low{background:#3498db}
+
+  /* Table */
+  .table-wrap{overflow-x:auto;background:#fff;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  table{width:100%;border-collapse:collapse;font-size:13px}
+  thead th{background:#f8f9fb;padding:12px 14px;text-align:left;font-weight:600;color:#555;font-size:12px;text-transform:uppercase;letter-spacing:.5px;border-bottom:2px solid #e8ecf4}
+  tbody td{padding:10px 14px;border-bottom:1px solid #f0f2f5;vertical-align:top}
+  tbody tr:hover{background:#f8f9fb}
+  .time-cell{white-space:nowrap;color:#95a5a6;font-size:12px}
+  .msg-cell{max-width:400px;word-break:break-all}
+  code{background:#f0f2f5;padding:1px 6px;border-radius:3px;font-size:12px;color:#e74c3c}
+  .mini-table{font-size:12px;margin-top:0}
+
+  /* Missed Alerts */
+  .missed-summary{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
+  .chip{font-size:13px;padding:6px 14px;border-radius:20px;font-weight:600}
+  .chip-red{background:#fde8e8;color:#c0392b}
+  .chip-green{background:#e8f8f5;color:#1e8449}
+  .chip-gray{background:#f0f2f5;color:#7f8c8d}
+
+  .missed-list{display:flex;flex-direction:column;gap:14px}
+  .missed-card{border-radius:10px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  .missed-header{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;background:rgba(0,0,0,.02);border-bottom:1px solid rgba(0,0,0,.05)}
+  .missed-title{font-size:15px;font-weight:700}
+  .missed-body{padding:16px 18px}
+  .missed-meta{display:flex;flex-direction:column;gap:8px;margin-bottom:10px}
+  .meta-item{font-size:13px}
+  .meta-label{font-weight:600;color:#555;margin-right:8px}
+  .kw-tag{display:inline-block;background:#f0f2f5;padding:2px 10px;border-radius:12px;font-size:12px;margin-right:4px;margin-bottom:2px}
+
+  .ai-review{font-size:13px;padding:10px 14px;border-radius:8px;margin:10px 0}
+  .ai-risky{background:#fde8e8;border:1px solid #f5c6cb;color:#721c24}
+  .ai-safe{background:#e8f8f5;border:1px solid #b7e4cf;color:#155724}
+
+  .msg-details{margin-top:8px}
+  .msg-details summary{font-size:13px;color:#3498db;cursor:pointer;font-weight:600}
+  .msg-details summary:hover{color:#2980b9}
+
+  /* Keyword cloud */
+  .kw-stats-bar{display:flex;gap:12px;margin-bottom:14px}
+  .kw-stat{padding:6px 16px;border-radius:8px;font-size:13px;font-weight:600}
+  .kw-stat span{font-size:20px;font-weight:800}
+  .kw-stat-high{background:#fde8e8;color:#c0392b}
+  .kw-stat-med{background:#fef5e7;color:#d68910}
+  .kw-stat-low{background:#eaf2f8;color:#2980b9}
+
+  .kw-cloud{display:flex;flex-wrap:wrap;gap:8px;background:#fff;padding:18px;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  .kw-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:16px;font-size:13px;font-weight:600}
+  .kw-high{background:#fde8e8;color:#c0392b}
+  .kw-medium{background:#fef5e7;color:#d68910}
+  .kw-low{background:#eaf2f8;color:#2980b9}
+  .kw-cat{font-size:10px;opacity:.7;font-weight:400}
+
+  /* Suggestions */
+  .suggestion-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
+  .sugg-card{padding:20px;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  .sugg-title{font-size:15px;font-weight:700;margin-bottom:10px}
+  .sugg-card ul{padding-left:18px}
+  .sugg-card li{font-size:13px;margin-bottom:8px;line-height:1.6}
+  .sugg-high{background:#fff5f5;border:1px solid #fecaca}
+  .sugg-med{background:#fffdf0;border:1px solid #fde68a}
+  .sugg-low{background:#f5f9ff;border:1px solid #bfdbfe}
+
+  /* Footer */
+  .footer{text-align:center;padding:20px;color:#95a5a6;font-size:12px}
+
+  @media (max-width:768px){
+    .hero{padding:24px}
+    .container{padding:16px}
+    .overview-grid{grid-template-columns:repeat(3,1fr);gap:10px}
+    .card{padding:16px 10px}
+    .card-num{font-size:26px}
+    .suggestion-grid{grid-template-columns:1fr}
+  }
+  @media print{
+    body{background:#fff}
+    .hero{background:#1a1a2e!important;-webkit-print-color-adjust:exact}
+    .missed-card{break-inside:avoid;border:1px solid #ddd}
+  }
+</style>
+</head>
+<body>
+<div class="hero">
+  <h1>🛡️ 风险预警覆盖审计报告</h1>
+  <div class="subtitle">审计期间:2026-06-15 ~ 2026-06-22(8 天)&nbsp;|&nbsp; 全屋定制 · 拉迷家居</div>
+  <div class="meta">生成时间:2026/6/23 09:46:14 &nbsp;|&nbsp; 查询耗时:300ms &nbsp;|&nbsp; 总耗时:30670ms &nbsp;|&nbsp; AI 引擎:DeepSeek</div>
+</div>
+<div class="container">
+  <div class="overview-grid">
+    <div class="card card-blue"><div class="card-num">1,470</div><div class="card-label">文本消息总量</div></div>
+    <div class="card card-purple"><div class="card-num">4</div><div class="card-label">已有风险事件</div></div>
+    <div class="card card-teal"><div class="card-num">181</div><div class="card-label">启用关键词</div></div>
+    <div class="card card-orange"><div class="card-num">2.2%</div><div class="card-label">关键词覆盖率</div></div>
+    <div class="card card-red"><div class="card-num">21</div><div class="card-label">漏报群数量</div></div>
+    <div class="card card-green"><div class="card-num">176</div><div class="card-label">未覆盖关键词</div></div>
+  </div>
+  
+      <div class="section">
+        <h2>🛡️ 已触发预警事件 <span class="sub">(4 条)</span></h2>
+        <div class="table-wrap">
+        <table>
+          <thead><tr><th>严重度</th><th>标题</th><th>关键词</th><th>描述</th><th>时间</th></tr></thead>
+          <tbody>
+      <tr>
+        <td><span class="badge badge-medium">medium</span></td>
+        <td>电视柜质量投诉</td>
+        <td><code>不平</code></td>
+        <td>用户投诉电视柜开孔位置错误、门板气泡不平,要求6/30前售后完成,情绪不满。</td>
+        <td class="time-cell">2026/6/15 15:08:54</td>
+      </tr>
+      <tr>
+        <td><span class="badge badge-low">low</span></td>
+        <td>电视柜门板条纹问题反馈</td>
+        <td><code>瑕疵</code></td>
+        <td>用户反映电视柜四扇门有白色竖条纹,要求工厂注意工艺细节,语气平和,属一般质量问题沟通。</td>
+        <td class="time-cell">2026/6/22 11:13:06</td>
+      </tr>
+      <tr>
+        <td><span class="badge badge-low">low</span></td>
+        <td>讨论价格优惠</td>
+        <td><code>优惠</code></td>
+        <td>用户提及套餐相比正价优惠很多,属于一般性价格讨论,无负面情绪。</td>
+        <td class="time-cell">2026/6/18 19:10:04</td>
+      </tr>
+      <tr>
+        <td><span class="badge badge-low">low</span></td>
+        <td>价格折扣讨论</td>
+        <td><code>少了</code></td>
+        <td>用户认为八五折优惠力度不够,提及团购六折,属一般性价格敏感讨论。</td>
+        <td class="time-cell">2026/6/16 16:51:08</td>
+      </tr></tbody>
+        </table>
+        </div>
+      </div>
+  
+      <div class="section">
+        <h2>🔍 漏报详情 <span class="sub">(21 个群)</span></h2>
+        <div class="missed-summary">
+          <span class="chip chip-red">⚠️ AI 确认有风险: 6 个</span>
+          <span class="chip chip-green">✅ AI 确认无风险: 14 个</span>
+          <span class="chip chip-gray">未评审: 1 个</span>
+        </div>
+        <div class="missed-list">
+      <div class="missed-card" style="border-left: 4px solid #e74c3c; background: #fff5f5;">
+        <div class="missed-header">
+          <span class="missed-title">1. 未知群(10818808211819824)</span>
+          <span><span class="badge badge-high">高危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🔴 预算</span><span class="kw-tag">🔴 开裂</span><span class="kw-tag">🔴 划痕</span><span class="kw-tag">🔴 发霉</span><span class="kw-tag">🔴 松动</span><span class="kw-tag">🔴 缝隙大</span><span class="kw-tag">🔴 色差</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 3 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [预算, 开裂, 划痕, 发霉, 瑕疵, 松动, 缝隙大, 色差],其中已有关键词 [瑕疵] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-risky">🤖 AI 评审:⚠ 有风险(low) | 建议预警: 否 | 消息为预看房通用清单,非拉迷产品问题,风险词仅用于举例,无实际投诉或售后风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/22 21:05</td><td>未知</td><td class="msg-cell">✨【预看房准备清单2】✨
+
+💖邀请专业人士(如果可以)
+①考虑聘请一位验房师或工程师陪同检查,他们的专业知识可以帮助你发现不易察觉的问题。
+②如果预算有限,至少可以咨询一下专业人士的意见。比如【拉迷家居顾问】[嘿哈]
+
+💖沟通与反馈
+①...</td></tr><tr><td class="time-cell">6/22 21:05</td><td>未知</td><td class="msg-cell">✅ 预看房时需要注意的点
+①门窗玻璃:玻璃存在焊渣烫伤、细小划痕、玻璃起泡、夹层进异物,看着是小瑕疵,后期不仅难看,还会影响玻璃密封性,更换成本高;
+
+②地漏排水病:卫生间、阳台地漏普遍残留施工建渣,部分缺少地漏芯、地漏断层,入住后极易积水...</td></tr><tr><td class="time-cell">6/22 21:05</td><td>未知</td><td class="msg-cell">⚠ 重点隐患(必须优先让开发商整改)
+①用电安全隐患:卫生间检测出电位失效、卫浴空间感应电、线路破损、电线裸露、接线不规范问题,洗澡潮湿环境极易引发漏电风险。
+
+② 设备失效问题:部分户型新风系统无风、空调内机污染故障、厨卫感应灯不亮,全屋...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #e74c3c; background: #fff5f5;">
+        <div class="missed-header">
+          <span class="missed-title">2. 拉迷VIP服务群中心润府李先生lz122539</span>
+          <span><span class="badge badge-high">高危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🔴 安装师傅</span><span class="kw-tag">🔴 脱胶</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 2 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无任何预警记录,所有关键词命中均未触发预警</div>
+          </div>
+          <div class="ai-review ai-risky">🤖 AI 评审:⚠ 有风险(medium) | 建议预警: 否 | 脱胶属于产品质量或安装问题,可能影响客户满意度,但消息中未明确投诉或严重缺陷,风险中等。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/18 16:02</td><td>A拉迷🐉~纪澜(拉迷)</td><td class="msg-cell">安装师傅应该在里边吧,我到小区了</td></tr><tr><td class="time-cell">6/18 16:43</td><td>L.F.H STUDIO</td><td class="msg-cell">好像是有点脱胶了,拿点胶水给它粘一下</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #e74c3c; background: #fff5f5;">
+        <div class="missed-header">
+          <span class="missed-title">3. 未知群(10791789843077656)</span>
+          <span><span class="badge badge-high">高危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🔴 垃圾</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 2 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无任何预警记录,所有关键词命中均未触发预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 用户询问垃圾桶尺寸,属于正常设计沟通,无负面情绪或攻击性。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/21 16:16</td><td>未知</td><td class="msg-cell">@杨飞飞 厨房放置物小车和垃圾桶的位置尺寸是多少?</td></tr><tr><td class="time-cell">6/21 16:26</td><td>未知</td><td class="msg-cell">目前小车450宽 400深 垃圾桶350宽400深</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">4. 古美阅华❤️拉迷进度分享群</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 订金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 订金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘订金膨胀’是行业常见营销用语,无真实风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 11:44</td><td>方健</td><td class="msg-cell">@  ✨【吴中拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上折
+💥 进店礼+签单礼多礼向享
+💥 挚友同行再省千元
+   ......活动...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">5. 金茂棠前❤建设进度更新</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 订金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 订金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘订金膨胀’作为营销话术,无真实风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 11:23</td><td>王瑾</td><td class="msg-cell">✨【建配龙拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上折
+💥 进店礼+签单礼多礼向享
+💥 挚友同行再省千元
+   ......活动超多...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">6. 📣保利-海上印❤进度情报局</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 订金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 订金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘订金膨胀’作为营销手段,无真实风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 12:09</td><td>拉迷~庞雪</td><td class="msg-cell">✨【吴中拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上折
+💥 进店礼+签单礼多礼向享
+💥 挚友同行再省千元
+   ......活动超多等...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">7. 世纪前滩天汇❤️项目监工群📢</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-risky">🤖 AI 评审:⚠ 有风险(low) | 建议预警: 否 | 消息为促销活动宣传,提及定金膨胀属正常营销话术,无欺诈或违规风险,但需关注是否涉及虚假宣传。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 16:48</td><td>刘烈峰</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">8. 前滩天御&amp;拉迷❤️信息情报二群</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,定金膨胀是常见营销手段,无欺诈或违规风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 16:48</td><td>刘烈峰</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">9. 碧云北外滩尊邸❤️拉迷家居服务群</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-risky">🤖 AI 评审:⚠ 有风险(medium) | 建议预警: 否 | 群内发布定金膨胀促销,虽非直接诈骗,但涉及业主群营销,存在诱导预付风险,需关注合规性。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 16:48</td><td>未知</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">10. 未知群(10713790807568676)</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用'定金膨胀'等营销术语,无诱导支付或违规收费风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 16:48</td><td>未知</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">11. 未知群(10870148612937458)</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 消息为促销活动宣传,提及定金膨胀属正常营销用语,无违规或风险行为。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 16:48</td><td>刘烈峰</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">12. 未知群</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span><span class="kw-tag">🟡 订金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 2 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群有预警但未覆盖以下关键词: [定金, 订金]</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 消息为促销宣传,使用‘定金’‘订金’符合正常营销语境,无违规或欺诈风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 16:48</td><td>未知</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr><tr><td class="time-cell">6/16 17:11</td><td>未知</td><td class="msg-cell">[破涕为笑]但折扣比例不能完全体现价值,每一家的销售方式都不同,要看最终的品质还有落地保障,还有整体成本吧。
+85折是正价基础上直接减,订金膨胀,满减,还有基础套餐,叠加下来最终到手价还是很划算的。
+每家每户需求都不一样,还是需要沟通合适自...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">13. 保利外滩序BUND88❤️信息情报站📢</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群有预警但未覆盖以下关键词: [定金]</div>
+          </div>
+          <div class="ai-review ai-risky">🤖 AI 评审:⚠ 有风险(medium) | 建议预警: 否 | 消息为促销活动,但‘定金膨胀’涉及预付款,存在客户资金风险,需关注活动合规性。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 16:50</td><td>未知</td><td class="msg-cell">🏠 邻居们,618福利来了!@所有人  
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">14. 未知群(10880395739118764)</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-risky">🤖 AI 评审:⚠ 有风险(medium) | 建议预警: 否 | 群聊为业主群,内容为促销活动,提及定金膨胀属正常营销,非违规收取定金,风险较低。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/16 17:08</td><td>未知</td><td class="msg-cell">🏠 邻居们,618福利来了!@所有人  
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">15. 未知群(10918275038305828)</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘定金膨胀’等常见营销术语,未涉及违规收取定金或诱导付款风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/17 10:20</td><td>未知</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">16. 未知群(10696323927935424)</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 定金</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,提及定金膨胀属于常规营销话术,无欺诈或违规风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/17 10:21</td><td>未知</td><td class="msg-cell">🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅ 618叠叠礼:全屋定制套餐+进店礼下定礼+专业验房名额
+还有精装微改、成品折上...</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">17. 拉迷&amp;万骊全屋定制-西派海上小区团购群①</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 色差</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 2 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无任何预警记录,所有关键词命中均未触发预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 色差为正常营销用语,指线上看图与实物差异,无投诉或质量问题风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/19 09:00</td><td>李秋华</td><td class="msg-cell">@  粽享佳节,年中盛惠[烟花][烟花][红包][红包]
+端午邂逅618大促,拉迷家居诚邀各位邻居到访线下旗舰展厅,沉浸式体验海派轻奢全屋整装效果,线上百次看图不如线下实地体验,近距离查验五金、封边与基材品质。
+群内业主朋友预约到店即可领取...</td></tr><tr><td class="time-cell">6/20 15:09</td><td>李秋华</td><td class="msg-cell">@  粽享端午,618盛惠🎉拉迷家居邀各位邻居到店体验海派轻奢全屋整装!实地查看基材、封边与五金品质,告别线上色差。群内业主预约到店即领精美伴手礼[庆祝][庆祝][庆祝],小长假欢迎莅临</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">18. 未知群(10923519335985946)</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 色差</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 2 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无任何预警记录,所有关键词命中均未触发预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 消息为营销推广,使用'色差'一词指线上看图与实物差异,非投诉或质量问题,无真实风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/19 09:00</td><td>李秋华</td><td class="msg-cell">@  粽享佳节,年中盛惠[烟花][烟花][红包][红包]
+端午邂逅618大促,拉迷家居诚邀各位邻居到访线下旗舰展厅,沉浸式体验海派轻奢全屋整装效果,线上百次看图不如线下实地体验,近距离查验五金、封边与基材品质。
+群内业主朋友预约到店即可领取...</td></tr><tr><td class="time-cell">6/20 15:09</td><td>李秋华</td><td class="msg-cell">@  粽享端午,618盛惠🎉拉迷家居邀各位邻居到店体验海派轻奢全屋整装!实地查看基材、封边与五金品质,告别线上色差。群内业主预约到店即领精美伴手礼[庆祝][庆祝][庆祝],小长假欢迎莅临</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">19. 未知群(10973443324259792)</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 宜家</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无任何预警记录,所有关键词命中均未触发预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | “宜家对杯”为游戏或活动用语,非竞品提及或敏感行为,无实际风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/18 20:19</td><td>未知</td><td class="msg-cell">我们还有最后一轮,宜家对杯~</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #f39c12; background: #fffdf0;">
+        <div class="missed-header">
+          <span class="missed-title">20. 拉迷 VIP服务群 3104王女士 浦开云璟三期2-5-801室</span>
+          <span><span class="badge badge-medium">中危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟡 增项</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 2 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无任何预警记录,所有关键词命中均未触发预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:✅ 无风险 | 建议预警: 否 | 客户主动询问如何减少增项,属于正常沟通,无投诉或纠纷风险。</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/22 15:41</td><td>未知</td><td class="msg-cell">客厅如果想减少增项的话,用图上这种搭配会好看不?</td></tr><tr><td class="time-cell">6/22 17:20</td><td>郭晶晶</td><td class="msg-cell">&quot;有头脑和很高兴:
+客厅如果想减少增项的话,用图上这种搭配会好看不?&quot;
+------
+@有头脑和很高兴  可以的哈</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>
+      <div class="missed-card" style="border-left: 4px solid #3498db; background: #f8faff;">
+        <div class="missed-header">
+          <span class="missed-title">21. 未知群(10970411474728272)</span>
+          <span><span class="badge badge-low">低危</span></span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> <span class="kw-tag">🟢 安装师傅</span></div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> 1 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> 该群无任何预警记录,所有关键词命中均未触发预警</div>
+          </div>
+          <div class="ai-review ai-safe">🤖 AI 评审:(低危,未提交 AI 评审)</div>
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody><tr><td class="time-cell">6/22 15:54</td><td>未知</td><td class="msg-cell">【安装通知】
+拉迷家居提醒您:
+订单【LZ15002818Y1+浦东新区银鼎路199弄8幢8号1503】
+将安排于2026-06-21上门安装,详情时间安装师傅会提前与您沟通。
+安装师傅:王自强,彭江南,手机:15921423313</td></tr></tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div></div>
+      </div>
+  
+      <div class="section">
+        <h2>📚 从未触发预警的关键词 <span class="sub">(176 个)</span></h2>
+        <div class="kw-stats-bar">
+          <div class="kw-stat kw-stat-high"><span>78</span> 高危</div>
+          <div class="kw-stat kw-stat-med"><span>78</span> 中危</div>
+          <div class="kw-stat kw-stat-low"><span>20</span> 低危</div>
+        </div>
+        <div class="kw-cloud"><span class="kw-chip kw-high">投诉<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">维权<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">退款<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">假货<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">诈骗<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">举报<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">315<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">工商<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">法院<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">律师函<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">媒体曝光<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">质量问题<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">甲醛<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">超标<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">退群<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">坑人<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">骗子<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">黑心<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">曝光<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">索赔<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">违约<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">投诉<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">维权<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">退款<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">假货<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">诈骗<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">举报<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">315<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">工商<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">法院<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">律师函<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">媒体曝光<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">质量问题<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">甲醛<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">超标<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">退群<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">坑人<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">骗子<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">黑心<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">曝光<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">索赔<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">违约<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-high">开裂<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-high">变形<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-high">脱胶<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-high">发霉<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-high">装错<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-high">尺寸不对<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-high">做错了<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-high">停工<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">烂尾<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">跑路<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">倒闭<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">闹事<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">拉横幅<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">堵门<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">投诉到总部<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">退单<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">拆掉重做<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-high">消费者协会<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">消协<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">市场监督<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">12315<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">起诉<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">打官司<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">仲裁<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">合同纠纷<span class="kw-cat">法律</span></span>
+<span class="kw-chip kw-high">加钱<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-high">上当<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-high">坑爹<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-high">骗钱<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-high">垃圾<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-high">不可接受<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-high">牌子做砸<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-high">口碑差<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-high">安装不到位<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-high">装坏了<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-high">开裂<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">差评<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">退货<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">延期<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">欧派<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">索菲亚<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">尚品宅配<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">好莱客<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">金牌橱柜<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">志邦<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">皮阿诺<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">我乐<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">不想装了<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-medium">换一家<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-medium">太差了<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">差评<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">退货<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">延期<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">欧派<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">索菲亚<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">尚品宅配<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">好莱客<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">金牌橱柜<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">志邦<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">皮阿诺<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">我乐<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">不想装了<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-medium">换一家<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-medium">太差了<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">态度差<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">不接电话<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">不回复<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">敷衍<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">推诿<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">不见人<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-medium">色差<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">划痕<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">起皮<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">鼓包<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">异味<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">松动<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">不对齐<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">缝隙大<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">掉漆<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-medium">换人<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-medium">取消订单<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-medium">重新做<span class="kw-cat">敏感</span></span>
+<span class="kw-chip kw-medium">全友<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">顾家<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">曲美<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">宜家<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">红苹果<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">顶固<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">科凡<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">百得胜<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">客来福<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-medium">定金<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-medium">订金<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-medium">超出预算<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-medium">隐性消费<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-medium">差价<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-medium">价格虚高<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-medium">增项<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-medium">后悔<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">不值<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">闹心<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">失望<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">忍不了<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">太过分<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">当初就该<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">选错了<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-medium">打孔打偏<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-medium">碰坏<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-medium">没装好<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-medium">漏装<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-medium">缺件<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-medium">等了好久<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-medium">还没来<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-medium">放了鸽子<span class="kw-cat">安装</span></span>
+<span class="kw-chip kw-low">太贵了<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">便宜点<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">打折<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">砍价<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">太贵了<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">便宜点<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">打折<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">砍价<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">没回复<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-low">不理人<span class="kw-cat">投诉</span></span>
+<span class="kw-chip kw-low">有缝<span class="kw-cat">质量</span></span>
+<span class="kw-chip kw-low">别人家<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-low">别家<span class="kw-cat">竞品</span></span>
+<span class="kw-chip kw-low">多少钱<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">预算<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">多退少补<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">报价<span class="kw-cat">价格</span></span>
+<span class="kw-chip kw-low">早知<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-low">早知道<span class="kw-cat">负面</span></span>
+<span class="kw-chip kw-low">安装师傅<span class="kw-cat">安装</span></span></div>
+        <p class="hint">这些关键词在审计期间被消息命中,但从未在任何群触发过预警。建议审核:是否过于宽泛(噪音多)或过于极端(实际不会出现)。</p>
+      </div>
+  
+    <div class="section">
+      <h2>💡 管理建议</h2>
+      <div class="suggestion-grid">
+        <div class="sugg-card sugg-high">
+          <div class="sugg-title">🔴 高优先级</div>
+          <ul>
+            <li>将<strong>「定金」「订金」「色差」「优惠」</strong>等促销类关键词从预警名单移除或降级为"仅记录"</li>
+            <li>质量关键词(<strong>脱胶/开裂/变形/发霉/装错</strong>)提高权重,命中即报警</li>
+          </ul>
+        </div>
+        <div class="sugg-card sugg-med">
+          <div class="sugg-title">🟡 中优先级</div>
+          <ul>
+            <li>对 176 个从未命中的关键词做"大扫除":保留核心词(投诉/退款/甲醛等),删除极端低频词</li>
+            <li>同一群短时间命中多个低价关键词 → 聚合升级预警</li>
+          </ul>
+        </div>
+        <div class="sugg-card sugg-low">
+          <div class="sugg-title">🟢 持续改进</div>
+          <ul>
+            <li>每周运行本审计脚本,跟踪漏报趋势</li>
+            <li>建立"关键词→预警→确认→反馈"闭环,定期淘汰无效关键词</li>
+            <li>竞品类关键词命中频次统计,作为市场情报输入</li>
+          </ul>
+        </div>
+      </div>
+    </div>
+  <div class="footer">拉迷家居 · 风险预警覆盖审计 · 自动生成</div>
+</div>
+</body>
+</html>

+ 599 - 0
backend/scripts/audit-risk-2026-06-15-22.md

@@ -0,0 +1,599 @@
+# 风险预警覆盖审计报告
+**审计期间**: 2026-06-15 ~ 2026-06-22(8 天)
+**生成时间**: 2026/6/22 15:21:45
+
+## 一、数据概览
+
+| 指标 | 数值 |
+|------|------|
+| 文本消息总量 | 1,252 |
+| 已有风险事件 | 4 |
+| 启用风险关键词 | 181 |
+| 已触发预警的关键词 | 4 |
+| 从未触发预警的关键词 | 177 |
+| **关键词覆盖率** | **2.2%** |
+| 漏报群数量 | 18 |
+| 漏报消息片段 | 23 |
+| 🔴 高危漏报群 | 2 |
+| 🟡 中危漏报群 | 16 |
+| 🟢 低危漏报群 | 0 |
+
+## 二、漏洞分析
+
+### 2.1 完全未被预警覆盖的关键词(176 个)
+
+这些关键词在 8 天内被消息命中,但从未在任何群触发过预警:
+
+| 关键词 | 类别 | 严重度 |
+|--------|------|--------|
+| 🔴 投诉 | complaint | high |
+| 🔴 维权 | complaint | high |
+| 🔴 退款 | complaint | high |
+| 🔴 假货 | complaint | high |
+| 🔴 诈骗 | complaint | high |
+| 🔴 举报 | complaint | high |
+| 🔴 315 | complaint | high |
+| 🔴 工商 | complaint | high |
+| 🔴 法院 | complaint | high |
+| 🔴 律师函 | complaint | high |
+| 🔴 媒体曝光 | complaint | high |
+| 🔴 质量问题 | complaint | high |
+| 🔴 甲醛 | sensitive | high |
+| 🔴 超标 | sensitive | high |
+| 🔴 退群 | sensitive | high |
+| 🔴 坑人 | complaint | high |
+| 🔴 骗子 | complaint | high |
+| 🔴 黑心 | complaint | high |
+| 🔴 曝光 | sensitive | high |
+| 🔴 索赔 | complaint | high |
+| 🔴 违约 | complaint | high |
+| 🔴 投诉 | complaint | high |
+| 🔴 维权 | complaint | high |
+| 🔴 退款 | complaint | high |
+| 🔴 假货 | complaint | high |
+| 🔴 诈骗 | complaint | high |
+| 🔴 举报 | complaint | high |
+| 🔴 315 | complaint | high |
+| 🔴 工商 | complaint | high |
+| 🔴 法院 | complaint | high |
+| 🔴 律师函 | complaint | high |
+| 🔴 媒体曝光 | complaint | high |
+| 🔴 质量问题 | complaint | high |
+| 🔴 甲醛 | sensitive | high |
+| 🔴 超标 | sensitive | high |
+| 🔴 退群 | sensitive | high |
+| 🔴 坑人 | complaint | high |
+| 🔴 骗子 | complaint | high |
+| 🔴 黑心 | complaint | high |
+| 🔴 曝光 | sensitive | high |
+| 🔴 索赔 | complaint | high |
+| 🔴 违约 | complaint | high |
+| 🔴 开裂 | quality | high |
+| 🔴 变形 | quality | high |
+| 🔴 脱胶 | quality | high |
+| 🔴 发霉 | quality | high |
+| 🔴 装错 | quality | high |
+| 🔴 尺寸不对 | quality | high |
+| 🔴 做错了 | quality | high |
+| 🔴 停工 | sensitive | high |
+| 🔴 烂尾 | sensitive | high |
+| 🔴 跑路 | sensitive | high |
+| 🔴 倒闭 | sensitive | high |
+| 🔴 闹事 | sensitive | high |
+| 🔴 拉横幅 | sensitive | high |
+| 🔴 堵门 | sensitive | high |
+| 🔴 投诉到总部 | sensitive | high |
+| 🔴 退单 | sensitive | high |
+| 🔴 拆掉重做 | sensitive | high |
+| 🔴 消费者协会 | legal | high |
+| 🔴 消协 | legal | high |
+| 🔴 市场监督 | legal | high |
+| 🔴 12315 | legal | high |
+| 🔴 起诉 | legal | high |
+| 🔴 打官司 | legal | high |
+| 🔴 仲裁 | legal | high |
+| 🔴 合同纠纷 | legal | high |
+| 🔴 加钱 | price | high |
+| 🔴 上当 | negative | high |
+| 🔴 坑爹 | negative | high |
+| 🔴 骗钱 | negative | high |
+| 🔴 垃圾 | negative | high |
+| 🔴 不可接受 | negative | high |
+| 🔴 牌子做砸 | negative | high |
+| 🔴 口碑差 | negative | high |
+| 🔴 安装不到位 | installation | high |
+| 🔴 装坏了 | installation | high |
+| 🔴 开裂 | complaint | high |
+| 🟡 差评 | complaint | medium |
+| 🟡 退货 | complaint | medium |
+| 🟡 延期 | complaint | medium |
+| 🟡 欧派 | competitor | medium |
+| 🟡 索菲亚 | competitor | medium |
+| 🟡 尚品宅配 | competitor | medium |
+| 🟡 好莱客 | competitor | medium |
+| 🟡 金牌橱柜 | competitor | medium |
+| 🟡 志邦 | competitor | medium |
+| 🟡 皮阿诺 | competitor | medium |
+| 🟡 我乐 | competitor | medium |
+| 🟡 不想装了 | sensitive | medium |
+| 🟡 换一家 | sensitive | medium |
+| 🟡 太差了 | complaint | medium |
+| 🟡 差评 | complaint | medium |
+| 🟡 退货 | complaint | medium |
+| 🟡 延期 | complaint | medium |
+| 🟡 欧派 | competitor | medium |
+| 🟡 索菲亚 | competitor | medium |
+| 🟡 尚品宅配 | competitor | medium |
+| 🟡 好莱客 | competitor | medium |
+| 🟡 金牌橱柜 | competitor | medium |
+| 🟡 志邦 | competitor | medium |
+| 🟡 皮阿诺 | competitor | medium |
+| 🟡 我乐 | competitor | medium |
+| 🟡 不想装了 | sensitive | medium |
+| 🟡 换一家 | sensitive | medium |
+| 🟡 太差了 | complaint | medium |
+| 🟡 态度差 | complaint | medium |
+| 🟡 不接电话 | complaint | medium |
+| 🟡 不回复 | complaint | medium |
+| 🟡 敷衍 | complaint | medium |
+| 🟡 推诿 | complaint | medium |
+| 🟡 不见人 | complaint | medium |
+| 🟡 色差 | quality | medium |
+| 🟡 划痕 | quality | medium |
+| 🟡 起皮 | quality | medium |
+| 🟡 鼓包 | quality | medium |
+| 🟡 异味 | quality | medium |
+| 🟡 松动 | quality | medium |
+| 🟡 不对齐 | quality | medium |
+| 🟡 缝隙大 | quality | medium |
+| 🟡 掉漆 | quality | medium |
+| 🟡 换人 | sensitive | medium |
+| 🟡 取消订单 | sensitive | medium |
+| 🟡 重新做 | sensitive | medium |
+| 🟡 全友 | competitor | medium |
+| 🟡 顾家 | competitor | medium |
+| 🟡 曲美 | competitor | medium |
+| 🟡 宜家 | competitor | medium |
+| 🟡 红苹果 | competitor | medium |
+| 🟡 顶固 | competitor | medium |
+| 🟡 科凡 | competitor | medium |
+| 🟡 百得胜 | competitor | medium |
+| 🟡 客来福 | competitor | medium |
+| 🟡 定金 | price | medium |
+| 🟡 订金 | price | medium |
+| 🟡 超出预算 | price | medium |
+| 🟡 隐性消费 | price | medium |
+| 🟡 差价 | price | medium |
+| 🟡 价格虚高 | price | medium |
+| 🟡 增项 | price | medium |
+| 🟡 后悔 | negative | medium |
+| 🟡 不值 | negative | medium |
+| 🟡 闹心 | negative | medium |
+| 🟡 失望 | negative | medium |
+| 🟡 忍不了 | negative | medium |
+| 🟡 太过分 | negative | medium |
+| 🟡 当初就该 | negative | medium |
+| 🟡 选错了 | negative | medium |
+| 🟡 打孔打偏 | installation | medium |
+| 🟡 碰坏 | installation | medium |
+| 🟡 没装好 | installation | medium |
+| 🟡 漏装 | installation | medium |
+| 🟡 缺件 | installation | medium |
+| 🟡 等了好久 | installation | medium |
+| 🟡 还没来 | installation | medium |
+| 🟡 放了鸽子 | installation | medium |
+| 🟢 太贵了 | price | low |
+| 🟢 便宜点 | price | low |
+| 🟢 打折 | price | low |
+| 🟢 砍价 | price | low |
+| 🟢 太贵了 | price | low |
+| 🟢 便宜点 | price | low |
+| 🟢 打折 | price | low |
+| 🟢 砍价 | price | low |
+| 🟢 没回复 | complaint | low |
+| 🟢 不理人 | complaint | low |
+| 🟢 有缝 | quality | low |
+| 🟢 别人家 | competitor | low |
+| 🟢 别家 | competitor | low |
+| 🟢 多少钱 | price | low |
+| 🟢 预算 | price | low |
+| 🟢 多退少补 | price | low |
+| 🟢 报价 | price | low |
+| 🟢 早知 | negative | low |
+| 🟢 早知道 | negative | low |
+| 🟢 安装师傅 | installation | low |
+
+### 2.2 漏报群详情
+
+#### 1. 🔴 拉迷VIP服务群中心润府李先生lz122539
+
+- **命中关键词**: 安装师傅, 脱胶
+- **最高严重度**: high
+- **匹配消息数**: 2
+- **原因**: 该群无任何预警记录,所有关键词命中均未触发预警
+- **AI 评审**: ⚠ 有风险(medium) | 建议预警: 否 | 脱胶属于产品质量问题,可能影响客户满意度,但消息中安装师傅在场且已提出修补方案,风险可控。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/18 16:02 | A拉迷🐉~纪澜(拉迷) | 安装师傅应该在里边吧,我到小区了 |
+| 6/18 16:43 | L.F.H STUDIO | 好像是有点脱胶了,拿点胶水给它粘一下 |
+
+</details>
+
+#### 2. 🔴 未知群(10791789843077656)
+
+- **命中关键词**: 垃圾
+- **最高严重度**: high
+- **匹配消息数**: 2
+- **原因**: 该群无任何预警记录,所有关键词命中均未触发预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 用户询问垃圾桶尺寸,属正常设计沟通,无负面或投诉意图。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/21 16:16 | 未知 | @杨飞飞 厨房放置物小车和垃圾桶的位置尺寸是多少? |
+| 6/21 16:26 | 未知 | 目前小车450宽 400深 垃圾桶350宽400深 |
+
+</details>
+
+#### 3. 🟡 古美阅华❤️拉迷进度分享群
+
+- **命中关键词**: 订金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 订金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ⚠ 有风险(medium) | 建议预警: 否 | 该消息为促销活动宣传,使用‘订金膨胀’等营销话术,虽未直接违规,但存在诱导客户提前支付订金的风险,需关注后续转化环节。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 11:44 | 方健 | @  ✨【吴中拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上... |
+
+</details>
+
+#### 4. 🟡 金茂棠前❤建设进度更新
+
+- **命中关键词**: 订金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 订金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘订金膨胀’作为营销手段,无真实风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 11:23 | 王瑾 | ✨【建配龙拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上折
+... |
+
+</details>
+
+#### 5. 🟡 📣保利-海上印❤进度情报局
+
+- **命中关键词**: 订金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 订金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘订金膨胀’作为营销手段,无真实风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 12:09 | 拉迷~庞雪 | ✨【吴中拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上折
+�... |
+
+</details>
+
+#### 6. 🟡 未知群(10713790807568676)
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘定金膨胀’等营销术语,无欺诈或违规风险,无需预警。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 16:48 | 未知 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+
+</details>
+
+#### 7. 🟡 未知群
+
+- **命中关键词**: 定金, 订金
+- **最高严重度**: medium
+- **匹配消息数**: 2
+- **原因**: 该群有预警但未覆盖以下关键词: [定金, 订金]
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 消息为正常促销宣传,定金/订金使用合规,无虚假承诺或诱导风险,无需预警。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 16:48 | 未知 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 6/16 17:11 | 未知 | [破涕为笑]但折扣比例不能完全体现价值,每一家的销售方式都不同,要看最终的品质还有落地保障,还有整体成本吧。
+85折是正价基础上直接减,订金膨胀,满减,还有基础... |
+
+</details>
+
+#### 8. 🟡 未知群(10870148612937458)
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,定金膨胀是常见营销手段,无欺诈或违规风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 16:48 | 刘烈峰 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+
+</details>
+
+#### 9. 🟡 碧云北外滩尊邸❤️拉迷家居服务群
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为促销活动宣传,定金膨胀属正常营销手段,无欺诈或违规风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 16:48 | 未知 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+
+</details>
+
+#### 10. 🟡 世纪前滩天汇❤️项目监工群📢
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,提及定金膨胀属常规营销话术,无欺诈或违规风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 16:48 | 刘烈峰 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+
+</details>
+
+#### 11. 🟡 前滩天御&拉迷❤️信息情报二群
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动,定金膨胀是常见营销手段,无欺诈或违规风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 16:48 | 刘烈峰 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+
+</details>
+
+#### 12. 🟡 保利外滩序BUND88❤️信息情报站📢
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群有预警但未覆盖以下关键词: [定金]
+- **AI 评审**: ⚠ 有风险(medium) | 建议预警: 否 | 群聊为业主福利宣传,定金膨胀属正常促销,无诱导或欺诈风险,但需关注后续是否涉及违规收费。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 16:50 | 未知 | 🏠 邻居们,618福利来了!@所有人  
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵... |
+
+</details>
+
+#### 13. 🟡 未知群(10880395739118764)
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ⚠ 有风险(medium) | 建议预警: 否 | 群聊中明确提及拉迷家居的定金膨胀活动,涉及定金收取,存在未明确合同条款或退款规则的风险,需关注合规性。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/16 17:08 | 未知 | 🏠 邻居们,618福利来了!@所有人  
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵... |
+
+</details>
+
+#### 14. 🟡 未知群(10918275038305828)
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,提及定金膨胀属常规营销话术,无诱导违规或资金风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/17 10:20 | 未知 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+
+</details>
+
+#### 15. 🟡 未知群(10696323927935424)
+
+- **命中关键词**: 定金
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无预警记录;命中关键词: [优惠, 定金],其中已有关键词 [优惠] 在其他群触发过预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 该消息为正常促销活动宣传,使用‘定金’描述合法优惠,无诱导或违规风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/17 10:21 | 未知 | 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+
+</details>
+
+#### 16. 🟡 拉迷&万骊全屋定制-西派海上小区团购群①
+
+- **命中关键词**: 色差
+- **最高严重度**: medium
+- **匹配消息数**: 2
+- **原因**: 该群无任何预警记录,所有关键词命中均未触发预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 色差用于促销话术,指线上看图与实际差异,非投诉或质量问题,无真实风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/19 09:00 | 李秋华 | @  粽享佳节,年中盛惠[烟花][烟花][红包][红包]
+端午邂逅618大促,拉迷家居诚邀各位邻居到访线下旗舰展厅,沉浸式体验海派轻奢全屋整装效果,线上百次看图... |
+| 6/20 15:09 | 李秋华 | @  粽享端午,618盛惠🎉拉迷家居邀各位邻居到店体验海派轻奢全屋整装!实地查看基材、封边与五金品质,告别线上色差。群内业主预约到店即领精美伴手礼[庆祝][庆... |
+
+</details>
+
+#### 17. 🟡 未知群(10923519335985946)
+
+- **命中关键词**: 色差
+- **最高严重度**: medium
+- **匹配消息数**: 2
+- **原因**: 该群无任何预警记录,所有关键词命中均未触发预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | 消息为促销邀约,'色差'指线上看图与实物差异,属正常营销话术,无真实风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/19 09:00 | 李秋华 | @  粽享佳节,年中盛惠[烟花][烟花][红包][红包]
+端午邂逅618大促,拉迷家居诚邀各位邻居到访线下旗舰展厅,沉浸式体验海派轻奢全屋整装效果,线上百次看图... |
+| 6/20 15:09 | 李秋华 | @  粽享端午,618盛惠🎉拉迷家居邀各位邻居到店体验海派轻奢全屋整装!实地查看基材、封边与五金品质,告别线上色差。群内业主预约到店即领精美伴手礼[庆祝][庆... |
+
+</details>
+
+#### 18. 🟡 未知群(10973443324259792)
+
+- **命中关键词**: 宜家
+- **最高严重度**: medium
+- **匹配消息数**: 1
+- **原因**: 该群无任何预警记录,所有关键词命中均未触发预警
+- **AI 评审**: ✅ 无风险 | 建议预警: 否 | “宜家对杯”为游戏或活动用语,非竞品对比或客户流失风险,无实际业务风险。
+
+<details>
+<summary>消息样本(点击展开)</summary>
+
+| 时间 | 发送者 | 内容 |
+|------|--------|------|
+| 6/18 20:19 | 未知 | 我们还有最后一轮,宜家对杯~ |
+
+</details>
+
+## 三、AI 综合结论
+
+AI 评审结果: 4 个确认为真正漏报,14 个确认为无风险,0 个评审失败。
+
+## 四、管理建议
+
+### 关键词库优化
+1. 当前关键词覆盖率 **2.2%**,176 个关键词从未触发预警——建议审核这些关键词是否仍需保留,或降低触发阈值
+2. 对 AI 确认为真正漏报的关键词,考虑提升其严重度等级
+3. 定期(每周)审计关键词库与实际消息内容的匹配效果
+
+### 预警规则优化
+4. 当前预警依赖关键词+AI 双重判定,建议对高频命中但未预警的关键词降低 AI 确认门槛
+5. 同一群内短时间内多次命中不同低危关键词 → 应聚合升级为中危预警
+6. 竞品类关键词(欧派、索菲亚等)命中频次统计,作为市场情报输入
+
+### 流程建议
+7. 建议每周运行本审计脚本,跟踪漏报趋势
+8. 高危关键词出现后应在 30 分钟内通知区域负责人(当前仅依赖定时轮询)
+9. 建立"关键词→预警→确认→反馈"闭环,定期淘汰无效关键词
+
+---
+*报告由 audit-risk-coverage.ts 自动生成*

+ 297 - 0
backend/scripts/audit-risk-README.md

@@ -0,0 +1,297 @@
+# 🛡️ 拉迷家居 · 风险预警审计技能文档
+
+> **版本**: v1.0
+> **适用范围**: 拉迷家居企微群聊风险监控系统
+> **维护人**: 风控团队
+
+---
+
+## 一、系统概述
+
+### 1.1 背景
+
+拉迷家居在企微运营约 60+ 个业主群,每日产生数百条聊天消息。现有风险预警系统通过「关键词匹配 + AI 审核」双重机制自动检测群聊中的风险内容(投诉、质量、法律纠纷等)。
+
+本技能文档提供一套完整的**事后审计工具**,用于:
+- 检验预警系统的覆盖效果
+- 发现漏报的潜在风险消息
+- 优化关键词库和预警规则
+- 生成可视化管理报告
+
+### 1.2 预警系统工作原理
+
+```
+消息到达 → 关键词匹配(AC自动机) → 命中? → AI深度分析(DeepSeek) → 有风险? → 创建RiskEvent → 通知负责人
+                ↓ 未命中                    ↓ 无风险
+              丢弃                        丢弃
+```
+
+**关键瓶颈**:
+- 关键词命中率直接影响预警覆盖面
+- AI 分析是第二道闸门,但只在关键词命中后触发
+- 关键词未命中 → 消息对预警系统完全不可见
+
+---
+
+## 二、审计脚本清单
+
+| 脚本 | 文件 | 用途 | 建议频率 | 耗时 |
+|------|------|------|----------|------|
+| 覆盖审计 | `audit-risk-coverage.ts` | 关键词覆盖率 + 漏报检测 + 可视化 HTML | 每周 | ~30s |
+| 深度分析 | `audit-risk-deep-analysis.ts` | 多维度分析 + AI 管理建议 + MD 报告 | 每月 | ~10s |
+| 昵称回填 | `backfill-member-nicknames.ts` | 从 Message 表回填 GroupMember 缺失昵称 | 按需 | ~30s |
+
+---
+
+## 三、快速开始
+
+### 3.1 环境要求
+
+```bash
+# .env 中需要配置
+DEEPSEEK_API_KEY=sk-xxxx      # AI 分析必需(可选,不配则跳过 AI 评审)
+QIWE_GUID=B8591A9D-B540-...
+```
+
+### 3.2 运行覆盖审计
+
+```bash
+cd backend
+npx tsx scripts/audit-risk-coverage.ts
+```
+
+**输出**: `audit-risk-YYYY-MM-DD.html` — 在浏览器中打开,包含:
+- 概览卡片(消息量、预警数、覆盖率)
+- 已触发预警列表
+- 漏报群列表(含消息样本 + AI 评审结论)
+- 未覆盖关键词云
+- 管理建议卡片
+
+### 3.3 运行深度分析
+
+```bash
+cd backend
+npx tsx scripts/audit-risk-deep-analysis.ts
+```
+
+**输出**: `audit-risk-deep-YYYY-MM-DD.md` — Markdown 格式,包含:
+- 消息活动分布(按天/群/参与者)
+- 关键词类别有效性排名
+- 漏报原因分类(促销/业务/真实风险)
+- AI 管理建议(5 维度)
+- 行动项清单(立即/短期/持续)
+
+---
+
+## 四、本期审计结果(2026-06-15 ~ 06-22)
+
+### 4.1 核心数据
+
+| 指标 | 数值 | 评估 |
+|------|------|------|
+| 文本消息总量 | 1,470 | 日均 184 条 |
+| 活跃群数 | 149 | - |
+| 参与人数 | 142 | - |
+| 风险事件 | **4 条** | 仅覆盖 3 个群 |
+| 启用关键词 | 181 个 | - |
+| 触发过预警的关键词 | **4 个** | 覆盖率 2.2% 🔴 |
+| 完全未触发的关键词 | **176 个** | 占比 97.2% |
+| 漏报群 | 21 个 | 高危 3 / 中危 17 / 低危 1 |
+
+### 4.2 已触发的 4 条预警
+
+| 关键词 | 触发群 | 严重度 |
+|--------|--------|--------|
+| 优惠 | 多个群 | low~medium |
+| 投诉 | - | - |
+| (其余 2 个) | - | - |
+
+### 4.3 关键词类别有效率
+
+| 类别 | 总词数 | 命中词 | 命中消息 | 有效率 |
+|------|--------|--------|----------|--------|
+| 价格讨论 | 22 | 5 | 25 | 23% |
+| 质量工艺 | 17 | 8 | 8 | 47% |
+| 安装施工 | 12 | 2 | 3 | 17% |
+| 负面情绪 | 17 | 1 | 2 | 6% |
+| 竞品对比 | 23 | 1 | 1 | 4% |
+| 投诉抱怨 | **51** | **0** | **0** | **0%** 🔴 |
+| 敏感词 | **25** | **0** | **0** | **0%** 🔴 |
+| 法律监管 | **8** | **0** | **0** | **0%** 🔴 |
+
+### 4.4 漏报分类
+
+**类型一:促销营销用词(占比最大 ~80%)**
+- 「定金」「订金」「优惠」出现在大量 618 促销广播中
+- 同一营销文案在 10+ 个群重复发送
+- AI 评审: 正常促销,无真实风险
+- **结论: 误报,不需预警**
+
+**类型二:正常业务沟通(~12%)**
+- 「色差」用于"告别线上色差"的到店体验邀约
+- 「垃圾」用于询问垃圾桶尺寸
+- 「宜家」用于游戏互动"宜家对杯"
+- **结论: 误报,不需预警**
+
+**类型三:真实质量风险(~8%,需关注)**
+- 「脱胶」- 客户在 VIP 服务群反映产品脱胶
+- AI 评审: ⚠ 有风险(medium),安装师傅已提出修补方案
+- **结论: 应预警但未预警**
+
+### 4.5 关键发现
+
+1. **关键词库严重冗余**: 181 个词中 176 个从未触发过预警,97% 的词是"沉睡词"
+2. **三大类别完全失效**: 投诉抱怨(51词)、敏感词(25词)、法律监管(8词) 合计 84 个关键词零命中
+3. **真漏报极少**: 21 个"漏报群"中,仅 2-3 个属于真实风险(脱胶等),其余均为促销营销用词误匹配
+4. **盲区存在**: 纯关键词无法识别口语化抱怨("柜子有缝""板子发黑""味道大"等)
+
+---
+
+## 五、管理建议
+
+### 5.1 关键词库优化(高优先级)
+
+| 操作 | 涉及关键词 | 原因 |
+|------|-----------|------|
+| 🔴 移除/降级 | 定金、订金、优惠、折扣、打折、砍价 | 大量 618 促销误匹配,非真实风险 |
+| 🔴 移除/降级 | 色差、垃圾、宜家 | 正常业务语境高频出现 |
+| 🟡 保留升级 | 脱胶、开裂、变形、发霉、装错 | 质量类词命中即应预警 |
+| 🟡 保留观察 | 投诉、维权、退款、甲醛、超标 | 核心敏感词,虽本期未命中但须保留 |
+| 🟢 新增 | 柜子有缝、板子发黑、味道大、掉皮 | 补充口语化投诉表达 |
+
+**目标**: 将关键词库从 181 精简至 60-80 个高质量词,覆盖率从 2.2% 提升至 10%+
+
+### 5.2 预警规则优化
+
+1. **质量类关键词独立规则**: "脱胶/开裂/变形/发霉/装错/尺寸不对" → 命中即报,不经过 AI 二次确认
+2. **聚合升级规则**: 同一群 1 小时内命中 ≥3 个不同中低危关键词 → 自动升级为高危预警
+3. **频次监控**: 连续 3 天命中但 AI 判定无风险的关键词 → 标记为疑似误报,人工审核
+
+### 5.3 盲区补充
+
+| 盲区类型 | 示例 | 检测方案 |
+|----------|------|----------|
+| 口语化投诉 | "柜子有缝""板子黑了一块" | 补充口语化关键词或正则变体 |
+| 情绪爆发 | 连发 5+ 条消息、大量感叹号 | 统计消息密度和标点密度 |
+| 图片/语音 | 客户发损坏照片 | 标注图片+文字交叉检测(需 OCR) |
+| 群内@负责人 | @店长 + 投诉内容 | 检测 @模式 + 负面词组合 |
+
+### 5.4 流程优化
+
+1. **三级响应机制**:
+   - 高危预警 → 15 分钟内客服主管介入
+   - 中危预警 → 30 分钟内区域经理跟进
+   - 低危预警 → 2 小时内门店负责人确认
+
+2. **闭环证据**: 预警关闭前需提供解决证据(维修照片、客户确认截图),防止"已读不回"
+
+3. **误报标记**: 确认为误报的事件必须在关闭原因中注明,作为优化关键词的输入
+
+### 5.5 定期审计节奏
+
+| 频率 | 动作 | 负责 |
+|------|------|------|
+| 每日 | 查看风控大屏 pending 事件 | 值班客服 |
+| 每周一 | 运行覆盖审计脚本,对比上周 | 风控专员 |
+| 每月初 | 运行深度分析,输出月度报告 | 风控负责人 |
+| 每月底 | 关键词大扫除(淘汰零命中词) | 风控 + 运营 |
+
+**核心 KPI**:
+- 关键词覆盖率 > 10%(当前 2.2%)
+- 误报率 < 30%
+- 预警响应时间 < 30 分钟
+- 漏报群 < 5 个/月(含高危 0 个)
+
+---
+
+## 六、代码架构说明
+
+### 6.1 audit-risk-coverage.ts
+
+```
+数据查询 (fetchAllData)
+  ├── Message (msgType=0,2,13,29)
+  ├── RiskEvent
+  ├── RiskKeyword (enabled=true)
+  └── GroupChat (roomId→roomName 映射)
+      ↓
+覆盖分析 (analyzeCoverage)
+  ├── 按群分组消息
+  ├── 关键词匹配 (matchKeywordsInMessages)
+  ├── 漏报检测 (对比 alertedRoomIds / alertedKeywords)
+  └── 按严重度排序输出
+      ↓
+AI 评审 (aiReviewMissedAlert)
+  └── 高危/中危漏报 → DeepSeek 二次判定
+      ↓
+HTML 报告 (generateHTML)
+  └── 可视化报告 + 管理建议卡片
+```
+
+### 6.2 audit-risk-deep-analysis.ts
+
+```
+数据查询 (fetchData) + 基础统计 (basicStats)
+  ├── 按天/按群/按发送者分布
+  └── 活跃群 TOP 15
+      ↓
+关键词匹配分析 (matchAnalysis)
+  ├── 全量关键词命中次数
+  ├── 各类别有效率排行
+  └── 命中消息列表
+      ↓
+漏报分析 (missedVsAlerted)
+  ├── 逐群对比命中词 vs 已预警词
+  ├── 漏报群分类(高危/中危/低危)
+  └── 真正未覆盖关键词
+      ↓
+AI 管理建议 (aiSummaryReport)
+  └── 5 维度管理建议(关键词/规则/盲区/流程/审计)
+      ↓
+MD 报告生成
+```
+
+### 6.3 关键依赖
+
+```typescript
+import Parse from '../src/db/parse-client.js';  // Parse Server SDK
+// AI: DeepSeek API (/v1/chat/completions)
+// 模型: deepseek-chat, temperature=0.2~0.4
+```
+
+---
+
+## 七、附录
+
+### A. 相关文件索引
+
+| 文件 | 说明 |
+|------|------|
+| `audit-risk-coverage.ts` | 覆盖审计脚本 |
+| `audit-risk-deep-analysis.ts` | 深度分析脚本 |
+| `backfill-member-nicknames.ts` | 昵称回填脚本 |
+| `audit-risk-2026-06-15-22.html` | 本期 HTML 报告 |
+| `audit-risk-2026-06-15-22.md` | 本期 MD 报告(简版) |
+| `audit-risk-deep-2026-06-15-22.md` | 本期深度 MD 报告 |
+| `../src/services/message-analyzer.service.ts` | 线上分析引擎 |
+| `../src/services/compliance-check.service.ts` | 合规检查 + AC 自动机 |
+| `../src/services/ai-analysis.service.ts` | DeepSeek AI 分析 |
+| `../src/services/risk-event.service.ts` | 风险事件 CRUD |
+
+### B. 常见问题
+
+**Q: 脚本跑很慢?**
+A: AI 评审阶段每个漏报群调一次 DeepSeek,限制最多 20 次。取消 AI 可不配 DEEPSEEK_API_KEY。
+
+**Q: 漏报数量为什么和实际感觉不一致?**
+A: 当前漏报检测基于"关键词命中但未触发预警"。关键词没命中的消息不在检测范围内(盲区)。
+
+**Q: 如何新增关键词?**
+A: RiskKeyword 表手动添加,或通过前端「关键词管理」页面操作。系统每 5 分钟自动刷新 AC 自动机。
+
+**Q: 如何查看实时预警?**
+A: 前端风控大屏 `http://localhost:4041/risk-control/dashboard`,或预警弹窗(每 30s 轮询)。
+
+---
+
+*技能文档由风控团队维护,每次重大变更后更新版本号。*

+ 751 - 0
backend/scripts/audit-risk-coverage.ts

@@ -0,0 +1,751 @@
+/**
+ * 风险预警覆盖审计脚本 (6/15-6/22)
+ *
+ * 用法: cd backend && npx tsx scripts/audit-risk-coverage.ts
+ * 输出: backend/scripts/audit-risk-2026-06-15-22.md
+ *
+ * 分析内容:
+ *   1. 查询 6/15-6/22 所有文本消息
+ *   2. 查询所有 RiskEvent(同期已有预警)
+ *   3. 查询所有 RiskKeyword(启用中的关键词)
+ *   4. 关键词匹配命中但无预警的消息列表(漏报)
+ *   5. AI 评审漏报消息的风险等级
+ *   6. 管理建议
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+import * as fs from 'fs';
+import * as path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/* ======================== AI 配置 ======================== */
+
+const AI_KEY = process.env.DEEPSEEK_API_KEY || '';
+const AI_URL = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions';
+const AI_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
+
+const AI_ENABLED = AI_KEY && AI_KEY !== 'sk-xxx';
+
+/* ======================== 类型 ======================== */
+
+interface KeywordInfo {
+  word: string;
+  category: string;
+  severity: 'high' | 'medium' | 'low';
+}
+
+interface MsgInfo {
+  id: string;
+  roomId: string;
+  senderName: string;
+  content: string;
+  timestamp: Date;
+  msgType: number;
+}
+
+interface RiskEventInfo {
+  id: string;
+  groupId: string;
+  keywords: string[];
+  severity: string;
+  title: string;
+  description: string;
+  createdAt: Date;
+}
+
+interface MissedAlert {
+  roomId: string;
+  roomName: string;
+  messages: MsgInfo[];          // 该群该时间段内所有匹配消息
+  matchedKeywords: string[];    // 命中的关键词
+  maxSeverity: string;          // 关键词最高严重度
+  reason: string;               // 为什么没触发预警(分析)
+}
+
+/* ======================== 数据查询 ======================== */
+
+const DATE_RANGE = {
+  start: new Date('2026-06-15T00:00:00+08:00'),
+  end: new Date('2026-06-22T23:59:59+08:00'),
+};
+
+async function fetchAllData() {
+  console.log('[审计] 查询 6/15-6/22 数据...\n');
+
+  const [allMessages, riskEvents, keywords, allGroups] = await Promise.all([
+    // 1. 所有文本消息
+    (async () => {
+      const q = new Parse.Query('Message');
+      q.greaterThanOrEqualTo('timestamp', DATE_RANGE.start);
+      q.lessThanOrEqualTo('timestamp', DATE_RANGE.end);
+      q.containedIn('msgType', [0, 2, 13, 29]);  // 文本、富文本、链接、Markdown
+      q.notEqualTo('content', '');
+      q.select(['roomId', 'senderName', 'content', 'timestamp', 'msgType']);
+      q.limit(200000);
+      q.ascending('timestamp');
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const msgs: MsgInfo[] = rows.map(r => ({
+        id: r.id,
+        roomId: (r.get('roomId') as string) || '',
+        senderName: (r.get('senderName') as string) || '',
+        content: (r.get('content') as string) || '',
+        timestamp: r.get('timestamp') as Date,
+        msgType: (r.get('msgType') as number) || 0,
+      }));
+      console.log(`  Message: ${msgs.length} 条文本消息`);
+      return msgs;
+    })(),
+
+    // 2. 同期 RiskEvent
+    (async () => {
+      const q = new Parse.Query('RiskEvent');
+      q.greaterThanOrEqualTo('createdAt', DATE_RANGE.start);
+      q.lessThanOrEqualTo('createdAt', DATE_RANGE.end);
+      q.select(['groupId', 'keywords', 'severity', 'title', 'description', 'createdAt']);
+      q.limit(5000);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const events: RiskEventInfo[] = rows.map(r => ({
+        id: r.id,
+        groupId: (r.get('groupId') as string) || '',
+        keywords: (r.get('keywords') as string[]) || [],
+        severity: (r.get('severity') as string) || '',
+        title: (r.get('title') as string) || '',
+        description: (r.get('description') as string) || '',
+        createdAt: r.get('createdAt') as Date,
+      }));
+      console.log(`  RiskEvent: ${events.length} 条预警`);
+      return events;
+    })(),
+
+    // 3. 启用关键词
+    (async () => {
+      const q = new Parse.Query('RiskKeyword');
+      q.equalTo('enabled', true);
+      q.select(['word', 'category', 'severity']);
+      q.limit(5000);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const kws: KeywordInfo[] = rows.map((r: any) => ({
+        word: r.get('word') as string,
+        category: r.get('category') as string,
+        severity: r.get('severity') as string,
+      }));
+      console.log(`  RiskKeyword: ${kws.length} 个启用关键词`);
+      return kws;
+    })(),
+
+    // 4. 群列表
+    (async () => {
+      const q = new Parse.Query('GroupChat');
+      q.select(['roomId', 'roomName']);
+      q.limit(5000);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const map = new Map<string, string>();
+      for (const r of rows) {
+        map.set((r.get('roomId') as string) || '', (r.get('roomName') as string) || '未知群');
+      }
+      return map;
+    })(),
+  ]);
+
+  return { allMessages, riskEvents, keywords, allGroups };
+}
+
+/* ======================== 分析引擎 ======================== */
+
+function matchKeywordsInMessages(messages: MsgInfo[], keywords: KeywordInfo[]): Map<string, { msgs: MsgInfo[]; kw: KeywordInfo }> {
+  const hits = new Map<string, { msgs: MsgInfo[]; kw: KeywordInfo }>();
+  for (const msg of messages) {
+    const content = msg.content;
+    if (!content) continue;
+    for (const kw of keywords) {
+      if (content.includes(kw.word)) {
+        const key = kw.word;
+        if (hits.has(key)) {
+          hits.get(key)!.msgs.push(msg);
+        } else {
+          hits.set(key, { msgs: [msg], kw });
+        }
+      }
+    }
+  }
+  return hits;
+}
+
+function analyzeCoverage(
+  allMessages: MsgInfo[],
+  riskEvents: RiskEventInfo[],
+  keywords: KeywordInfo[],
+  groupMap: Map<string, string>,
+) {
+  // 按群分组消息
+  const msgsByRoom = new Map<string, MsgInfo[]>();
+  for (const m of allMessages) {
+    if (!m.roomId) continue;
+    const arr = msgsByRoom.get(m.roomId);
+    if (arr) arr.push(m);
+    else msgsByRoom.set(m.roomId, [m]);
+  }
+
+  // 已有预警的 roomId 集合
+  const alertedRoomIds = new Set(riskEvents.map(e => e.groupId));
+  const alertedKeywords = new Set(riskEvents.flatMap(e => e.keywords));
+
+  // 在每个群内做关键词匹配,找出漏报
+  const missedAlerts: MissedAlert[] = [];
+
+  for (const [roomId, msgs] of msgsByRoom) {
+    const hits = matchKeywordsInMessages(msgs, keywords);
+    if (hits.size === 0) continue;
+
+    // 该群是否有预警
+    const hasAlert = alertedRoomIds.has(roomId);
+
+    // 命中的关键词
+    const hitWords = [...hits.keys()];
+
+    // 哪些关键词命中了但未预警
+    const missedWords = hitWords.filter(w => !alertedKeywords.has(w));
+    const alertedWords = hitWords.filter(w => alertedKeywords.has(w));
+
+    if (missedWords.length > 0) {
+      // 收集漏报消息
+      const allMissedMsgs: MsgInfo[] = [];
+      for (const w of missedWords) {
+        const entry = hits.get(w);
+        if (entry) allMissedMsgs.push(...entry.msgs);
+      }
+      // 去重
+      const uniqueMsgs = [...new Map(allMissedMsgs.map(m => [m.id, m])).values()]
+        .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
+
+      // 最高严重度
+      const severities = missedWords.map(w => hits.get(w)?.kw.severity || 'low');
+      const maxSeverity = severities.includes('high') ? 'high'
+        : severities.includes('medium') ? 'medium' : 'low';
+
+      let reason: string;
+      if (!hasAlert && alertedWords.length === 0) {
+        reason = '该群无任何预警记录,所有关键词命中均未触发预警';
+      } else if (!hasAlert) {
+        reason = `该群无预警记录;命中关键词: [${hitWords.join(', ')}],其中已有关键词 [${alertedWords.join(', ')}] 在其他群触发过预警`;
+      } else {
+        reason = `该群有预警但未覆盖以下关键词: [${missedWords.join(', ')}]`;
+      }
+
+      missedAlerts.push({
+        roomId,
+        roomName: groupMap.get(roomId) || `未知群(${roomId})`,
+        messages: uniqueMsgs.slice(0, 30), // 每个群最多 30 条
+        matchedKeywords: missedWords,
+        maxSeverity,
+        reason,
+      });
+    }
+  }
+
+  // 按严重度排序
+  const severityOrder = { high: 0, medium: 1, low: 2 };
+  missedAlerts.sort((a, b) => severityOrder[a.maxSeverity as keyof typeof severityOrder] - severityOrder[b.maxSeverity as keyof typeof severityOrder]);
+
+  // 汇总统计
+  const totalKeywords = keywords.length;
+  const triggeredKeywords = alertedKeywords.size;
+  const untriggeredKeywords = totalKeywords - triggeredKeywords;
+  const coverageRate = totalKeywords > 0 ? ((triggeredKeywords / totalKeywords) * 100).toFixed(1) : '0';
+
+  const totalMatchedMessages = missedAlerts.reduce((sum, a) => sum + a.messages.length, 0);
+  const highCount = missedAlerts.filter(a => a.maxSeverity === 'high').length;
+  const mediumCount = missedAlerts.filter(a => a.maxSeverity === 'medium').length;
+  const lowCount = missedAlerts.filter(a => a.maxSeverity === 'low').length;
+
+  const allHitWords = new Set<string>();
+  for (const a of missedAlerts) {
+    for (const w of a.matchedKeywords) allHitWords.add(w);
+  }
+
+  // 真正漏报的关键词(不在任何预警中出现过)
+  const trulyMissedKeywords = keywords.filter(kw => !alertedKeywords.has(kw.word));
+
+  return {
+    missedAlerts,
+    stats: {
+      totalMessages: allMessages.length,
+      totalRiskEvents: riskEvents.length,
+      totalKeywords,
+      triggeredKeywords,
+      untriggeredKeywords,
+      coverageRate,
+      missedGroups: missedAlerts.length,
+      totalMatchedMessages,
+      highCount,
+      mediumCount,
+      lowCount,
+      trulyMissedCount: trulyMissedKeywords.length,
+      trulyMissedKeywords,
+      allHitWords: [...allHitWords],
+    },
+  };
+}
+
+/* ======================== AI 深度评审 ======================== */
+
+async function aiReviewMissedAlert(alert: MissedAlert): Promise<string> {
+  if (!AI_ENABLED) return 'AI 未启用';
+
+  const samples = alert.messages.slice(0, 15).map(m => {
+    const ts = m.timestamp;
+    const time = ts ? `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}` : '';
+    return `[${time}] ${m.senderName || '未知'}: ${m.content}`;
+  }).join('\n');
+
+  const prompt = `你是拉迷家居(全屋定制)的风险审核员。以下群聊消息命中了风险关键词 [${alert.matchedKeywords.join(', ')}],但系统未触发预警。
+
+请判断:
+1. 这些消息是否有真实风险?(true/false)
+2. 风险等级?(high/medium/low/none)
+3. 是否建议将相关词加入预警?(yes/no — 仅当命中词不在关键词库中时考虑)
+4. 简要理由(≤80字)
+
+群: ${alert.roomName}
+命中词: ${alert.matchedKeywords.join(', ')}
+消息样本:
+${samples}
+
+回复 JSON(不含 markdown 代码块):
+{"hasRisk":true/false,"severity":"high"/"medium"/"low"/"none","shouldAlert":true/false,"reason":"≤80字"}`;
+
+  try {
+    const res = await fetch(AI_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_KEY}` },
+      body: JSON.stringify({
+        model: AI_MODEL,
+        messages: [
+          { role: 'system', content: '你是全屋定制行业风险审核员。只回复 JSON,不含 markdown。' },
+          { role: 'user', content: prompt },
+        ],
+        temperature: 0.2,
+        max_tokens: 256,
+      }),
+    });
+
+    if (!res.ok) return `AI 调用失败: ${res.status}`;
+    const json = await res.json() as any;
+    const content = json?.choices?.[0]?.message?.content || '';
+    // 尝试解析
+    const match = content.match(/\{[\s\S]*\}/);
+    if (match) {
+      const parsed = JSON.parse(match[0]);
+      const riskLabel = parsed.hasRisk ? `⚠ 有风险(${parsed.severity})` : '✅ 无风险';
+      return `${riskLabel} | 建议预警: ${parsed.shouldAlert ? '是' : '否'} | ${parsed.reason || ''}`;
+    }
+    return `AI 响应格式异常: ${content.slice(0, 100)}`;
+  } catch (err: any) {
+    return `AI 错误: ${err.message}`;
+  }
+}
+
+/* ======================== HTML 报告生成 ======================== */
+
+function escapeHtml(s: string): string {
+  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+}
+
+function sevBadge(severity: string): string {
+  const map: Record<string, string> = { high: '高危', medium: '中危', low: '低危' };
+  return `<span class="badge badge-${severity}">${map[severity] || severity}</span>`;
+}
+
+function sevIcon(s: string): string {
+  return s === 'high' ? '🔴' : s === 'medium' ? '🟡' : '🟢';
+}
+
+function categoryCn(cat: string): string {
+  const map: Record<string, string> = {
+    complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律',
+    competitor: '竞品', price: '价格', negative: '负面', installation: '安装',
+  };
+  return map[cat] || cat;
+}
+
+function generateHTML(
+  stats: ReturnType<typeof analyzeCoverage> extends { stats: infer S } ? S : never,
+  missedAlerts: MissedAlert[],
+  aiReviews: string[],
+  riskEvents: RiskEventInfo[],
+  queryMs: number,
+  totalMs: number,
+): string {
+  const genTime = new Date().toLocaleString('zh-CN');
+
+  // ── 概览卡片 ──
+  const overviewCards = `
+    <div class="card card-blue"><div class="card-num">${(stats.totalMessages).toLocaleString()}</div><div class="card-label">文本消息总量</div></div>
+    <div class="card card-purple"><div class="card-num">${stats.totalRiskEvents}</div><div class="card-label">已有风险事件</div></div>
+    <div class="card card-teal"><div class="card-num">${stats.totalKeywords}</div><div class="card-label">启用关键词</div></div>
+    <div class="card card-orange"><div class="card-num">${stats.coverageRate}%</div><div class="card-label">关键词覆盖率</div></div>
+    <div class="card card-red"><div class="card-num">${stats.missedGroups}</div><div class="card-label">漏报群数量</div></div>
+    <div class="card card-green"><div class="card-num">${stats.trulyMissedCount}</div><div class="card-label">未覆盖关键词</div></div>
+  `;
+
+  // ── 已触发预警列表 ──
+  let alertSection = '';
+  if (riskEvents.length > 0) {
+    const alertRows = riskEvents.map(e => `
+      <tr>
+        <td><span class="badge badge-${e.severity}">${e.severity}</span></td>
+        <td>${escapeHtml(e.title)}</td>
+        <td>${(e.keywords || []).map(k => `<code>${escapeHtml(k)}</code>`).join(' ')}</td>
+        <td>${escapeHtml(e.description?.slice(0, 60) || '')}</td>
+        <td class="time-cell">${e.createdAt ? new Date(e.createdAt).toLocaleString('zh-CN') : ''}</td>
+      </tr>`).join('');
+    alertSection = `
+      <div class="section">
+        <h2>🛡️ 已触发预警事件 <span class="sub">(${riskEvents.length} 条)</span></h2>
+        <div class="table-wrap">
+        <table>
+          <thead><tr><th>严重度</th><th>标题</th><th>关键词</th><th>描述</th><th>时间</th></tr></thead>
+          <tbody>${alertRows}</tbody>
+        </table>
+        </div>
+      </div>`;
+  } else {
+    alertSection = `<div class="section"><h2>🛡️ 已触发预警事件</h2><div class="empty">该期间无风险事件</div></div>`;
+  }
+
+  // ── 漏报详情 ──
+  let missedSection = '';
+  if (missedAlerts.length === 0) {
+    missedSection = `<div class="section"><h2>🔍 漏报检查</h2><div class="empty success">✅ 未发现漏报,所有关键词命中均已触发预警</div></div>`;
+  } else {
+    const realRisky = aiReviews.filter(r => r.includes('有风险')).length;
+    const falsePositive = aiReviews.filter(r => r.includes('无风险')).length;
+
+    let cards = '';
+    for (let i = 0; i < missedAlerts.length; i++) {
+      const a = missedAlerts[i];
+      const review = aiReviews[i] || '';
+      const isRisky = review.includes('有风险');
+      const borderColor = a.maxSeverity === 'high' ? '#e74c3c' : a.maxSeverity === 'medium' ? '#f39c12' : '#3498db';
+      const bgColor = a.maxSeverity === 'high' ? '#fff5f5' : a.maxSeverity === 'medium' ? '#fffdf0' : '#f8faff';
+
+      const sampleRows = a.messages.slice(0, 5).map(m => {
+        const ts = m.timestamp;
+        const time = ts ? `${ts.getMonth() + 1}/${ts.getDate()} ${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}` : '';
+        return `<tr><td class="time-cell">${time}</td><td>${escapeHtml(m.senderName || '未知')}</td><td class="msg-cell">${escapeHtml(m.content.length > 120 ? m.content.slice(0, 120) + '...' : m.content)}</td></tr>`;
+      }).join('');
+
+      const kwTags = a.matchedKeywords.map(k => `<span class="kw-tag">${sevIcon(a.maxSeverity)} ${escapeHtml(k)}</span>`).join('');
+
+      cards += `
+      <div class="missed-card" style="border-left: 4px solid ${borderColor}; background: ${bgColor};">
+        <div class="missed-header">
+          <span class="missed-title">${i + 1}. ${escapeHtml(a.roomName)}</span>
+          <span>${sevBadge(a.maxSeverity)}</span>
+        </div>
+        <div class="missed-body">
+          <div class="missed-meta">
+            <div class="meta-item"><span class="meta-label">命中关键词</span> ${kwTags}</div>
+            <div class="meta-item"><span class="meta-label">匹配消息</span> ${a.messages.length} 条</div>
+            <div class="meta-item"><span class="meta-label">原因</span> ${escapeHtml(a.reason)}</div>
+          </div>
+          ${review ? `<div class="ai-review ${isRisky ? 'ai-risky' : 'ai-safe'}">🤖 AI 评审:${escapeHtml(review)}</div>` : ''}
+          <details class="msg-details">
+            <summary>📋 消息样本(点击展开)</summary>
+            <div class="table-wrap mini-table">
+            <table>
+              <thead><tr><th>时间</th><th>发送者</th><th>内容</th></tr></thead>
+              <tbody>${sampleRows}</tbody>
+            </table>
+            </div>
+          </details>
+        </div>
+      </div>`;
+    }
+
+    missedSection = `
+      <div class="section">
+        <h2>🔍 漏报详情 <span class="sub">(${missedAlerts.length} 个群)</span></h2>
+        <div class="missed-summary">
+          <span class="chip chip-red">⚠️ AI 确认有风险: ${realRisky} 个</span>
+          <span class="chip chip-green">✅ AI 确认无风险: ${falsePositive} 个</span>
+          <span class="chip chip-gray">未评审: ${missedAlerts.length - realRisky - falsePositive} 个</span>
+        </div>
+        <div class="missed-list">${cards}</div>
+      </div>`;
+  }
+
+  // ── 未覆盖关键词 ──
+  let kwSection = '';
+  if (stats.trulyMissedCount > 0) {
+    const severityOrder = { high: 0, medium: 1, low: 2 };
+    const sorted = [...stats.trulyMissedKeywords].sort((a, b) => severityOrder[a.severity] - severityOrder[b.severity]);
+    const kwTags = sorted.map(k => `<span class="kw-chip kw-${k.severity}">${k.word}<span class="kw-cat">${categoryCn(k.category)}</span></span>`).join('\n');
+    // 分类统计
+    const highCount = sorted.filter(k => k.severity === 'high').length;
+    const medCount = sorted.filter(k => k.severity === 'medium').length;
+    const lowCount = sorted.filter(k => k.severity === 'low').length;
+
+    kwSection = `
+      <div class="section">
+        <h2>📚 从未触发预警的关键词 <span class="sub">(${stats.trulyMissedCount} 个)</span></h2>
+        <div class="kw-stats-bar">
+          <div class="kw-stat kw-stat-high"><span>${highCount}</span> 高危</div>
+          <div class="kw-stat kw-stat-med"><span>${medCount}</span> 中危</div>
+          <div class="kw-stat kw-stat-low"><span>${lowCount}</span> 低危</div>
+        </div>
+        <div class="kw-cloud">${kwTags}</div>
+        <p class="hint">这些关键词在审计期间被消息命中,但从未在任何群触发过预警。建议审核:是否过于宽泛(噪音多)或过于极端(实际不会出现)。</p>
+      </div>`;
+  }
+
+  // ── 管理建议 ──
+  const suggestions = `
+    <div class="section">
+      <h2>💡 管理建议</h2>
+      <div class="suggestion-grid">
+        <div class="sugg-card sugg-high">
+          <div class="sugg-title">🔴 高优先级</div>
+          <ul>
+            <li>将<strong>「定金」「订金」「色差」「优惠」</strong>等促销类关键词从预警名单移除或降级为"仅记录"</li>
+            <li>质量关键词(<strong>脱胶/开裂/变形/发霉/装错</strong>)提高权重,命中即报警</li>
+          </ul>
+        </div>
+        <div class="sugg-card sugg-med">
+          <div class="sugg-title">🟡 中优先级</div>
+          <ul>
+            <li>对 176 个从未命中的关键词做"大扫除":保留核心词(投诉/退款/甲醛等),删除极端低频词</li>
+            <li>同一群短时间命中多个低价关键词 → 聚合升级预警</li>
+          </ul>
+        </div>
+        <div class="sugg-card sugg-low">
+          <div class="sugg-title">🟢 持续改进</div>
+          <ul>
+            <li>每周运行本审计脚本,跟踪漏报趋势</li>
+            <li>建立"关键词→预警→确认→反馈"闭环,定期淘汰无效关键词</li>
+            <li>竞品类关键词命中频次统计,作为市场情报输入</li>
+          </ul>
+        </div>
+      </div>
+    </div>`;
+
+  // ── 组合完整 HTML ──
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>拉迷风险预警覆盖审计 — 2026.06.15-22</title>
+<style>
+  *,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
+  body{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif;background:#f0f2f5;color:#1a1a2e;line-height:1.6}
+
+  /* Header */
+  .hero{background:linear-gradient(135deg,#0f0c29 0%,#1a1a2e 40%,#16213e 100%);color:#fff;padding:40px 48px;position:relative;overflow:hidden}
+  .hero::after{content:'';position:absolute;top:-50%;right:-10%;width:400px;height:400px;background:radial-gradient(circle,rgba(231,76,60,.15),transparent 70%);border-radius:50%}
+  .hero h1{font-size:26px;font-weight:700;position:relative;z-index:1}
+  .hero .subtitle{font-size:14px;color:#8892b0;margin-top:6px;position:relative;z-index:1}
+  .hero .meta{font-size:12px;color:#5a6785;margin-top:4px;position:relative;z-index:1}
+
+  /* Container */
+  .container{max-width:1100px;margin:0 auto;padding:28px 24px}
+
+  /* Overview Cards */
+  .overview-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:16px;margin-bottom:36px}
+  .card{background:#fff;border-radius:12px;padding:24px 20px;text-align:center;box-shadow:0 2px 12px rgba(0,0,0,.06);transition:transform .15s}
+  .card:hover{transform:translateY(-2px)}
+  .card-num{font-size:36px;font-weight:800;line-height:1.2}
+  .card-label{font-size:13px;color:#7f8c8d;margin-top:6px}
+  .card-blue .card-num{color:#2980b9}
+  .card-purple .card-num{color:#8e44ad}
+  .card-teal .card-num{color:#16a085}
+  .card-orange .card-num{color:#e67e22}
+  .card-red .card-num{color:#e74c3c}
+  .card-green .card-num{color:#27ae60}
+
+  /* Sections */
+  .section{margin-bottom:36px}
+  .section h2{font-size:18px;font-weight:700;color:#1a1a2e;margin-bottom:16px;padding-bottom:10px;border-bottom:2px solid #e8ecf4}
+  .sub{font-size:13px;color:#95a5a6;font-weight:400}
+  .empty{padding:40px;text-align:center;color:#95a5a6;background:#fff;border-radius:10px}
+  .empty.success{color:#27ae60;background:#eafaf1}
+  .hint{font-size:13px;color:#95a5a6;margin-top:12px;line-height:1.6}
+
+  /* Badge */
+  .badge{font-size:11px;font-weight:700;padding:3px 10px;border-radius:12px;color:#fff;white-space:nowrap}
+  .badge-high{background:#e74c3c}
+  .badge-medium{background:#f39c12}
+  .badge-low{background:#3498db}
+
+  /* Table */
+  .table-wrap{overflow-x:auto;background:#fff;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  table{width:100%;border-collapse:collapse;font-size:13px}
+  thead th{background:#f8f9fb;padding:12px 14px;text-align:left;font-weight:600;color:#555;font-size:12px;text-transform:uppercase;letter-spacing:.5px;border-bottom:2px solid #e8ecf4}
+  tbody td{padding:10px 14px;border-bottom:1px solid #f0f2f5;vertical-align:top}
+  tbody tr:hover{background:#f8f9fb}
+  .time-cell{white-space:nowrap;color:#95a5a6;font-size:12px}
+  .msg-cell{max-width:400px;word-break:break-all}
+  code{background:#f0f2f5;padding:1px 6px;border-radius:3px;font-size:12px;color:#e74c3c}
+  .mini-table{font-size:12px;margin-top:0}
+
+  /* Missed Alerts */
+  .missed-summary{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
+  .chip{font-size:13px;padding:6px 14px;border-radius:20px;font-weight:600}
+  .chip-red{background:#fde8e8;color:#c0392b}
+  .chip-green{background:#e8f8f5;color:#1e8449}
+  .chip-gray{background:#f0f2f5;color:#7f8c8d}
+
+  .missed-list{display:flex;flex-direction:column;gap:14px}
+  .missed-card{border-radius:10px;overflow:hidden;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  .missed-header{display:flex;justify-content:space-between;align-items:center;padding:14px 18px;background:rgba(0,0,0,.02);border-bottom:1px solid rgba(0,0,0,.05)}
+  .missed-title{font-size:15px;font-weight:700}
+  .missed-body{padding:16px 18px}
+  .missed-meta{display:flex;flex-direction:column;gap:8px;margin-bottom:10px}
+  .meta-item{font-size:13px}
+  .meta-label{font-weight:600;color:#555;margin-right:8px}
+  .kw-tag{display:inline-block;background:#f0f2f5;padding:2px 10px;border-radius:12px;font-size:12px;margin-right:4px;margin-bottom:2px}
+
+  .ai-review{font-size:13px;padding:10px 14px;border-radius:8px;margin:10px 0}
+  .ai-risky{background:#fde8e8;border:1px solid #f5c6cb;color:#721c24}
+  .ai-safe{background:#e8f8f5;border:1px solid #b7e4cf;color:#155724}
+
+  .msg-details{margin-top:8px}
+  .msg-details summary{font-size:13px;color:#3498db;cursor:pointer;font-weight:600}
+  .msg-details summary:hover{color:#2980b9}
+
+  /* Keyword cloud */
+  .kw-stats-bar{display:flex;gap:12px;margin-bottom:14px}
+  .kw-stat{padding:6px 16px;border-radius:8px;font-size:13px;font-weight:600}
+  .kw-stat span{font-size:20px;font-weight:800}
+  .kw-stat-high{background:#fde8e8;color:#c0392b}
+  .kw-stat-med{background:#fef5e7;color:#d68910}
+  .kw-stat-low{background:#eaf2f8;color:#2980b9}
+
+  .kw-cloud{display:flex;flex-wrap:wrap;gap:8px;background:#fff;padding:18px;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  .kw-chip{display:inline-flex;align-items:center;gap:6px;padding:5px 12px;border-radius:16px;font-size:13px;font-weight:600}
+  .kw-high{background:#fde8e8;color:#c0392b}
+  .kw-medium{background:#fef5e7;color:#d68910}
+  .kw-low{background:#eaf2f8;color:#2980b9}
+  .kw-cat{font-size:10px;opacity:.7;font-weight:400}
+
+  /* Suggestions */
+  .suggestion-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:16px}
+  .sugg-card{padding:20px;border-radius:10px;box-shadow:0 2px 8px rgba(0,0,0,.04)}
+  .sugg-title{font-size:15px;font-weight:700;margin-bottom:10px}
+  .sugg-card ul{padding-left:18px}
+  .sugg-card li{font-size:13px;margin-bottom:8px;line-height:1.6}
+  .sugg-high{background:#fff5f5;border:1px solid #fecaca}
+  .sugg-med{background:#fffdf0;border:1px solid #fde68a}
+  .sugg-low{background:#f5f9ff;border:1px solid #bfdbfe}
+
+  /* Footer */
+  .footer{text-align:center;padding:20px;color:#95a5a6;font-size:12px}
+
+  @media (max-width:768px){
+    .hero{padding:24px}
+    .container{padding:16px}
+    .overview-grid{grid-template-columns:repeat(3,1fr);gap:10px}
+    .card{padding:16px 10px}
+    .card-num{font-size:26px}
+    .suggestion-grid{grid-template-columns:1fr}
+  }
+  @media print{
+    body{background:#fff}
+    .hero{background:#1a1a2e!important;-webkit-print-color-adjust:exact}
+    .missed-card{break-inside:avoid;border:1px solid #ddd}
+  }
+</style>
+</head>
+<body>
+<div class="hero">
+  <h1>🛡️ 风险预警覆盖审计报告</h1>
+  <div class="subtitle">审计期间:2026-06-15 ~ 2026-06-22(8 天)&nbsp;|&nbsp; 全屋定制 · 拉迷家居</div>
+  <div class="meta">生成时间:${genTime} &nbsp;|&nbsp; 查询耗时:${queryMs}ms &nbsp;|&nbsp; 总耗时:${totalMs}ms &nbsp;|&nbsp; AI 引擎:${AI_ENABLED ? 'DeepSeek' : '未启用'}</div>
+</div>
+<div class="container">
+  <div class="overview-grid">${overviewCards}</div>
+  ${alertSection}
+  ${missedSection}
+  ${kwSection}
+  ${suggestions}
+  <div class="footer">拉迷家居 · 风险预警覆盖审计 · 自动生成</div>
+</div>
+</body>
+</html>`;
+}
+
+/* ======================== 主流程 ======================== */
+
+async function main(): Promise<void> {
+  const t0 = Date.now();
+  console.log('═══════════════════════════════════════');
+  console.log('  风险预警覆盖审计 (6/15 - 6/22)');
+  console.log(`  AI: ${AI_ENABLED ? `DeepSeek (${AI_MODEL})` : '禁用'}`);
+  console.log('═══════════════════════════════════════\n');
+
+  // 1. 查询数据
+  const t1 = Date.now();
+  const { allMessages, riskEvents, keywords, allGroups } = await fetchAllData();
+  const queryMs = Date.now() - t1;
+  console.log(`\n  查询耗时: ${queryMs}ms\n`);
+
+  // 2. 覆盖分析
+  console.log('[审计] 关键词覆盖分析...');
+  const { missedAlerts, stats } = analyzeCoverage(allMessages, riskEvents, keywords, allGroups);
+  console.log(`  漏报群: ${stats.missedGroups},其中高危 ${stats.highCount} / 中危 ${stats.mediumCount} / 低危 ${stats.lowCount}`);
+  console.log(`  完全未触发预警的关键词: ${stats.trulyMissedCount}\n`);
+
+  // 3. AI 评审漏报(限前 20 个高危/中危)
+  console.log('[审计] AI 深度评审漏报...');
+  const toReview = missedAlerts
+    .filter(a => a.maxSeverity === 'high' || a.maxSeverity === 'medium')
+    .slice(0, 20);
+
+  const aiReviews: string[] = [];
+  for (let i = 0; i < toReview.length; i++) {
+    const a = toReview[i];
+    console.log(`  → AI 评审 ${i + 1}/${toReview.length}: ${a.roomName} (${a.maxSeverity})`);
+    const review = await aiReviewMissedAlert(a);
+    aiReviews.push(review);
+    console.log(`  ← ${review}`);
+  }
+
+  // 为所有漏报创建 AI 评审数组(与 missedAlerts 对齐)
+  const allAiReviews: string[] = missedAlerts.map(a => {
+    const idx = toReview.indexOf(a);
+    return idx >= 0 ? aiReviews[idx] : '(低危,未提交 AI 评审)';
+  });
+
+  // 4. 生成 HTML 报告
+  const totalMs = Date.now() - t0;
+  const html = generateHTML(stats, missedAlerts, allAiReviews, riskEvents, queryMs, totalMs);
+
+  const outputPath = path.resolve(__dirname, 'audit-risk-2026-06-15-22.html');
+  fs.writeFileSync(outputPath, html, 'utf-8');
+
+  console.log(`\n✅ 报告已生成: ${outputPath}`);
+  console.log(`   文件大小: ${(Buffer.byteLength(html, 'utf-8') / 1024).toFixed(1)} KB`);
+  console.log(`   总耗时: ${totalMs}ms\n`);
+
+  // 控制台摘要
+  console.log('═══════════════════════════════════════');
+  console.log('  关键发现');
+  console.log('═══════════════════════════════════════');
+  console.log(`  关键词覆盖率: ${stats.coverageRate}% (${stats.triggeredKeywords}/${stats.totalKeywords})`);
+  console.log(`  完全未覆盖关键词: ${stats.trulyMissedCount} 个`);
+  console.log(`  漏报群: ${stats.missedGroups} (高危 ${stats.highCount} / 中危 ${stats.mediumCount} / 低危 ${stats.lowCount})`);
+  if (stats.trulyMissedCount > 0) {
+    console.log(`\n  未覆盖关键词 (前 15):`);
+    for (const kw of stats.trulyMissedKeywords.slice(0, 15)) {
+      console.log(`    - ${kw.word} (${kw.category}/${kw.severity})`);
+    }
+  }
+  console.log('');
+}
+
+main().catch((err) => {
+  console.error('[审计] 失败:', err);
+  process.exit(1);
+});

+ 303 - 0
backend/scripts/audit-risk-deep-2026-06-15-22.md

@@ -0,0 +1,303 @@
+# 🛡️ 拉迷家居 · 风险预警深度分析报告
+
+**审计期间**: 2026-06-15 ~ 2026-06-22(8 天)
+**生成时间**: 2026/6/23 09:47:57
+**分析耗时**: 9.2s
+**AI 引擎**: DeepSeek (deepseek-chat)
+
+---
+
+## 一、数据概览
+
+| 指标 | 数值 |
+|------|------|
+| 文本消息总量 | 1,470 |
+| 活跃群数 | 149 |
+| 参与人数 | 142 |
+| 已有风险事件 | 4 |
+| 启用风险关键词 | 181 |
+| 已触发预警的关键词 | 4 |
+| 从未触发预警的关键词 | 176 |
+| **关键词覆盖率** | **2.2%** |
+| 漏报群数量 | 21 |
+| 🔴 高危漏报群 | 3 |
+| 🟡 中危漏报群 | 17 |
+| 🟢 低危漏报群 | 1 |
+
+---
+
+## 二、消息活动分布
+
+### 2.1 按天分布
+
+| 日期 | 消息数 |
+|------|--------|
+| 2026-06-14 | 1 |
+| 2026-06-15 | 62 |
+| 2026-06-16 | 152 |
+| 2026-06-17 | 112 |
+| 2026-06-18 | 413 |
+| 2026-06-19 | 137 |
+| 2026-06-20 | 41 |
+| 2026-06-21 | 154 |
+| 2026-06-22 | 398 |
+
+### 2.2 活跃群 TOP 15
+
+| 排名 | 群名称 | 小区 | 消息数 | 参与人数 |
+|------|--------|------|--------|----------|
+| 1 | 拉迷VIP服务群中心润府李先生lz122539 | - | 88 | 9 |
+| 2 | 大华公园柏翠❤️拉迷家居服务群 | - | 85 | 19 |
+| 3 | 海阅首府❤️建设进度分享群 | - | 62 | 9 |
+| 4 | 古美阅华❤️拉迷进度分享群 | - | 53 | 5 |
+| 5 | 未知群 | - | 35 | 1 |
+| 6 | 安高申陇院汶水拉迷福利群🧧 | - | 34 | 10 |
+| 7 | 未知群 | - | 33 | 3 |
+| 8 | 拉迷VIP服务群-5607单大华星曜邵女士 | - | 31 | 4 |
+| 9 | 拉迷VIP服务群5653浦开云璟三期4-16-1001李小姐家 | - | 31 | 4 |
+| 10 | 未知群 | - | 30 | 2 |
+| 11 | 未知群 | - | 29 | 1 |
+| 12 | 保利天奕2群 ❤️ 建设进度分享 | - | 29 | 7 |
+| 13 | 拉迷VIP服务群中心润府罗女士雅居设计群 | - | 28 | 2 |
+| 14 | 未知群 | - | 26 | 1 |
+| 15 | 拉迷VIP服务群5695单新杨思104A19栋801吴小姐家服务群 | - | 26 | 4 |
+
+---
+
+## 三、关键词有效性分析
+
+### 3.1 各类别命中情况
+
+| 类别 | 总词数 | 命中词数 | 命中消息数 | 有效率 |
+|------|--------|----------|------------|--------|
+| 价格讨论 | 22 | 5 | 65 | 23% |
+| 质量工艺 | 19 | 9 | 20 | 47% |
+| 安装施工 | 12 | 2 | 3 | 17% |
+| 负面情绪 | 17 | 1 | 2 | 6% |
+| 竞品对比 | 27 | 1 | 1 | 4% |
+| 投诉抱怨 | 51 | 0 | 0 | 0% |
+| 敏感词 | 25 | 0 | 0 | 0% |
+| 法律监管 | 8 | 0 | 0 | 0% |
+
+### 3.2 完全未触发预警的关键词(TOP 30)
+
+**投诉抱怨** (15个): 🔴投诉、🔴维权、🔴退款、🔴假货、🔴诈骗、🔴举报、🔴315、🔴工商、🔴法院、🔴律师函、🔴媒体曝光、🔴质量问题、🟡差评、🟡退货、🟡延期
+
+**敏感词** (3个): 🔴甲醛、🔴超标、🔴退群
+
+**竞品对比** (8个): 🟡欧派、🟡索菲亚、🟡尚品宅配、🟡好莱客、🟡金牌橱柜、🟡志邦、🟡皮阿诺、🟡我乐
+
+**价格讨论** (4个): 🟢太贵了、🟢便宜点、🟢打折、🟢砍价
+
+---
+
+## 四、已触发预警详情
+
+
+| 时间 | 群 | 关键词 | 严重度 | 状态 | 标题 |
+|------|-----|--------|--------|------|------|
+| 2026/6/15 15:08:54 | 拉迷VIP 服务群蒋先生紫薇之星14号1101室2422 | 不平 | medium | resolved | 电视柜质量投诉 |
+| 2026/6/22 11:13:06 | 拉迷VIP 服务群蒋先生紫薇之星14号1101室2422 | 瑕疵 | low | processing | 电视柜门板条纹问题反馈 |
+| 2026/6/18 19:10:04 | 保利外滩序BUND88❤️信息情报站📢 | 优惠 | low | processing | 讨论价格优惠 |
+| 2026/6/16 16:51:08 | 未知群 | 少了 | low | processing | 价格折扣讨论 |
+
+
+---
+
+## 五、漏报分析
+
+### 5.1 漏报群详情
+
+| # | 群 | 未覆盖关键词 | 最高严重度 | 预警状态 | 消息样本 |
+|---|-----|-------------|------------|----------|----------|
+| 1 | 🔴 未知群(10818808211819824) | 预算, 开裂, 划痕, 发霉, 松动, 缝隙大, 色差 | 🔴 high | 部分覆盖 | [6/22 21:05] 未知: ✨【预看房准备清单2】✨
+
+💖邀请专业人士(如果可以)
+①考虑聘请一位验房师或工程师陪同检查,他们的专业知识可以帮助你发现不易察觉的问题。
+②如果预算有限,至...<br>[6/22 21:05] 未知: ✅ 预看房时需要注意的点
+①门窗玻璃:玻璃存在焊渣烫伤、细小划痕、玻璃起泡、夹层进异物,看着是小瑕疵,后期不仅难看,还会影响玻璃密封性,更换成本高;
+
+②地漏排...<br>[6/22 21:05] 未知: ⚠ 重点隐患(必须优先让开发商整改)
+①用电安全隐患:卫生间检测出电位失效、卫浴空间感应电、线路破损、电线裸露、接线不规范问题,洗澡潮湿环境极易引发漏电风险。
+... |
+| 2 | 🔴 拉迷VIP服务群中心润府李先生lz122539 | 安装师傅, 脱胶 | 🔴 high | 完全漏报 | [6/18 16:02] A拉迷🐉~纪澜(拉迷): 安装师傅应该在里边吧,我到小区了<br>[6/18 16:43] L.F.H STUDIO: 好像是有点脱胶了,拿点胶水给它粘一下 |
+| 3 | 🔴 未知群(10791789843077656) | 垃圾 | 🔴 high | 完全漏报 | [6/21 16:16] 未知: @杨飞飞 厨房放置物小车和垃圾桶的位置尺寸是多少?<br>[6/21 16:26] 未知: 目前小车450宽 400深 垃圾桶350宽400深 |
+| 4 | 🟡 古美阅华❤️拉迷进度分享群 | 订金 | 🟡 medium | 部分覆盖 | [6/16 11:44] 方健: @  ✨【吴中拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上... |
+| 5 | 🟡 金茂棠前❤建设进度更新 | 订金 | 🟡 medium | 部分覆盖 | [6/16 11:23] 王瑾: ✨【建配龙拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上折
+... |
+| 6 | 🟡 📣保利-海上印❤进度情报局 | 订金 | 🟡 medium | 部分覆盖 | [6/16 12:09] 拉迷~庞雪: ✨【吴中拉迷|粽享 618】✨
+
+【端午+618 】 buff 叠满,小区业主专属福利来啦!🐲
+
+💥订金膨胀至高减3000元
+💥 多品类叠加年中折上折
+�... |
+| 7 | 🟡 世纪前滩天汇❤️项目监工群📢 | 定金 | 🟡 medium | 部分覆盖 | [6/16 16:48] 刘烈峰: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 8 | 🟡 前滩天御&拉迷❤️信息情报二群 | 定金 | 🟡 medium | 部分覆盖 | [6/16 16:48] 刘烈峰: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 9 | 🟡 碧云北外滩尊邸❤️拉迷家居服务群 | 定金 | 🟡 medium | 部分覆盖 | [6/16 16:48] 未知: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 10 | 🟡 未知群(10713790807568676) | 定金 | 🟡 medium | 部分覆盖 | [6/16 16:48] 未知: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 11 | 🟡 未知群(10870148612937458) | 定金 | 🟡 medium | 部分覆盖 | [6/16 16:48] 刘烈峰: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 12 | 🟡 未知群 | 定金, 订金 | 🟡 medium | 部分覆盖 | [6/16 16:48] 未知: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅...<br>[6/16 17:11] 未知: [破涕为笑]但折扣比例不能完全体现价值,每一家的销售方式都不同,要看最终的品质还有落地保障,还有整体成本吧。
+85折是正价基础上直接减,订金膨胀,满减,还有基础... |
+| 13 | 🟡 保利外滩序BUND88❤️信息情报站📢 | 定金 | 🟡 medium | 部分覆盖 | [6/16 16:50] 未知: 🏠 邻居们,618福利来了!@所有人  
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵... |
+| 14 | 🟡 未知群(10880395739118764) | 定金 | 🟡 medium | 部分覆盖 | [6/16 17:08] 未知: 🏠 邻居们,618福利来了!@所有人  
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵... |
+| 15 | 🟡 未知群(10918275038305828) | 定金 | 🟡 medium | 部分覆盖 | [6/17 10:20] 未知: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 16 | 🟡 未知群(10696323927935424) | 定金 | 🟡 medium | 部分覆盖 | [6/17 10:21] 未知: 🏠 邻居们,618福利来了!
+6月18-21日,拉迷澳门路月星店给咱小区业主专属优惠👇
+✅ 正价商品85折,业主专属
+✅ 定金膨胀,3000元抵扣增值礼
+✅... |
+| 17 | 🟡 拉迷&万骊全屋定制-西派海上小区团购群① | 色差 | 🟡 medium | 完全漏报 | [6/19 09:00] 李秋华: @  粽享佳节,年中盛惠[烟花][烟花][红包][红包]
+端午邂逅618大促,拉迷家居诚邀各位邻居到访线下旗舰展厅,沉浸式体验海派轻奢全屋整装效果,线上百次看图...<br>[6/20 15:09] 李秋华: @  粽享端午,618盛惠🎉拉迷家居邀各位邻居到店体验海派轻奢全屋整装!实地查看基材、封边与五金品质,告别线上色差。群内业主预约到店即领精美伴手礼[庆祝][庆... |
+| 18 | 🟡 未知群(10923519335985946) | 色差 | 🟡 medium | 完全漏报 | [6/19 09:00] 李秋华: @  粽享佳节,年中盛惠[烟花][烟花][红包][红包]
+端午邂逅618大促,拉迷家居诚邀各位邻居到访线下旗舰展厅,沉浸式体验海派轻奢全屋整装效果,线上百次看图...<br>[6/20 15:09] 李秋华: @  粽享端午,618盛惠🎉拉迷家居邀各位邻居到店体验海派轻奢全屋整装!实地查看基材、封边与五金品质,告别线上色差。群内业主预约到店即领精美伴手礼[庆祝][庆... |
+| 19 | 🟡 未知群(10973443324259792) | 宜家 | 🟡 medium | 完全漏报 | [6/18 20:19] 未知: 我们还有最后一轮,宜家对杯~ |
+| 20 | 🟡 拉迷 VIP服务群 3104王女士 浦开云璟三期2-5-801室 | 增项 | 🟡 medium | 完全漏报 | [6/22 15:41] 未知: 客厅如果想减少增项的话,用图上这种搭配会好看不?<br>[6/22 17:20] 郭晶晶: "有头脑和很高兴:
+客厅如果想减少增项的话,用图上这种搭配会好看不?"
+------
+@有头脑和很高兴  可以的哈 |
+
+### 5.2 漏报原因分析
+
+#### 类型一:促销营销用词(占比最大)
+- **「定金」「订金」「优惠」「折扣」** 等关键词出现在大量 618 促销广播中
+- 这些词触发情景为拉迷员工在各业主群发布统一营销文案
+- AI 评审结论:正常促销,无真实风险
+- **建议**: 将此类词从预警列表移除或降级为"仅统计不预警"
+
+#### 类型二:正常业务沟通
+- **「色差」** 出现在"告别线上色差"的到店体验邀约中
+- **「垃圾」** 出现在询问垃圾桶尺寸的设计沟通中
+- **「宜家」** 出现在"宜家对杯"的游戏互动中
+- **建议**: 这些词在营销和日常沟通中高频出现,需更精准的上下文判断
+
+#### 类型三:真实质量风险(需关注)
+- **「脱胶」** 在拉迷VIP服务群中被提及,客户反映产品脱胶问题
+- 安装师傅在场并提出了修补方案,但此类问题应被预警
+- **建议**: 质量类关键词(脱胶、开裂、变形、发霉等)应独立为"即报即警"
+
+---
+
+## 六、AI 管理建议
+
+拉迷家居风险管理顾问管理建议报告(6/15-6/22)
+
+一、关键词库优化
+1. 删除或降级从未触发预警的176个关键词,尤其是“complaint”(0/51)、“sensitive”(0/25)、“legal”(0/8)类别中大量无效词,建议将“订金”从低危升级为中危,因其在2个群中被命中且涉及资金敏感话题。
+2. 升级“预算”“开裂”“划痕”“发霉”“松动”“缝隙大”“色差”至高危,因这些词在高危漏报群中集中出现,反映产品质量和安装问题。
+3. 保留“安装师傅”“脱胶”“垃圾”并评估是否需要增加同义词,如“安装工”“胶水脱落”“废料”等。
+
+二、预警规则优化
+1. 对高危关键词(如“开裂”“色差”)设置单条消息触发即预警,无需频次阈值;对中危词(如“订金”)可设置2小时内同一群内出现2次以上触发。
+2. 增加聚合规则:同一群内30分钟内命中3个不同高危关键词,自动升级为紧急事件并通知主管。
+
+三、盲区发现
+1. 纯关键词匹配无法识别隐晦表达,如“柜子有缝”“板子发黑”“味道大”等口语化投诉,建议引入语义模型或正则表达式匹配常见变体。
+2. 漏报群中“垃圾”一词可能被误解为普通抱怨,但结合上下文(如“安装垃圾”“板材垃圾”)应视为质量问题,需人工复核此类模糊词。
+
+四、流程优化
+1. 建立三级响应机制:高危漏报群(如中心润府群)需在15分钟内由客服主管介入,中危群(如古美阅华群)30分钟内由区域经理跟进。
+2. 预警关闭前需确认客户问题已解决并记录闭环证据(如维修照片、客户确认截图),避免“已读不回”即关闭。
+
+五、定期审计
+1. 建议每周一次关键词库审计,根据本周命中数据调整词表,每月一次全面规则复盘。
+2. 关注指标:关键词覆盖率(当前2.2%偏低,目标提升至5%以上)、漏报群数量(当前21个,需降至5个以下)、预警响应时间(目标<30分钟)。
+
+---
+
+## 七、结论与行动项
+
+### 立即执行(本周)
+1. **清理促销类关键词**: 将"定金/订金/优惠/折扣/打折/砍价"标记为 "仅统计",不再触发预警
+2. **质量关键词升级**: "脱胶/开裂/变形/发霉/装错/尺寸不对/色差(非营销语境)" 设为命中即报警
+3. **竞品关键词独立统计**: 不触发预警,但每月统计竞品提及频次作为市场情报
+
+### 短期优化(本月)
+4. **聚合规则**: 同一群 1 小时内命中 ≥3 个不同关键词 → 自动升级预警
+5. **频次阈值**: 关键词命中频次 TOP 10 的词自动进入月度复审
+6. **盲区补充**: 增加"情绪检测"维度(感叹号密度、负面表情包、长语音消息密集度)
+
+### 持续改进
+7. **周审计**: 每周一运行本审计脚本,对比上周数据
+8. **月度关键词大扫除**: 淘汰连续 30 天零命中的关键词
+9. **预警闭环率**: 追踪 pending→resolved 的平均时长,目标 < 4 小时
+10. **误报率监控**: 标记为 false_alarm 的事件占比应 < 30%
+
+---
+
+## 八、脚本使用说明
+
+### 审计脚本清单
+
+| 脚本 | 用途 | 频率 |
+|------|------|------|
+| `audit-risk-coverage.ts` | 关键词覆盖审计 + HTML 报告 | 每周 |
+| `audit-risk-deep-analysis.ts` | 深度分析 + MD 报告 | 每月 |
+| `backfill-member-nicknames.ts` | 回填 GroupMember 昵称 | 按需 |
+
+### 运行方式
+
+```bash
+cd backend
+
+# 覆盖审计(含 AI 评审,约 30s)
+npx tsx scripts/audit-risk-coverage.ts
+
+# 深度分析(含 AI 管理建议,约 30s)
+npx tsx scripts/audit-risk-deep-analysis.ts
+```
+
+### 输出文件
+
+| 文件 | 说明 |
+|------|------|
+| `audit-risk-YYYY-MM-DD.html` | 可视化 HTML 报告(含消息样本和 AI 评审) |
+| `audit-risk-deep-YYYY-MM-DD.md` | 深度分析 MD 报告(含管理建议) |
+
+---
+
+*报告由 audit-risk-deep-analysis.ts 自动生成*

+ 678 - 0
backend/scripts/audit-risk-deep-analysis.ts

@@ -0,0 +1,678 @@
+/**
+ * 风险预警深度分析脚本 (6/15-6/22)
+ *
+ * 用法: cd backend && npx tsx scripts/audit-risk-deep-analysis.ts
+ *
+ * 分析维度:
+ *   1. 消息按天/按群/按类型分布
+ *   2. 关键词命中但未预警的消息(漏报)
+ *   3. 关键词命中且AI确认为有风险的(真漏报)
+ *   4. 无关键词命中但可能隐含风险的(盲区)
+ *   5. 已触发预警的回溯分析
+ *   6. 竞品提及统计
+ */
+
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+import * as fs from 'fs';
+import * as path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const GUID = process.env.QIWE_GUID || '';
+
+/* ======================== 类型 ======================== */
+
+interface KeywordInfo {
+  word: string;
+  category: string;
+  severity: 'high' | 'medium' | 'low';
+}
+
+interface MsgInfo {
+  id: string;
+  roomId: string;
+  senderName: string;
+  content: string;
+  timestamp: Date;
+  msgType: number;
+}
+
+interface RiskEventInfo {
+  id: string;
+  groupId: string;
+  keywords: string[];
+  severity: string;
+  title: string;
+  description: string;
+  status: string;
+  createdAt: Date;
+}
+
+const DATE_RANGE = {
+  start: new Date('2026-06-15T00:00:00+08:00'),
+  end: new Date('2026-06-22T23:59:59+08:00'),
+};
+
+/* ======================== 数据查询 ======================== */
+
+async function fetchData() {
+  console.log('[分析] 拉取数据...\n');
+
+  const [allMessages, riskEvents, keywords, groupMap] = await Promise.all([
+    (async () => {
+      const q = new Parse.Query('Message');
+      q.greaterThanOrEqualTo('timestamp', DATE_RANGE.start);
+      q.lessThanOrEqualTo('timestamp', DATE_RANGE.end);
+      q.containedIn('msgType', [0, 2, 13, 29]);
+      q.notEqualTo('content', '');
+      q.select(['roomId', 'senderName', 'content', 'timestamp', 'msgType']);
+      q.limit(200000);
+      q.ascending('timestamp');
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const msgs: MsgInfo[] = rows.map(r => ({
+        id: r.id,
+        roomId: (r.get('roomId') as string) || '',
+        senderName: (r.get('senderName') as string) || '',
+        content: (r.get('content') as string) || '',
+        timestamp: r.get('timestamp') as Date,
+        msgType: (r.get('msgType') as number) || 0,
+      }));
+      console.log(`  Message: ${msgs.length} 条`);
+      return msgs;
+    })(),
+
+    (async () => {
+      const q = new Parse.Query('RiskEvent');
+      q.greaterThanOrEqualTo('createdAt', DATE_RANGE.start);
+      q.lessThanOrEqualTo('createdAt', DATE_RANGE.end);
+      q.select(['groupId', 'keywords', 'severity', 'title', 'status', 'createdAt']);
+      q.limit(5000);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const events: RiskEventInfo[] = rows.map(r => ({
+        id: r.id,
+        groupId: (r.get('groupId') as string) || '',
+        keywords: (r.get('keywords') as string[]) || [],
+        severity: (r.get('severity') as string) || '',
+        title: (r.get('title') as string) || '',
+        description: (r.get('description') as string) || '',
+        status: (r.get('status') as string) || 'pending',
+        createdAt: r.get('createdAt') as Date,
+      }));
+      console.log(`  RiskEvent: ${events.length} 条`);
+      return events;
+    })(),
+
+    (async () => {
+      const q = new Parse.Query('RiskKeyword');
+      q.equalTo('enabled', true);
+      q.select(['word', 'category', 'severity']);
+      q.limit(5000);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const kws: KeywordInfo[] = rows.map((r: any) => ({
+        word: r.get('word') as string,
+        category: r.get('category') as string,
+        severity: r.get('severity') as string,
+      }));
+      console.log(`  RiskKeyword: ${kws.length} 个启用`);
+      return kws;
+    })(),
+
+    (async () => {
+      const q = new Parse.Query('GroupChat');
+      q.select(['roomId', 'roomName', 'communityName']);
+      q.limit(5000);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const map = new Map<string, { name: string; community: string }>();
+      for (const r of rows) {
+        map.set(
+          (r.get('roomId') as string) || '',
+          {
+            name: (r.get('roomName') as string) || '未知群',
+            community: (r.get('communityName') as string) || '',
+          }
+        );
+      }
+      console.log(`  GroupChat: ${map.size} 个群`);
+      return map;
+    })(),
+  ]);
+
+  return { allMessages, riskEvents, keywords, groupMap };
+}
+
+/* ======================== 消息基础统计 ======================== */
+
+function basicStats(messages: MsgInfo[]) {
+  // 按天
+  const byDay: Record<string, number> = {};
+  const byType: Record<number, number> = {};
+  const byRoom: Record<string, { count: number; senders: Set<string> }> = {};
+  const allSenders = new Set<string>();
+
+  for (const m of messages) {
+    const day = m.timestamp.toISOString().slice(0, 10);
+    byDay[day] = (byDay[day] || 0) + 1;
+
+    const mt = m.msgType;
+    byType[mt] = (byType[mt] || 0) + 1;
+
+    if (m.roomId) {
+      if (!byRoom[m.roomId]) byRoom[m.roomId] = { count: 0, senders: new Set() };
+      byRoom[m.roomId].count++;
+      byRoom[m.roomId].senders.add(m.senderName || '未知');
+    }
+    if (m.senderName) allSenders.add(m.senderName);
+  }
+
+  const topRooms = Object.entries(byRoom)
+    .sort((a, b) => b[1].count - a[1].count)
+    .slice(0, 15);
+
+  return { byDay, byType, byRoom, topRooms, uniqueSenders: allSenders.size };
+}
+
+/* ======================== 关键词匹配分析 ======================== */
+
+function matchAnalysis(messages: MsgInfo[], keywords: KeywordInfo[], groupMap: Map<string, { name: string; community: string }>) {
+  // 全量关键词命中
+  const kwHitCount: Record<string, { count: number; kw: KeywordInfo; rooms: Set<string> }> = {};
+  const msgsWithHits = new Map<string, { msg: MsgInfo; hitKws: KeywordInfo[] }>();
+
+  for (const msg of messages) {
+    const content = msg.content;
+    if (!content) continue;
+    const hits: KeywordInfo[] = [];
+    for (const kw of keywords) {
+      if (content.includes(kw.word)) {
+        hits.push(kw);
+        if (!kwHitCount[kw.word]) kwHitCount[kw.word] = { count: 0, kw, rooms: new Set() };
+        kwHitCount[kw.word].count++;
+        kwHitCount[kw.word].rooms.add(msg.roomId);
+      }
+    }
+    if (hits.length > 0) {
+      msgsWithHits.set(msg.id, { msg, hitKws: hits });
+    }
+  }
+
+  // 按类别汇总
+  const catStats: Record<string, { total: number; hit: number; words: Set<string> }> = {};
+  for (const kw of keywords) {
+    if (!catStats[kw.category]) catStats[kw.category] = { total: 0, hit: 0, words: new Set() };
+    catStats[kw.category].total++;
+    catStats[kw.category].words.add(kw.word);
+  }
+  for (const [word, info] of Object.entries(kwHitCount)) {
+    catStats[info.kw.category].hit++;
+  }
+
+  // 类别有效率 = 命中词数 / 总词数
+  const catEffectiveness = Object.entries(catStats).map(([cat, s]) => ({
+    category: cat,
+    totalWords: s.total,
+    hitWords: s.hit,
+    hitMessages: Object.entries(kwHitCount)
+      .filter(([_, v]) => v.kw.category === cat)
+      .reduce((sum, [_, v]) => sum + v.count, 0),
+    effectiveness: ((s.hit / s.total) * 100).toFixed(0),
+  })).sort((a, b) => b.hitMessages - a.hitMessages);
+
+  return { kwHitCount, msgsWithHits, catEffectiveness };
+}
+
+/* ======================== 漏报 vs 已预警 ======================== */
+
+function missedVsAlerted(
+  messages: MsgInfo[],
+  riskEvents: RiskEventInfo[],
+  keywords: KeywordInfo[],
+  groupMap: Map<string, { name: string; community: string }>,
+) {
+  const alertedRoomIds = new Set(riskEvents.map(e => e.groupId));
+  const alertedKeywords = new Set(riskEvents.flatMap(e => e.keywords));
+
+  // 按群分组
+  const msgsByRoom = new Map<string, MsgInfo[]>();
+  for (const m of messages) {
+    if (!m.roomId) continue;
+    const arr = msgsByRoom.get(m.roomId);
+    if (arr) arr.push(m);
+    else msgsByRoom.set(m.roomId, [m]);
+  }
+
+  // 每个群的命中分析
+  const missedRooms: Array<{
+    roomId: string;
+    roomName: string;
+    hitWords: string[];
+    missedWords: string[];
+    alertedWords: string[];
+    sampleMsgs: MsgInfo[];
+    maxSeverity: string;
+  }> = [];
+
+  for (const [roomId, msgs] of msgsByRoom) {
+    const hitWords = new Set<string>();
+    for (const m of msgs) {
+      for (const kw of keywords) {
+        if (m.content.includes(kw.word)) hitWords.add(kw.word);
+      }
+    }
+    if (hitWords.size === 0) continue;
+
+    const hitArr = [...hitWords];
+    const missedWords = hitArr.filter(w => !alertedKeywords.has(w));
+    const alertedWords = hitArr.filter(w => alertedKeywords.has(w));
+
+    if (missedWords.length > 0) {
+      const sevs = missedWords.map(w => keywords.find(k => k.word === w)?.severity || 'low');
+      const maxSeverity = sevs.includes('high') ? 'high' : sevs.includes('medium') ? 'medium' : 'low';
+
+      const samples = msgs.filter(m => missedWords.some(w => m.content.includes(w))).slice(0, 10);
+
+      missedRooms.push({
+        roomId,
+        roomName: groupMap.get(roomId)?.name || `未知群(${roomId})`,
+        hitWords: hitArr,
+        missedWords,
+        alertedWords,
+        sampleMsgs: samples,
+        maxSeverity,
+      });
+    }
+  }
+
+  // 按严重度排序
+  const sevOrder = { high: 0, medium: 1, low: 2 };
+  missedRooms.sort((a, b) => sevOrder[a.maxSeverity as keyof typeof sevOrder] - sevOrder[b.maxSeverity as keyof typeof sevOrder]);
+
+  // 真正从未触发预警的关键词
+  const trulyMissed = keywords.filter(k => !alertedKeywords.has(k.word));
+
+  return {
+    missedRooms,
+    trulyMissed,
+    alertedRoomIds,
+    alertedKeywords,
+    stats: {
+      alertedRooms: alertedRoomIds.size,
+      alertedKeywords: alertedKeywords.size,
+      missedRoomsCount: missedRooms.length,
+      trulyMissedCount: trulyMissed.length,
+      highMissed: missedRooms.filter(r => r.maxSeverity === 'high').length,
+      mediumMissed: missedRooms.filter(r => r.maxSeverity === 'medium').length,
+      lowMissed: missedRooms.filter(r => r.maxSeverity === 'low').length,
+    },
+  };
+}
+
+/* ======================== AI 深度分析(代表性样本) ======================== */
+
+const AI_KEY = process.env.DEEPSEEK_API_KEY || '';
+const AI_URL = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions';
+const AI_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
+const AI_ENABLED = AI_KEY && AI_KEY !== 'sk-xxx';
+
+async function aiSummaryReport(context: {
+  totalMessages: number;
+  totalEvents: number;
+  totalKeywords: number;
+  coverageRate: string;
+  missedRooms: ReturnType<typeof missedVsAlerted>['missedRooms'];
+  catEffectiveness: ReturnType<typeof matchAnalysis>['catEffectiveness'];
+  trulyMissedCount: number;
+}): Promise<string> {
+  if (!AI_ENABLED) return '(AI 未启用,请配置 DEEPSEEK_API_KEY)';
+
+  const sevCounts = {
+    high: context.missedRooms.filter(r => r.maxSeverity === 'high').length,
+    medium: context.missedRooms.filter(r => r.maxSeverity === 'medium').length,
+    low: context.missedRooms.filter(r => r.maxSeverity === 'low').length,
+  };
+
+  const topMissed = context.missedRooms.slice(0, 5).map(r =>
+    `- ${r.roomName}: [${r.maxSeverity}] 命中 ${r.missedWords.join(', ')}`
+  ).join('\n');
+
+  const catInfo = context.catEffectiveness.map(c =>
+    `${c.category}: ${c.hitWords}/${c.totalWords} 个关键词被命中, 共 ${c.hitMessages} 条消息命中`
+  ).join('\n');
+
+  const prompt = `你是拉迷家居(全屋定制品牌)的风险管理顾问。请基于以下 6/15-6/22 的群聊监控数据,输出一份管理建议报告(300-500字,中文)。
+
+## 数据
+- 文本消息总量: ${context.totalMessages}
+- 已有预警事件: ${context.totalEvents}
+- 启用关键词: ${context.totalKeywords} 个
+- 关键词覆盖率(触发过预警的): ${context.coverageRate}%
+- 从未触发预警的关键词: ${context.trulyMissedCount} 个
+- 漏报群: ${context.missedRooms.length}(高危${sevCounts.high}/中危${sevCounts.medium}/低危${sevCounts.low})
+
+## 关键词类别有效性
+${catInfo}
+
+## 高危/中危漏报群
+${topMissed}
+
+## 要求
+请从以下5个维度给出管理建议(每个维度1-3条具体建议):
+1. **关键词库优化**: 哪些词该删、该降级、该升级
+2. **预警规则优化**: 是否需要关键词频次阈值、聚合规则
+3. **盲区发现**: 当前纯关键词匹配的方法可能漏掉什么
+4. **流程优化**: 预警→响应→关闭的闭环改进
+5. **定期审计**: 建议的审计频率和关注指标
+
+回复格式: 直接输出管理建议报告,不要 JSON,不要 markdown 代码块。`;
+
+  try {
+    const res = await fetch(AI_URL, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_KEY}` },
+      body: JSON.stringify({
+        model: AI_MODEL,
+        messages: [
+          { role: 'system', content: '你是全屋定制行业风险管理顾问。输出简洁、具体、可落地的管理建议。' },
+          { role: 'user', content: prompt },
+        ],
+        temperature: 0.4,
+        max_tokens: 1200,
+      }),
+    });
+    if (!res.ok) return `AI 调用失败: ${res.status}`;
+    const json = await res.json() as any;
+    return json?.choices?.[0]?.message?.content || 'AI 返回为空';
+  } catch (err: any) {
+    return `AI 错误: ${err.message}`;
+  }
+}
+
+/* ======================== 主流程 ======================== */
+
+async function main(): Promise<void> {
+  const t0 = Date.now();
+  console.log('═══════════════════════════════════════');
+  console.log('  风险预警深度分析 (6/15 - 6/22)');
+  console.log('═══════════════════════════════════════\n');
+
+  // 1. 数据
+  const { allMessages, riskEvents, keywords, groupMap } = await fetchData();
+
+  // 2. 基础统计
+  console.log('\n[1/4] 基础统计...');
+  const stats = basicStats(allMessages);
+  console.log(`  消息天数: ${Object.keys(stats.byDay).length}`);
+  console.log(`  活跃群数: ${Object.keys(stats.byRoom).length}`);
+  console.log(`  参与人数: ${stats.uniqueSenders}`);
+
+  // 3. 关键词匹配
+  console.log('\n[2/4] 关键词匹配分析...');
+  const { kwHitCount, msgsWithHits, catEffectiveness } = matchAnalysis(allMessages, keywords, groupMap);
+  console.log(`  命中关键词的消息: ${msgsWithHits.size} 条`);
+  console.log(`  命中关键词种类: ${Object.keys(kwHitCount).length} 个`);
+
+  // 4. 漏报分析
+  console.log('\n[3/4] 漏报分析...');
+  const { missedRooms, trulyMissed, stats: missStats } = missedVsAlerted(allMessages, riskEvents, keywords, groupMap);
+  console.log(`  已有预警群: ${missStats.alertedRooms}`);
+  console.log(`  漏报群: ${missStats.missedRoomsCount}(高危${missStats.highMissed}/中危${missStats.mediumMissed}/低危${missStats.lowMissed})`);
+  console.log(`  从未触发预警的关键词: ${missStats.trulyMissedCount}`);
+
+  // 5. AI 综合建议
+  console.log('\n[4/4] AI 生成管理建议...');
+  const coverageRate = keywords.length > 0 ? ((missStats.alertedKeywords / keywords.length) * 100).toFixed(1) : '0';
+  const aiReport = await aiSummaryReport({
+    totalMessages: allMessages.length,
+    totalEvents: riskEvents.length,
+    totalKeywords: keywords.length,
+    coverageRate,
+    missedRooms,
+    catEffectiveness,
+    trulyMissedCount: missStats.trulyMissedCount,
+  });
+  console.log('  AI 报告生成完成');
+
+  // 6. 生成 MD 报告
+  const genTime = new Date().toLocaleString('zh-CN');
+  const totalMs = Date.now() - t0;
+
+  // 类别有效性表格
+  const catRows = catEffectiveness.map(c =>
+    `| ${categoryCn(c.category)} | ${c.totalWords} | ${c.hitWords} | ${c.hitMessages} | ${c.effectiveness}% |`
+  ).join('\n');
+
+  // 漏报群表格
+  const missedRows = missedRooms.slice(0, 20).map((r, i) => {
+    const sevIcon = r.maxSeverity === 'high' ? '🔴' : r.maxSeverity === 'medium' ? '🟡' : '🟢';
+    const samples = r.sampleMsgs.slice(0, 3).map(m => {
+      const ts = m.timestamp;
+      const time = `${ts.getMonth() + 1}/${ts.getDate()} ${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}`;
+      return `[${time}] ${m.senderName || '未知'}: ${m.content.length > 80 ? m.content.slice(0, 80) + '...' : m.content}`;
+    }).join('<br>');
+    return `| ${i + 1} | ${sevIcon} ${escapeMd(r.roomName)} | ${r.missedWords.join(', ')} | ${sevIcon} ${r.maxSeverity} | ${r.hitWords.length - r.missedWords.length > 0 ? '部分覆盖' : '完全漏报'} | ${samples} |`;
+  }).join('\n');
+
+  // 已触发预警表格
+  const alertedRows = riskEvents.map(e => {
+    const g = groupMap.get(e.groupId);
+    return `| ${e.createdAt ? new Date(e.createdAt).toLocaleString('zh-CN') : ''} | ${escapeMd(g?.name || e.groupId)} | ${e.keywords.join(', ')} | ${e.severity} | ${e.status} | ${escapeMd(e.title)} |`;
+  }).join('\n');
+
+  // 消息分布表格
+  const dayRows = Object.keys(stats.byDay).sort().map(d =>
+    `| ${d} | ${stats.byDay[d]} |`
+  ).join('\n');
+
+  // TOP 群表格
+  const topRoomRows = stats.topRooms.map(([rid, info], i) => {
+    const g = groupMap.get(rid);
+    return `| ${i + 1} | ${escapeMd(g?.name || '未知群')} | ${escapeMd(g?.community || '-')} | ${info.count} | ${info.senders.size} |`;
+  }).join('\n');
+
+  // 未覆盖关键词 TOP 30
+  const trulyMissedTop = trulyMissed.slice(0, 30);
+  const trulyMissedByCat: Record<string, KeywordInfo[]> = {};
+  for (const kw of trulyMissedTop) {
+    if (!trulyMissedByCat[kw.category]) trulyMissedByCat[kw.category] = [];
+    trulyMissedByCat[kw.category].push(kw);
+  }
+  const trulyMissedSection = Object.entries(trulyMissedByCat).map(([cat, kws]) => {
+    return `**${categoryCn(cat)}** (${kws.length}个): ${kws.map(k => {
+      const s = k.severity === 'high' ? '🔴' : k.severity === 'medium' ? '🟡' : '🟢';
+      return `${s}${k.word}`;
+    }).join('、')}`;
+  }).join('\n\n');
+
+  const md = `# 🛡️ 拉迷家居 · 风险预警深度分析报告
+
+**审计期间**: 2026-06-15 ~ 2026-06-22(8 天)
+**生成时间**: ${genTime}
+**分析耗时**: ${(totalMs / 1000).toFixed(1)}s
+**AI 引擎**: ${AI_ENABLED ? `DeepSeek (${AI_MODEL})` : '未启用'}
+
+---
+
+## 一、数据概览
+
+| 指标 | 数值 |
+|------|------|
+| 文本消息总量 | ${allMessages.length.toLocaleString()} |
+| 活跃群数 | ${Object.keys(stats.byRoom).length} |
+| 参与人数 | ${stats.uniqueSenders} |
+| 已有风险事件 | ${riskEvents.length} |
+| 启用风险关键词 | ${keywords.length} |
+| 已触发预警的关键词 | ${missStats.alertedKeywords} |
+| 从未触发预警的关键词 | ${missStats.trulyMissedCount} |
+| **关键词覆盖率** | **${coverageRate}%** |
+| 漏报群数量 | ${missStats.missedRoomsCount} |
+| 🔴 高危漏报群 | ${missStats.highMissed} |
+| 🟡 中危漏报群 | ${missStats.mediumMissed} |
+| 🟢 低危漏报群 | ${missStats.lowMissed} |
+
+---
+
+## 二、消息活动分布
+
+### 2.1 按天分布
+
+| 日期 | 消息数 |
+|------|--------|
+${dayRows}
+
+### 2.2 活跃群 TOP 15
+
+| 排名 | 群名称 | 小区 | 消息数 | 参与人数 |
+|------|--------|------|--------|----------|
+${topRoomRows}
+
+---
+
+## 三、关键词有效性分析
+
+### 3.1 各类别命中情况
+
+| 类别 | 总词数 | 命中词数 | 命中消息数 | 有效率 |
+|------|--------|----------|------------|--------|
+${catRows}
+
+### 3.2 完全未触发预警的关键词(TOP 30)
+
+${trulyMissedSection}
+
+---
+
+## 四、已触发预警详情
+
+${riskEvents.length > 0 ? `
+| 时间 | 群 | 关键词 | 严重度 | 状态 | 标题 |
+|------|-----|--------|--------|------|------|
+${alertedRows}
+` : '> ⚠️ 该期间无风险事件'}
+
+---
+
+## 五、漏报分析
+
+### 5.1 漏报群详情
+
+| # | 群 | 未覆盖关键词 | 最高严重度 | 预警状态 | 消息样本 |
+|---|-----|-------------|------------|----------|----------|
+${missedRows}
+
+### 5.2 漏报原因分析
+
+#### 类型一:促销营销用词(占比最大)
+- **「定金」「订金」「优惠」「折扣」** 等关键词出现在大量 618 促销广播中
+- 这些词触发情景为拉迷员工在各业主群发布统一营销文案
+- AI 评审结论:正常促销,无真实风险
+- **建议**: 将此类词从预警列表移除或降级为"仅统计不预警"
+
+#### 类型二:正常业务沟通
+- **「色差」** 出现在"告别线上色差"的到店体验邀约中
+- **「垃圾」** 出现在询问垃圾桶尺寸的设计沟通中
+- **「宜家」** 出现在"宜家对杯"的游戏互动中
+- **建议**: 这些词在营销和日常沟通中高频出现,需更精准的上下文判断
+
+#### 类型三:真实质量风险(需关注)
+- **「脱胶」** 在拉迷VIP服务群中被提及,客户反映产品脱胶问题
+- 安装师傅在场并提出了修补方案,但此类问题应被预警
+- **建议**: 质量类关键词(脱胶、开裂、变形、发霉等)应独立为"即报即警"
+
+---
+
+## 六、AI 管理建议
+
+${aiReport}
+
+---
+
+## 七、结论与行动项
+
+### 立即执行(本周)
+1. **清理促销类关键词**: 将"定金/订金/优惠/折扣/打折/砍价"标记为 "仅统计",不再触发预警
+2. **质量关键词升级**: "脱胶/开裂/变形/发霉/装错/尺寸不对/色差(非营销语境)" 设为命中即报警
+3. **竞品关键词独立统计**: 不触发预警,但每月统计竞品提及频次作为市场情报
+
+### 短期优化(本月)
+4. **聚合规则**: 同一群 1 小时内命中 ≥3 个不同关键词 → 自动升级预警
+5. **频次阈值**: 关键词命中频次 TOP 10 的词自动进入月度复审
+6. **盲区补充**: 增加"情绪检测"维度(感叹号密度、负面表情包、长语音消息密集度)
+
+### 持续改进
+7. **周审计**: 每周一运行本审计脚本,对比上周数据
+8. **月度关键词大扫除**: 淘汰连续 30 天零命中的关键词
+9. **预警闭环率**: 追踪 pending→resolved 的平均时长,目标 < 4 小时
+10. **误报率监控**: 标记为 false_alarm 的事件占比应 < 30%
+
+---
+
+## 八、脚本使用说明
+
+### 审计脚本清单
+
+| 脚本 | 用途 | 频率 |
+|------|------|------|
+| \`audit-risk-coverage.ts\` | 关键词覆盖审计 + HTML 报告 | 每周 |
+| \`audit-risk-deep-analysis.ts\` | 深度分析 + MD 报告 | 每月 |
+| \`backfill-member-nicknames.ts\` | 回填 GroupMember 昵称 | 按需 |
+
+### 运行方式
+
+\`\`\`bash
+cd backend
+
+# 覆盖审计(含 AI 评审,约 30s)
+npx tsx scripts/audit-risk-coverage.ts
+
+# 深度分析(含 AI 管理建议,约 30s)
+npx tsx scripts/audit-risk-deep-analysis.ts
+\`\`\`
+
+### 输出文件
+
+| 文件 | 说明 |
+|------|------|
+| \`audit-risk-YYYY-MM-DD.html\` | 可视化 HTML 报告(含消息样本和 AI 评审) |
+| \`audit-risk-deep-YYYY-MM-DD.md\` | 深度分析 MD 报告(含管理建议) |
+
+---
+
+*报告由 audit-risk-deep-analysis.ts 自动生成*
+`;
+
+  const outputPath = path.resolve(__dirname, 'audit-risk-deep-2026-06-15-22.md');
+  fs.writeFileSync(outputPath, md, 'utf-8');
+
+  console.log(`\n✅ 深度分析报告已生成: ${outputPath}`);
+  console.log(`   文件大小: ${(Buffer.byteLength(md, 'utf-8') / 1024).toFixed(1)} KB`);
+  console.log(`   总耗时: ${totalMs}ms\n`);
+
+  // 控制台摘要
+  console.log('═══════════════════════════════════════');
+  console.log('  关键发现');
+  console.log('═══════════════════════════════════════');
+  console.log(`  消息总量: ${allMessages.length} | 预警: ${riskEvents.length} | 关键词: ${keywords.length}`);
+  console.log(`  覆盖率: ${coverageRate}% | 漏报群: ${missStats.missedRoomsCount}`);
+  console.log(`  高危漏报: ${missStats.highMissed} | 中危: ${missStats.mediumMissed} | 低危: ${missStats.lowMissed}`);
+  console.log(`  类别有效率排名: ${catEffectiveness.slice(0, 5).map(c => `${categoryCn(c.category)}(${c.effectiveness}%)`).join(' > ')}`);
+}
+
+/* ======================== 工具函数 ======================== */
+
+function categoryCn(cat: string): string {
+  const map: Record<string, string> = {
+    complaint: '投诉抱怨', quality: '质量工艺', sensitive: '敏感词',
+    legal: '法律监管', competitor: '竞品对比', price: '价格讨论',
+    negative: '负面情绪', installation: '安装施工',
+  };
+  return map[cat] || cat;
+}
+
+function escapeMd(s: string): string {
+  return s.replace(/\|/g, '\\|').replace(/\n/g, ' ');
+}
+
+main().catch((err) => {
+  console.error('[深度分析] 失败:', err);
+  process.exit(1);
+});

+ 115 - 0
backend/scripts/backfill-member-nicknames.ts

@@ -0,0 +1,115 @@
+/**
+ * 手动脚本:从 Message 表的 senderName 回填 GroupMember 缺失的昵称
+ *
+ * 用法: cd backend && npx tsx scripts/backfill-member-nicknames.ts
+ *
+ * 逻辑:
+ *   1. 查找 nickname 为空或 '' 的 GroupMember
+ *   2. 跨所有房间,从 Message 表查找同一 userId 的 senderName
+ *   3. 批量写入 GroupMember.nickname
+ */
+
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+const GUID = process.env.QIWE_GUID || '';
+const CONTAINED_IN_BATCH = 500; // containedIn 单次最多 500 个 userId
+
+async function main(): Promise<void> {
+  console.log('=== GroupMember 昵称回填 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`GUID: ${GUID}\n`);
+
+  // ── 1. 查找缺少昵称的成员 ──
+  console.log('[1/3] 查找缺失昵称的成员...');
+  const memberQuery = new Parse.Query('GroupMember');
+  memberQuery.equalTo('guid', GUID);
+  memberQuery.equalTo('status', 'active');
+  memberQuery.limit(10000);
+  const allMembers = await memberQuery.find({ useMasterKey: true });
+
+  const noNick: Parse.Object[] = [];
+  let hasNick = 0;
+  for (const m of allMembers) {
+    const nick = m.get('nickname');
+    if (!nick || (typeof nick === 'string' && nick.trim() === '')) {
+      noNick.push(m);
+    } else {
+      hasNick++;
+    }
+  }
+  console.log(`  总数: ${allMembers.length} | 有昵称: ${hasNick} | 缺昵称: ${noNick.length}`);
+
+  if (noNick.length === 0) {
+    console.log('\n✅ 所有成员已有昵称,无需回填。');
+    return;
+  }
+
+  // ── 2. 跨房间从 Message 查找 senderName ──
+  // 收集所有无昵称成员的唯一 userId(去重)
+  const noNickUserIds = [...new Set(noNick.map((m) => String(m.get('userId') || '')).filter(Boolean))];
+  console.log(`  去重后唯一 userId: ${noNickUserIds.length} 个`);
+
+  // 分批用 containedIn 查询消息(避免单次参数过多)
+  const userIdToName = new Map<string, string>();
+  const batches: string[][] = [];
+  for (let i = 0; i < noNickUserIds.length; i += CONTAINED_IN_BATCH) {
+    batches.push(noNickUserIds.slice(i, i + CONTAINED_IN_BATCH));
+  }
+  console.log(`  分 ${batches.length} 批查询消息...`);
+
+  for (let bi = 0; bi < batches.length; bi++) {
+    const batch = batches[bi];
+    try {
+      const msgQuery = new Parse.Query('Message');
+      msgQuery.equalTo('guid', GUID);
+      msgQuery.equalTo('isGroupChat', 1);
+      msgQuery.containedIn('senderId', batch);
+      msgQuery.exists('senderName');
+      msgQuery.notEqualTo('senderName', '');
+      msgQuery.limit(5000);
+      const messages = await msgQuery.find({ useMasterKey: true });
+
+      for (const msg of messages) {
+        const sid = msg.get('senderId') as string;
+        const sname = msg.get('senderName') as string;
+        if (sid && sname && !userIdToName.has(sid)) {
+          userIdToName.set(sid, sname);
+        }
+      }
+      console.log(`  批次 ${bi + 1}/${batches.length}: ${messages.length} 条消息, 已累积 ${userIdToName.size} 个 senderName`);
+    } catch (err: any) {
+      console.warn(`  ⚠ 批次 ${bi + 1} 查询失败: ${err.message}`);
+    }
+  }
+
+  // ── 3. 回填 ──
+  console.log(`\n[3/3] 回填 GroupMember.nickname...`);
+  let filled = 0;
+  let skipped = 0;
+  const toSave: Parse.Object[] = [];
+
+  for (const m of noNick) {
+    const uid = String(m.get('userId') || '');
+    const senderName = userIdToName.get(uid);
+    if (senderName) {
+      m.set('nickname', senderName);
+      toSave.push(m);
+      filled++;
+    } else {
+      skipped++;
+    }
+  }
+
+  // 分批写入
+  for (let i = 0; i < toSave.length; i += 100) {
+    await Parse.Object.saveAll(toSave.slice(i, i + 100), { useMasterKey: true });
+  }
+
+  console.log(`\n✅ 完成 — 回填: ${filled} | 找不到消息: ${skipped} | 总计无昵称: ${noNick.length}`);
+}
+
+main().catch((err) => {
+  console.error('回填失败:', err.message);
+  process.exit(1);
+});

+ 74 - 0
backend/scripts/cleanup-dead-fields.ts

@@ -0,0 +1,74 @@
+/**
+ * 删除前后端均不使用的死字段
+ *
+ * GroupChat: guid, lastActivityAt, hasDocument (avatarUrl 已在 test-delete-field 中删除)
+ * Store:     managerId, managerName, status
+ *
+ * 用法: cd backend && npx tsx scripts/cleanup-dead-fields.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+interface CleanupTask {
+  className: string;
+  fields: string[];
+}
+
+const TASKS: CleanupTask[] = [
+  {
+    className: 'GroupChat',
+    fields: ['guid', 'lastActivityAt', 'hasDocument'],
+  },
+  {
+    className: 'Store',
+    fields: ['managerId', 'managerName', 'status'],
+  },
+];
+
+async function main(): Promise<void> {
+  console.log('=== 清理死字段 ===\n');
+
+  // 先打印当前 Schema
+  const beforeSchemas = await Parse.Schema.all({ useMasterKey: true });
+
+  for (const task of TASKS) {
+    const before = beforeSchemas.find((s: any) => s.className === task.className);
+    if (!before) {
+      console.log(`⚠️ ${task.className} 表不存在,跳过`);
+      continue;
+    }
+    const existingFields = Object.keys(before.fields).filter((k: string) => !k.startsWith('_'));
+    const deletable = task.fields.filter(f => existingFields.includes(f));
+
+    if (deletable.length === 0) {
+      console.log(`${task.className}: 目标字段均不存在,跳过`);
+      continue;
+    }
+
+    const schema = new Parse.Schema(task.className);
+    for (const field of deletable) {
+      schema.deleteField(field);
+    }
+    await schema.update({ useMasterKey: true });
+    console.log(`${task.className}: ✅ 已删除 ${deletable.join(', ')}`);
+  }
+
+  // 验证
+  console.log('\n--- 验证 ---');
+  const afterSchemas = await Parse.Schema.all({ useMasterKey: true });
+
+  for (const task of TASKS) {
+    const after = afterSchemas.find((s: any) => s.className === task.className);
+    if (!after) continue;
+    const remaining = Object.keys(after.fields).filter((k: string) => !k.startsWith('_')).sort();
+    const stillThere = task.fields.filter(f => remaining.includes(f));
+    console.log(`${task.className}: ${stillThere.length > 0 ? '❌ 残留 ' + stillThere.join(', ') : '✅ 全部清除'} — 现有字段: ${remaining.join(', ')}`);
+  }
+
+  console.log('\n✅ 清理完成!');
+}
+
+main().catch((err) => {
+  console.error('❌ 失败:', err);
+  process.exit(1);
+});

+ 70 - 0
backend/scripts/cleanup-groupchat-dead-fields.ts

@@ -0,0 +1,70 @@
+/**
+ * 删除 GroupChat 表中前端不再读取的死字段
+ *
+ * - lastActivityAt: 前端完全不读,后端已停止写入
+ * - avatarUrl:      前端完全不读,后端已停止写入(企微头像 URL 无用)
+ * - ownerName:      前端从 ResponsibilityAssignment 读取负责人,不再读此字段
+ *
+ * 保留字段说明:
+ * - status:         仅群解散 webhook 写入 'dismissed',前端用于判定健康分(不删)
+ * - messageCountToday / messageCountTotal / memberCount: 前端暂时仍读取,后续改为纯计算后再删
+ *
+ * 用法: cd backend && npx tsx scripts/cleanup-groupchat-dead-fields.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+const DEAD_FIELDS = ['lastActivityAt', 'avatarUrl', 'ownerName', 'memberCount', 'messageCountToday', 'messageCountTotal'];
+
+async function main(): Promise<void> {
+  console.log('=== 清理 GroupChat 死字段 ===\n');
+
+  const schemas = await Parse.Schema.all({ useMasterKey: true });
+  const groupChat = schemas.find((s: any) => s.className === 'GroupChat');
+
+  if (!groupChat) {
+    console.error('❌ GroupChat 表不存在');
+    process.exit(1);
+  }
+
+  const existingFields = Object.keys(groupChat.fields).filter((k: string) => !k.startsWith('_'));
+  console.log(`当前 GroupChat 字段: ${existingFields.join(', ')}\n`);
+
+  const deletable = DEAD_FIELDS.filter(f => existingFields.includes(f));
+
+  if (deletable.length === 0) {
+    console.log('目标字段均已不存在,无需操作');
+    return;
+  }
+
+  console.log(`准备删除: ${deletable.join(', ')}\n`);
+
+  const schema = new Parse.Schema('GroupChat');
+  for (const field of deletable) {
+    schema.deleteField(field);
+    console.log(`  ✅ 已标记删除: ${field}`);
+  }
+  await schema.update({ useMasterKey: true });
+
+  // 验证
+  console.log('\n--- 验证 ---');
+  const afterSchemas = await Parse.Schema.all({ useMasterKey: true });
+  const after = afterSchemas.find((s: any) => s.className === 'GroupChat');
+  if (after) {
+    const remaining = Object.keys(after.fields).filter((k: string) => !k.startsWith('_')).sort();
+    const stillThere = deletable.filter(f => remaining.includes(f));
+    if (stillThere.length > 0) {
+      console.log(`❌ 残留字段: ${stillThere.join(', ')}`);
+    } else {
+      console.log(`✅ 全部清除成功`);
+    }
+    console.log(`现有字段 (${remaining.length}): ${remaining.join(', ')}`);
+  }
+
+  console.log('\n✅ 清理完成!');
+}
+
+main().catch((err) => {
+  console.error('❌ 失败:', err);
+  process.exit(1);
+});

+ 76 - 0
backend/scripts/cleanup-permission-data.ts

@@ -0,0 +1,76 @@
+/**
+ * 清除 _User 表的权限字段 (role/store/district) + 清空 ResponsibilityAssignment 表
+ * 用于重置权限数据,方便重新测试
+ * 注意:总监 (role='director') 不会被清除,因为是管理员
+ *
+ * 用法: cd backend && npx tsx scripts/cleanup-permission-data.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function main(): Promise<void> {
+  console.log('=== 清理权限数据 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  // 验证连接
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+
+  // ======== 1. 清除 _User 权限字段(跳过总监) ========
+  console.log('--- 1. 清除 _User 权限字段 (role, store, district) ---');
+
+  const userQ = new Parse.Query('_User');
+  userQ.limit(500);
+  const users = await userQ.find({ useMasterKey: true }) as any[];
+
+  let userCleared = 0;
+  let directorSkipped = 0;
+  for (const user of users) {
+    const role = user.get('role');
+    const store = user.get('store');
+    const district = user.get('district');
+
+    // 跳过总监(管理员),不清除其权限
+    if (role === 'director') {
+      directorSkipped++;
+      continue;
+    }
+
+    if (!role && !store && !district) continue;
+
+    user.unset('role');
+    user.unset('store');
+    user.unset('district');
+    await user.save(null, { useMasterKey: true });
+    userCleared++;
+  }
+  console.log(`  已清除 ${userCleared} 个用户的权限字段`);
+  if (directorSkipped > 0) console.log(`  跳过 ${directorSkipped} 个总监(管理员)`);
+
+  // ======== 2. 清空 ResponsibilityAssignment ========
+  console.log('\n--- 2. 清空 ResponsibilityAssignment 表 ---');
+
+  const raQ = new Parse.Query('ResponsibilityAssignment');
+  raQ.limit(1000);
+  const assignments = await raQ.find({ useMasterKey: true }) as any[];
+
+  if (assignments.length > 0) {
+    await Parse.Object.destroyAll(assignments, { useMasterKey: true });
+    console.log(`  已删除 ${assignments.length} 条记录`);
+  } else {
+    console.log('  表中无数据');
+  }
+
+  console.log('\n=== 清理完成 ===');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 123 - 0
backend/scripts/cleanup-redundant-fields.ts

@@ -0,0 +1,123 @@
+/**
+ * 清除冗余 String 字段——外键关系统一使用 Pointer
+ *
+ * 清除的字段:
+ *   GroupChat: storeId, storeName, communityId, communityName
+ *   Store:     districtId, districtName
+ *   Community: storeId, storeName, districtId, districtName
+ *
+ * 前置条件: assign-store-ids.ts 已运行,所有 Pointer 字段已就位
+ * 用法: npx tsx scripts/cleanup-redundant-fields.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function main(): Promise<void> {
+  console.log('[Cleanup] 开始清除冗余 String 字段…\n');
+
+  // 1. GroupChat: 移除 storeId, storeName, communityId, communityName
+  console.log('[Cleanup] 处理 GroupChat…');
+  const gq = new Parse.Query('GroupChat');
+  gq.limit(1000);
+  const groups = await gq.find({ useMasterKey: true });
+  let gCleaned = 0;
+  const gToSave: Parse.Object[] = [];
+
+  for (const g of groups) {
+    const hasStorePtr = !!g.get('store');
+    const hasCommunityPtr = !!g.get('community');
+    const hasStoreId = g.get('storeId');
+    const hasStoreName = g.get('storeName');
+    const hasCommunityId = g.get('communityId');
+    const hasCommunityName = g.get('communityName');
+
+    if (!hasStorePtr && !hasCommunityPtr && !hasStoreId && !hasStoreName && !hasCommunityId && !hasCommunityName) {
+      continue;
+    }
+
+    // 只在 Pointer 已设置时清除对应的 String 字段
+    if (hasStorePtr && (hasStoreId || hasStoreName)) {
+      g.unset('storeId');
+      g.unset('storeName');
+      gCleaned++;
+    }
+    if (hasCommunityPtr && (hasCommunityId || hasCommunityName)) {
+      g.unset('communityId');
+      g.unset('communityName');
+      gCleaned++;
+    }
+
+    gToSave.push(g);
+  }
+
+  if (gToSave.length > 0) {
+    for (let i = 0; i < gToSave.length; i += 100) {
+      await Parse.Object.saveAll(gToSave.slice(i, i + 100), { useMasterKey: true });
+    }
+  }
+  console.log(`  GroupChat: ${groups.length} 条, 清除 ${gCleaned} 个冗余字段`);
+
+  // 2. Store: 移除 districtId, districtName
+  console.log('[Cleanup] 处理 Store…');
+  const sq = new Parse.Query('Store');
+  sq.limit(500);
+  const stores = await sq.find({ useMasterKey: true });
+  let sCleaned = 0;
+  const sToSave: Parse.Object[] = [];
+
+  for (const s of stores) {
+    const hasDistrictPtr = !!s.get('district');
+    const hasDistrictId = s.get('districtId');
+
+    if (hasDistrictPtr && hasDistrictId !== undefined) {
+      s.unset('districtId');
+      s.unset('districtName');
+      sCleaned++;
+      sToSave.push(s);
+    }
+  }
+
+  if (sToSave.length > 0) {
+    await Parse.Object.saveAll(sToSave, { useMasterKey: true });
+  }
+  console.log(`  Store: ${stores.length} 条, 清除 ${sCleaned} 个冗余字段`);
+
+  // 3. Community: 移除 storeId, storeName, districtId, districtName
+  console.log('[Cleanup] 处理 Community…');
+  const cq = new Parse.Query('Community');
+  cq.limit(500);
+  const communities = await cq.find({ useMasterKey: true });
+  let cStoreCleaned = 0;
+  let cDistrictCleaned = 0;
+  const cToSave: Parse.Object[] = [];
+
+  for (const c of communities) {
+    let changed = false;
+    const hasStorePtr = !!c.get('store');
+    if (hasStorePtr && c.get('storeId') !== undefined) {
+      c.unset('storeId');
+      c.unset('storeName');
+      cStoreCleaned++;
+      changed = true;
+    }
+    if (c.get('districtId') !== undefined || c.get('districtName') !== undefined) {
+      c.unset('districtId');
+      c.unset('districtName');
+      cDistrictCleaned++;
+      changed = true;
+    }
+    if (changed) cToSave.push(c);
+  }
+
+  if (cToSave.length > 0) {
+    await Parse.Object.saveAll(cToSave, { useMasterKey: true });
+  }
+  console.log(`  Community: ${communities.length} 条, store 冗余 ${cStoreCleaned}, district 冗余 ${cDistrictCleaned}`);
+
+  console.log('\n[Cleanup] 全部完成!');
+}
+
+main().catch((err) => {
+  console.error('[Cleanup] 失败:', err);
+  process.exit(1);
+});

+ 181 - 0
backend/scripts/create-contact-webhooklog-schema.ts

@@ -0,0 +1,181 @@
+/**
+ * 创建 Contact(联系人)和 WebhookLog(系统事件日志)表
+ *
+ * 用法: cd backend && npx tsx scripts/create-contact-webhooklog-schema.ts
+ *
+ * 幂等:可重复执行,已存在的字段自动跳过
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function testConnection(): Promise<void> {
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+}
+
+async function ensureClassExists(className: string): Promise<void> {
+  try {
+    await new Parse.Schema(className).get({ useMasterKey: true });
+  } catch {
+    // 类不存在,创建一条空记录来建表,然后立即删除
+    const tmp = new Parse.Object(className);
+    tmp.set('_placeholder', true);
+    await tmp.save(null, { useMasterKey: true });
+    await tmp.destroy({ useMasterKey: true });
+    console.log(`  📦 ${className} 表已创建`);
+  }
+}
+
+/* ═══════════════════════════════════════════════
+   1. Contact(联系人表)
+   ═══════════════════════════════════════════════ */
+async function createContactTable(): Promise<void> {
+  console.log('═══ 1/3 Contact(联系人表)═══');
+
+  await ensureClassExists('Contact');
+
+  const schema = new Parse.Schema('Contact');
+
+  const fields: Array<{ name: string; type: string }> = [
+    { name: 'userId', type: 'String' },
+    { name: 'guid', type: 'String' },
+    { name: 'nickname', type: 'String' },
+    { name: 'realName', type: 'String' },
+    { name: 'remark', type: 'String' },
+    { name: 'avatar', type: 'String' },
+    { name: 'sex', type: 'Number' },
+    { name: 'phone', type: 'String' },
+    { name: 'corpName', type: 'String' },
+    { name: 'corpFullName', type: 'String' },
+    { name: 'corpId', type: 'String' },
+    { name: 'position', type: 'String' },
+    { name: 'alias', type: 'String' },
+    { name: 'unionid', type: 'String' },
+    { name: 'addTime', type: 'Number' },
+    { name: 'contactType', type: 'Number' },
+    { name: 'partyId', type: 'String' },
+    { name: 'status', type: 'String' },
+  ];
+
+  for (const f of fields) {
+    try {
+      await schema.addField(f.name, f.type);
+      console.log(`  + ${f.name} (${f.type})`);
+    } catch (err: any) {
+      if (err.message?.includes('already')) {
+        console.log(`  ~ ${f.name} 已存在`);
+      } else {
+        console.warn(`  ⚠ ${f.name} 添加失败: ${err.message}`);
+      }
+    }
+  }
+
+  // CLP 权限:仅 masterKey 可读写
+  try {
+    await schema.setCLP({
+      find: { 'requiresAuthentication': true },
+      get: { 'requiresAuthentication': true },
+      create: { 'requiresAuthentication': true },
+      update: { 'requiresAuthentication': true },
+      delete: { 'requiresAuthentication': true },
+    });
+  } catch { /* CLP 设置失败不阻塞 */ }
+
+  await schema.update({ useMasterKey: true });
+  console.log('  Contact 表更新完成\n');
+}
+
+/* ═══════════════════════════════════════════════
+   2. WebhookLog(系统事件日志表)
+   ═══════════════════════════════════════════════ */
+async function createWebhookLogTable(): Promise<void> {
+  console.log('═══ 2/3 WebhookLog(系统事件日志表)═══');
+
+  await ensureClassExists('WebhookLog');
+
+  const schema = new Parse.Schema('WebhookLog');
+
+  const fields: Array<{ name: string; type: string }> = [
+    { name: 'guid', type: 'String' },
+    { name: 'cmd', type: 'Number' },
+    { name: 'msgType', type: 'Number' },
+    { name: 'msgData', type: 'Object' },
+    { name: 'timestamp', type: 'Date' },
+  ];
+
+  for (const f of fields) {
+    try {
+      await schema.addField(f.name, f.type);
+      console.log(`  + ${f.name} (${f.type})`);
+    } catch (err: any) {
+      if (err.message?.includes('already')) {
+        console.log(`  ~ ${f.name} 已存在`);
+      } else {
+        console.warn(`  ⚠ ${f.name} 添加失败: ${err.message}`);
+      }
+    }
+  }
+
+  // CLP 权限
+  try {
+    await schema.setCLP({
+      find: { 'requiresAuthentication': true },
+      get: { 'requiresAuthentication': true },
+      create: { 'requiresAuthentication': true },
+      update: { 'requiresAuthentication': true },
+      delete: { 'requiresAuthentication': true },
+    });
+  } catch { /* CLP 设置失败不阻塞 */ }
+
+  await schema.update({ useMasterKey: true });
+  console.log('  WebhookLog 表更新完成\n');
+}
+
+/* ═══════════════════════════════════════════════
+   3. GroupChat 补字段(ownerName)
+   ═══════════════════════════════════════════════ */
+async function ensureGroupChatOwnerName(): Promise<void> {
+  console.log('═══ 3/3 GroupChat 补 ownerName 字段 ═══');
+
+  const schema = new Parse.Schema('GroupChat');
+  try {
+    await schema.addField('ownerName', 'String');
+    console.log('  + ownerName (String)');
+  } catch (err: any) {
+    if (err.message?.includes('already')) {
+      console.log('  ~ ownerName 已存在');
+    } else {
+      console.warn(`  ⚠ ownerName 添加失败: ${err.message}`);
+    }
+  }
+
+  await schema.update({ useMasterKey: true });
+  console.log('  GroupChat 表更新完成\n');
+}
+
+/* ═══════════════════════════════════════════════
+   Main
+   ═══════════════════════════════════════════════ */
+async function main(): Promise<void> {
+  console.log('=== Contact + WebhookLog 表创建 & GroupChat 字段补全 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  await testConnection();
+
+  await createContactTable();
+  await createWebhookLogTable();
+  await ensureGroupChatOwnerName();
+
+  console.log('=== 完成 ===\n');
+}
+
+main().catch((err) => {
+  console.error('创建失败:', err.message);
+  process.exit(1);
+});

+ 134 - 0
backend/scripts/discover-db.ts

@@ -0,0 +1,134 @@
+/**
+ * 探查数据库表结构 + 数据量 + 示例数据
+ *
+ * 用法: cd backend && npx tsx scripts/discover-db.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function countRows(className: string): Promise<number> {
+  try {
+    const n = await new Parse.Query(className).count({ useMasterKey: true });
+    return n;
+  } catch {
+    return -1;
+  }
+}
+
+async function showSample(className: string, fields: string[], limit = 2): Promise<void> {
+  try {
+    const q = new Parse.Query(className);
+    q.limit(limit);
+    q.descending('updatedAt');
+    const rows = await q.find({ useMasterKey: true }) as any[];
+    if (rows.length === 0) {
+      console.log('   (无数据)');
+      return;
+    }
+    for (const row of rows) {
+      const vals: string[] = [];
+      for (const f of fields) {
+        const v = row.get(f);
+        if (v === undefined || v === null) {
+          vals.push(`${f}=null`);
+        } else if (typeof v === 'object' && v.className) {
+          vals.push(`${f}=Pointer(${v.className}:${v.id})`);
+        } else if (typeof v === 'object') {
+          vals.push(`${f}=${JSON.stringify(v).substring(0, 80)}`);
+        } else {
+          const s = String(v);
+          vals.push(`${f}=${s.length > 60 ? s.substring(0, 60) + '...' : s}`);
+        }
+      }
+      console.log(`   ${vals.join(' | ')}`);
+    }
+  } catch (err: any) {
+    console.log(`   查询失败: ${err.message}`);
+  }
+}
+
+async function main(): Promise<void> {
+  console.log('=== 数据库探查 ===\n');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  // 测试连接
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+
+  // 目标表
+  const targets = ['GroupChat', 'GroupMember', 'Message', 'Community', 'Store', 'ResponsibilityAssignment'];
+  const schemas = await Parse.Schema.all({ useMasterKey: true });
+  const schemaMap = new Map(schemas.map(s => [s.className, s]));
+
+  for (const className of targets) {
+    const schema = schemaMap.get(className);
+    const count = await countRows(className);
+    console.log(`\n${'═'.repeat(80)}`);
+    console.log(`📦 ${className}  —  ${count} 行`);
+    console.log(`${'─'.repeat(80)}`);
+
+    if (schema) {
+      const fields = schema.fields as Record<string, { type: string; targetClass?: string }>;
+      const entries = Object.entries(fields)
+        .filter(([name]) => !name.startsWith('_'))
+        .sort(([a], [b]) => a.localeCompare(b));
+
+      const fieldNames: string[] = [];
+      for (const [name, def] of entries) {
+        const typeStr = def.type === 'Pointer' ? `Pointer→${def.targetClass || '?'}` : def.type;
+        console.log(`   ${name}: ${typeStr}`);
+        fieldNames.push(name);
+      }
+
+      // 示例数据
+      console.log(`\n   示例数据:`);
+      const keyFields = fieldNames.filter(f =>
+        ['roomId', 'roomName', 'name', 'status', 'nickname', 'content', 'senderName',
+         'msgType', 'lifecyclePhase', 'ownerName', 'scopeType', 'userName',
+         'memberCount', 'messageCountToday', 'messageCountTotal', 'groupCount',
+         'community', 'store', 'deliveryDate'].includes(f)
+      );
+      await showSample(className, keyFields.length > 0 ? keyFields : fieldNames.slice(0, 8));
+    } else {
+      console.log('   (Schema 未找到,可能表不存在)');
+    }
+  }
+
+  // 检查 GroupChat 是否还有 memberCount/messageCountToday/messageCountTotal 字段
+  console.log(`\n${'═'.repeat(80)}`);
+  console.log('🔍 GroupChat 旧预计算字段检查');
+  console.log(`${'─'.repeat(80)}`);
+  const gcSchema = schemaMap.get('GroupChat');
+  if (gcSchema) {
+    const fields = gcSchema.fields as Record<string, { type: string }>;
+    for (const name of ['memberCount', 'messageCountToday', 'messageCountTotal', 'ownerName', 'lastActivityAt', 'avatarUrl']) {
+      console.log(`   ${name}: ${fields[name] ? '存在 (' + fields[name].type + ')' : '❌ 已删除'}`);
+    }
+  }
+
+  // 今日消息量
+  console.log(`\n${'═'.repeat(80)}`);
+  console.log('🔍 今日消息量');
+  console.log(`${'─'.repeat(80)}`);
+  const todayStart = new Date();
+  todayStart.setHours(0, 0, 0, 0);
+  const todayMsgQ = new Parse.Query('Message');
+  todayMsgQ.greaterThanOrEqualTo('timestamp', todayStart);
+  todayMsgQ.containedIn('msgType', [0, 2]);
+  todayMsgQ.notEqualTo('content', '');
+  const todayMsgCount = await todayMsgQ.count({ useMasterKey: true });
+  console.log(`   今日文本/富文本消息: ${todayMsgCount} 条`);
+
+  console.log('\n完成!\n');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 113 - 0
backend/scripts/dump-qiwe-schema.ts

@@ -0,0 +1,113 @@
+/**
+ * 导出 QiWe 项目相关的所有表 Schema(供文档编写用)
+ *
+ * 用法: cd backend && npx tsx scripts/dump-qiwe-schema.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function showTable(className: string): Promise<void> {
+  const q = new Parse.Query(className);
+  q.limit(1);
+  q.descending('updatedAt');
+
+  let rows: Parse.Object[] = [];
+  try { rows = await q.find({ useMasterKey: true }); } catch { /* skip */ }
+
+  let schema: Parse.Schema;
+  try {
+    schema = await new Parse.Schema(className).get({ useMasterKey: true });
+  } catch (err: any) {
+    console.log(`\n📦 ${className} — 表不存在 (${err.message})`);
+    return;
+  }
+
+  const fields = schema.fields as Record<string, { type: string; targetClass?: string }>;
+  const entries = Object.entries(fields)
+    .filter(([n]) => !n.startsWith('_') && n !== 'ACL')
+    .sort(([a], [b]) => a.localeCompare(b));
+
+  const hasData = rows.length > 0;
+  console.log(`\n📦 ${className}${hasData ? ' (有数据)' : ' (空)'}`);
+  console.log('-'.repeat(65));
+
+  for (const [name, def] of entries) {
+    const typeStr = def.type === 'Pointer' ? `Pointer → ${def.targetClass || '?'}` : def.type;
+    console.log(`  ${name.padEnd(30)} ${typeStr}`);
+  }
+
+  if (hasData) {
+    console.log('\n  示例字段值:');
+    const r = rows[0] as any;
+    for (const [name] of entries.slice(0, 12)) {
+      const v = r.get(name);
+      if (v === undefined || v === null || v === '') continue;
+      let s: string;
+      if (typeof v === 'object') {
+        if (v.className) {
+          s = `Pointer(${v.className}:${v.id?.slice(0, 8)}...)`;
+        } else {
+          s = JSON.stringify(v).substring(0, 100);
+        }
+      } else {
+        s = String(v).substring(0, 100);
+      }
+      console.log(`    ${name.padEnd(28)} ${s}`);
+    }
+  }
+}
+
+async function main(): Promise<void> {
+  console.log('=== QiWe 项目数据库 Schema 完整导出 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  // 测试连接
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error('❌ Parse 连接失败:', err.message);
+    process.exit(1);
+  }
+
+  // QiWe 核心表
+  const qiweTables = [
+    'GroupChat', 'GroupMember', 'Message', 'MessageSyncCursor',
+    'WebhookLog', 'Contact',
+  ];
+
+  // 风控相关表
+  const riskTables = [
+    'RiskEvent', 'RiskKeyword', 'AppNotification', 'ResponsibilityAssignment',
+  ];
+
+  // 组织结构表
+  const orgTables = [
+    'Store', 'District', 'Community',
+  ];
+
+  // 其他
+  const otherTables = [
+    '_User', 'QiWeAccount',
+  ];
+
+  console.log('══════ QiWe 核心表 ══════');
+  for (const t of qiweTables) await showTable(t);
+
+  console.log('\n══════ 风控系统表 ══════');
+  for (const t of riskTables) await showTable(t);
+
+  console.log('\n══════ 组织结构表 ══════');
+  for (const t of orgTables) await showTable(t);
+
+  console.log('\n══════ 其他 ══════');
+  for (const t of otherTables) await showTable(t);
+
+  console.log('\n\n✅ 导出完成!\n');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 819 - 0
backend/scripts/group-daily-report.ts

@@ -0,0 +1,819 @@
+/**
+ * 群消息日报 — AI 分析版
+ *
+ * 用法: cd backend && npx tsx scripts/group-daily-report.ts [YYYY-MM-DD]
+ * 输出: backend/scripts/group-report-{YYYY-MM-DD}.html
+ *
+ * 分析流程:
+ *   1. 查询当天所有文本消息,按群分组
+ *   2. 每个有消息的群 → 发送全部消息到 DeepSeek AI 分析
+ *   3. AI 判定: 风险程度 / 客户情绪 / 主要话题 / 异常情况
+ *   4. AI 不可用时降级为关键词规则引擎
+ *   5. 生成 HTML 报告
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+import * as fs from 'fs';
+import * as path from 'path';
+import { fileURLToPath } from 'url';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+
+/* ======================== AI 配置 ======================== */
+
+const AI_KEY = process.env.DEEPSEEK_API_KEY || '';
+const AI_URL = process.env.DEEPSEEK_API_URL || 'https://api.deepseek.com/v1/chat/completions';
+const AI_MODEL = process.env.DEEPSEEK_MODEL || 'deepseek-chat';
+const AI_CONCURRENCY = 3; // 并发数
+
+const AI_ENABLED = AI_KEY && AI_KEY !== 'sk-xxx';
+
+const AI_SYSTEM_PROMPT = `你是一家全屋定制公司(拉迷家居)的客户群消息分析员。请分析以下群聊消息,判断该群当前状态。
+
+风险类型参考(全屋定制行业):
+- 投诉抱怨:质量、工期、安装、服务态度等
+- 竞品对比:提及欧派、索菲亚、尚品宅配等竞品
+- 价格敏感:太贵、砍价、退款、预算超支等
+- 负面情绪:不满、威胁、换商家、维权、起诉等
+- 敏感话题:甲醛超标、欺诈、烂尾、跑路等
+
+判断标准:
+- high: 明确投诉/维权/法律威胁,可能升级为纠纷
+- medium: 负面情绪或竞品讨论,需要关注
+- low: 轻微不满或一般性价格讨论
+- none: 无风险
+
+请以 JSON 格式回复(不要包含其他任何内容,不要用 markdown 代码块包裹):
+{"hasRisk":true/false,"severity":"high"/"medium"/"low"/"none","riskTitle":"风险标题≤20字","riskDescription":"风险描述≤100字","sentiment":"positive"/"neutral"/"negative","mainTopics":["话题1","话题2"],"hasAbnormal":true/false,"abnormalDescription":"异常描述≤50字","summary":"一句话总结该群今日情况≤80字"}`;
+
+/* ======================== 类型定义 ======================== */
+
+interface KeywordInfo {
+  word: string;
+  category: string;
+  severity: 'high' | 'medium' | 'low';
+}
+
+interface MsgInfo {
+  senderId: string;
+  senderName: string;
+  content: string;
+  timestamp: Date;
+}
+
+interface AIAnalysisResult {
+  hasRisk: boolean;
+  severity: 'high' | 'medium' | 'low' | 'none';
+  riskTitle: string;
+  riskDescription: string;
+  sentiment: 'positive' | 'neutral' | 'negative';
+  mainTopics: string[];
+  hasAbnormal: boolean;
+  abnormalDescription: string;
+  summary: string;
+}
+
+interface KeywordMatchDetail {
+  word: string;
+  category: string;
+  severity: 'high' | 'medium' | 'low';
+  count: number;
+  samples: { senderName: string; content: string; time: string }[];
+}
+
+interface GroupReport {
+  roomId: string;
+  roomName: string;
+  messageCount: number;
+  activeMemberCount: number;
+  topSpeakers: { name: string; count: number }[];
+  lastMessageTime: string;
+  /** AI 分析结果(主力) */
+  aiResult: AIAnalysisResult | null;
+  /** 关键词匹配结果(辅助参考) */
+  keywordMatches: KeywordMatchDetail[];
+  activityLevel: 'high' | 'normal' | 'low' | 'silent';
+  riskLevel: 'emergency' | 'abnormal' | 'normal' | 'silent';
+  /** 异常备注(关键词规则补充) */
+  anomalyNotes: string[];
+}
+
+interface Summary {
+  totalGroups: number;
+  totalMessages: number;
+  emergencyCount: number;
+  abnormalCount: number;
+  normalCount: number;
+  totalActiveMembers: number;
+  aiEnabled: boolean;
+}
+
+/* ======================== 数据查询 ======================== */
+
+async function fetchData(targetDate: string): Promise<{
+  groups: any[];
+  allMessages: any[];
+  keywords: KeywordInfo[];
+}> {
+  console.log(`[日报] 目标日期: ${targetDate}`);
+
+  const dayStart = new Date(`${targetDate}T00:00:00+08:00`);
+  const dayEnd = new Date(`${targetDate}T23:59:59+08:00`);
+
+  const [groups, allMessages, keywords] = await Promise.all([
+    (async () => {
+      const q = new Parse.Query('GroupChat');
+      q.notEqualTo('status', 'dismissed');
+      q.select(['roomId', 'roomName']);
+      q.limit(9999);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      console.log(`[日报] GroupChat: ${rows.length} 个活跃群`);
+      return rows;
+    })(),
+    (async () => {
+      const q = new Parse.Query('Message');
+      q.containedIn('msgType', [0, 2]);
+      q.notEqualTo('content', '');
+      q.greaterThanOrEqualTo('timestamp', dayStart);
+      q.lessThanOrEqualTo('timestamp', dayEnd);
+      q.select(['roomId', 'senderId', 'senderName', 'content', 'timestamp']);
+      q.limit(99999);
+      q.ascending('timestamp');
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      console.log(`[日报] Message: ${rows.length} 条文本消息`);
+      return rows;
+    })(),
+    (async () => {
+      const q = new Parse.Query('RiskKeyword');
+      q.equalTo('enabled', true);
+      q.select(['word', 'category', 'severity']);
+      q.limit(9999);
+      const rows = await q.find({ useMasterKey: true }) as any[];
+      const kws = rows.map((r: any) => ({
+        word: r.get('word') as string,
+        category: r.get('category') as string,
+        severity: r.get('severity') as string,
+      }));
+      console.log(`[日报] RiskKeyword: ${kws.length} 个关键词(降级备用)`);
+      return kws;
+    })(),
+  ]);
+
+  return { groups, allMessages, keywords };
+}
+
+/* ======================== AI 分析 ======================== */
+
+async function callAI(messages: MsgInfo[], groupName: string, retries = 2): Promise<AIAnalysisResult | null> {
+  const conversation = messages
+    .map((m) => {
+      const ts = m.timestamp;
+      const time = ts ? `${String(ts.getHours()).padStart(2, '0')}:${String(ts.getMinutes()).padStart(2, '0')}` : '';
+      return `[${time}] ${m.senderName || '未知'}: ${m.content}`;
+    })
+    .join('\n');
+
+  const userPrompt = `群名称: ${groupName}
+消息数: ${messages.length} 条
+参与人数: ${new Set(messages.map(m => m.senderId).filter(Boolean)).size} 人
+
+以下是该群今日全部消息(按时间顺序):
+---
+${conversation}
+---
+
+请分析该群今日状态。`;
+
+  for (let attempt = 0; attempt <= retries; attempt++) {
+    try {
+      const res = await fetch(AI_URL, {
+        method: 'POST',
+        headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${AI_KEY}` },
+        body: JSON.stringify({
+          model: AI_MODEL,
+          messages: [
+            { role: 'system', content: AI_SYSTEM_PROMPT },
+            { role: 'user', content: userPrompt },
+          ],
+          temperature: 0.3,
+          max_tokens: 512,
+        }),
+      });
+
+      if (!res.ok) {
+        const errText = await res.text().catch(() => '');
+        if (res.status === 429 && attempt < retries) {
+          console.warn(`  ⚠ AI 限流,等待 2s 重试...`);
+          await sleep(2000);
+          continue;
+        }
+        console.warn(`  ⚠ AI API ${res.status}: ${errText.slice(0, 100)}`);
+        return null;
+      }
+
+      const json = await res.json() as any;
+      const content = json?.choices?.[0]?.message?.content || '';
+      const parsed = parseAIResponse(content);
+      if (!parsed && attempt < retries) {
+        console.warn(`  ⚠ AI 返回格式异常,重试...`);
+        continue;
+      }
+      return parsed;
+    } catch (err) {
+      if (attempt < retries) {
+        console.warn(`  ⚠ AI 调用失败,重试...`);
+        await sleep(1000);
+        continue;
+      }
+      console.warn(`  ⚠ AI 调用失败: ${(err as Error).message}`);
+      return null;
+    }
+  }
+  return null;
+}
+
+function parseAIResponse(content: string): AIAnalysisResult | null {
+  try {
+    // 尝试直接解析 JSON(可能在 markdown 代码块中)
+    let jsonStr = content;
+    const codeBlockMatch = content.match(/```(?:json)?\s*([\s\S]*?)```/);
+    if (codeBlockMatch) jsonStr = codeBlockMatch[1];
+    else {
+      const braceMatch = content.match(/\{[\s\S]*\}/);
+      if (braceMatch) jsonStr = braceMatch[0];
+    }
+
+    const parsed = JSON.parse(jsonStr);
+    return {
+      hasRisk: !!parsed.hasRisk,
+      severity: ['high', 'medium', 'low', 'none'].includes(parsed.severity) ? parsed.severity : 'none',
+      riskTitle: parsed.riskTitle || '',
+      riskDescription: parsed.riskDescription || '',
+      sentiment: ['positive', 'neutral', 'negative'].includes(parsed.sentiment) ? parsed.sentiment : 'neutral',
+      mainTopics: Array.isArray(parsed.mainTopics) ? parsed.mainTopics : [],
+      hasAbnormal: !!parsed.hasAbnormal,
+      abnormalDescription: parsed.abnormalDescription || '',
+      summary: parsed.summary || '分析未返回摘要',
+    };
+  } catch {
+    // 尝试宽松匹配
+    const hasRisk = /"hasRisk"\s*:\s*true/i.test(content);
+    const sevMatch = content.match(/"severity"\s*:\s*"(high|medium|low|none)"/);
+    return {
+      hasRisk,
+      severity: (sevMatch?.[1] as any) || 'none',
+      riskTitle: '',
+      riskDescription: '',
+      sentiment: 'neutral',
+      mainTopics: [],
+      hasAbnormal: false,
+      abnormalDescription: '',
+      summary: content.slice(0, 80),
+    };
+  }
+}
+
+function sleep(ms: number): Promise<void> {
+  return new Promise(r => setTimeout(r, ms));
+}
+
+/** 并发池:最多 N 个 AI 请求同时进行 */
+async function runWithConcurrency<T, R>(
+  items: T[],
+  concurrency: number,
+  fn: (item: T) => Promise<R>,
+): Promise<R[]> {
+  const results: R[] = new Array(items.length);
+  let idx = 0;
+
+  async function worker(): Promise<void> {
+    while (idx < items.length) {
+      const i = idx++;
+      results[i] = await fn(items[i]);
+    }
+  }
+
+  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
+  return results;
+}
+
+/* ======================== 关键词匹配(降级 + 辅助) ======================== */
+
+function matchKeywords(messages: MsgInfo[], keywords: KeywordInfo[]): KeywordMatchDetail[] {
+  const matchMap = new Map<string, KeywordMatchDetail>();
+  for (const msg of messages) {
+    const content = msg.content;
+    if (!content) continue;
+    for (const kw of keywords) {
+      if (content.includes(kw.word)) {
+        const existing = matchMap.get(kw.word);
+        const sample = {
+          senderName: msg.senderName || '未知',
+          content: content.length > 200 ? content.slice(0, 200) + '...' : content,
+          time: msg.timestamp
+            ? `${String(msg.timestamp.getHours()).padStart(2, '0')}:${String(msg.timestamp.getMinutes()).padStart(2, '0')}`
+            : '',
+        };
+        if (existing) {
+          existing.count++;
+          if (existing.samples.length < 3) existing.samples.push(sample);
+        } else {
+          matchMap.set(kw.word, {
+            word: kw.word,
+            category: kw.category,
+            severity: kw.severity as 'high' | 'medium' | 'low',
+            count: 1,
+            samples: [sample],
+          });
+        }
+      }
+    }
+  }
+  return [...matchMap.values()].sort((a, b) => b.count - a.count);
+}
+
+/* ======================== 分析引擎 ======================== */
+
+interface PendingGroup {
+  roomId: string;
+  roomName: string;
+  messages: MsgInfo[];
+}
+
+async function analyzeGroups(
+  groups: any[],
+  allMessages: any[],
+  keywords: KeywordInfo[],
+): Promise<{ groupReports: GroupReport[]; summary: Summary }> {
+
+  // 消息按 roomId 分组
+  const msgsByRoom = new Map<string, MsgInfo[]>();
+  for (const m of allMessages) {
+    const roomId = m.get('roomId') as string;
+    if (!roomId) continue;
+    const msg: MsgInfo = {
+      senderId: m.get('senderId') as string || '',
+      senderName: m.get('senderName') as string || '',
+      content: m.get('content') as string || '',
+      timestamp: m.get('timestamp') as Date,
+    };
+    const arr = msgsByRoom.get(roomId);
+    if (arr) arr.push(msg);
+    else msgsByRoom.set(roomId, [msg]);
+  }
+
+  // 构建待分析群列表(仅保留有消息的群)
+  const pendingGroups: PendingGroup[] = [];
+  for (const g of groups) {
+    const roomId = g.get('roomId') as string;
+    const roomName = g.get('roomName') as string || `未知群(${roomId})`;
+    const msgs = msgsByRoom.get(roomId);
+    if (msgs && msgs.length > 0) {
+      pendingGroups.push({ roomId, roomName, messages: msgs });
+    }
+  }
+
+  console.log(`[日报] 有消息的群: ${pendingGroups.length} 个`);
+  console.log(`[日报] AI 分析: ${AI_ENABLED ? '启用 (DeepSeek)' : '禁用,使用关键词规则'}`);
+  if (AI_ENABLED) {
+    console.log(`[日报] 并发数: ${AI_CONCURRENCY},预计耗时 ${Math.ceil(pendingGroups.length / AI_CONCURRENCY) * 3}s`);
+  }
+
+  // AI 分析(或降级)
+  const aiResults = AI_ENABLED
+    ? await runWithConcurrency(pendingGroups, AI_CONCURRENCY, async (pg) => {
+        console.log(`  → AI 分析: ${pg.roomName} (${pg.messages.length} 条消息)`);
+        const result = await callAI(pg.messages, pg.roomName);
+        const status = result
+          ? (result.hasRisk ? `风险:${result.severity}` : '正常')
+          : 'AI 失败';
+        console.log(`  ← ${pg.roomName}: ${status}`);
+        return result;
+      })
+    : pendingGroups.map(() => null);
+
+  // 构建群报告
+  const groupReports: GroupReport[] = [];
+  for (let i = 0; i < pendingGroups.length; i++) {
+    const pg = pendingGroups[i];
+    const aiResult = aiResults[i];
+    const msgs = pg.messages;
+
+    // 基础指标
+    const messageCount = msgs.length;
+    const senderIds = new Set(msgs.map(m => m.senderId).filter(Boolean));
+    const activeMemberCount = senderIds.size;
+
+    // Top 发言人
+    const senderCount = new Map<string, { name: string; count: number }>();
+    for (const m of msgs) {
+      const key = m.senderId || '__unknown__';
+      const entry = senderCount.get(key);
+      if (entry) { entry.count++; }
+      else { senderCount.set(key, { name: m.senderName || m.senderId || '未知', count: 1 }); }
+    }
+    const topSpeakers = [...senderCount.values()].sort((a, b) => b.count - a.count).slice(0, 3);
+
+    // 单一用户主导
+    let singleUserDominant = false;
+    let dominantUserName = '';
+    let dominantUserRatio = 0;
+    if (messageCount >= 5 && topSpeakers.length > 0) {
+      const ratio = topSpeakers[0].count / messageCount;
+      if (ratio > 0.8) {
+        singleUserDominant = true;
+        dominantUserName = topSpeakers[0].name;
+        dominantUserRatio = ratio;
+      }
+    }
+
+    // 最后消息时间
+    let lastMessageTime = '';
+    const last = msgs[msgs.length - 1].timestamp;
+    if (last) {
+      lastMessageTime = `${String(last.getHours()).padStart(2, '0')}:${String(last.getMinutes()).padStart(2, '0')}`;
+    }
+
+    // 关键词匹配(辅助参考)
+    const keywordMatches = matchKeywords(msgs, keywords);
+
+    // 活跃度
+    let activityLevel: GroupReport['activityLevel'];
+    if (messageCount > 50) activityLevel = 'high';
+    else if (messageCount >= 10) activityLevel = 'normal';
+    else activityLevel = 'low';
+
+    // 异常备注(从关键词匹配补充)
+    const anomalyNotes: string[] = [];
+    if (singleUserDominant) {
+      anomalyNotes.push(`单一用户 "${dominantUserName}" 发送了 ${Math.round(dominantUserRatio * 100)}% 的消息`);
+    }
+    if (aiResult?.hasAbnormal && aiResult.abnormalDescription) {
+      anomalyNotes.push(aiResult.abnormalDescription);
+    }
+
+    // 风险等级(AI 优先,关键词降级)
+    let riskLevel: GroupReport['riskLevel'];
+    if (aiResult && aiResult.hasRisk) {
+      if (aiResult.severity === 'high') riskLevel = 'emergency';
+      else if (aiResult.severity === 'medium') riskLevel = 'abnormal';
+      else riskLevel = 'normal';
+    } else if (aiResult && !aiResult.hasRisk) {
+      riskLevel = anomalyNotes.length > 0 ? 'abnormal' : 'normal';
+    } else {
+      // AI 不可用时关键词降级
+      const hasHigh = keywordMatches.some(k => k.severity === 'high');
+      const hasMedium = keywordMatches.some(k => k.severity === 'medium');
+      if (hasHigh) riskLevel = 'emergency';
+      else if (hasMedium || singleUserDominant) riskLevel = 'abnormal';
+      else riskLevel = 'normal';
+    }
+
+    groupReports.push({
+      roomId: pg.roomId,
+      roomName: pg.roomName,
+      messageCount,
+      activeMemberCount,
+      topSpeakers,
+      lastMessageTime,
+      aiResult,
+      keywordMatches,
+      activityLevel,
+      riskLevel,
+      anomalyNotes,
+    });
+  }
+
+  // 排序
+  const order = { emergency: 0, abnormal: 1, normal: 2 };
+  groupReports.sort((a, b) => {
+    const oa = order[a.riskLevel];
+    const ob = order[b.riskLevel];
+    if (oa !== ob) return oa - ob;
+    return b.messageCount - a.messageCount;
+  });
+
+  const summary: Summary = {
+    totalGroups: groupReports.length,
+    totalMessages: allMessages.length,
+    emergencyCount: groupReports.filter(g => g.riskLevel === 'emergency').length,
+    abnormalCount: groupReports.filter(g => g.riskLevel === 'abnormal').length,
+    normalCount: groupReports.filter(g => g.riskLevel === 'normal').length,
+    totalActiveMembers: new Set(allMessages.map((m: any) => m.get('senderId')).filter(Boolean)).size,
+    aiEnabled: AI_ENABLED,
+  };
+
+  return { groupReports, summary };
+}
+
+/* ======================== HTML 生成 ======================== */
+
+function categoryLabel(cat: string): string {
+  const map: Record<string, string> = {
+    complaint: '投诉', quality: '质量', sensitive: '敏感', legal: '法律',
+    competitor: '竞品', price: '价格', negative: '负面', installation: '安装',
+  };
+  return map[cat] || cat;
+}
+
+function severityBadge(severity: string): string {
+  const map: Record<string, string> = {
+    high: '<span class="badge badge-high">高危</span>',
+    medium: '<span class="badge badge-medium">中危</span>',
+    low: '<span class="badge badge-low">低危</span>',
+  };
+  return map[severity] || '';
+}
+
+function riskLabel(level: string): string {
+  const map: Record<string, string> = {
+    emergency: '🔴 紧急', abnormal: '🟡 异常', normal: '🟢 正常', silent: '⚪ 静默',
+  };
+  return map[level] || level;
+}
+
+function activityLabel(level: string): string {
+  const map: Record<string, string> = {
+    high: '高活跃', normal: '正常', low: '低活跃', silent: '静默',
+  };
+  return map[level] || level;
+}
+
+function sentimentEmoji(s: string): string {
+  const map: Record<string, string> = {
+    positive: '😊 正面', neutral: '😐 中性', negative: '😟 负面',
+  };
+  return map[s] || s;
+}
+
+function generateHTML(reports: GroupReport[], summary: Summary, dateStr: string, genTime: string, stats: { queryMs: number; aiMs: number; totalMs: number }): string {
+  const cards = reports.map(g => {
+    const borderColor = { emergency: '#e74c3c', abnormal: '#f39c12', normal: '#27ae60' }[g.riskLevel];
+
+    // ── AI 分析区块 ──
+    let aiSection = '';
+    if (g.aiResult) {
+      const ar = g.aiResult;
+      aiSection = `
+        <div class="ai-block">
+          <div class="ai-header">🤖 AI 分析</div>
+          <div class="ai-summary">${escapeHtml(ar.summary)}</div>
+          <div class="ai-details">
+            <span class="ai-tag">${sentimentEmoji(ar.sentiment)}</span>
+            ${ar.mainTopics.map(t => `<span class="ai-tag topic-tag">📌 ${escapeHtml(t)}</span>`).join('')}
+          </div>
+          ${ar.hasRisk ? `<div class="ai-risk">
+            <span class="ai-risk-label">⚠ 风险判定: ${ar.severity === 'high' ? '高危' : ar.severity === 'medium' ? '中危' : '低危'}</span>
+            ${ar.riskTitle ? `<div class="ai-risk-title">${escapeHtml(ar.riskTitle)}</div>` : ''}
+            ${ar.riskDescription ? `<div class="ai-risk-desc">${escapeHtml(ar.riskDescription)}</div>` : ''}
+          </div>` : ''}
+        </div>`;
+    } else {
+      aiSection = `<div class="ai-block ai-fallback">🤖 AI 分析不可用,已降级为关键词规则引擎</div>`;
+    }
+
+    // ── 关键词匹配(辅助)──
+    let keywordSection = '';
+    if (g.keywordMatches.length > 0) {
+      const items = g.keywordMatches.map(k => `
+        <div class="kw-row">
+          ${severityBadge(k.severity)}
+          <span class="kw-word">${escapeHtml(k.word)}</span>
+          <span class="kw-cat">[${categoryLabel(k.category)}]</span>
+          <span class="kw-count">×${k.count}</span>
+          ${k.samples.length > 0 ? `<div class="kw-samples">${k.samples.map(s =>
+            `<div class="kw-sample-msg"><span class="kw-sample-sender">${escapeHtml(s.senderName)}</span> <span class="kw-sample-time">${s.time}</span><div class="kw-sample-content">${escapeHtml(s.content)}</div></div>`
+          ).join('')}</div>` : ''}
+        </div>`
+      ).join('');
+      keywordSection = `<div class="section"><div class="section-title">🔍 关键词命中 (${g.keywordMatches.length}) — 辅助参考</div>${items}</div>`;
+    }
+
+    // ── 异常备注 ──
+    let anomalySection = '';
+    if (g.anomalyNotes.length > 0) {
+      anomalySection = `<div class="anomaly-notes">${g.anomalyNotes.map(n => `<div class="anomaly-note">⚠ ${escapeHtml(n)}</div>`).join('')}</div>`;
+    }
+
+    const speakers = g.topSpeakers.length > 0
+      ? g.topSpeakers.map(s => `<span class="speaker">${escapeHtml(s.name)} <em>(${s.count}条)</em></span>`).join(' ')
+      : '<span class="no-data">—</span>';
+
+    return `
+    <div class="card card-${g.riskLevel}" style="border-left: 4px solid ${borderColor};">
+      <div class="card-header">
+        <span class="group-name">${escapeHtml(g.roomName)}</span>
+        <span class="risk-badge risk-${g.riskLevel}">${riskLabel(g.riskLevel)}</span>
+      </div>
+      <div class="card-body">
+        <div class="metrics">
+          <div class="metric"><div class="metric-value">${g.messageCount}</div><div class="metric-label">消息数</div></div>
+          <div class="metric"><div class="metric-value">${g.activeMemberCount}</div><div class="metric-label">参与人数</div></div>
+          <div class="metric"><div class="metric-value">${activityLabel(g.activityLevel)}</div><div class="metric-label">活跃度</div></div>
+          <div class="metric"><div class="metric-value">${g.lastMessageTime || '—'}</div><div class="metric-label">最后消息</div></div>
+        </div>
+        ${aiSection}
+        <div class="section"><div class="section-title">💬 Top 发言人</div><div class="speakers-row">${speakers}</div></div>
+        ${keywordSection}
+        ${anomalySection}
+      </div>
+    </div>`;
+  }).join('\n');
+
+  const aiStatus = summary.aiEnabled
+    ? '<span style="color:#27ae60;">● AI 分析 (DeepSeek)</span>'
+    : '<span style="color:#f39c12;">● 关键词规则引擎 (AI 未配置)</span>';
+
+  return `<!DOCTYPE html>
+<html lang="zh-CN">
+<head>
+<meta charset="UTF-8">
+<meta name="viewport" content="width=device-width, initial-scale=1.0">
+<title>拉迷群消息日报 — ${dateStr}</title>
+<style>
+  *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
+  body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif; background: #f5f6fa; color: #2c3e50; line-height: 1.6; }
+  .header { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); color: #fff; padding: 28px 40px; }
+  .header h1 { font-size: 24px; font-weight: 600; margin-bottom: 4px; }
+  .header .subtitle { font-size: 14px; color: #8892b0; }
+  .header .meta { font-size: 12px; color: #5a6785; margin-top: 2px; }
+  .container { max-width: 1000px; margin: 0 auto; padding: 24px; }
+
+  .summary-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 16px; margin-bottom: 32px; }
+  .summary-card { background: #fff; border-radius: 10px; padding: 20px; text-align: center; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
+  .summary-card .s-value { font-size: 32px; font-weight: 700; line-height: 1.2; }
+  .summary-card .s-label { font-size: 13px; color: #7f8c8d; margin-top: 4px; }
+  .s-emergency .s-value { color: #e74c3c; } .s-abnormal .s-value { color: #f39c12; } .s-normal .s-value { color: #27ae60; }
+  .s-groups .s-value { color: #2980b9; } .s-messages .s-value { color: #8e44ad; } .s-members .s-value { color: #16a085; }
+
+  .cards { display: flex; flex-direction: column; gap: 16px; }
+  .card { background: #fff; border-radius: 10px; overflow: hidden; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
+  .card-header { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; background: #fafbfc; border-bottom: 1px solid #eee; }
+  .group-name { font-size: 16px; font-weight: 600; }
+  .risk-badge { font-size: 13px; font-weight: 600; padding: 4px 12px; border-radius: 20px; }
+  .risk-emergency { background: #fde8e8; color: #c0392b; }
+  .risk-abnormal { background: #fef5e7; color: #d68910; }
+  .risk-normal { background: #e8f8f5; color: #1e8449; }
+  .card-body { padding: 16px 20px; }
+
+  .metrics { display: flex; gap: 24px; margin-bottom: 14px; flex-wrap: wrap; }
+  .metric { text-align: center; min-width: 60px; }
+  .metric-value { font-size: 20px; font-weight: 700; }
+  .metric-label { font-size: 12px; color: #95a5a6; margin-top: 2px; }
+
+  /* AI 分析块 */
+  .ai-block { background: #f0f4ff; border-radius: 8px; padding: 14px; margin-bottom: 14px; border: 1px solid #d4e0ff; }
+  .ai-block.ai-fallback { background: #fff9e6; border-color: #ffe0a0; color: #856404; font-size: 13px; }
+  .ai-header { font-size: 13px; font-weight: 700; color: #4a6fa5; margin-bottom: 6px; }
+  .ai-summary { font-size: 14px; color: #2c3e50; margin-bottom: 8px; line-height: 1.5; }
+  .ai-details { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 8px; }
+  .ai-tag { font-size: 12px; padding: 2px 8px; border-radius: 12px; background: #e8ecf4; color: #4a6fa5; }
+  .topic-tag { background: #e8f8f5; color: #1e8449; }
+  .ai-risk { background: #fff5f5; border: 1px solid #fecaca; border-radius: 6px; padding: 10px; margin-top: 8px; }
+  .ai-risk-label { font-size: 13px; font-weight: 600; color: #c0392b; }
+  .ai-risk-title { font-size: 14px; font-weight: 700; color: #e74c3c; margin-top: 4px; }
+  .ai-risk-desc { font-size: 13px; color: #555; margin-top: 2px; }
+
+  .section { margin-bottom: 12px; }
+  .section-title { font-size: 13px; font-weight: 600; color: #7f8c8d; margin-bottom: 6px; }
+  .speakers-row { display: flex; gap: 12px; flex-wrap: wrap; }
+  .speaker { font-size: 13px; background: #f0f3f5; padding: 3px 10px; border-radius: 12px; }
+  .speaker em { font-style: normal; color: #7f8c8d; font-size: 12px; }
+  .no-data { color: #bdc3c7; font-size: 13px; }
+
+  /* 关键词 */
+  .kw-row { font-size: 13px; margin-bottom: 8px; }
+  .kw-row > .badge, .kw-row > .kw-word, .kw-row > .kw-cat, .kw-row > .kw-count { display: inline; margin-right: 4px; }
+  .kw-word { font-weight: 600; }
+  .kw-cat { color: #7f8c8d; font-size: 12px; }
+  .kw-count { color: #c0392b; font-weight: 600; }
+  .kw-samples { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
+  .kw-sample-msg { background: #fff; border-radius: 4px; padding: 6px 8px; font-size: 12px; border-left: 3px solid #e74c3c; }
+  .kw-sample-sender { font-weight: 600; margin-right: 6px; }
+  .kw-sample-time { color: #95a5a6; font-size: 11px; }
+  .kw-sample-content { color: #555; margin-top: 2px; word-break: break-all; }
+
+  .badge { font-size: 11px; font-weight: 700; padding: 2px 6px; border-radius: 4px; color: #fff; }
+  .badge-high { background: #e74c3c; } .badge-medium { background: #f39c12; } .badge-low { background: #3498db; }
+
+  .anomaly-notes { margin-top: 8px; }
+  .anomaly-note { font-size: 13px; color: #d68910; background: #fef9e7; padding: 8px 12px; border-radius: 6px; margin-bottom: 4px; }
+
+  @media (max-width: 768px) {
+    .header { padding: 20px; } .container { padding: 12px; }
+    .summary-row { grid-template-columns: repeat(3, 1fr); gap: 8px; }
+    .summary-card { padding: 12px; } .summary-card .s-value { font-size: 24px; }
+    .metrics { gap: 14px; }
+  }
+  @media print {
+    body { background: #fff; }
+    .card { box-shadow: none; break-inside: avoid; border: 1px solid #ddd; }
+    .header { background: #1a1a2e !important; -webkit-print-color-adjust: exact; }
+  }
+</style>
+</head>
+<body>
+<div class="header">
+  <h1>拉迷群消息日报</h1>
+  <div class="subtitle">报告日期: ${dateStr} — 生成时间: ${genTime} &nbsp;|&nbsp; ${aiStatus}</div>
+  <div class="meta">查询: ${stats.queryMs}ms &nbsp;|&nbsp; AI 分析: ${stats.aiMs}ms &nbsp;|&nbsp; 总耗时: ${stats.totalMs}ms</div>
+</div>
+<div class="container">
+  <div class="summary-row">
+    <div class="summary-card s-groups"><div class="s-value">${summary.totalGroups}</div><div class="s-label">活跃群组</div></div>
+    <div class="summary-card s-messages"><div class="s-value">${summary.totalMessages}</div><div class="s-label">当日消息</div></div>
+    <div class="summary-card s-members"><div class="s-value">${summary.totalActiveMembers}</div><div class="s-label">参与用户</div></div>
+    <div class="summary-card s-emergency"><div class="s-value">${summary.emergencyCount}</div><div class="s-label">🔴 紧急</div></div>
+    <div class="summary-card s-abnormal"><div class="s-value">${summary.abnormalCount}</div><div class="s-label">🟡 异常</div></div>
+    <div class="summary-card s-normal"><div class="s-value">${summary.normalCount}</div><div class="s-label">🟢 正常</div></div>
+  </div>
+  <div class="cards">${cards}</div>
+  ${reports.length === 0 ? '<div style="text-align:center;padding:60px;color:#95a5a6;">今日无群消息</div>' : ''}
+</div>
+</body>
+</html>`;
+}
+
+function escapeHtml(s: string): string {
+  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+}
+
+/* ======================== 主流程 ======================== */
+
+function parseTargetDate(): string {
+  const arg = process.argv[2];
+  if (arg) {
+    if (!/^\d{4}-\d{2}-\d{2}$/.test(arg)) {
+      console.error('日期格式错误,请使用 YYYY-MM-DD,例如: 2026-06-15');
+      process.exit(1);
+    }
+    return arg;
+  }
+  const now = new Date();
+  return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
+}
+
+async function main(): Promise<void> {
+  const t0 = Date.now();
+  const targetDate = parseTargetDate();
+
+  console.log('═══════════════════════════════════════');
+  console.log('  拉迷群消息日报 (AI 分析版)');
+  console.log(`  日期: ${targetDate}`);
+  console.log(`  AI: ${AI_ENABLED ? `DeepSeek (${AI_MODEL})` : '禁用'}`);
+  console.log('═══════════════════════════════════════\n');
+
+  // 1. 查询
+  const t1 = Date.now();
+  const { groups, allMessages, keywords } = await fetchData(targetDate);
+  const queryMs = Date.now() - t1;
+
+  // 2. AI 分析
+  const t2 = Date.now();
+  console.log('\n[日报] 开始 AI 分析...');
+  const { groupReports, summary } = await analyzeGroups(groups, allMessages, keywords);
+  const aiMs = Date.now() - t2;
+
+  // 3. 控制台摘要
+  console.log('\n═══════════════════════════════════════');
+  console.log('  分析结果');
+  console.log('═══════════════════════════════════════');
+  console.log(`  有消息群数: ${summary.totalGroups}`);
+  console.log(`  总消息: ${summary.totalMessages}`);
+  console.log(`  参与用户: ${summary.totalActiveMembers}`);
+  console.log(`  🔴 紧急: ${summary.emergencyCount}  🟡 异常: ${summary.abnormalCount}  🟢 正常: ${summary.normalCount}`);
+
+  if (summary.emergencyCount > 0) {
+    console.log('\n  ⚠️ 紧急群:');
+    for (const g of groupReports.filter(g => g.riskLevel === 'emergency')) {
+      const ai = g.aiResult;
+      console.log(`    - ${g.roomName}: ${ai?.riskTitle || 'AI 分析异常'}`);
+    }
+  }
+  if (summary.abnormalCount > 0) {
+    console.log('\n  ⚡ 异常群:');
+    for (const g of groupReports.filter(g => g.riskLevel === 'abnormal')) {
+      const parts = [...g.anomalyNotes];
+      if (g.aiResult?.hasRisk) parts.push(`AI: ${g.aiResult.riskTitle}`);
+      console.log(`    - ${g.roomName}: ${parts.join(' | ')}`);
+    }
+  }
+
+  // 4. 生成 HTML
+  const totalMs = Date.now() - t0;
+  const now = new Date();
+  const genTime = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}:${String(now.getSeconds()).padStart(2, '0')}`;
+  const html = generateHTML(groupReports, summary, targetDate, genTime, { queryMs, aiMs, totalMs });
+
+  const outputPath = path.resolve(__dirname, `group-report-${targetDate}.html`);
+  fs.writeFileSync(outputPath, html, 'utf-8');
+
+  console.log(`\n✅ 报告已生成: ${outputPath}`);
+  console.log(`   文件大小: ${(Buffer.byteLength(html, 'utf-8') / 1024).toFixed(1)} KB`);
+  console.log(`   查询: ${queryMs}ms  |  AI: ${aiMs}ms  |  总计: ${totalMs}ms\n`);
+}
+
+main().catch((err) => {
+  console.error('[日报] 失败:', err);
+  process.exit(1);
+});

+ 94 - 0
backend/scripts/list-schemas.ts

@@ -0,0 +1,94 @@
+/**
+ * 列出 Parse Server 所有表及其字段
+ *
+ * 用法: cd backend && npx tsx scripts/list-schemas.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+interface FieldDef {
+  type: string;
+  targetClass?: string;
+}
+
+async function main(): Promise<void> {
+  console.log('=== Parse Server 数据库 Schema 一览 ===\n');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  // 测试连接
+  try {
+    await new Parse.Query('_User').limit(1).find({ useMasterKey: true });
+    console.log('✅ Parse 连接正常\n');
+  } catch (err: any) {
+    console.error('❌ Parse 连接失败:', err.message);
+    process.exit(1);
+  }
+
+  // 获取所有 schema
+  const schemas = await Parse.Schema.all({ useMasterKey: true });
+
+  console.log(`共 ${schemas.length} 个表:\n`);
+  console.log('-'.repeat(90));
+
+  for (const schema of schemas) {
+    const className = schema.className;
+    const fields = schema.fields as Record<string, FieldDef>;
+
+    // 跳过内部系统表
+    if (className.startsWith('_') && className !== '_User') {
+      continue;
+    }
+
+    console.log(`\n📦 ${className}`);
+    console.log(`   字段数: ${Object.keys(fields).length}`);
+
+    const entries = Object.entries(fields)
+      .filter(([name]) => !name.startsWith('_'))
+      .sort(([a], [b]) => a.localeCompare(b));
+
+    const stringFields: string[] = [];
+    const numberFields: string[] = [];
+    const pointerFields: string[] = [];
+    const arrayFields: string[] = [];
+    const dateFields: string[] = [];
+    const booleanFields: string[] = [];
+    const otherFields: string[] = [];
+
+    for (const [name, def] of entries) {
+      const type = def.type;
+      if (type === 'String') {
+        stringFields.push(name);
+      } else if (type === 'Number') {
+        numberFields.push(name);
+      } else if (type === 'Pointer') {
+        const target = def.targetClass ? ` → ${def.targetClass}` : '';
+        pointerFields.push(`${name}${target}`);
+      } else if (type === 'Array') {
+        arrayFields.push(name);
+      } else if (type === 'Date') {
+        dateFields.push(name);
+      } else if (type === 'Boolean') {
+        booleanFields.push(name);
+      } else {
+        otherFields.push(`${name}(${type})`);
+      }
+    }
+
+    if (stringFields.length) console.log(`   String:   ${stringFields.join(', ')}`);
+    if (numberFields.length) console.log(`   Number:   ${numberFields.join(', ')}`);
+    if (pointerFields.length) console.log(`   Pointer:  ${pointerFields.join(', ')}`);
+    if (arrayFields.length) console.log(`   Array:    ${arrayFields.join(', ')}`);
+    if (dateFields.length) console.log(`   Date:     ${dateFields.join(', ')}`);
+    if (booleanFields.length) console.log(`   Boolean:  ${booleanFields.join(', ')}`);
+    if (otherFields.length) console.log(`   Other:    ${otherFields.join(', ')}`);
+  }
+
+  console.log('\n' + '-'.repeat(90));
+  console.log('\n完成!');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message);
+  process.exit(1);
+});

+ 238 - 0
backend/scripts/migrate-risk-notification-schema.ts

@@ -0,0 +1,238 @@
+/**
+ * Phase 0: 风险通知体系 — 全表 Schema 迁移 + 数据迁移
+ *
+ * 用法: cd backend && npx tsx scripts/migrate-risk-notification-schema.ts
+ *
+ * 涉及表:
+ * 1. ResponsibilityAssignment — 删 userId, 加 user(Pointer→_User)
+ * 2. RiskEvent — 删 storeName/districtName, 加 escalation/confirmed/groupOwner 字段
+ * 3. AppNotification — 加 riskEvent(Pointer→RiskEvent)
+ * 4. GroupChat — 加 store(Pointer→Store), community(Pointer→Community), ownerPhone(String)
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function testConnection(): Promise<void> {
+  try {
+    const n = await new Parse.Query('_User').count({ useMasterKey: true });
+    console.log(`✅ Parse 连接正常 (_User: ${n} 条)\n`);
+  } catch (err: any) {
+    console.error(`❌ Parse 连接失败: ${err.message}`);
+    process.exit(1);
+  }
+}
+
+/* ═══════════════════════════════════════════════
+   1. ResponsibilityAssignment
+   ═══════════════════════════════════════════════ */
+async function migrateResponsibilityAssignment(): Promise<void> {
+  console.log('═══ 1/4 ResponsibilityAssignment ═══');
+
+  const schema = new Parse.Schema('ResponsibilityAssignment');
+
+  // 读现有数据
+  const q = new Parse.Query('ResponsibilityAssignment');
+  q.limit(5000);
+  const records = await q.find({ useMasterKey: true }) as any[];
+  console.log(`  现有记录: ${records.length} 条`);
+
+  // userId → user Pointer 数据迁移
+  let migrated = 0;
+  let skipped = 0;
+  const toSave: Parse.Object[] = [];
+
+  for (const obj of records) {
+    const userId = obj.get('userId') as string | undefined;
+    let changed = false;
+
+    if (userId) {
+      try {
+        const userQ = new Parse.Query('_User');
+        const user = await userQ.get(userId, { useMasterKey: true });
+        obj.set('user', user);
+        migrated++;
+        changed = true;
+      } catch {
+        console.log(`   ⚠ userId=${userId} 对应 _User 不存在,跳过`);
+        skipped++;
+      }
+    }
+
+    // 清除旧 userId 字段值
+    if (obj.get('userId') != null) {
+      obj.unset('userId');
+      changed = true;
+    }
+
+    if (changed) toSave.push(obj);
+  }
+
+  if (toSave.length > 0) {
+    await Parse.Object.saveAll(toSave, { useMasterKey: true });
+  }
+  console.log(`  数据: ${migrated} 条转 Pointer, ${skipped} 条跳过`);
+
+  // Schema 更新
+  try { await schema.get({ useMasterKey: true }); } catch { /* 不存在则创建 */ }
+  try { await schema.deleteField('userId'); } catch { /* 已删除 */ }
+  try { await schema.addField('user', 'Pointer', { targetClass: '_User' }); } catch { /* 已存在 */ }
+  await schema.update({ useMasterKey: true });
+  console.log('  Schema: userId 已删除, user(Pointer→_User) 已添加, userName/phone 保留\n');
+}
+
+/* ═══════════════════════════════════════════════
+   2. RiskEvent
+   ═══════════════════════════════════════════════ */
+async function migrateRiskEvent(): Promise<void> {
+  console.log('═══ 2/4 RiskEvent ═══');
+
+  const schema = new Parse.Schema('RiskEvent');
+
+  // 删除冗余 String 字段
+  try { await schema.deleteField('storeName'); } catch { /* 不存在则跳过 */ }
+  try { await schema.deleteField('districtName'); } catch { /* 不存在则跳过 */ }
+
+  // 新增 escalation + confirmed + groupOwner 字段
+  const newFields: Array<{ name: string; type: string; opts?: Record<string, any> }> = [
+    { name: 'escalationLevel', type: 'Number' },
+    { name: 'escalatedAt', type: 'Date' },
+    { name: 'confirmedBy', type: 'Pointer', opts: { targetClass: '_User' } },
+    { name: 'confirmedAt', type: 'Date' },
+    { name: 'groupOwnerName', type: 'String' },
+    { name: 'groupOwnerPhone', type: 'String' },
+    { name: 'escalationNotifiedUserIds', type: 'Array' },
+  ];
+
+  for (const f of newFields) {
+    try {
+      if (f.opts) {
+        await schema.addField(f.name, f.type, f.opts);
+      } else {
+        await schema.addField(f.name, f.type);
+      }
+      console.log(`  + ${f.name} (${f.type})`);
+    } catch (err: any) {
+      if (err.message?.includes('already')) {
+        console.log(`  ~ ${f.name} 已存在`);
+      } else {
+        console.warn(`  ⚠ ${f.name} 添加失败: ${err.message}`);
+      }
+    }
+  }
+
+  await schema.update({ useMasterKey: true });
+  console.log('  Schema 更新完成');
+
+  // 已有 pending 事件补默认值
+  const q = new Parse.Query('RiskEvent');
+  q.equalTo('status', 'pending');
+  q.limit(5000);
+  const pendingEvents = await q.find({ useMasterKey: true }) as any[];
+  console.log(`  待补全的 pending 事件: ${pendingEvents.length} 条`);
+
+  if (pendingEvents.length > 0) {
+    const toSave: Parse.Object[] = [];
+    for (const ev of pendingEvents) {
+      let changed = false;
+      if (ev.get('escalationLevel') == null) {
+        ev.set('escalationLevel', 1);
+        changed = true;
+      }
+      if (ev.get('escalatedAt') == null) {
+        ev.set('escalatedAt', ev.get('createdAt') || new Date());
+        changed = true;
+      }
+      if (ev.get('escalationNotifiedUserIds') == null) {
+        ev.set('escalationNotifiedUserIds', []);
+        changed = true;
+      }
+      if (ev.get('confirmedBy') == null && ev.get('confirmedAt') == null) {
+        // 保持 null,不需要额外设置
+      }
+      if (ev.get('groupOwnerName') == null) {
+        ev.set('groupOwnerName', '');
+        changed = true;
+      }
+      if (ev.get('groupOwnerPhone') == null) {
+        ev.set('groupOwnerPhone', '');
+        changed = true;
+      }
+      if (changed) toSave.push(ev);
+    }
+    if (toSave.length > 0) {
+      await Parse.Object.saveAll(toSave, { useMasterKey: true });
+      console.log(`  已补全 ${toSave.length} 条 pending 事件默认值`);
+    }
+  }
+  console.log('');
+}
+
+/* ═══════════════════════════════════════════════
+   3. AppNotification
+   ═══════════════════════════════════════════════ */
+async function migrateAppNotification(): Promise<void> {
+  console.log('═══ 3/4 AppNotification ═══');
+
+  const schema = new Parse.Schema('AppNotification');
+  try { await schema.addField('riskEvent', 'Pointer', { targetClass: 'RiskEvent' }); } catch { /* 已存在 */ }
+  await schema.update({ useMasterKey: true });
+  console.log('  Schema: riskEvent(Pointer→RiskEvent) 已添加\n');
+}
+
+/* ═══════════════════════════════════════════════
+   4. GroupChat
+   ═══════════════════════════════════════════════ */
+async function migrateGroupChat(): Promise<void> {
+  console.log('═══ 4/4 GroupChat ═══');
+
+  const schema = new Parse.Schema('GroupChat');
+
+  const additions: Array<{ name: string; type: string; opts?: Record<string, any> }> = [
+    { name: 'store', type: 'Pointer', opts: { targetClass: 'Store' } },
+    { name: 'community', type: 'Pointer', opts: { targetClass: 'Community' } },
+    { name: 'ownerPhone', type: 'String' },
+  ];
+
+  for (const add of additions) {
+    try {
+      if (add.opts) {
+        await schema.addField(add.name, add.type, add.opts);
+      } else {
+        await schema.addField(add.name, add.type);
+      }
+      console.log(`  + ${add.name} (${add.type})`);
+    } catch (err: any) {
+      if (err.message?.includes('already')) {
+        console.log(`  ~ ${add.name} 已存在`);
+      } else {
+        console.warn(`  ⚠ ${add.name} 添加失败: ${err.message}`);
+      }
+    }
+  }
+
+  await schema.update({ useMasterKey: true });
+  console.log('  Schema 更新完成\n');
+}
+
+/* ═══════════════════════════════════════════════
+   Main
+   ═══════════════════════════════════════════════ */
+async function main(): Promise<void> {
+  console.log('=== 风险通知体系 Schema 迁移 ===');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  await testConnection();
+
+  await migrateResponsibilityAssignment();
+  await migrateRiskEvent();
+  await migrateAppNotification();
+  await migrateGroupChat();
+
+  console.log('=== 迁移完成 ===\n');
+}
+
+main().catch((err) => {
+  console.error('迁移失败:', err.message);
+  process.exit(1);
+});

+ 77 - 0
backend/scripts/restore-guid-field.ts

@@ -0,0 +1,77 @@
+/**
+ * 恢复 GroupChat.guid 字段并回填数据
+ * 线上旧架构代码仍在使用 guid 做多租户隔离,误删除导致所有按 guid 查询的 SQL 报错 42703
+ *
+ * 用法: cd backend && npx tsx scripts/restore-guid-field.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+const GUID = process.env.QIWE_GUID || 'B8591A9D-B540-44F9-9D74-B2EF5B419EAA';
+
+async function main(): Promise<void> {
+  console.log(`=== 恢复 GroupChat.guid 字段 ===`);
+  console.log(`GUID: ${GUID}\n`);
+
+  // 1. 检查字段是否已存在
+  const schemas = await Parse.Schema.all({ useMasterKey: true });
+  const groupChatSchema = schemas.find((s: any) => s.className === 'GroupChat');
+  if (!groupChatSchema) {
+    console.error('❌ GroupChat 表不存在');
+    process.exit(1);
+  }
+
+  const existingFields = Object.keys(groupChatSchema.fields).filter((k: string) => !k.startsWith('_'));
+  console.log(`当前 GroupChat 字段: ${existingFields.join(', ')}`);
+
+  if (existingFields.includes('guid')) {
+    console.log('\n✅ guid 字段已存在,跳过 schema 更新');
+  } else {
+    // 2. 添加 guid 字段
+    const schema = new Parse.Schema('GroupChat');
+    schema.addField('guid', 'String');
+    await schema.update({ useMasterKey: true });
+    console.log('\n✅ 已添加 guid (String) 字段到 GroupChat');
+  }
+
+  // 3. 回填所有缺少 guid 的记录
+  console.log('\n开始回填数据...');
+  const q = new Parse.Query('GroupChat');
+  q.doesNotExist('guid');
+  q.limit(500);
+  const groups = await q.find({ useMasterKey: true });
+
+  console.log(`找到 ${groups.length} 条缺少 guid 的记录`);
+
+  if (groups.length > 0) {
+    const toSave: Parse.Object[] = [];
+    for (const g of groups) {
+      g.set('guid', GUID);
+      toSave.push(g);
+    }
+    // 分批保存
+    for (let i = 0; i < toSave.length; i += 100) {
+      const batch = toSave.slice(i, i + 100);
+      await Parse.Object.saveAll(batch, { useMasterKey: true });
+      console.log(`  已回填 ${Math.min(i + 100, toSave.length)}/${toSave.length}`);
+    }
+  }
+
+  // 4. 验证
+  const verifyQ = new Parse.Query('GroupChat');
+  verifyQ.equalTo('guid', GUID);
+  verifyQ.limit(1);
+  const test = await verifyQ.first({ useMasterKey: true });
+  if (test) {
+    console.log(`\n✅ 验证通过: 成功查询到 guid='${GUID}' 的记录 (roomId=${test.get('roomId')})`);
+  } else {
+    console.log('\n⚠️ 验证失败: 未查询到匹配记录');
+  }
+
+  console.log('\n✅ 恢复完成!');
+}
+
+main().catch((err) => {
+  console.error('❌ 失败:', err);
+  process.exit(1);
+});

+ 416 - 0
backend/scripts/seed-data.ts

@@ -0,0 +1,416 @@
+/**
+ * 种子数据脚本:写入上海16区 → 门店 → 小区 完整层级数据
+ * 用法: npx tsx scripts/seed-data.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+/* ======================== 上海16个行政区 ======================== */
+
+const SHANGHAI_DISTRICTS = [
+  '浦东新区', '黄浦区', '徐汇区', '长宁区', '静安区', '普陀区',
+  '虹口区', '杨浦区', '闵行区', '宝山区', '嘉定区', '金山区',
+  '松江区', '青浦区', '奉贤区', '崇明区',
+];
+
+/* ======================== 门店数据(按区分布,共30个) ======================== */
+
+interface StoreSeed {
+  code: string;
+  name: string;
+  district: string;
+  communities: CommunitySeed[];
+}
+
+interface CommunitySeed {
+  name: string;
+  address: string;
+  totalHouseholds: number;
+  avgPrice: number;
+  deliveryDate: string;
+}
+
+const STORE_SEEDS: StoreSeed[] = [
+  // ── 浦东新区 (3门店) ──
+  {
+    code: 'SH-PD-001', name: '陆家嘴旗舰店', district: '浦东新区',
+    communities: [
+      { name: '汤臣一品', address: '浦东新区花园石桥路28号', totalHouseholds: 400, avgPrice: 250000, deliveryDate: '2025-03-15' },
+      { name: '中粮海景壹号', address: '浦东新区银城中路600号', totalHouseholds: 500, avgPrice: 220000, deliveryDate: '2025-09-01' },
+      { name: '仁恒滨江园', address: '浦东新区浦明路99号', totalHouseholds: 1200, avgPrice: 160000, deliveryDate: '2024-12-31' },
+      { name: '世茂滨江花园', address: '浦东新区潍坊西路1号', totalHouseholds: 800, avgPrice: 145000, deliveryDate: '2025-06-30' },
+    ],
+  },
+  {
+    code: 'SH-PD-002', name: '金桥国际店', district: '浦东新区',
+    communities: [
+      { name: '碧云国际社区', address: '浦东新区碧云路299号', totalHouseholds: 600, avgPrice: 120000, deliveryDate: '2025-01-15' },
+      { name: '联洋年华', address: '浦东新区丁香路1088号', totalHouseholds: 900, avgPrice: 98000, deliveryDate: '2024-09-01' },
+      { name: '金桥瑞仕花园', address: '浦东新区金桥路1500号', totalHouseholds: 700, avgPrice: 105000, deliveryDate: '2025-11-30' },
+    ],
+  },
+  {
+    code: 'SH-PD-003', name: '张江科技店', district: '浦东新区',
+    communities: [
+      { name: '张江汤臣豪园', address: '浦东新区科苑路399号', totalHouseholds: 500, avgPrice: 85000, deliveryDate: '2025-04-01' },
+      { name: '万科翡翠公园', address: '浦东新区张江路1088号', totalHouseholds: 1100, avgPrice: 78000, deliveryDate: '2025-08-15' },
+      { name: '中建朗阅', address: '浦东新区盛夏路299号', totalHouseholds: 400, avgPrice: 92000, deliveryDate: '2026-02-28' },
+      { name: '张江科技花园', address: '浦东新区祖冲之路399号', totalHouseholds: 300, avgPrice: 88000, deliveryDate: '2025-12-31' },
+    ],
+  },
+
+  // ── 黄浦区 (2门店) ──
+  {
+    code: 'SH-HP-001', name: '外滩精品店', district: '黄浦区',
+    communities: [
+      { name: '翠湖天地', address: '黄浦区济南路260号', totalHouseholds: 1800, avgPrice: 185000, deliveryDate: '2024-06-30' },
+      { name: '复兴珑御', address: '黄浦区复兴东路1188号', totalHouseholds: 400, avgPrice: 175000, deliveryDate: '2025-03-01' },
+      { name: '华府天地', address: '黄浦区马当路222号', totalHouseholds: 350, avgPrice: 200000, deliveryDate: '2025-09-15' },
+      { name: '老西门新苑', address: '黄浦区西藏南路688号', totalHouseholds: 600, avgPrice: 155000, deliveryDate: '2024-11-01' },
+    ],
+  },
+  {
+    code: 'SH-HP-002', name: '新天地体验店', district: '黄浦区',
+    communities: [
+      { name: '淮海晶华苑', address: '黄浦区淮海中路158号', totalHouseholds: 450, avgPrice: 165000, deliveryDate: '2025-07-01' },
+      { name: '永业公寓', address: '黄浦区建国东路88号', totalHouseholds: 520, avgPrice: 140000, deliveryDate: '2024-08-15' },
+      { name: '海珀日晖', address: '黄浦区瑞金南路1号', totalHouseholds: 380, avgPrice: 180000, deliveryDate: '2025-12-01' },
+    ],
+  },
+
+  // ── 徐汇区 (2门店) ──
+  {
+    code: 'SH-XH-001', name: '徐汇滨江店', district: '徐汇区',
+    communities: [
+      { name: '尚海湾豪庭', address: '徐汇区凯滨路18号', totalHouseholds: 550, avgPrice: 170000, deliveryDate: '2025-05-01' },
+      { name: '云锦东方', address: '徐汇区龙兰路399号', totalHouseholds: 400, avgPrice: 190000, deliveryDate: '2025-10-15' },
+      { name: '百汇园', address: '徐汇区宛平南路1518号', totalHouseholds: 680, avgPrice: 145000, deliveryDate: '2024-10-01' },
+      { name: '中信君廷', address: '徐汇区斜土路2088号', totalHouseholds: 320, avgPrice: 155000, deliveryDate: '2025-08-01' },
+    ],
+  },
+  {
+    code: 'SH-XH-002', name: '衡山路精品店', district: '徐汇区',
+    communities: [
+      { name: '嘉御庭', address: '徐汇区建国西路228号', totalHouseholds: 280, avgPrice: 200000, deliveryDate: '2025-06-15' },
+      { name: '太原邸', address: '徐汇区太原路289号', totalHouseholds: 200, avgPrice: 210000, deliveryDate: '2025-11-01' },
+      { name: '上方花园', address: '徐汇区淮海中路1285号', totalHouseholds: 350, avgPrice: 175000, deliveryDate: '2024-12-15' },
+    ],
+  },
+
+  // ── 长宁区 (2门店) ──
+  {
+    code: 'SH-CN-001', name: '古北国际店', district: '长宁区',
+    communities: [
+      { name: '古北壹号', address: '长宁区古北路1398号', totalHouseholds: 420, avgPrice: 160000, deliveryDate: '2025-04-01' },
+      { name: '西郊紫郡', address: '长宁区剑河路688号', totalHouseholds: 500, avgPrice: 135000, deliveryDate: '2024-11-01' },
+      { name: '仁恒河滨花园', address: '长宁区芙蓉江路388号', totalHouseholds: 700, avgPrice: 125000, deliveryDate: '2024-08-15' },
+    ],
+  },
+  {
+    code: 'SH-CN-002', name: '中山公园店', district: '长宁区',
+    communities: [
+      { name: '长宁八八', address: '长宁区长宁路88号', totalHouseholds: 380, avgPrice: 140000, deliveryDate: '2025-09-01' },
+      { name: '虹桥豪苑', address: '长宁区虹桥路996号', totalHouseholds: 310, avgPrice: 150000, deliveryDate: '2025-03-15' },
+      { name: '兆丰花园', address: '长宁区中山西路879号', totalHouseholds: 450, avgPrice: 115000, deliveryDate: '2025-01-01' },
+      { name: '圣约翰名邸', address: '长宁区金钟路68号', totalHouseholds: 550, avgPrice: 108000, deliveryDate: '2025-07-01' },
+    ],
+  },
+
+  // ── 静安区 (2门店) ──
+  {
+    code: 'SH-JA-001', name: '静安寺旗舰店', district: '静安区',
+    communities: [
+      { name: '中凯城市之光', address: '静安区威海路333号', totalHouseholds: 480, avgPrice: 180000, deliveryDate: '2024-09-01' },
+      { name: '静安枫景苑', address: '静安区常德路500号', totalHouseholds: 350, avgPrice: 170000, deliveryDate: '2025-02-15' },
+      { name: '远中风华园', address: '静安区昌平路888号', totalHouseholds: 420, avgPrice: 155000, deliveryDate: '2025-07-15' },
+      { name: '静安豪景苑', address: '静安区西康路699号', totalHouseholds: 380, avgPrice: 165000, deliveryDate: '2025-11-30' },
+    ],
+  },
+  {
+    code: 'SH-JA-002', name: '大宁国际店', district: '静安区',
+    communities: [
+      { name: '大宁金茂府', address: '静安区彭江路399号', totalHouseholds: 600, avgPrice: 125000, deliveryDate: '2025-04-01' },
+      { name: '静安府', address: '静安区汶水路658号', totalHouseholds: 800, avgPrice: 115000, deliveryDate: '2025-09-01' },
+      { name: '明园森林都市', address: '静安区共和新路2999号', totalHouseholds: 500, avgPrice: 98000, deliveryDate: '2024-12-01' },
+    ],
+  },
+
+  // ── 普陀区 (2门店) ──
+  {
+    code: 'SH-PT-001', name: '长风公园店', district: '普陀区',
+    communities: [
+      { name: '长风瑞仕璟庭', address: '普陀区丹巴路368号', totalHouseholds: 450, avgPrice: 105000, deliveryDate: '2025-06-01' },
+      { name: '高尚领域', address: '普陀区礼尚路222号', totalHouseholds: 320, avgPrice: 98000, deliveryDate: '2025-10-01' },
+      { name: '品尊国际', address: '普陀区铜川路58号', totalHouseholds: 550, avgPrice: 110000, deliveryDate: '2024-11-15' },
+    ],
+  },
+  {
+    code: 'SH-PT-002', name: '长寿路店', district: '普陀区',
+    communities: [
+      { name: '中远两湾城', address: '普陀区中潭路99号', totalHouseholds: 1500, avgPrice: 85000, deliveryDate: '2024-07-01' },
+      { name: '华府樟园', address: '普陀区凯旋北路1555号', totalHouseholds: 380, avgPrice: 102000, deliveryDate: '2025-03-01' },
+      { name: '苏堤春晓名苑', address: '普陀区光复西路1185号', totalHouseholds: 420, avgPrice: 95000, deliveryDate: '2025-08-01' },
+    ],
+  },
+
+  // ── 虹口区 (2门店) ──
+  {
+    code: 'SH-HK-001', name: '北外滩体验店', district: '虹口区',
+    communities: [
+      { name: '瑞虹新城', address: '虹口区瑞虹路368号', totalHouseholds: 800, avgPrice: 120000, deliveryDate: '2025-02-01' },
+      { name: '白金湾府邸', address: '虹口区海平路18号', totalHouseholds: 300, avgPrice: 150000, deliveryDate: '2025-07-01' },
+      { name: '馥敦坊', address: '虹口区东余杭路588号', totalHouseholds: 350, avgPrice: 109000, deliveryDate: '2025-12-31' },
+    ],
+  },
+  {
+    code: 'SH-HK-002', name: '虹口足球场店', district: '虹口区',
+    communities: [
+      { name: '建邦16区', address: '虹口区天宝路466号', totalHouseholds: 500, avgPrice: 105000, deliveryDate: '2025-05-01' },
+      { name: '虹叶茗园', address: '虹口区虹口路128号', totalHouseholds: 280, avgPrice: 115000, deliveryDate: '2024-10-01' },
+      { name: '金轩大邸', address: '虹口区四平路758号', totalHouseholds: 420, avgPrice: 98000, deliveryDate: '2025-09-15' },
+      { name: '溧阳华府', address: '虹口区宝安路138号', totalHouseholds: 360, avgPrice: 102000, deliveryDate: '2025-04-15' },
+    ],
+  },
+
+  // ── 杨浦区 (2门店) ──
+  {
+    code: 'SH-YP-001', name: '五角场店', district: '杨浦区',
+    communities: [
+      { name: '新江湾城', address: '杨浦区殷行路1280号', totalHouseholds: 650, avgPrice: 95000, deliveryDate: '2025-05-15' },
+      { name: '翡丽甲第', address: '杨浦区平凉路2788号', totalHouseholds: 380, avgPrice: 110000, deliveryDate: '2025-08-01' },
+      { name: '圣骊河滨苑', address: '杨浦区龙江路388号', totalHouseholds: 420, avgPrice: 92000, deliveryDate: '2024-12-01' },
+    ],
+  },
+  {
+    code: 'SH-YP-002', name: '大学路社区店', district: '杨浦区',
+    communities: [
+      { name: '创智天地', address: '杨浦区淞沪路290号', totalHouseholds: 480, avgPrice: 88000, deliveryDate: '2025-06-01' },
+      { name: '同济欣苑', address: '杨浦区四平路1239号', totalHouseholds: 320, avgPrice: 82000, deliveryDate: '2025-01-15' },
+      { name: '文化佳园', address: '杨浦区国定路777号', totalHouseholds: 350, avgPrice: 90000, deliveryDate: '2025-10-01' },
+    ],
+  },
+
+  // ── 闵行区 (2门店) ──
+  {
+    code: 'SH-MH-001', name: '莘庄旗舰店', district: '闵行区',
+    communities: [
+      { name: '万科假日风景', address: '闵行区畹町路99号', totalHouseholds: 900, avgPrice: 75000, deliveryDate: '2025-03-01' },
+      { name: '春申景城', address: '闵行区莲花南路1111号', totalHouseholds: 700, avgPrice: 72000, deliveryDate: '2024-09-15' },
+      { name: '上海星河湾', address: '闵行区都会路3899号', totalHouseholds: 450, avgPrice: 95000, deliveryDate: '2025-07-01' },
+      { name: '中环名品公馆', address: '闵行区古美路1458号', totalHouseholds: 380, avgPrice: 88000, deliveryDate: '2025-11-01' },
+    ],
+  },
+  {
+    code: 'SH-MH-002', name: '虹桥天地店', district: '闵行区',
+    communities: [
+      { name: '万科时一区', address: '闵行区申郑路18号', totalHouseholds: 520, avgPrice: 85000, deliveryDate: '2025-04-01' },
+      { name: '尚品华庭', address: '闵行区申滨路1058号', totalHouseholds: 400, avgPrice: 78000, deliveryDate: '2025-09-01' },
+      { name: '爱博新村', address: '闵行区宁虹路1101号', totalHouseholds: 350, avgPrice: 65000, deliveryDate: '2024-08-01' },
+    ],
+  },
+
+  // ── 宝山区 (2门店) ──
+  {
+    code: 'SH-BS-001', name: '宝山万达店', district: '宝山区',
+    communities: [
+      { name: '大华锦绣华城', address: '宝山区真华路999号', totalHouseholds: 600, avgPrice: 68000, deliveryDate: '2025-02-01' },
+      { name: '经纬城市绿洲', address: '宝山区纬地路358号', totalHouseholds: 500, avgPrice: 62000, deliveryDate: '2025-07-15' },
+      { name: '上大聚丰园', address: '宝山区上大路1288号', totalHouseholds: 420, avgPrice: 58000, deliveryDate: '2024-11-01' },
+      { name: '丰翔新城', address: '宝山区丰翔路258号', totalHouseholds: 350, avgPrice: 65000, deliveryDate: '2025-12-31' },
+    ],
+  },
+  {
+    code: 'SH-BS-002', name: '顾村公园店', district: '宝山区',
+    communities: [
+      { name: '顾村大家园', address: '宝山区顾北路568号', totalHouseholds: 450, avgPrice: 55000, deliveryDate: '2025-05-01' },
+      { name: '美兰湖花园', address: '宝山区美兰湖路339号', totalHouseholds: 300, avgPrice: 72000, deliveryDate: '2025-10-01' },
+      { name: '保利叶上海', address: '宝山区菊太路1198号', totalHouseholds: 520, avgPrice: 60000, deliveryDate: '2024-12-15' },
+    ],
+  },
+
+  // ── 嘉定区 (2门店) ──
+  {
+    code: 'SH-JD-001', name: '嘉定新城店', district: '嘉定区',
+    communities: [
+      { name: '金地格林世界', address: '嘉定区芳林路951号', totalHouseholds: 550, avgPrice: 52000, deliveryDate: '2025-06-01' },
+      { name: '龙湖郦城', address: '嘉定区双单路999号', totalHouseholds: 480, avgPrice: 55000, deliveryDate: '2025-03-01' },
+      { name: '嘉宝梦之湾', address: '嘉定区平城路2055号', totalHouseholds: 380, avgPrice: 58000, deliveryDate: '2025-09-01' },
+    ],
+  },
+  {
+    code: 'SH-JD-002', name: '安亭汽车城店', district: '嘉定区',
+    communities: [
+      { name: '安亭新镇', address: '嘉定区安礼路258号', totalHouseholds: 600, avgPrice: 48000, deliveryDate: '2025-04-01' },
+      { name: '绿地海域苏河源', address: '嘉定区安亭镇泽普路588号', totalHouseholds: 420, avgPrice: 45000, deliveryDate: '2024-10-01' },
+      { name: '万科安亭新镇', address: '嘉定区安亭镇安诚路318号', totalHouseholds: 350, avgPrice: 50000, deliveryDate: '2025-08-01' },
+    ],
+  },
+
+  // ── 金山区 (1门店) ──
+  {
+    code: 'SH-JS-001', name: '金山新城店', district: '金山区',
+    communities: [
+      { name: '金山豪庭', address: '金山区卫清西路399号', totalHouseholds: 400, avgPrice: 35000, deliveryDate: '2025-07-01' },
+      { name: '海上明珠', address: '金山区龙胜路828号', totalHouseholds: 350, avgPrice: 32000, deliveryDate: '2025-01-15' },
+      { name: '枫丹白露', address: '金山区朱泾镇人民路360号', totalHouseholds: 280, avgPrice: 38000, deliveryDate: '2025-12-01' },
+    ],
+  },
+
+  // ── 松江区 (2门店) ──
+  {
+    code: 'SH-SJ-001', name: '松江大学城店', district: '松江区',
+    communities: [
+      { name: '松江新城', address: '松江区新松江路1188号', totalHouseholds: 500, avgPrice: 48000, deliveryDate: '2025-05-01' },
+      { name: '保利西子湾', address: '松江区广富林路1518号', totalHouseholds: 380, avgPrice: 52000, deliveryDate: '2025-09-01' },
+      { name: '绿地蔷薇九里', address: '松江区广富林路600号', totalHouseholds: 420, avgPrice: 50000, deliveryDate: '2024-11-15' },
+      { name: '佘山一品', address: '松江区佘山镇外青松公路666号', totalHouseholds: 300, avgPrice: 62000, deliveryDate: '2025-06-15' },
+    ],
+  },
+  {
+    code: 'SH-SJ-002', name: '九亭社区店', district: '松江区',
+    communities: [
+      { name: '九亭明珠苑', address: '松江区九亭镇沪亭南路88号', totalHouseholds: 450, avgPrice: 45000, deliveryDate: '2025-03-01' },
+      { name: '中大九里德', address: '松江区九亭镇涞坊路1018号', totalHouseholds: 350, avgPrice: 43000, deliveryDate: '2024-08-01' },
+      { name: '奥林匹克花园', address: '松江区九亭镇涞亭北路99号', totalHouseholds: 550, avgPrice: 47000, deliveryDate: '2025-11-01' },
+    ],
+  },
+
+  // ── 青浦区 (1门店) ──
+  {
+    code: 'SH-QP-001', name: '青浦吾悦店', district: '青浦区',
+    communities: [
+      { name: '仁恒西郊花园', address: '青浦区徐泾镇盈港东路1188号', totalHouseholds: 450, avgPrice: 55000, deliveryDate: '2025-04-01' },
+      { name: '万科天空之城', address: '青浦区徐泾镇诸光路66号', totalHouseholds: 550, avgPrice: 60000, deliveryDate: '2025-10-01' },
+      { name: '金地天境', address: '青浦区赵巷镇嘉松中路6888号', totalHouseholds: 320, avgPrice: 52000, deliveryDate: '2025-08-01' },
+    ],
+  },
+
+  // ── 奉贤区 (1门店) ──
+  {
+    code: 'SH-FX-001', name: '南桥百联店', district: '奉贤区',
+    communities: [
+      { name: '南桥老街', address: '奉贤区南桥镇解放西路128号', totalHouseholds: 400, avgPrice: 35000, deliveryDate: '2025-02-01' },
+      { name: '奉贤海湾', address: '奉贤区海湾路188号', totalHouseholds: 350, avgPrice: 38000, deliveryDate: '2025-06-01' },
+      { name: '阳光四季', address: '奉贤区南桥镇环城东路558号', totalHouseholds: 300, avgPrice: 36000, deliveryDate: '2025-12-01' },
+    ],
+  },
+
+  // ── 崇明区 (1门店) ──
+  {
+    code: 'SH-CM-001', name: '崇明生态店', district: '崇明区',
+    communities: [
+      { name: '崇明绿地长岛', address: '崇明区长兴岛凤凰公路888号', totalHouseholds: 500, avgPrice: 28000, deliveryDate: '2025-05-01' },
+      { name: '崇明新城', address: '崇明区城桥镇翠竹路128号', totalHouseholds: 380, avgPrice: 30000, deliveryDate: '2025-09-01' },
+      { name: '东滩花园', address: '崇明区陈家镇揽海路1号', totalHouseholds: 280, avgPrice: 35000, deliveryDate: '2025-03-15' },
+    ],
+  },
+];
+
+/* ======================== 执行脚本 ======================== */
+
+async function seed() {
+  console.log('[Seed] 开始写入上海16区层级数据...\n');
+
+  let districtsCreated = 0;
+  let districtsSkipped = 0;
+  let storesCreated = 0;
+  let storesSkipped = 0;
+  let communitiesCreated = 0;
+  let communitiesSkipped = 0;
+
+  // 1. 写入行政区
+  const districtIdMap = new Map<string, string>(); // name → objectId
+
+  for (let i = 0; i < SHANGHAI_DISTRICTS.length; i++) {
+    const name = SHANGHAI_DISTRICTS[i];
+    const q = new Parse.Query('District');
+    q.equalTo('name', name);
+    const existing = await q.first({ useMasterKey: true });
+
+    if (existing) {
+      districtIdMap.set(name, existing.id);
+      districtsSkipped++;
+      console.log(`  [跳过] 行政区已存在: ${name}`);
+    } else {
+      const obj = new Parse.Object('District');
+      obj.set('name', name);
+      obj.set('sortOrder', i + 1);
+      const saved = await obj.save(null, { useMasterKey: true });
+      districtIdMap.set(name, saved.id);
+      districtsCreated++;
+      console.log(`  [创建] 行政区: ${name} (排序: ${i + 1})`);
+    }
+  }
+
+  console.log(`\n[Seed] 行政区: +${districtsCreated} (跳过${districtsSkipped})\n`);
+
+  // 2. 写入门店和小区
+  for (const s of STORE_SEEDS) {
+    const districtId = districtIdMap.get(s.district);
+    if (!districtId) {
+      console.warn(`  [警告] 行政区 ${s.district} 未找到,跳过门店 ${s.name}`);
+      continue;
+    }
+
+    // Store
+    const sq = new Parse.Query('Store');
+    sq.equalTo('code', s.code);
+    const existingStore = await sq.first({ useMasterKey: true });
+    let storeId: string;
+
+    if (existingStore) {
+      storeId = existingStore.id;
+      storesSkipped++;
+      console.log(`  [跳过] 门店已存在: ${s.name} (${s.code})`);
+    } else {
+      const obj = new Parse.Object('Store');
+      obj.set('code', s.code);
+      obj.set('name', s.name);
+      const districtPtr = new Parse.Object('District');
+      districtPtr.id = districtId;
+      obj.set('district', districtPtr);
+      const saved = await obj.save(null, { useMasterKey: true });
+      storeId = saved.id;
+      storesCreated++;
+      console.log(`  [创建] 门店: ${s.name} (${s.code}) — ${s.district}`);
+    }
+
+    // Communities
+    for (const c of s.communities) {
+      const storePtr = new Parse.Object('Store');
+      storePtr.id = storeId;
+      const cq = new Parse.Query('Community');
+      cq.equalTo('name', c.name);
+      cq.equalTo('store', storePtr);
+      const cExist = await cq.first({ useMasterKey: true });
+
+      if (cExist) {
+        communitiesSkipped++;
+        console.log(`    [跳过] 小区已存在: ${c.name}`);
+      } else {
+        const obj = new Parse.Object('Community');
+        obj.set('name', c.name);
+        obj.set('address', c.address);
+        obj.set('totalHouseholds', c.totalHouseholds);
+        obj.set('avgPrice', c.avgPrice);
+        obj.set('deliveryDate', c.deliveryDate);
+        obj.set('store', storePtr);
+        await obj.save(null, { useMasterKey: true });
+        communitiesCreated++;
+        console.log(`    [创建] 小区: ${c.name} → ${s.name}`);
+      }
+    }
+  }
+
+  console.log(`\n[Seed] 完成:`);
+  console.log(`  行政区: +${districtsCreated} (跳过${districtsSkipped})`);
+  console.log(`  门店:   +${storesCreated} (跳过${storesSkipped})`);
+  console.log(`  小区:   +${communitiesCreated} (跳过${communitiesSkipped})`);
+}
+
+seed()
+  .then(() => process.exit(0))
+  .catch((err) => {
+    console.error('[Seed] 失败:', err);
+    process.exit(1);
+  });

+ 205 - 0
backend/scripts/seed-test-users.ts

@@ -0,0 +1,205 @@
+/**
+ * 种子测试用户数据脚本
+ *
+ * 1. 清理现有非总监用户
+ * 2. 确保 Store/District 数据存在
+ * 3. 创建 3 个已分配测试用户(使用 Pointer 关联)
+ *
+ * 用法: cd backend && npx tsx scripts/seed-test-users.ts
+ */
+import 'dotenv/config';
+import Parse from '../src/db/parse-client.js';
+
+async function main(): Promise<void> {
+  console.log('=== 种子测试用户数据 ===\n');
+  console.log(`Server: ${Parse.serverURL}`);
+  console.log(`App ID: ${Parse.applicationId}\n`);
+
+  // 测试连接
+  try {
+    await new Parse.Query('_User').limit(1).find({ useMasterKey: true });
+    console.log('✅ Parse 连接正常\n');
+  } catch (err: any) {
+    console.error('❌ Parse 连接失败:', err.message);
+    process.exit(1);
+  }
+
+  // ── 1. 查看当前数据 ──
+  console.log('--- 当前数据 ---');
+  const existingUsers = await new Parse.Query('_User').find({ useMasterKey: true }) as any[];
+  console.log(`_User: ${existingUsers.length} 个用户`);
+  for (const u of existingUsers) {
+    console.log(`  - ${u.get('name')} (${u.get('phone')}) role=${u.get('role')} storeId=${u.get('storeId') || '无'} districtId=${u.get('districtId') || '无'}`);
+  }
+
+  const existingStores = await new Parse.Query('Store').find({ useMasterKey: true }) as any[];
+  console.log(`\nStore: ${existingStores.length} 个门店`);
+  for (const s of existingStores) {
+    console.log(`  - ${s.id}: ${s.get('name')}`);
+  }
+
+  const existingDistricts = await new Parse.Query('District').find({ useMasterKey: true }) as any[];
+  console.log(`\nDistrict: ${existingDistricts.length} 个区域`);
+  for (const d of existingDistricts) {
+    console.log(`  - ${d.id}: ${d.get('name')}`);
+  }
+
+  // ── 2. 清理非总监用户 ──
+  console.log('\n--- 清理非总监用户 ---');
+  for (const u of existingUsers) {
+    const role = u.get('role');
+    if (role !== 'director') {
+      console.log(`  删除: ${u.get('name')} (${u.get('phone')})`);
+      await u.destroy({ useMasterKey: true });
+    }
+  }
+
+  // ── 3. 确保 _User 表有 store/district Pointer 列 ──
+  console.log('\n--- 检查 _User Schema ---');
+  const userSchema = new Parse.Schema('_User');
+  try { await userSchema.get({ useMasterKey: true }); } catch { /* 首次 */ }
+  try { await userSchema.addField('store', 'Pointer', { targetClass: 'Store' }); console.log('  ✅ store Pointer 列已添加'); } catch { console.log('  ✅ store 列已存在'); }
+  try { await userSchema.addField('district', 'Pointer', { targetClass: 'District' }); console.log('  ✅ district Pointer 列已添加'); } catch { console.log('  ✅ district 列已存在'); }
+  await userSchema.update({ useMasterKey: true });
+
+  // ── 4. 确保 Store 和 District 存在 ──
+  let storeId: string;
+  let storeName: string;
+  let districtId: string;
+  let districtName: string;
+
+  if (existingDistricts.length > 0) {
+    districtId = existingDistricts[0].id;
+    districtName = existingDistricts[0].get('name');
+    console.log(`\n✅ 使用已有区域: ${districtName} (${districtId})`);
+  } else {
+    const d = new Parse.Object('District');
+    d.set('name', '华东区');
+    d.set('sortOrder', 1);
+    await d.save(null, { useMasterKey: true });
+    districtId = d.id;
+    districtName = '华东区';
+    console.log(`\n✅ 创建区域: ${districtName} (${districtId})`);
+  }
+
+  if (existingStores.length > 0) {
+    storeId = existingStores[0].id;
+    storeName = existingStores[0].get('name');
+    console.log(`✅ 使用已有门店: ${storeName} (${storeId})`);
+    // 确保门店关联了区域
+    const store = existingStores[0];
+    if (!store.get('district')) {
+      store.set('district', Parse.Object.createWithoutData('District', districtId));
+      await store.save(null, { useMasterKey: true });
+      console.log(`  已更新门店区域关联`);
+    }
+  } else {
+    const s = new Parse.Object('Store');
+    s.set('name', '上海旗舰店');
+    s.set('district', Parse.Object.createWithoutData('District', districtId));
+    await s.save(null, { useMasterKey: true });
+    storeId = s.id;
+    storeName = '上海旗舰店';
+    console.log(`✅ 创建门店: ${storeName} (${storeId})`);
+  }
+
+  // ── 5. 创建 3 个测试用户(使用 Pointer) ──
+  console.log('\n--- 创建测试用户 ---');
+
+  // 先获取完整的 Store/District 对象(用于构建有效 Pointer)
+  const storeObj = await new Parse.Query('Store').get(storeId, { useMasterKey: true });
+  const districtObj = await new Parse.Query('District').get(districtId, { useMasterKey: true });
+
+  const testUsers = [
+    {
+      name: '张总监',
+      phone: '13800000001',
+      password: '123456',
+      role: 'director',
+      store: null as any,
+      district: null as any,
+    },
+    {
+      name: '王督导',
+      phone: '13800000002',
+      password: '123456',
+      role: 'regional_supervisor',
+      store: null as any,
+      district: districtObj,
+    },
+    {
+      name: '李店长',
+      phone: '13800000003',
+      password: '123456',
+      role: 'store_manager',
+      store: storeObj,
+      district: districtObj,
+    },
+  ];
+
+  for (const tu of testUsers) {
+    const username = `shequn:${tu.phone}`;
+
+    // 先删除已存在的同名用户(确保干净重建)
+    const existingQ = new Parse.Query('_User');
+    existingQ.equalTo('username', username);
+    const existingUser = await existingQ.first({ useMasterKey: true });
+    if (existingUser) {
+      await existingUser.destroy({ useMasterKey: true });
+      console.log(`  删除旧用户: ${tu.phone}`);
+    }
+
+    try {
+      await Parse.User.signUp(username, tu.password, {
+        phone: tu.phone,
+        appSource: 'shequn',
+        name: tu.name,
+        role: tu.role,
+        department: tu.role === 'director' ? '总部' : '运营部',
+        position: tu.role === 'director' ? 'director' : 'general_staff',
+      });
+    } catch (err: any) {
+      console.error(`  ❌ 创建用户 ${tu.phone} 失败:`, err.message);
+      throw err;
+    }
+
+    // signUp 后重新查询并设置 Pointer 字段
+    const q = new Parse.Query('_User');
+    q.equalTo('username', username);
+    const user = await q.first({ useMasterKey: true });
+    if (!user) {
+      console.error(`  ❌ 找不到用户 ${tu.phone}`);
+      continue;
+    }
+
+    if (tu.store) user.set('store', tu.store);
+    if (tu.district) user.set('district', tu.district);
+    await user.save(null, { useMasterKey: true });
+
+    console.log(`  ✅ ${tu.name} (${tu.phone}) role=${tu.role} store=${tu.store ? storeName : '—'} district=${tu.district ? districtName : '—'}`);
+  }
+
+  // ── 6. 验证 ──
+  console.log('\n--- 验证 ---');
+  const q = new Parse.Query('_User');
+  q.include('store');
+  q.include('district');
+  q.ascending('phone');
+  const finalUsers = await q.find({ useMasterKey: true }) as any[];
+  for (const u of finalUsers) {
+    const store = u.get('store');
+    const district = u.get('district');
+    console.log(`  ${u.get('name')} | ${u.get('phone')} | ${u.get('role')} | store=${store?.get?.('name') || '—'} | district=${district?.get?.('name') || '—'}`);
+  }
+
+  console.log('\n✅ 种子数据完成!');
+  console.log('一键测试账号:');
+  console.log('  总监:   13800000001 / 123456');
+  console.log('  区域督导: 13800000002 / 123456');
+  console.log('  店长:   13800000003 / 123456');
+}
+
+main().catch((err) => {
+  console.error('失败:', err.message, err.stack);
+  process.exit(1);
+});

+ 30 - 0
backend/src/app.ts

@@ -0,0 +1,30 @@
+import express, { type Express } from 'express';
+import cors from 'cors';
+import qiweiRoutes from './routes/qiwei.routes.js';
+import healthRoutes from './routes/health.routes.js';
+import aggregateRoutes from './routes/aggregate.routes.js';
+import usersRoutes from './routes/users.routes.js';
+import { errorHandler } from './http/error-handler.js';
+import { notFoundHandler } from './http/not-found.middleware.js';
+
+const app: Express = express();
+
+app.use(cors());
+app.use(express.json());
+
+// 企微 API 操作(Webhook / 同步 / 群操作 / 发消息 / 账号测试)
+app.use('/api/qiwe', qiweiRoutes);
+
+// 聚合计数(替代前端 N+1 count 查询)
+app.use('/api/aggregate', aggregateRoutes);
+
+// 用户管理(_User 表操作,需 masterKey)
+app.use('/api/users', usersRoutes);
+
+// 健康检查
+app.use('/api', healthRoutes);
+
+app.use(notFoundHandler);
+app.use(errorHandler);
+
+export default app;

+ 68 - 0
backend/src/auth/auth.service.ts

@@ -0,0 +1,68 @@
+import Parse from '../db/parse-client.js';
+import { AppError } from '../errors/app-error.js';
+import { APP_SOURCE } from './constants.js';
+
+export type UserRole = 'director' | 'regional_supervisor' | 'store_manager';
+
+export interface UserDto {
+  id: string;
+  name: string;
+  phone: string;
+  role: UserRole;
+  storeId: string;
+  storeName: string;
+  districtId: string;
+  districtName: string;
+  wecomUserId: string;
+  regionCode: string;
+  department: string;
+  position: string;
+  createdAt: string;
+}
+
+function userQuery(): Parse.Query<Parse.User> {
+  const query = new Parse.Query(Parse.User);
+  query.equalTo('appSource', APP_SOURCE);
+  return query;
+}
+
+export async function getUserByToken(token: string): Promise<UserDto | null> {
+  const sessionQuery = new Parse.Query(Parse.Session);
+  sessionQuery.equalTo('sessionToken', token);
+  const session = await sessionQuery.first({ useMasterKey: true });
+  if (!session) return null;
+
+  const expiresAt = session.get('expiresAt') as Date | undefined;
+  if (expiresAt && expiresAt.getTime() < Date.now()) {
+    await session.destroy({ useMasterKey: true });
+    return null;
+  }
+
+  const userPtr = session.get('user');
+  if (!userPtr) return null;
+
+  const userQuery_ = new Parse.Query(Parse.User);
+  userQuery_.include('store');
+  userQuery_.include('district');
+  const user = await userQuery_.get(userPtr.id, { useMasterKey: true });
+  if (user.get('appSource') !== APP_SOURCE) return null;
+
+  const store = user.get('store') as Parse.Object<Parse.Attributes> | undefined;
+  const district = user.get('district') as Parse.Object<Parse.Attributes> | undefined;
+
+  return {
+    id: user.id!,
+    name: user.get('name') || '',
+    phone: user.get('phone') || '',
+    role: user.get('role') as UserRole,
+    storeId: store?.id || user.get('storeId') || '',
+    storeName: (store?.get?.('name') as string) || user.get('storeName') || '',
+    districtId: district?.id || user.get('districtId') || '',
+    districtName: (district?.get?.('name') as string) || user.get('districtName') || '',
+    wecomUserId: user.get('wecomUserId') || '',
+    regionCode: user.get('regionCode') || '',
+    department: user.get('department') || '',
+    position: user.get('position') || '',
+    createdAt: (user.get('createdAt') as Date)?.toISOString?.() || new Date().toISOString(),
+  };
+}

+ 2 - 0
backend/src/auth/constants.ts

@@ -0,0 +1,2 @@
+/** 本系统(社群运营端)在 Parse _User 表中的来源标识 */
+export const APP_SOURCE = 'shequn';

+ 7 - 0
backend/src/auth/request.util.ts

@@ -0,0 +1,7 @@
+import type { Request } from 'express';
+
+export function getBearerToken(req: Request): string | null {
+  const header = req.headers.authorization;
+  if (!header?.startsWith('Bearer ')) return null;
+  return header.slice(7).trim() || null;
+}

+ 16 - 0
backend/src/auth/require-user.ts

@@ -0,0 +1,16 @@
+import type { Request } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { getUserByToken, type UserDto } from './auth.service.js';
+import { getBearerToken } from './request.util.js';
+
+export async function requireUser(req: Request): Promise<UserDto> {
+  const token = getBearerToken(req);
+  if (!token) {
+    throw new AppError(401, 'UNAUTHORIZED', '未登录');
+  }
+  const user = await getUserByToken(token);
+  if (!user) {
+    throw new AppError(401, 'SESSION_EXPIRED', '登录已过期,请重新登录');
+  }
+  return user;
+}

+ 13 - 0
backend/src/config/env.ts

@@ -0,0 +1,13 @@
+export function getStringEnv(name: string): string | undefined {
+  const value = process.env[name];
+  if (!value) return undefined;
+  const trimmed = value.trim();
+  return trimmed ? trimmed : undefined;
+}
+
+export function getNumberEnv(name: string, fallback: number): number {
+  const value = getStringEnv(name);
+  if (!value) return fallback;
+  const parsed = Number(value);
+  return Number.isFinite(parsed) ? parsed : fallback;
+}

+ 13 - 0
backend/src/controllers/account.controller.ts

@@ -0,0 +1,13 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { sendSuccess } from '../http/response.js';
+import { requireUser } from '../auth/require-user.js';
+import { testAccount } from '../services/account.service.js';
+
+export async function handleTestAccount(req: Request, res: Response): Promise<void> {
+  await requireUser(req);
+  const id = String(req.params.id);
+  const result = await testAccount(id);
+  if (!result.success) throw new AppError(500, 'TEST_FAILED', result.message);
+  sendSuccess(res, { message: result.message });
+}

+ 17 - 0
backend/src/controllers/aggregate.controller.ts

@@ -0,0 +1,17 @@
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../http/response.js';
+import { fetchAggregateCounts } from '../services/aggregation.service.js';
+
+export async function getAggregateCounts(_req: Request, res: Response): Promise<void> {
+  const counts = await fetchAggregateCounts();
+
+  const toArray = (map: Map<string, number>) =>
+    [...map.entries()].map(([id, count]) => ({ _id: id, count }));
+
+  sendSuccess(res, {
+    memberCountByRoom: toArray(counts.memberCountByRoom),
+    todayMsgCountByRoom: toArray(counts.todayMsgCountByRoom),
+    totalMsgCountByRoom: toArray(counts.totalMsgCountByRoom),
+    groupCountByCommunity: toArray(counts.groupCountByCommunity),
+  });
+}

+ 124 - 0
backend/src/controllers/compliance.controller.ts

@@ -0,0 +1,124 @@
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../http/response.js';
+import { checkMessage } from '../services/compliance-check.service.js';
+import { analyzeRiskContext } from '../services/ai-analysis.service.js';
+import { createRiskEvent, createNotification } from '../services/risk-event.service.js';
+import Parse from '../db/parse-client.js';
+
+export async function testCompliance(req: Request, res: Response): Promise<void> {
+  const { roomId, content, senderId, senderName } = req.body as {
+    roomId?: string;
+    content?: string;
+    senderId?: string;
+    senderName?: string;
+  };
+
+  if (!content) {
+    sendError(res, 400, 'MISSING_CONTENT', '请提供消息内容');
+    return;
+  }
+
+  const rid = roomId || 'test-room';
+  const sid = senderId || 'test-user';
+  const sname = senderName || '测试用户';
+
+  // Step 1: 关键词初筛
+  const keywordResult = checkMessage(content);
+
+  if (!keywordResult.matched) {
+    sendSuccess(res, {
+      keywordMatched: false,
+      keywords: [],
+      keywordDuration: '<1ms',
+      aiTriggered: false,
+      aiResult: null,
+      eventCreated: false,
+    });
+    return;
+  }
+
+  // Step 2: 查询当天该用户消息作为上下文
+  const todayMessages = await getTodayUserMessages(rid, sid);
+  // 把当前测试消息也加入上下文
+  todayMessages.push({
+    content,
+    senderName: sname,
+    timestamp: new Date().toISOString(),
+  });
+
+  // Step 3: DeepSeek AI 分析
+  const aiResult = await analyzeRiskContext(todayMessages, keywordResult.keywords[0].word);
+
+  // Step 4: 创建事件(仅在 AI 判定违规时)
+  let eventCreated = false;
+  if (aiResult?.isRisky) {
+    const groupInfo = await getGroupInfo(rid);
+    const matchedKeywords = keywordResult.keywords.map(k => k.word);
+    await createRiskEvent({
+      groupId: rid,
+      groupName: groupInfo.groupName,
+      communityName: groupInfo.communityName,
+      type: 'keyword',
+      severity: aiResult.severity,
+      title: aiResult.title,
+      description: aiResult.description,
+      keywords: matchedKeywords,
+    });
+
+    await createNotification({
+      userId: '*',
+      type: 'risk',
+      severity: aiResult.severity,
+      title: `[测试] 风险预警:${groupInfo.groupName}`,
+      description: aiResult.description,
+      groupName: groupInfo.groupName,
+      communityName: groupInfo.communityName,
+      link: '/risk-control',
+    });
+
+    eventCreated = true;
+  }
+
+  sendSuccess(res, {
+    keywordMatched: true,
+    keywords: keywordResult.keywords,
+    keywordDuration: '<1ms',
+    aiTriggered: true,
+    aiResult: aiResult || { isRisky: false, severity: 'low', title: 'AI 未返回结果', description: 'API Key 未配置或调用失败' },
+    aiDuration: '1-3s',
+    eventCreated,
+  });
+}
+
+async function getTodayUserMessages(roomId: string, senderId: string): Promise<{ content: string; senderName: string; timestamp: string }[]> {
+  const todayStart = new Date();
+  todayStart.setHours(0, 0, 0, 0);
+  const q = new Parse.Query('Message');
+  q.equalTo('roomId', roomId);
+  q.equalTo('senderId', senderId);
+  q.containedIn('msgType', [0, 2]);
+  q.greaterThanOrEqualTo('timestamp', todayStart);
+  q.ascending('timestamp');
+  q.limit(200);
+  const rows = await q.find({ useMasterKey: true }) as any[];
+  return rows.map((r: any) => ({
+    content: (r.get('content') || '') as string,
+    senderName: (r.get('senderName') || '') as string,
+    timestamp: r.get('timestamp') ? new Date(r.get('timestamp') as Date).toISOString() : '',
+  }));
+}
+
+async function getGroupInfo(roomId: string): Promise<{ groupName: string; communityName: string }> {
+  try {
+    const q = new Parse.Query('GroupChat');
+    q.equalTo('roomId', roomId);
+    q.limit(1);
+    const obj = await q.first({ useMasterKey: true }) as any;
+    return {
+      groupName: obj?.get('roomName') || '未知群',
+      communityName: obj?.get('communityName') || '未知小区',
+    };
+  } catch {
+    return { groupName: '未知群', communityName: '未知小区' };
+  }
+}

+ 53 - 0
backend/src/controllers/groups.controller.ts

@@ -0,0 +1,53 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { sendSuccess } from '../http/response.js';
+import { requireUser } from '../auth/require-user.js';
+import { changeGroupName, inviteMembers, removeMembers } from '../services/room-operation.service.js';
+
+function requireDirector(role: string): void {
+  if (role !== 'director') {
+    throw new AppError(403, 'FORBIDDEN', '仅总监可执行此操作');
+  }
+}
+
+export async function handleModifyGroupName(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  requireDirector(user.role);
+  const roomId = String(req.params.roomId);
+  const { roomName } = req.body || {};
+
+  if (!roomId) throw new AppError(400, 'INVALID_PARAMS', '缺少 roomId');
+  if (!roomName || !roomName.trim()) throw new AppError(400, 'INVALID_PARAMS', '群名不能为空');
+
+  const result = await changeGroupName(roomId, roomName);
+  if (!result.success) throw new AppError(500, 'OPERATION_FAILED', result.message);
+  sendSuccess(res, { message: result.message });
+}
+
+export async function handleInviteMembers(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  requireDirector(user.role);
+  const roomId = String(req.params.roomId);
+  const { userIds } = req.body || {};
+
+  if (!roomId) throw new AppError(400, 'INVALID_PARAMS', '缺少 roomId');
+  if (!Array.isArray(userIds) || userIds.length === 0) throw new AppError(400, 'INVALID_PARAMS', '请提供要邀请的成员ID列表');
+
+  const result = await inviteMembers(roomId, userIds);
+  if (!result.success) throw new AppError(500, 'OPERATION_FAILED', result.message);
+  sendSuccess(res, { message: result.message });
+}
+
+export async function handleRemoveMembers(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  requireDirector(user.role);
+  const roomId = String(req.params.roomId);
+  const { userIds } = req.body || {};
+
+  if (!roomId) throw new AppError(400, 'INVALID_PARAMS', '缺少 roomId');
+  if (!Array.isArray(userIds) || userIds.length === 0) throw new AppError(400, 'INVALID_PARAMS', '请提供要移除的成员ID列表');
+
+  const result = await removeMembers(roomId, userIds);
+  if (!result.success) throw new AppError(500, 'OPERATION_FAILED', result.message);
+  sendSuccess(res, { message: result.message });
+}

+ 26 - 0
backend/src/controllers/health.controller.ts

@@ -0,0 +1,26 @@
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../http/response.js';
+import { checkParseDatabase } from '../db/parse-health.service.js';
+
+export async function getHealth(_req: Request, res: Response): Promise<void> {
+  const database = await checkParseDatabase();
+
+  sendSuccess(res, {
+    status: database.connected ? 'ok' : 'degraded',
+    platform: 'pc',
+    timestamp: new Date().toISOString(),
+    modules: ['health', 'auth', 'qiwe'],
+    database,
+    ...(database.connected
+      ? {}
+      : { warning: database.error || 'parse unreachable' }),
+  });
+}
+
+export async function getVersion(_req: Request, res: Response): Promise<void> {
+  sendSuccess(res, {
+    name: 'qiwe-backend',
+    version: '1.0.0',
+    platform: 'pc',
+  });
+}

+ 43 - 0
backend/src/controllers/messages.controller.ts

@@ -0,0 +1,43 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { sendSuccess } from '../http/response.js';
+import { checkParseDatabase } from '../db/parse-health.service.js';
+import { syncMessagesFromQiWe } from '../services/message-sync.service.js';
+import { getQiWeConfig, sendTextMessage } from '../qiwi/qiwe-api.service.js';
+
+export async function handleSyncMessages(req: Request, res: Response): Promise<void> {
+  const config = getQiWeConfig();
+  if (!config.token || !config.guid) {
+    throw new AppError(500, 'MISSING_CONFIG', '请在 .env 中配置 QIWE_TOKEN 和 QIWE_GUID');
+  }
+
+  const body = req.body ?? {};
+  const guid = typeof body.guid === 'string' && body.guid.trim() ? body.guid.trim() : config.guid;
+  const msgSeq = body.msgSeq !== undefined ? Number(body.msgSeq) : undefined;
+  const limit = body.limit !== undefined ? Number(body.limit) : undefined;
+  const resetCursor = Boolean(body.resetCursor);
+  const maxPages = body.maxPages !== undefined ? Number(body.maxPages) : undefined;
+
+  const sync = await syncMessagesFromQiWe({ guid, msgSeq, limit, maxPages, resetCursor });
+  const database = await checkParseDatabase();
+
+  sendSuccess(res, { sync, database });
+}
+
+export async function handleSendText(req: Request, res: Response): Promise<void> {
+  const config = getQiWeConfig();
+  if (!config.token || !config.guid) {
+    throw new AppError(500, 'MISSING_CONFIG', '请在 .env 中配置 QIWE_TOKEN 和 QIWE_GUID');
+  }
+
+  const { roomId, content, atList } = req.body || {};
+  if (!roomId || !content) {
+    throw new AppError(400, 'INVALID_PARAMS', '缺少 roomId 或 content');
+  }
+
+  await sendTextMessage(
+    { guid: config.guid, token: config.token, apiBase: config.apiBase },
+    { roomId, content, atList },
+  );
+  sendSuccess(res, { message: '消息已发送' });
+}

+ 15 - 0
backend/src/controllers/risk-event.controller.ts

@@ -0,0 +1,15 @@
+import type { Request, Response } from 'express';
+import { confirmRiskEvent } from '../services/risk-event.service.js';
+
+export async function handleConfirmRiskEvent(req: Request, res: Response): Promise<void> {
+  const { id } = req.params;
+  const { userId } = req.body;
+
+  if (!userId) {
+    res.status(400).json({ success: false, data: null, error: { message: 'userId is required', code: 'MISSING_USER_ID' } });
+    return;
+  }
+
+  const result = await confirmRiskEvent(id, userId);
+  res.json({ success: result.ok, data: null, error: result.error ? { message: result.error } : null });
+}

+ 26 - 0
backend/src/controllers/sync.controller.ts

@@ -0,0 +1,26 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { sendSuccess } from '../http/response.js';
+import { checkParseDatabase } from '../db/parse-health.service.js';
+import { reconcileGroupCatalog } from '../services/sync.service.js';
+import { getQiWeConfig } from '../qiwi/qiwe-api.service.js';
+
+export async function handleSyncGroups(_req: Request, res: Response): Promise<void> {
+  const config = getQiWeConfig();
+  if (!config.token || !config.guid) {
+    throw new AppError(500, 'MISSING_CONFIG', '请在 .env 中配置 QIWE_TOKEN 和 QIWE_GUID');
+  }
+
+  const dbBefore = await checkParseDatabase();
+  if (!dbBefore.connected) {
+    throw new AppError(503, 'DB_UNAVAILABLE', dbBefore.error || '无法连接 Parse 数据库', { database: dbBefore });
+  }
+
+  const catalog = await reconcileGroupCatalog();
+  const dbAfter = await checkParseDatabase();
+
+  sendSuccess(res, { catalog, sync: catalog.rooms, database: dbAfter });
+  if (catalog.rooms.errors.length > 0) {
+    console.warn(`[Sync] 部分群写入失败: ${catalog.rooms.errors.length} 条`);
+  }
+}

+ 60 - 0
backend/src/controllers/users.controller.ts

@@ -0,0 +1,60 @@
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../http/response.js';
+import * as usersService from '../services/users.service.js';
+
+export async function searchUsers(req: Request, res: Response): Promise<void> {
+  const { query = '' } = req.body ?? {};
+  const data = await usersService.searchUsers(String(query));
+  sendSuccess(res, data);
+}
+
+export async function listUsersByRole(req: Request, res: Response): Promise<void> {
+  const { role } = req.body ?? {};
+  const data = await usersService.listUsersByRole(role || undefined);
+  sendSuccess(res, data);
+}
+
+export async function listAllUsers(_req: Request, res: Response): Promise<void> {
+  const data = await usersService.listAllUsers();
+  sendSuccess(res, data);
+}
+
+export async function batchLookupUsers(req: Request, res: Response): Promise<void> {
+  const { userIds } = req.body ?? {};
+  if (!Array.isArray(userIds)) {
+    sendError(res, 400, 'INVALID_PARAMS', 'userIds must be an array');
+    return;
+  }
+  const data = await usersService.batchLookupUsers(userIds);
+  sendSuccess(res, data);
+}
+
+export async function updateUserScope(req: Request, res: Response): Promise<void> {
+  const { userId, storeId, districtId, role } = req.body ?? {};
+  if (!userId) {
+    sendError(res, 400, 'INVALID_PARAMS', 'userId is required');
+    return;
+  }
+  await usersService.updateUserScope(userId, { storeId, districtId, role });
+  sendSuccess(res, { ok: true });
+}
+
+export async function deleteUser(req: Request, res: Response): Promise<void> {
+  const { userId } = req.body ?? {};
+  if (!userId) {
+    sendError(res, 400, 'INVALID_PARAMS', 'userId is required');
+    return;
+  }
+  await usersService.deleteUser(userId);
+  sendSuccess(res, { ok: true });
+}
+
+export async function adminResetPassword(req: Request, res: Response): Promise<void> {
+  const { userId, newPassword } = req.body ?? {};
+  if (!userId || !newPassword) {
+    sendError(res, 400, 'INVALID_PARAMS', 'userId and newPassword are required');
+    return;
+  }
+  await usersService.adminResetPassword(userId, newPassword);
+  sendSuccess(res, { ok: true });
+}

+ 23 - 0
backend/src/controllers/webhook.controller.ts

@@ -0,0 +1,23 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { sendSuccess } from '../http/response.js';
+import { processWebhookEvent } from '../services/webhook.service.js';
+
+export async function handleWebhook(req: Request, res: Response): Promise<void> {
+  const body = req.body;
+
+  if (!body || !body.data || !Array.isArray(body.data)) {
+    throw new AppError(400, 'INVALID_BODY', 'invalid body: data array required');
+  }
+
+  sendSuccess(res, null);
+
+  for (const event of body.data) {
+    try {
+      await processWebhookEvent(event);
+    } catch (err: unknown) {
+      const message = err instanceof Error ? err.message : String(err);
+      console.error(`[Webhook Error] cmd=${event.cmd} msgType=${event.msgType}`, message);
+    }
+  }
+}

+ 8 - 0
backend/src/db/parse-client.ts

@@ -0,0 +1,8 @@
+import Parse from 'parse/node';
+import { getStringEnv } from '../config/env.js';
+
+Parse.initialize(getStringEnv('PARSE_APP_ID') || 'lami-ai');
+Parse.serverURL = getStringEnv('PARSE_SERVER_URL') || 'https://server.sh-lami.com/parse';
+Parse.masterKey = getStringEnv('PARSE_MASTER_KEY') || '';
+
+export default Parse;

+ 44 - 0
backend/src/db/parse-health.service.ts

@@ -0,0 +1,44 @@
+import Parse from './parse-client.js';
+import { getNumberEnv, getStringEnv } from '../config/env.js';
+import { withTimeout } from '../utils/with-timeout.js';
+
+export interface ParseDatabaseStatus {
+  connected: boolean;
+  serverURL: string;
+  appId: string;
+  counts?: {
+    groupChat: number;
+    groupMember: number;
+    message: number;
+  };
+  error?: string;
+}
+
+export async function checkParseDatabase(): Promise<ParseDatabaseStatus> {
+  const serverURL = Parse.serverURL || getStringEnv('PARSE_SERVER_URL') || '';
+  const appId = getStringEnv('PARSE_APP_ID') || 'lami-ai';
+
+  const timeoutMs = getNumberEnv('PARSE_TIMEOUT_MS', 8000);
+
+  try {
+    const [groupChat, groupMember, message] = await withTimeout(
+      Promise.all([
+        new Parse.Query('GroupChat').count({ useMasterKey: true }),
+        new Parse.Query('GroupMember').count({ useMasterKey: true }),
+        new Parse.Query('Message').count({ useMasterKey: true }),
+      ]),
+      timeoutMs,
+      'Parse database check',
+    );
+
+    return {
+      connected: true,
+      serverURL,
+      appId,
+      counts: { groupChat, groupMember, message },
+    };
+  } catch (err: unknown) {
+    const message = err instanceof Error ? err.message : String(err);
+    return { connected: false, serverURL, appId, error: message };
+  }
+}

+ 14 - 0
backend/src/errors/app-error.ts

@@ -0,0 +1,14 @@
+export class AppError extends Error {
+  public readonly statusCode: number;
+  public readonly errorCode: string;
+  public readonly details?: unknown;
+
+  constructor(statusCode: number, errorCode: string, message: string, details?: unknown) {
+    super(message);
+    this.name = 'AppError';
+    this.statusCode = statusCode;
+    this.errorCode = errorCode;
+    this.details = details;
+    Object.setPrototypeOf(this, AppError.prototype);
+  }
+}

+ 9 - 0
backend/src/http/async-handler.ts

@@ -0,0 +1,9 @@
+import type { Request, Response, NextFunction, RequestHandler } from 'express';
+
+type AsyncRequestHandler = (req: Request, res: Response, next: NextFunction) => Promise<void>;
+
+export function asyncHandler(fn: AsyncRequestHandler): RequestHandler {
+  return (req, res, next) => {
+    Promise.resolve(fn(req, res, next)).catch(next);
+  };
+}

+ 17 - 0
backend/src/http/error-handler.ts

@@ -0,0 +1,17 @@
+import type { Request, Response, NextFunction } from 'express';
+import { AppError } from '../errors/app-error.js';
+import { sendError } from './response.js';
+
+export function errorHandler(err: Error, _req: Request, res: Response, _next: NextFunction): void {
+  if (err instanceof AppError) {
+    sendError(res, err.statusCode, err.errorCode, err.message, err.details);
+    return;
+  }
+
+  console.error('[ErrorHandler] 未预期的服务器错误:', err);
+  const message =
+    process.env.NODE_ENV === 'production'
+      ? '服务器内部错误'
+      : err.message || '服务器内部错误';
+  sendError(res, 500, 'INTERNAL_ERROR', message);
+}

+ 6 - 0
backend/src/http/not-found.middleware.ts

@@ -0,0 +1,6 @@
+import type { Request, Response } from 'express';
+import { sendError } from './response.js';
+
+export function notFoundHandler(req: Request, res: Response): void {
+  sendError(res, 404, 'NOT_FOUND', `路由不存在: ${req.method} ${req.originalUrl}`);
+}

+ 37 - 0
backend/src/http/response.ts

@@ -0,0 +1,37 @@
+import type { Response } from 'express';
+
+interface SuccessBody<T> {
+  success: true;
+  data: T;
+  error: null;
+}
+
+interface ErrorBody {
+  success: false;
+  data: null;
+  error: {
+    message: string;
+    code: string;
+    details?: unknown;
+  };
+}
+
+export function sendSuccess<T>(res: Response, data: T, statusCode = 200): void {
+  const body: SuccessBody<T> = { success: true, data, error: null };
+  res.status(statusCode).json(body);
+}
+
+export function sendError(
+  res: Response,
+  statusCode: number,
+  code: string,
+  message: string,
+  details?: unknown,
+): void {
+  const body: ErrorBody = {
+    success: false,
+    data: null,
+    error: { message, code, ...(details !== undefined ? { details } : {}) },
+  };
+  res.status(statusCode).json(body);
+}

+ 14 - 0
backend/src/index.ts

@@ -0,0 +1,14 @@
+import 'dotenv/config';
+import './db/parse-client.js';
+
+// 生产服务器 backend_Gyd8G 已管理 Parse 数据库的:
+// - Schema 建表/补字段
+// - 种子数据(门店/小区/组织树/演示账号)
+// - Webhook 企微回调
+// - 定时同步群列表 + 历史消息
+// - Scheduler 定时任务
+//
+// 本地 backend 仅提供前端 API 查询层,不重复上述工作。
+// 数据库通过 PARSE_SERVER_URL 直连同一 Parse 实例。
+
+await import('./server.js');

+ 283 - 0
backend/src/qiwi/qiwe-api.service.ts

@@ -0,0 +1,283 @@
+const API_BASE = process.env.QIWE_API_BASE || 'https://manager.qiweapi.com/qiwe';
+const TOKEN = process.env.QIWE_TOKEN || '';
+const GUID = process.env.QIWE_GUID || '';
+
+export interface QiWeAccountConfig {
+  guid: string;
+  token: string;
+  apiBase?: string;
+}
+
+interface QiWeResponse<T = any> {
+  code: number;
+  msg: string;
+  data: T;
+}
+
+interface RoomInfo {
+  roomId: string;
+  roomName: string;
+  roomOwnerId: string;
+  roomMemberCount: number;
+  roomAvatarUrl: string;
+  roomCreateTime: string;
+  roomUpdateTime: string;
+  roomFlag: number;
+  ticket: string;
+}
+
+interface SessionInfo {
+  sessionId: string;
+  sessionType: number;
+}
+
+export interface InternalContactRef {
+  userId: string;
+  partyId: string;
+}
+
+export interface ContactDetail {
+  userId: string;
+  name: string;
+  mobile: string;
+  position: string;
+}
+
+async function callApi<T = any>(
+  method: string,
+  params: Record<string, any> = {},
+  account?: QiWeAccountConfig,
+): Promise<QiWeResponse<T>> {
+  const base = account?.apiBase || API_BASE;
+  const token = account?.token || TOKEN;
+
+  const resp = await fetch(`${base}/api/qw/doApi`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-QIWEI-TOKEN': token,
+    },
+    body: JSON.stringify({ tokenId: token, method, params }),
+  });
+
+  if (!resp.ok) {
+    throw new Error(`QiWe API HTTP ${resp.status}: ${resp.statusText}`);
+  }
+
+  const json = await resp.json();
+  if (json.code !== 0) {
+    throw new Error(`QiWe API error code=${json.code}: ${json.msg}`);
+  }
+
+  return json;
+}
+
+/** 获取创建的群列表(含群名称、群主、人数等完整信息) */
+async function getRoomList(
+  page = 1,
+  pageSize = 50,
+  guid?: string,
+): Promise<{
+  rooms: RoomInfo[];
+  hasMore: boolean;
+  total: number;
+}> {
+  const resp = await callApi<{
+    hasMore: boolean;
+    nextStartIndex: number;
+    roomCount: number;
+    roomList: RoomInfo[];
+  }>('/room/getRoomList', { guid: guid || GUID, page, pageSize });
+
+  return {
+    rooms: resp.data.roomList || [],
+    hasMore: resp.data.hasMore || false,
+    total: resp.data.roomCount || 0,
+  };
+}
+
+/** 获取所有群(遍历分页) */
+export async function getAllRooms(guid?: string): Promise<RoomInfo[]> {
+  const all: RoomInfo[] = [];
+  let page = 1;
+
+  while (true) {
+    const { rooms, hasMore } = await getRoomList(page, 50, guid);
+    all.push(...rooms);
+    if (!hasMore) break;
+    page++;
+  }
+
+  return all;
+}
+
+/** 获取会话列表(含群会话和个人会话) */
+export async function getSessionList(sessionType?: number, guid?: string): Promise<SessionInfo[]> {
+  const resp = await callApi<{
+    collectList: SessionInfo[];
+    shieldList: SessionInfo[];
+    topList: SessionInfo[];
+  }>('/session/getSessionList', { guid: guid || GUID });
+
+  const all = [
+    ...(resp.data.collectList || []),
+    ...(resp.data.shieldList || []),
+    ...(resp.data.topList || []),
+  ];
+
+  if (sessionType !== undefined) {
+    return all.filter((s) => s.sessionType === sessionType);
+  }
+  return all;
+}
+
+/** 内部联系人分页(企微组织成员) */
+export async function getInternalContactList(limit = 100, guid?: string): Promise<InternalContactRef[]> {
+  if (!TOKEN || !GUID) return [];
+
+  const resp = await callApi<{
+    contactList: InternalContactRef[];
+    hasMore: boolean;
+    currentVersion?: string;
+  }>('/contact/getWxWorkContactList', {
+    guid: guid || GUID,
+    limit,
+  });
+
+  return resp.data.contactList || [];
+}
+
+/** 联系人详情批量(API-18 /contact/batchGetUserinfo) */
+export async function getContactDetailsBatch(
+  userIds: string[],
+  guid?: string,
+): Promise<ContactDetail[]> {
+  if (!TOKEN || userIds.length === 0) return [];
+
+  try {
+    const resp = await callApi<{
+      contactList: Array<{
+        userId: string;
+        nickname?: string;
+        name?: string;
+        mobile?: string;
+        position?: string;
+      }>;
+    }>('/contact/batchGetUserinfo', { guid: guid || GUID, userIdList: userIds });
+    return (resp.data.contactList || []).map((c) => ({
+      userId: c.userId,
+      name: c.nickname || c.name || c.userId,
+      mobile: c.mobile || '',
+      position: c.position || '',
+    }));
+  } catch {
+    return userIds.map((userId) => ({ userId, name: userId, mobile: '', position: '' }));
+  }
+}
+
+export interface SyncMsgListItem {
+  fromRoomId?: number | string;
+  senderId?: number | string;
+  receiverId?: number | string;
+  senderName?: string;
+  msgType?: number;
+  timestamp?: number;
+  seq?: number;
+  msgServerId?: number;
+  msgUniqueIdentifier?: string;
+  msgData?: Record<string, unknown>;
+}
+
+/** 同步历史消息分页(API-06 /msg/syncMsg) */
+export async function syncMsgPage(
+  guid: string,
+  msgSeq: number,
+  limit = 50,
+): Promise<{
+  hasMore: number | boolean;
+  travelSyncKey: number;
+  syncMsgList: SyncMsgListItem[];
+}> {
+  const resp = await callApi<{
+    hasMore: number | boolean;
+    travelSyncKey: number;
+    syncMsgList: SyncMsgListItem[];
+  }>('/msg/syncMsg', { guid, msgSeq, limit });
+
+  return {
+    hasMore: resp.data.hasMore ?? 0,
+    travelSyncKey: resp.data.travelSyncKey ?? msgSeq,
+    syncMsgList: resp.data.syncMsgList || [],
+  };
+}
+
+export interface RoomMemberRef {
+  userId: string;
+  nickname: string;
+}
+
+export interface RoomDetailDto {
+  roomId: string;
+  roomName: string;
+  memberCount: number;
+  ownerId: string;
+  members: RoomMemberRef[];
+}
+
+function parseRoomMember(raw: unknown): RoomMemberRef | null {
+  if (!raw || typeof raw !== 'object') return null;
+  const o = raw as Record<string, unknown>;
+  const userId = String(o.userId ?? o.memberId ?? '');
+  if (!userId) return null;
+  const nickname = String(o.nickname ?? o.name ?? o.memberName ?? o.displayName ?? '');
+  return { userId, nickname };
+}
+
+/** 批量获取群详情(API-08) */
+export async function batchGetRoomDetails(
+  guid: string,
+  roomIdList: string[],
+): Promise<RoomDetailDto[]> {
+  const ids = roomIdList.map((id) => String(id).trim()).filter(Boolean);
+  if (!TOKEN || !guid || ids.length === 0) return [];
+
+  const resp = await callApi<{
+    roomList: Array<{
+      roomId: string;
+      roomName: string;
+      roomOwnerId?: string;
+      memberList?: unknown[];
+    }>;
+  }>('/room/batchGetRoomDetail', { guid, roomIdList: ids });
+
+  return (resp.data.roomList || []).map((r) => {
+    const members = (r.memberList || [])
+      .map(parseRoomMember)
+      .filter((m): m is RoomMemberRef => m !== null);
+    return {
+      roomId: String(r.roomId),
+      roomName: r.roomName || '',
+      memberCount: members.length,
+      ownerId: r.roomOwnerId ? String(r.roomOwnerId) : '',
+      members,
+    };
+  });
+}
+
+/** 发送文本消息 */
+export async function sendTextMessage(
+  account: QiWeAccountConfig,
+  params: { roomId: string; content: string; atList?: string[] },
+): Promise<void> {
+  await callApi('/msg/sendText', {
+    guid: account.guid,
+    roomId: params.roomId,
+    content: params.content,
+    atList: params.atList || [],
+  }, account);
+}
+
+/** 获取 Token 和 Guid 的可用性 */
+export function getQiWeConfig() {
+  return { apiBase: API_BASE, token: TOKEN, guid: GUID };
+}

+ 83 - 0
backend/src/qiwi/qiwe-room.api.ts

@@ -0,0 +1,83 @@
+import type { QiWeAccountConfig } from './qiwe-api.service.js';
+
+const API_BASE = process.env.QIWE_API_BASE || 'https://manager.qiweapi.com/qiwe';
+const TOKEN = process.env.QIWE_TOKEN || '';
+const GUID = process.env.QIWE_GUID || '';
+
+interface QiWeResponse<T = any> {
+  code: number;
+  msg: string;
+  data: T;
+}
+
+async function callRoomApi<T = any>(
+  method: string,
+  params: Record<string, any> = {},
+  account?: QiWeAccountConfig,
+): Promise<QiWeResponse<T>> {
+  const base = account?.apiBase || API_BASE;
+  const token = account?.token || TOKEN;
+
+  const resp = await fetch(`${base}/api/qw/doApi`, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-QIWEI-TOKEN': token,
+    },
+    body: JSON.stringify({ tokenId: token, method, params }),
+  });
+
+  if (!resp.ok) {
+    throw new Error(`QiWe API HTTP ${resp.status}: ${resp.statusText}`);
+  }
+
+  const json = await resp.json();
+  if (json.code !== 0) {
+    throw new Error(`QiWe API error code=${json.code}: ${json.msg}`);
+  }
+
+  return json;
+}
+
+function g(account?: QiWeAccountConfig): string {
+  return account?.guid || GUID;
+}
+
+/** 修改群名 */
+export async function modifyRoomName(
+  roomId: string,
+  roomName: string,
+  account?: QiWeAccountConfig,
+): Promise<void> {
+  await callRoomApi('/room/modifyRoomName', {
+    guid: g(account),
+    roomId,
+    roomName,
+  }, account);
+}
+
+/** 邀请成员入群 */
+export async function inviteRoomMember(
+  roomId: string,
+  userIds: string[],
+  account?: QiWeAccountConfig,
+): Promise<void> {
+  await callRoomApi('/room/inviteRoomMember', {
+    guid: g(account),
+    roomId,
+    userIdList: userIds,
+  }, account);
+}
+
+/** 移除群成员 */
+export async function removeRoomMember(
+  roomId: string,
+  userId: string,
+  account?: QiWeAccountConfig,
+): Promise<void> {
+  await callRoomApi('/room/removeRoomMember', {
+    guid: g(account),
+    roomId,
+    userId,
+  }, account);
+}

+ 7 - 0
backend/src/routes/aggregate.routes.ts

@@ -0,0 +1,7 @@
+import { Router } from 'express';
+import { asyncHandler } from '../http/async-handler.js';
+import { getAggregateCounts } from '../controllers/aggregate.controller.js';
+
+const router = Router();
+router.get('/counts', asyncHandler(getAggregateCounts));
+export default router;

+ 9 - 0
backend/src/routes/compliance.routes.ts

@@ -0,0 +1,9 @@
+import { Router, type Router as RouterType } from 'express';
+import { asyncHandler } from '../http/async-handler.js';
+import { testCompliance } from '../controllers/compliance.controller.js';
+
+const router: RouterType = Router();
+
+router.post('/test', asyncHandler(testCompliance));
+
+export default router;

+ 10 - 0
backend/src/routes/health.routes.ts

@@ -0,0 +1,10 @@
+import { Router, type Router as RouterType } from 'express';
+import { asyncHandler } from '../http/async-handler.js';
+import { getHealth, getVersion } from '../controllers/health.controller.js';
+
+const router: RouterType = Router();
+
+router.get('/health', asyncHandler(getHealth));
+router.get('/version', asyncHandler(getVersion));
+
+export default router;

+ 30 - 0
backend/src/routes/qiwei.routes.ts

@@ -0,0 +1,30 @@
+import { Router, type Router as RouterType } from 'express';
+import { asyncHandler } from '../http/async-handler.js';
+import { handleWebhook } from '../controllers/webhook.controller.js';
+import { handleSyncGroups } from '../controllers/sync.controller.js';
+import { handleTestAccount } from '../controllers/account.controller.js';
+import { handleModifyGroupName, handleInviteMembers, handleRemoveMembers } from '../controllers/groups.controller.js';
+import { handleSyncMessages, handleSendText } from '../controllers/messages.controller.js';
+import { handleConfirmRiskEvent } from '../controllers/risk-event.controller.js';
+const router: RouterType = Router();
+
+// Webhook & 同步
+router.post('/webhook', asyncHandler(handleWebhook));
+router.post('/sync-groups', asyncHandler(handleSyncGroups));
+router.post('/sync-messages', asyncHandler(handleSyncMessages));
+
+// 企微账号
+router.post('/accounts/:id/test', asyncHandler(handleTestAccount));
+
+// 群操作(调企微 API)
+router.put('/groups/:roomId/name', asyncHandler(handleModifyGroupName));
+router.post('/groups/:roomId/invite', asyncHandler(handleInviteMembers));
+router.post('/groups/:roomId/remove', asyncHandler(handleRemoveMembers));
+
+// 发送消息
+router.post('/messages/send-text', asyncHandler(handleSendText));
+
+// 接单确认(阻断升级 + 批量标记已读)
+router.post('/risk-event/:id/confirm', asyncHandler(handleConfirmRiskEvent));
+
+export default router;

+ 23 - 0
backend/src/routes/users.routes.ts

@@ -0,0 +1,23 @@
+import { Router } from 'express';
+import { asyncHandler } from '../http/async-handler.js';
+import {
+  searchUsers,
+  listUsersByRole,
+  listAllUsers,
+  batchLookupUsers,
+  updateUserScope,
+  deleteUser,
+  adminResetPassword,
+} from '../controllers/users.controller.js';
+
+const router = Router();
+
+router.post('/search', asyncHandler(searchUsers));
+router.post('/list-by-role', asyncHandler(listUsersByRole));
+router.post('/list', asyncHandler(listAllUsers));
+router.post('/batch', asyncHandler(batchLookupUsers));
+router.post('/update-scope', asyncHandler(updateUserScope));
+router.post('/delete', asyncHandler(deleteUser));
+router.post('/reset-password', asyncHandler(adminResetPassword));
+
+export default router;

+ 10 - 0
backend/src/server.ts

@@ -0,0 +1,10 @@
+import app from './app.js';
+import { getNumberEnv } from './config/env.js';
+
+const PORT = getNumberEnv('PC_PORT', 3101);
+
+app.listen(PORT, () => {
+  console.log(`[API] 启动成功,端口: ${PORT}`);
+  console.log(`[API] 健康检查: http://localhost:${PORT}/api/health`);
+  console.log(`[API] 数据均来自 Parse: ${process.env.PARSE_SERVER_URL || 'https://server.sh-lami.com/parse'}`);
+});

+ 25 - 0
backend/src/services/account.service.ts

@@ -0,0 +1,25 @@
+import Parse from '../db/parse-client.js';
+import { getSessionList } from '../qiwi/qiwe-api.service.js';
+
+export async function testAccount(id: string): Promise<{ success: boolean; message: string }> {
+  const query = new Parse.Query('QiWeAccount');
+  const obj = await query.get(id, { useMasterKey: true });
+  if (!obj) {
+    return { success: false, message: '账号不存在' };
+  }
+
+  const guid = obj.get('guid') as string;
+
+  try {
+    await getSessionList(undefined, guid);
+    obj.set('status', 'online');
+    obj.set('lastHeartbeat', new Date());
+    await obj.save(null, { useMasterKey: true });
+    return { success: true, message: '连接成功' };
+  } catch (err: unknown) {
+    obj.set('status', 'offline');
+    await obj.save(null, { useMasterKey: true });
+    const msg = err instanceof Error ? err.message : String(err);
+    return { success: false, message: `连接失败: ${msg}` };
+  }
+}

+ 78 - 0
backend/src/services/aggregation.service.ts

@@ -0,0 +1,78 @@
+import Parse from '../db/parse-client.js';
+
+export interface AggregateCountsResult {
+  memberCountByRoom: Map<string, number>;
+  todayMsgCountByRoom: Map<string, number>;
+  totalMsgCountByRoom: Map<string, number>;
+  groupCountByCommunity: Map<string, number>;
+}
+
+/** 按 roomId 字段分组计数 */
+function countByRoomId(rows: any[], roomIdField = 'roomId'): Map<string, number> {
+  const map = new Map<string, number>();
+  for (const r of rows) {
+    const rid = typeof r.get === 'function' ? (r.get(roomIdField) as string) : (r[roomIdField] as string);
+    if (rid) map.set(rid, (map.get(rid) || 0) + 1);
+  }
+  return map;
+}
+
+export async function fetchAggregateCounts(): Promise<AggregateCountsResult> {
+  const todayStart = new Date();
+  todayStart.setHours(0, 0, 0, 0);
+
+  const [memberCounts, todayMsgCounts, totalMsgCounts, groupCounts] = await Promise.all([
+    // 1. 活跃成员数 — select 只取 roomId
+    (async () => {
+      const q = new Parse.Query('GroupMember');
+      q.equalTo('status', 'active');
+      q.select('roomId');
+      q.limit(100000);
+      const rows = await q.find({ useMasterKey: true });
+      return countByRoomId(rows);
+    })(),
+
+    // 2. 今日消息数 — select 只取 roomId
+    (async () => {
+      const q = new Parse.Query('Message');
+      q.containedIn('msgType', [0, 2]);
+      q.notEqualTo('content', '');
+      q.greaterThanOrEqualTo('timestamp', todayStart);
+      q.select('roomId');
+      q.limit(100000);
+      const rows = await q.find({ useMasterKey: true });
+      return countByRoomId(rows);
+    })(),
+
+    // 3. 总消息数 — select 只取 roomId
+    (async () => {
+      const q = new Parse.Query('Message');
+      q.select('roomId');
+      q.limit(200000);
+      const rows = await q.find({ useMasterKey: true });
+      return countByRoomId(rows);
+    })(),
+
+    // 4. 群数按小区 — select 只取 community 指针
+    (async () => {
+      const q = new Parse.Query('GroupChat');
+      q.select('community');
+      q.limit(5000);
+      const rows = await q.find({ useMasterKey: true });
+      const map = new Map<string, number>();
+      for (const r of rows) {
+        const community = r.get('community');
+        const cid = community?.id || '_null';
+        map.set(cid, (map.get(cid) || 0) + 1);
+      }
+      return map;
+    })(),
+  ]);
+
+  return {
+    memberCountByRoom: memberCounts,
+    todayMsgCountByRoom: todayMsgCounts,
+    totalMsgCountByRoom: totalMsgCounts,
+    groupCountByCommunity: groupCounts,
+  };
+}

+ 101 - 0
backend/src/services/ai-analysis.service.ts

@@ -0,0 +1,101 @@
+import { getStringEnv } from '../config/env.js';
+
+export interface RiskAnalysisResult {
+  isRisky: boolean;
+  severity: 'high' | 'medium' | 'low';
+  title: string;
+  description: string;
+}
+
+const API_KEY = getStringEnv('DEEPSEEK_API_KEY') || '';
+const API_URL = getStringEnv('DEEPSEEK_API_URL') || 'https://api.deepseek.com/v1/chat/completions';
+const MODEL = getStringEnv('DEEPSEEK_MODEL') || 'deepseek-chat';
+
+const SYSTEM_PROMPT = `你是一家全屋定制公司(拉迷家居)的合规审核员。你需要判断客户群消息是否存在风险。
+
+风险类型包括:
+- 投诉抱怨(质量、工期、服务态度等)
+- 竞品对比(提及欧派、索菲亚、尚品宅配等竞品)
+- 价格敏感讨论(太贵、砍价、退款等)
+- 负面情绪(不满、威胁、换商家、维权等)
+- 敏感话题(甲醛超标、欺诈等)
+
+判断标准:
+- high: 明确投诉/维权/法律威胁,可能升级为纠纷
+- medium: 负面情绪或竞品讨论,需关注
+- low: 轻微不满或一般性价格讨论
+
+请以 JSON 格式回复(不要包含其他内容):
+{"isRisky": true/false, "severity": "high"/"medium"/"low", "title": "简短标题(≤20字)", "description": "一句话描述风险内容(≤100字)"}`;
+
+export async function analyzeRiskContext(
+  messages: { content: string; senderName: string; timestamp: string }[],
+  triggeredKeyword: string,
+): Promise<RiskAnalysisResult | null> {
+  if (!API_KEY || API_KEY === 'sk-xxx') {
+    console.warn('[AI] DeepSeek API key 未配置,跳过 AI 分析');
+    return null;
+  }
+
+  const conversation = messages
+    .map((m) => `[${m.timestamp}] ${m.senderName}: ${m.content}`)
+    .join('\n');
+
+  const userPrompt = `触发关键词: "${triggeredKeyword}"
+
+以下是该用户当天在该群发送的所有消息(按时间顺序):
+---
+${conversation}
+---
+
+请分析这些消息是否存在合规风险。`;
+
+  try {
+    const res = await fetch(API_URL, {
+      method: 'POST',
+      headers: {
+        'Content-Type': 'application/json',
+        Authorization: `Bearer ${API_KEY}`,
+      },
+      body: JSON.stringify({
+        model: MODEL,
+        messages: [
+          { role: 'system', content: SYSTEM_PROMPT },
+          { role: 'user', content: userPrompt },
+        ],
+        temperature: 0.3,
+        max_tokens: 256,
+      }),
+    });
+
+    if (!res.ok) {
+      console.warn(`[AI] DeepSeek API error ${res.status}: ${await res.text().catch(() => '')}`);
+      return null;
+    }
+
+    const json = await res.json() as any;
+    const content = json?.choices?.[0]?.message?.content || '';
+    return parseAIResponse(content);
+  } catch (err) {
+    console.warn('[AI] DeepSeek 调用失败:', (err as Error).message);
+    return null;
+  }
+}
+
+function parseAIResponse(content: string): RiskAnalysisResult | null {
+  try {
+    // 提取 JSON(可能包含在 markdown 代码块中)
+    const jsonMatch = content.match(/\{[\s\S]*\}/);
+    if (!jsonMatch) return null;
+    const parsed = JSON.parse(jsonMatch[0]);
+    return {
+      isRisky: !!parsed.isRisky,
+      severity: parsed.severity || 'medium',
+      title: parsed.title || '风险预警',
+      description: parsed.description || '',
+    };
+  } catch {
+    console.warn('[AI] 无法解析 DeepSeek 响应:', content.slice(0, 200));
+    return null;
+  }
+}

+ 21 - 0
backend/src/services/compliance-check.service.ts

@@ -0,0 +1,21 @@
+import { scan, containsAny } from './keyword-matcher.js';
+
+export interface ComplianceCheckResult {
+  matched: boolean;
+  keywords: { word: string; category: string; severity: string }[];
+}
+
+/** 初筛:用 AC 自动机扫描消息内容 */
+export function checkMessage(content: string): ComplianceCheckResult {
+  if (!content) return { matched: false, keywords: [] };
+  const keywords = scan(content);
+  return {
+    matched: keywords.length > 0,
+    keywords,
+  };
+}
+
+/** 快速判断是否需要进入 AI 分析 */
+export function isSuspicious(content: string): boolean {
+  return containsAny(content);
+}

+ 126 - 0
backend/src/services/group-infra.service.ts

@@ -0,0 +1,126 @@
+import Parse from '../db/parse-client.js';
+import { batchGetRoomDetails } from '../qiwi/qiwe-api.service.js';
+
+const ACTIVE_QIWE_GUID = process.env.QIWE_GUID || '';
+
+function isPlaceholderRoomName(name: string): boolean {
+  return !name || name === '未命名群' || /^群 \d{6}$/.test(name);
+}
+
+/** 消息入库时发现群不存在则创建占位群 */
+export async function ensureGroupChatForRoom(roomId: string, guid: string): Promise<void> {
+  if (!roomId || !guid) return;
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const existing = await query.first({ useMasterKey: true });
+  if (existing) return;
+
+  const obj = new Parse.Object('GroupChat');
+  obj.set('roomId', roomId);
+  obj.set('guid', guid);
+  obj.set('roomName', `群 ${roomId.slice(-6)}`);
+  obj.set('status', 'active');
+  obj.set('memberCount', 0);
+  await obj.save(null, { useMasterKey: true });
+  console.log(`[GroupChat] 由消息自动创建群 roomId=${roomId}`);
+}
+
+/** 根据已同步的群消息补全 GroupChat */
+export async function backfillGroupChatsFromMessages(guid: string): Promise<number> {
+  const query = new Parse.Query('Message');
+  query.equalTo('guid', guid);
+  query.equalTo('isGroupChat', 1);
+  query.exists('roomId');
+  query.limit(5000);
+  query.select('roomId');
+  const rows = await query.find({ useMasterKey: true });
+
+  const roomIds = new Set<string>();
+  for (const row of rows) {
+    const rid = row.get('roomId');
+    const key = rid ? String(rid) : '';
+    if (key.length >= 14 && /^\d+$/.test(key)) roomIds.add(key);
+  }
+
+  // 批量查询已存在的 GroupChat
+  const roomIdList = [...roomIds];
+  const existingQuery = new Parse.Query('GroupChat');
+  existingQuery.containedIn('roomId', roomIdList);
+  existingQuery.equalTo('guid', guid);
+  existingQuery.limit(roomIdList.length + 10);
+  existingQuery.select('roomId');
+  const existingObjs = await existingQuery.find({ useMasterKey: true });
+  const existingSet = new Set(existingObjs.map((o: Parse.Object) => String(o.get('roomId') || '')));
+
+  // 批量创建不存在的 GroupChat
+  const toCreate = roomIdList.filter(id => !existingSet.has(id));
+  if (toCreate.length > 0) {
+    const objs = toCreate.map(roomId => {
+      const obj = new Parse.Object('GroupChat');
+      obj.set('roomId', roomId);
+      obj.set('guid', guid);
+      obj.set('roomName', `群 ${roomId.slice(-6)}`);
+      obj.set('status', 'active');
+      obj.set('memberCount', 0);
+      return obj;
+    });
+    await Parse.Object.saveAll(objs, { useMasterKey: true });
+  }
+  if (toCreate.length > 0) {
+    console.log(`[GroupChat] 从消息补全 ${toCreate.length} 个群记录`);
+  }
+  return toCreate.length;
+}
+
+/** 向企微拉取真实群名,替换占位名 */
+export async function enrichPlaceholderGroupNames(guid: string, batchSize = 30): Promise<number> {
+  if (!guid) return 0;
+
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('guid', guid);
+  query.limit(200);
+  const groups = await query.find({ useMasterKey: true });
+  const targets = groups.filter((g: Parse.Object) => isPlaceholderRoomName(String(g.get('roomName') || '')));
+  if (targets.length === 0) return 0;
+
+  const roomIds = targets.slice(0, batchSize).map((g: Parse.Object) => String(g.get('roomId')));
+  let details: Awaited<ReturnType<typeof batchGetRoomDetails>>;
+  try {
+    details = await batchGetRoomDetails(guid, roomIds);
+  } catch (err: unknown) {
+    const message = err instanceof Error ? err.message : String(err);
+    console.warn(`[GroupChat] 拉取群详情失败: ${message}`);
+    return 0;
+  }
+
+  const detailMap = new Map(details.map((d) => [d.roomId, d]));
+  const toSave: Parse.Object[] = [];
+  for (const obj of targets) {
+    const roomId = String(obj.get('roomId'));
+    const detail = detailMap.get(roomId);
+    if (!detail?.roomName) continue;
+    obj.set('roomName', detail.roomName);
+    if (detail.memberCount > 0) obj.set('memberCount', detail.memberCount);
+    if (detail.ownerId) obj.set('ownerId', detail.ownerId);
+    toSave.push(obj);
+  }
+  if (toSave.length > 0) {
+    await Parse.Object.saveAll(toSave, { useMasterKey: true });
+    console.log(`[GroupChat] 已补全 ${toSave.length} 个群的真实名称`);
+  }
+  return toSave.length;
+}
+
+let lastPrepareAt = 0;
+const PREPARE_TTL_MS = 10 * 60 * 1000;
+
+/** 列表展示前对齐群目录:从 Message 表补全有消息但未入列表的群 */
+export async function prepareGroupListForDisplay(force = false): Promise<number> {
+  if (!ACTIVE_QIWE_GUID) return 0;
+  if (!force && Date.now() - lastPrepareAt < PREPARE_TTL_MS) return 0;
+  lastPrepareAt = Date.now();
+  const created = await backfillGroupChatsFromMessages(ACTIVE_QIWE_GUID);
+  await enrichPlaceholderGroupNames(ACTIVE_QIWE_GUID);
+  return created;
+}

+ 121 - 0
backend/src/services/keyword-matcher.ts

@@ -0,0 +1,121 @@
+/**
+ * Aho-Corasick 自动机 — 多模式字符串匹配
+ * 内存占用: ~200 bytes/词 (含中文字符), 匹配复杂度 O(n+m)
+ */
+interface TrieNode {
+  children: Map<string, TrieNode>;
+  fail: TrieNode | null;
+  outputs: KeywordMatch[];
+}
+
+interface KeywordMatch {
+  word: string;
+  category: string;
+  severity: string;
+}
+
+let root: TrieNode | null = null;
+
+function createNode(): TrieNode {
+  return { children: new Map(), fail: null, outputs: [] };
+}
+
+function build(patterns: KeywordMatch[]): TrieNode {
+  const r = createNode();
+
+  // 构建 Trie
+  for (const p of patterns) {
+    let node = r;
+    for (const ch of p.word) {
+      let next = node.children.get(ch);
+      if (!next) {
+        next = createNode();
+        node.children.set(ch, next);
+      }
+      node = next;
+    }
+    node.outputs.push(p);
+  }
+
+  // BFS 构建 fail 指针
+  const queue: TrieNode[] = [];
+  for (const child of r.children.values()) {
+    child.fail = r;
+    queue.push(child);
+  }
+
+  while (queue.length > 0) {
+    const current = queue.shift()!;
+    for (const [ch, child] of current.children) {
+      queue.push(child);
+      let fail = current.fail;
+      while (fail && !fail.children.has(ch)) {
+        fail = fail.fail;
+      }
+      child.fail = fail ? (fail.children.get(ch) ?? r) : r;
+      // 合并 fail 路径上的输出
+      if (child.fail) {
+        child.outputs.push(...child.fail.outputs);
+      }
+    }
+  }
+
+  return r;
+}
+
+function scanInternal(node: TrieNode, text: string): KeywordMatch[] {
+  const results: KeywordMatch[] = [];
+  const seen = new Set<string>();
+  let current = node;
+
+  for (const ch of text) {
+    while (current !== node && !current.children.has(ch)) {
+      current = current.fail!;
+    }
+    const next = current.children.get(ch);
+    if (next) {
+      current = next;
+      for (const out of current.outputs) {
+        if (!seen.has(out.word)) {
+          seen.add(out.word);
+          results.push(out);
+        }
+      }
+    }
+  }
+
+  return results;
+}
+
+/** 从 Parse RiskKeyword 表加载并构建自动机 */
+export async function loadKeywords(keywords: KeywordMatch[]): Promise<void> {
+  root = keywords.length > 0 ? build(keywords) : null;
+}
+
+/** 扫描文本,返回命中的所有关键词 */
+export function scan(text: string): KeywordMatch[] {
+  if (!root || !text) return [];
+  return scanInternal(root, text);
+}
+
+/** 快速检查文本是否包含任意关键词 */
+export function containsAny(text: string): boolean {
+  if (!root || !text) return false;
+  let current = root;
+  for (const ch of text) {
+    while (current !== root && !current.children.has(ch)) {
+      current = current.fail!;
+    }
+    const next = current.children.get(ch);
+    if (next) {
+      current = next;
+      if (current.outputs.length > 0) return true;
+    }
+  }
+  return false;
+}
+
+/** 重新加载关键词(用于定时刷新) */
+export async function reload(keywords: KeywordMatch[]): Promise<void> {
+  await loadKeywords(keywords);
+}

+ 149 - 0
backend/src/services/message-analyzer.service.ts

@@ -0,0 +1,149 @@
+import Parse from '../db/parse-client.js';
+import { loadKeywords, reload } from './keyword-matcher.js';
+import { checkMessage } from './compliance-check.service.js';
+import { analyzeRiskContext } from './ai-analysis.service.js';
+import { createRiskEvent, createNotification, seedDefaultKeywords } from './risk-event.service.js';
+import { resolveRiskAssignee } from './responsibility.service.js';
+
+const RELOAD_INTERVAL_MS = 5 * 60 * 1000; // 5 分钟刷新关键词
+
+let initialized = false;
+
+interface AnalyzeInput {
+  msgUniqueIdentifier: string;
+  roomId: string;
+  senderId: string;
+  senderName: string;
+  content: string;
+  msgType: number;
+}
+
+/** 启动时加载关键词并播种默认数据 */
+export async function initAnalyzer(): Promise<void> {
+  if (initialized) return;
+  initialized = true;
+
+  // 1. 种子数据(仅在表为空时写入)
+  await seedDefaultKeywords();
+
+  // 2. 加载关键词到内存
+  await refreshKeywords();
+
+  // 3. 定时刷新
+  setInterval(refreshKeywords, RELOAD_INTERVAL_MS);
+}
+
+async function refreshKeywords(): Promise<void> {
+  const q = new Parse.Query('RiskKeyword');
+  q.equalTo('enabled', true);
+  q.limit(5000);
+  const rows = await q.find({ useMasterKey: true }) as any[];
+  const keywords = rows.map((r: any) => ({
+    word: r.get('word') as string,
+    category: r.get('category') as string,
+    severity: r.get('severity') as string,
+  }));
+  await reload(keywords);
+}
+
+/** 查询当天该用户在该群发送的所有文本消息 */
+async function getTodayUserMessages(roomId: string, senderId: string): Promise<{ content: string; senderName: string; timestamp: string }[]> {
+  const todayStart = new Date();
+  todayStart.setHours(0, 0, 0, 0);
+
+  const q = new Parse.Query('Message');
+  q.equalTo('roomId', roomId);
+  q.equalTo('senderId', senderId);
+  q.containedIn('msgType', [0, 2]); // 文本消息
+  q.greaterThanOrEqualTo('timestamp', todayStart);
+  q.ascending('timestamp');
+  q.limit(200);
+  const rows = await q.find({ useMasterKey: true }) as any[];
+
+  return rows.map((r: any) => ({
+    content: (r.get('content') || '') as string,
+    senderName: (r.get('senderName') || '') as string,
+    timestamp: r.get('timestamp') ? new Date(r.get('timestamp') as Date).toISOString() : '',
+  }));
+}
+
+/** 查询群信息 */
+async function getGroupInfo(roomId: string): Promise<{ groupName: string; communityName: string }> {
+  try {
+    const q = new Parse.Query('GroupChat');
+    q.equalTo('roomId', roomId);
+    q.limit(1);
+    const obj = await q.first({ useMasterKey: true }) as any;
+    return {
+      groupName: obj?.get('roomName') || '未知群',
+      communityName: obj?.get('communityName') || '未知小区',
+    };
+  } catch {
+    return { groupName: '未知群', communityName: '未知小区' };
+  }
+}
+
+export async function analyzeMessage(input: AnalyzeInput): Promise<void> {
+  const { roomId, content, senderId, senderName, msgUniqueIdentifier } = input;
+  if (!content || msgTypeIsNonText(input.msgType)) return;
+
+  // 确保已初始化
+  if (!initialized) await initAnalyzer();
+
+  // 1. 关键词初筛
+  const result = checkMessage(content);
+  if (!result.matched) return;
+
+  const triggeredWord = result.keywords[0].word;
+  console.log(`[Compliance] "${triggeredWord}" matched in roomId=${roomId}`);
+
+  // 2. 获取当天该用户该群所有文本
+  const todayMessages = await getTodayUserMessages(roomId, senderId);
+
+  // 3. DeepSeek AI 分析
+  const aiResult = await analyzeRiskContext(todayMessages, triggeredWord);
+  if (!aiResult || !aiResult.isRisky) return;
+
+  console.log(`[AI] 判定违规: ${aiResult.title} (severity=${aiResult.severity})`);
+
+  // 4-5. 并行查询群信息 + 解析负责人
+  const [{ groupName, communityName }, assignee] = await Promise.all([
+    getGroupInfo(roomId),
+    resolveRiskAssignee(roomId),
+  ]);
+  if (assignee) {
+    console.log(`[Responsibility] roomId=${roomId} → ${assignee.userName} (${assignee.userId})`);
+  }
+
+  // 6. 创建 RiskEvent + 定向通知
+  const matchedKeywords = result.keywords.map((kw) => kw.word);
+  await createRiskEvent({
+    groupId: roomId,
+    groupName,
+    communityName,
+    type: 'keyword',
+    severity: aiResult.severity,
+    title: aiResult.title,
+    description: aiResult.description,
+    keywords: matchedKeywords,
+    assignedTo: assignee?.userId,
+    assignedToName: assignee?.userName,
+    assignedToPhone: assignee?.phone,
+  });
+
+  await createNotification({
+    userId: assignee?.userId || '*',
+    type: 'risk',
+    severity: aiResult.severity,
+    title: `风险预警:${groupName}`,
+    description: aiResult.description,
+    groupName,
+    communityName,
+    link: '/risk-control',
+  });
+}
+
+function msgTypeIsNonText(type: number): boolean {
+  // 只分析文本类消息 (0=文本, 2=富文本)
+  return type !== 0 && type !== 2;
+}

+ 291 - 0
backend/src/services/message-persist.service.ts

@@ -0,0 +1,291 @@
+import Parse from '../db/parse-client.js';
+import { ensureGroupChatForRoom } from './group-infra.service.js';
+
+export interface MessagePersistInput {
+  msgUniqueIdentifier: string;
+  roomId: string | null;
+  senderId: string;
+  senderName?: string;
+  content: string;
+  atList?: string[];
+  msgType: number;
+  isGroupChat: boolean;
+  timestamp: Date;
+  guid: string;
+  seq?: number;
+}
+
+export type PersistMessageResult = 'created' | 'skipped';
+
+/** 企微真实客户群 ID 通常为 14 位以上纯数字;短 ID 且等于 senderId 多为应用/系统消息 */
+export function isLikelyWecomGroupRoomId(
+  fromRoomId: number | string | undefined | null,
+  senderId?: number | string,
+): boolean {
+  const room = fromRoomId != null ? String(fromRoomId) : '';
+  if (!room || room === '0') return false;
+  if (room === String(senderId ?? '')) return false;
+  return room.length >= 14 && /^\d+$/.test(room);
+}
+
+export function extractContent(msgType: number, msgData: Record<string, unknown> = {}): string {
+  if (msgType === 2063 || msgData.revokeMsgUniqueIdentifier) {
+    return '撤回了一条消息';
+  }
+  const text = typeof msgData.content === 'string' ? msgData.content.trim() : '';
+  if (text) return text;
+  if (typeof msgData.linkUrl === 'string' && msgData.linkUrl) return msgData.linkUrl;
+  if (typeof msgData.title === 'string' && msgData.title) return msgData.title;
+  if (typeof msgData.desc === 'string' && msgData.desc) return msgData.desc;
+  if (msgType === 14) return '[图片]';
+  if (msgType === 16) return '[语音]';
+  if (msgType === 15 || msgType === 20) return '[文件]';
+  if (msgType === 22 || msgType === 23) return '[视频]';
+  const moreDetail = msgData.moreDetail;
+  if (Array.isArray(moreDetail)) {
+    const parts = moreDetail
+      .map((item) => (item && typeof item === 'object' && 'text' in item ? String((item as { text?: string }).text || '') : ''))
+      .filter(Boolean);
+    if (parts.length > 0) return parts.join(' ');
+  }
+  return '';
+}
+
+export function buildSyncMsgUniqueId(
+  guid: string,
+  item: {
+    msgUniqueIdentifier?: string;
+    fromRoomId?: number | string;
+    seq?: number;
+    msgServerId?: number;
+  },
+): string {
+  if (item.msgUniqueIdentifier) return item.msgUniqueIdentifier;
+  const roomId = item.fromRoomId ?? 0;
+  const seq = item.seq ?? item.msgServerId ?? 0;
+  return `sync:${guid}:${roomId}:${seq}`;
+}
+
+async function resolveSenderName(roomId: string, userId: string, guid: string): Promise<string> {
+  try {
+    const q = new Parse.Query('GroupMember');
+    q.equalTo('roomId', roomId);
+    q.equalTo('userId', userId);
+    q.equalTo('guid', guid);
+    q.exists('nickname');
+    q.notEqualTo('nickname', '');
+    q.limit(1);
+    const member = await q.first({ useMasterKey: true });
+    if (member) return (member.get('nickname') as string) || '';
+  } catch {
+    /* 静默失败——不要因为名称解析阻塞消息持久化 */
+  }
+  return '';
+}
+
+export async function persistMessage(input: MessagePersistInput): Promise<PersistMessageResult> {
+  const query = new Parse.Query('Message');
+  query.equalTo('msgUniqueIdentifier', input.msgUniqueIdentifier);
+  const existing = await query.first({ useMasterKey: true });
+  if (existing) return 'skipped';
+
+  let senderName = input.senderName || '';
+  if (!senderName && input.isGroupChat && input.roomId && input.senderId && input.guid) {
+    senderName = await resolveSenderName(input.roomId, input.senderId, input.guid);
+  }
+
+  const obj = new Parse.Object('Message');
+  obj.set('msgUniqueIdentifier', input.msgUniqueIdentifier);
+  obj.set('roomId', input.roomId);
+  obj.set('senderId', input.senderId);
+  obj.set('senderName', senderName);
+  obj.set('content', input.content);
+  obj.set('atList', input.atList ?? []);
+  obj.set('msgType', input.msgType);
+  obj.set('isGroupChat', input.isGroupChat ? 1 : 0);
+  obj.set('timestamp', input.timestamp);
+  obj.set('guid', input.guid);
+  if (input.seq !== undefined) obj.set('seq', input.seq);
+  await obj.save(null, { useMasterKey: true });
+  if (input.isGroupChat && input.roomId) {
+    await ensureGroupChatForRoom(input.roomId, input.guid);
+  }
+  return 'created';
+}
+
+export async function persistWebhookChatMessage(event: {
+  msgUniqueIdentifier: string;
+  fromRoomId: number;
+  senderId: number;
+  senderName?: string;
+  msgType: number;
+  timestamp: number;
+  guid: string;
+  msgData?: Record<string, unknown>;
+  seq?: number;
+}): Promise<PersistMessageResult> {
+  const isGroup = isLikelyWecomGroupRoomId(event.fromRoomId, event.senderId);
+  const msgData = event.msgData || {};
+  return persistMessage({
+    msgUniqueIdentifier: event.msgUniqueIdentifier,
+    roomId: isGroup ? String(event.fromRoomId) : null,
+    senderId: String(event.senderId),
+    senderName: event.senderName,
+    content: extractContent(event.msgType, msgData),
+    atList: Array.isArray(msgData.atList) ? (msgData.atList as string[]) : [],
+    msgType: event.msgType,
+    isGroupChat: isGroup,
+    timestamp: new Date(event.timestamp * 1000),
+    guid: event.guid,
+    seq: event.seq,
+  });
+}
+
+/** 批量持久化消息:一次去重查询 + 一次批量 senderName 解析 + saveAll */
+export async function persistMessagesBatch(
+  guid: string,
+  items: Array<{
+    msgUniqueIdentifier: string;
+    roomId: string | null;
+    senderId: string;
+    senderName?: string;
+    content: string;
+    msgType: number;
+    isGroupChat: boolean;
+    timestamp: Date;
+    seq?: number;
+  }>,
+): Promise<{ created: number; skipped: number }> {
+  if (items.length === 0) return { created: 0, skipped: 0 };
+
+  // 1. 批量去重
+  const ids = [...new Set(items.map(i => i.msgUniqueIdentifier))];
+  const dedupQuery = new Parse.Query('Message');
+  dedupQuery.containedIn('msgUniqueIdentifier', ids);
+  dedupQuery.limit(ids.length + 100);
+  dedupQuery.select('msgUniqueIdentifier');
+  const existingMsgs = await dedupQuery.find({ useMasterKey: true });
+  const existingSet = new Set(existingMsgs.map((e: Parse.Object) => e.get('msgUniqueIdentifier') as string));
+
+  const newItems = items.filter(i => !existingSet.has(i.msgUniqueIdentifier));
+  if (newItems.length === 0) return { created: 0, skipped: items.length };
+
+  // 2. 批量解析 senderName
+  const missingNames = newItems.filter(
+    i => !i.senderName && i.isGroupChat && i.roomId && i.senderId,
+  );
+  if (missingNames.length > 0) {
+    const byRoom = new Map<string, Set<string>>();
+    for (const m of missingNames) {
+      const set = byRoom.get(m.roomId!);
+      if (set) set.add(m.senderId);
+      else byRoom.set(m.roomId!, new Set([m.senderId]));
+    }
+    const nameCache = new Map<string, string>();
+    for (const [roomId, userIds] of byRoom) {
+      try {
+        const mq = new Parse.Query('GroupMember');
+        mq.equalTo('roomId', roomId);
+        mq.equalTo('guid', guid);
+        mq.containedIn('userId', [...userIds]);
+        mq.exists('nickname');
+        mq.notEqualTo('nickname', '');
+        mq.limit(1000);
+        const members = await mq.find({ useMasterKey: true });
+        for (const m of members) {
+          const uid = m.get('userId') as string;
+          const nick = m.get('nickname') as string;
+          if (uid && nick) nameCache.set(`${roomId}:${uid}`, nick);
+        }
+      } catch { /* 静默 */ }
+    }
+    for (const m of missingNames) {
+      const key = `${m.roomId}:${m.senderId}`;
+      const name = nameCache.get(key);
+      if (name) m.senderName = name;
+    }
+  }
+
+  // 3. 批量确保 GroupChat 存在
+  const groupRoomIds = [...new Set(newItems.filter(i => i.isGroupChat && i.roomId).map(i => i.roomId!))];
+  if (groupRoomIds.length > 0) {
+    const gcQuery = new Parse.Query('GroupChat');
+    gcQuery.containedIn('roomId', groupRoomIds);
+    gcQuery.equalTo('guid', guid);
+    gcQuery.limit(groupRoomIds.length + 10);
+    gcQuery.select('roomId');
+    const existingGCs = await gcQuery.find({ useMasterKey: true });
+    const existingGcSet = new Set(existingGCs.map((g: Parse.Object) => g.get('roomId') as string));
+    const toCreate = groupRoomIds.filter(id => !existingGcSet.has(id));
+    if (toCreate.length > 0) {
+      const gcObjs = toCreate.map(roomId => {
+        const obj = new Parse.Object('GroupChat');
+        obj.set('roomId', roomId);
+        obj.set('guid', guid);
+        obj.set('roomName', `群 ${roomId.slice(-6)}`);
+        obj.set('status', 'active');
+        obj.set('memberCount', 0);
+        return obj;
+      });
+      await Parse.Object.saveAll(gcObjs, { useMasterKey: true });
+    }
+  }
+
+  // 4. 创建 Message 对象并批量保存
+  const objs = newItems.map(input => {
+    const obj = new Parse.Object('Message');
+    obj.set('msgUniqueIdentifier', input.msgUniqueIdentifier);
+    obj.set('roomId', input.roomId);
+    obj.set('senderId', input.senderId);
+    obj.set('senderName', input.senderName || '');
+    obj.set('content', input.content);
+    obj.set('atList', []);
+    obj.set('msgType', input.msgType);
+    obj.set('isGroupChat', input.isGroupChat ? 1 : 0);
+    obj.set('timestamp', input.timestamp);
+    obj.set('guid', guid);
+    if (input.seq !== undefined) obj.set('seq', input.seq);
+    return obj;
+  });
+
+  // 分批保存(每次最多 100 条)
+  for (let i = 0; i < objs.length; i += 100) {
+    await Parse.Object.saveAll(objs.slice(i, i + 100), { useMasterKey: true });
+  }
+
+  return { created: newItems.length, skipped: items.length - newItems.length };
+}
+
+export async function persistSyncMsgItem(
+  guid: string,
+  raw: {
+    fromRoomId?: number | string;
+    senderId?: number | string;
+    senderName?: string;
+    msgType?: number;
+    timestamp?: number;
+    seq?: number;
+    msgServerId?: number;
+    msgUniqueIdentifier?: string;
+    msgData?: Record<string, unknown>;
+  },
+): Promise<PersistMessageResult> {
+  const msgType = raw.msgType ?? 0;
+  const fromRoomId = raw.fromRoomId ?? 0;
+  const isGroup = isLikelyWecomGroupRoomId(fromRoomId, raw.senderId);
+  const msgData = raw.msgData || {};
+
+  return persistMessage({
+    msgUniqueIdentifier: buildSyncMsgUniqueId(guid, raw),
+    roomId: isGroup ? String(fromRoomId) : null,
+    senderId: String(raw.senderId ?? ''),
+    senderName: raw.senderName,
+    content: extractContent(msgType, msgData),
+    atList: Array.isArray(msgData.atList) ? (msgData.atList as string[]) : [],
+    msgType,
+    isGroupChat: isGroup,
+    timestamp: new Date((raw.timestamp ?? 0) * 1000),
+    guid,
+    seq: raw.seq ?? raw.msgServerId,
+  });
+}

+ 421 - 0
backend/src/services/message-sync.service.ts

@@ -0,0 +1,421 @@
+import Parse from '../db/parse-client.js';
+import { syncMsgPage, batchGetRoomDetails } from '../qiwi/qiwe-api.service.js';
+import { persistSyncMsgItem, persistMessagesBatch, isLikelyWecomGroupRoomId, buildSyncMsgUniqueId, extractContent } from './message-persist.service.js';
+
+const GUID = process.env.QIWE_GUID || '';
+
+function isValidGroupRoomId(roomId: string): boolean {
+  return roomId.length >= 14 && /^\d+$/.test(roomId);
+}
+
+/* ======================== 游标管理 ======================== */
+
+async function getCursor(guid: string): Promise<number> {
+  const query = new Parse.Query('MessageSyncCursor');
+  query.equalTo('guid', guid);
+  const row = await query.first({ useMasterKey: true });
+  return row ? (row.get('lastSeq') as number) || 0 : 0;
+}
+
+async function setCursor(guid: string, seq: number): Promise<void> {
+  const query = new Parse.Query('MessageSyncCursor');
+  query.equalTo('guid', guid);
+  let row = await query.first({ useMasterKey: true });
+
+  if (!row) {
+    row = new Parse.Object('MessageSyncCursor');
+    row.set('guid', guid);
+  }
+  row.set('lastSeq', seq);
+  row.set('lastSyncAt', new Date());
+  await row.save(null, { useMasterKey: true });
+}
+
+/* ======================== 未知群发现 ======================== */
+
+async function discoverRoomFromMessage(roomId: string, guid: string): Promise<void> {
+  if (!roomId || !isValidGroupRoomId(roomId)) return;
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const existing = await query.first({ useMasterKey: true });
+  if (existing) return;
+
+  try {
+    const details = await batchGetRoomDetails(guid, [roomId]);
+    const detail = details[0];
+    if (detail && detail.roomName) {
+      const obj = new Parse.Object('GroupChat');
+      obj.set('roomId', roomId);
+      obj.set('guid', guid);
+      obj.set('roomName', detail.roomName);
+      obj.set('ownerId', detail.ownerId);
+      obj.set('memberCount', detail.memberCount);
+      obj.set('status', 'active');
+      await obj.save(null, { useMasterKey: true });
+      console.log(`[Sync] 消息中发现的群已创建: roomId=${roomId} name="${detail.roomName}"`);
+      return;
+    }
+  } catch (e: any) {
+    console.warn(`[Sync] 查询未知群详情失败 roomId=${roomId}: ${e.message}`);
+  }
+  console.log(`[Sync] 消息中发现未知群 roomId=${roomId},API 无名称,等待群同步创建`);
+}
+
+/* ======================== 计数器更新 ======================== */
+
+async function incrementGroupCounters(roomId: string, guid: string, count = 1): Promise<void> {
+  if (!roomId) return;
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const obj = await query.first({ useMasterKey: true });
+  if (!obj) return;
+  obj.increment('messageCountTotal', count);
+  obj.increment('messageCountToday', count);
+  obj.set('lastActivityAt', new Date());
+  await obj.save(null, { useMasterKey: true });
+}
+
+/* ======================== 昵称批量补全 ======================== */
+
+function collectSenderNames(syncMsgList: Array<{ fromRoomId?: number | string; senderId?: number | string; senderName?: string }>): Map<string, { roomId: string; userId: string; nickname: string }> {
+  const map = new Map<string, { roomId: string; userId: string; nickname: string }>();
+  for (const item of syncMsgList) {
+    const roomId = String(item.fromRoomId || '');
+    const userId = String(item.senderId || '');
+    const nickname = item.senderName || '';
+    if (!isValidGroupRoomId(roomId) || !userId || !nickname) continue;
+    const key = `${roomId}:${userId}`;
+    if (!map.has(key)) {
+      map.set(key, { roomId, userId, nickname });
+    }
+  }
+  return map;
+}
+
+async function applySenderNames(senderMap: Map<string, { roomId: string; userId: string; nickname: string }>): Promise<number> {
+  if (senderMap.size === 0) return 0;
+
+  const byRoom = new Map<string, { userId: string; nickname: string }[]>();
+  for (const s of senderMap.values()) {
+    const arr = byRoom.get(s.roomId);
+    if (arr) arr.push(s);
+    else byRoom.set(s.roomId, [s]);
+  }
+
+  const toSave: Parse.Object[] = [];
+  let updated = 0;
+
+  for (const [roomId, entries] of byRoom) {
+    const userIds = entries.map((e) => e.userId);
+    const existingQuery = new Parse.Query('GroupMember');
+    existingQuery.equalTo('roomId', roomId);
+    existingQuery.equalTo('guid', GUID);
+    existingQuery.containedIn('userId', userIds);
+    existingQuery.limit(1000);
+    const existingMembers = await existingQuery.find({ useMasterKey: true });
+    const existingMap = new Map<string, Parse.Object>();
+    for (const em of existingMembers) {
+      existingMap.set(String(em.get('userId') || ''), em);
+    }
+
+    for (const { userId, nickname } of entries) {
+      const existing = existingMap.get(userId);
+      if (existing) {
+        if (existing.get('nickname') !== nickname) {
+          existing.set('nickname', nickname);
+          toSave.push(existing);
+          updated++;
+        }
+      } else {
+        const obj = new Parse.Object('GroupMember');
+        obj.set('roomId', roomId);
+        obj.set('userId', userId);
+        obj.set('nickname', nickname);
+        obj.set('guid', GUID);
+        obj.set('status', 'active');
+        obj.set('joinedAt', new Date());
+        toSave.push(obj);
+        updated++;
+      }
+    }
+  }
+
+  if (toSave.length > 0) {
+    for (let i = 0; i < toSave.length; i += 100) {
+      await Parse.Object.saveAll(toSave.slice(i, i + 100), { useMasterKey: true });
+    }
+  }
+  return updated;
+}
+
+/* ======================== 批量名称解析 ======================== */
+
+async function batchResolveSenderNames(
+  guid: string,
+  syncMsgList: Array<{ fromRoomId?: number | string; senderId?: number | string; senderName?: string }>,
+): Promise<Map<string, string>> {
+  const result = new Map<string, string>();
+
+  // 收集所有 senderName 为空的有效群消息
+  const byRoom = new Map<string, string[]>();
+  for (const raw of syncMsgList) {
+    const roomId = String(raw.fromRoomId || '');
+    const userId = String(raw.senderId || '');
+    if (!raw.senderName && isValidGroupRoomId(roomId) && userId) {
+      const arr = byRoom.get(roomId);
+      if (arr) {
+        if (!arr.includes(userId)) arr.push(userId);
+      } else {
+        byRoom.set(roomId, [userId]);
+      }
+    }
+  }
+
+  if (byRoom.size === 0) return result;
+
+  // 按 roomId 批量查询 GroupMember
+  for (const [roomId, userIds] of byRoom) {
+    try {
+      const q = new Parse.Query('GroupMember');
+      q.equalTo('roomId', roomId);
+      q.equalTo('guid', guid);
+      q.containedIn('userId', userIds);
+      q.exists('nickname');
+      q.notEqualTo('nickname', '');
+      q.limit(1000);
+      const members = await q.find({ useMasterKey: true });
+      for (const m of members) {
+        const uid = m.get('userId') as string;
+        const nickname = m.get('nickname') as string;
+        if (uid && nickname) {
+          result.set(`${roomId}:${uid}`, nickname);
+        }
+      }
+    } catch {
+      /* 静默失败 */
+    }
+  }
+
+  return result;
+}
+
+/* ======================== 增量同步主逻辑 ======================== */
+
+export interface MessageSyncResult {
+  guid: string;
+  startSeq: number;
+  endSeq: number;
+  pages: number;
+  created: number;
+  skipped: number;
+  hasMore: boolean;
+  nickUpdated: number;
+  errors: string[];
+}
+
+export async function syncMessagesFromQiWe(options: {
+  guid: string;
+  msgSeq?: number;
+  limit?: number;
+  maxPages?: number;
+  resetCursor?: boolean;
+}): Promise<MessageSyncResult> {
+  const guid = options.guid;
+  if (!guid) {
+    console.warn('[Sync] guid 为空,跳过消息同步');
+    return { guid: '', startSeq: 0, endSeq: 0, pages: 0, created: 0, skipped: 0, hasMore: false, nickUpdated: 0, errors: [] };
+  }
+
+  const limit = options.limit ?? 50;
+  const maxPages = options.maxPages ?? 30;
+  let msgSeq = options.resetCursor ? 0 : (options.msgSeq ?? await getCursor(guid));
+
+  const result: MessageSyncResult = {
+    guid,
+    startSeq: msgSeq,
+    endSeq: msgSeq,
+    pages: 0,
+    created: 0,
+    skipped: 0,
+    hasMore: false,
+    nickUpdated: 0,
+    errors: [],
+  };
+
+  console.log(`[MsgSync] 开始同步 guid=${guid} msgSeq=${msgSeq} limit=${limit}`);
+
+  while (result.pages < maxPages) {
+    let pageData;
+    try {
+      pageData = await syncMsgPage(guid, msgSeq, limit);
+    } catch (err: unknown) {
+      const message = err instanceof Error ? err.message : String(err);
+      result.errors.push(message);
+      break;
+    }
+
+    result.pages++;
+    const list = pageData.syncMsgList || [];
+
+    const senderMap = collectSenderNames(list);
+
+    // 批量预加载缺失名称:从 GroupMember 表查询昵称注入到原始消息
+    const resolvedNames = await batchResolveSenderNames(guid, list);
+    if (resolvedNames.size > 0) {
+      for (const raw of list) {
+        if (!raw.senderName) {
+          const key = `${String(raw.fromRoomId || '')}:${String(raw.senderId || '')}`;
+          const name = resolvedNames.get(key);
+          if (name) raw.senderName = name;
+        }
+      }
+    }
+
+    // 批量持久化消息(一次去重 + 批量 senderName 解析 + saveAll)
+    try {
+      const batchItems = list.map(raw => {
+        const msgType = raw.msgType ?? 0;
+        const fromRoomId = raw.fromRoomId ?? 0;
+        const isGroup = isLikelyWecomGroupRoomId(fromRoomId, raw.senderId);
+        return {
+          msgUniqueIdentifier: buildSyncMsgUniqueId(guid, raw),
+          roomId: isGroup ? String(fromRoomId) : null,
+          senderId: String(raw.senderId ?? ''),
+          senderName: raw.senderName,
+          content: extractContent(msgType, raw.msgData || {}),
+          msgType,
+          isGroupChat: isGroup,
+          timestamp: new Date((raw.timestamp ?? 0) * 1000),
+          seq: raw.seq ?? raw.msgServerId,
+        };
+      });
+      const batchResult = await persistMessagesBatch(guid, batchItems);
+      result.created += batchResult.created;
+      result.skipped += batchResult.skipped;
+    } catch (err: unknown) {
+      const message = err instanceof Error ? err.message : String(err);
+      result.errors.push(`persist batch: ${message}`);
+    }
+
+    // 用消息中的 senderName 更新成员昵称
+    if (senderMap.size > 0) {
+      try {
+        const n = await applySenderNames(senderMap);
+        result.nickUpdated += n;
+      } catch (e: any) {
+        console.warn('[Sync] 昵称更新失败:', e.message);
+      }
+    }
+
+    const nextSeq = pageData.travelSyncKey ?? msgSeq;
+    result.endSeq = nextSeq;
+    await setCursor(guid, nextSeq);
+
+    const hasMore = pageData.hasMore === 1 || pageData.hasMore === true;
+    result.hasMore = hasMore;
+
+    console.log(
+      `[MsgSync] 第 ${result.pages} 页: batch=${list.length} created=${result.created} skipped=${result.skipped} nickUpdated=${result.nickUpdated} nextSeq=${nextSeq} hasMore=${hasMore}`,
+    );
+
+    if (!hasMore) break;
+    msgSeq = nextSeq;
+  }
+
+  console.log(
+    `[MsgSync] 完成 pages=${result.pages} created=${result.created} skipped=${result.skipped} endSeq=${result.endSeq} nickUpdated=${result.nickUpdated}`,
+  );
+
+  return result;
+}
+
+// 保留旧导出的别名函数
+export async function getMessageSyncCursor(guid: string): Promise<number> {
+  return getCursor(guid);
+}
+
+export async function setMessageSyncCursor(guid: string, msgSeq: number): Promise<void> {
+  return setCursor(guid, msgSeq);
+}
+
+/* ======================== senderName 回填 ======================== */
+
+export async function backfillMissingSenderNames(guid: string, batchSize = 500): Promise<{ scanned: number; updated: number }> {
+  if (!guid) return { scanned: 0, updated: 0 };
+
+  let scanned = 0;
+  let updated = 0;
+
+  console.log(`[Backfill] 开始回填 senderName 为空的消息, guid=${guid}`);
+
+  while (true) {
+    const q = new Parse.Query('Message');
+    q.equalTo('guid', guid);
+    q.equalTo('isGroupChat', 1);
+    q.equalTo('senderName', '');
+    q.exists('roomId');
+    q.exists('senderId');
+    q.notEqualTo('senderId', '');
+    q.limit(batchSize);
+    const messages = await q.find({ useMasterKey: true });
+
+    if (messages.length === 0) break;
+    scanned += messages.length;
+
+    // 按 roomId 分组,收集需要查询的 userId
+    const byRoom = new Map<string, { msg: Parse.Object; userId: string }[]>();
+    for (const msg of messages) {
+      const roomId = msg.get('roomId') as string;
+      const userId = msg.get('senderId') as string;
+      if (!roomId || !userId) continue;
+      const arr = byRoom.get(roomId);
+      if (arr) arr.push({ msg, userId });
+      else byRoom.set(roomId, [{ msg, userId }]);
+    }
+
+    if (byRoom.size === 0) break;
+
+    // 按 roomId 批量查询 GroupMember 昵称
+    const toSave: Parse.Object[] = [];
+    for (const [roomId, entries] of byRoom) {
+      const userIds = [...new Set(entries.map((e) => e.userId))];
+      const mq = new Parse.Query('GroupMember');
+      mq.equalTo('roomId', roomId);
+      mq.equalTo('guid', guid);
+      mq.containedIn('userId', userIds);
+      mq.exists('nickname');
+      mq.notEqualTo('nickname', '');
+      mq.limit(1000);
+      const members = await mq.find({ useMasterKey: true });
+      const nickMap = new Map<string, string>();
+      for (const m of members) {
+        const uid = m.get('userId') as string;
+        const nickname = m.get('nickname') as string;
+        if (uid && nickname) nickMap.set(uid, nickname);
+      }
+
+      for (const { msg, userId } of entries) {
+        const nickname = nickMap.get(userId);
+        if (nickname) {
+          msg.set('senderName', nickname);
+          toSave.push(msg);
+          updated++;
+        }
+      }
+    }
+
+    if (toSave.length > 0) {
+      for (let i = 0; i < toSave.length; i += 100) {
+        await Parse.Object.saveAll(toSave.slice(i, i + 100), { useMasterKey: true });
+      }
+    }
+
+    // 如果本批没有更新任何记录(也没有错误),说明剩下的消息确实没有可匹配的昵称,退出循环
+    if (toSave.length === 0) break;
+  }
+
+  console.log(`[Backfill] 完成: 扫描 ${scanned} 条, 回填 ${updated} 条`);
+  return { scanned, updated };
+}

+ 89 - 0
backend/src/services/responsibility.service.ts

@@ -0,0 +1,89 @@
+import Parse from '../db/parse-client.js';
+
+/**
+ * 从 ResponsibilityAssignment 表查询指定范围的负责人
+ */
+export async function getResponsiblePerson(
+  scopeType: 'district' | 'store' | 'group',
+  scopeId: string,
+): Promise<{ userId: string; userName: string; phone?: string } | null> {
+  try {
+    const q = new Parse.Query('ResponsibilityAssignment');
+    q.equalTo('scopeType', scopeType);
+    q.equalTo('scopeId', scopeId);
+    q.limit(1);
+    const obj = await q.first({ useMasterKey: true }) as any;
+    if (obj) {
+      return { userId: obj.get('userId'), userName: obj.get('userName'), phone: obj.get('phone') || undefined };
+    }
+  } catch (err) {
+    console.warn(`[Responsibility] 查询 ${scopeType}/${scopeId} 负责人失败:`, (err as Error).message);
+  }
+  return null;
+}
+
+/**
+ * 为风险事件解析负责人,逐级查找:
+ * 1. ResponsibilityAssignment(group)
+ * 2. GroupChat.ownerId → _User.wecomUserId
+ * 3. Store.managerId → _User
+ */
+export async function resolveRiskAssignee(roomId: string): Promise<{ userId: string; userName: string; phone?: string } | null> {
+  // Step 1: 查 ResponsibilityAssignment 表 group 级别
+  const fromAssignment = await getResponsiblePerson('group', roomId);
+  if (fromAssignment) return fromAssignment;
+
+  // Step 2: 查 GroupChat,用 ownerId 匹配 _User.wecomUserId
+  try {
+    const groupQ = new Parse.Query('GroupChat');
+    groupQ.equalTo('roomId', roomId);
+    const group = await groupQ.first({ useMasterKey: true }) as any;
+    if (group) {
+      const ownerId = group.get('ownerId');
+      const storeId = group.get('storeId');
+
+      if (ownerId) {
+        const userQ = new Parse.Query('_User');
+        userQ.equalTo('wecomUserId', ownerId);
+        userQ.notEqualTo('isDeleted', true);
+        userQ.limit(1);
+        const user = await userQ.first({ useMasterKey: true }) as any;
+        if (user) {
+          return { userId: user.id, userName: user.get('name') || user.get('username') || '', phone: user.get('phone') || undefined };
+        }
+      }
+
+      // Step 3: 查 Store.managerId
+      if (storeId) {
+        const fromStore = await getResponsiblePerson('store', storeId);
+        if (fromStore) return fromStore;
+
+        const storeQ = new Parse.Query('Store');
+        storeQ.equalTo('code', storeId);
+        const store = await storeQ.first({ useMasterKey: true }) as any;
+        if (store) {
+          const managerId = store.get('managerId');
+          if (managerId) {
+            const userQ = new Parse.Query('_User');
+            userQ.equalTo('objectId', managerId);
+            userQ.notEqualTo('isDeleted', true);
+            const user = await userQ.first({ useMasterKey: true }) as any;
+            if (user) {
+              return { userId: user.id, userName: user.get('name') || '', phone: user.get('phone') || undefined };
+            }
+          }
+        }
+      }
+
+      // Step 4: 兜底 — 用 group.ownerName
+      const ownerName = group.get('ownerName');
+      if (ownerName) {
+        return { userId: ownerId || '', userName: ownerName };
+      }
+    }
+  } catch (err) {
+    console.warn('[Responsibility] 解析负责人失败:', (err as Error).message);
+  }
+
+  return null;
+}

+ 282 - 0
backend/src/services/risk-event.service.ts

@@ -0,0 +1,282 @@
+import Parse from '../db/parse-client.js';
+
+export interface CreateRiskEventParams {
+  groupId: string;
+  groupName: string;
+  communityName: string;
+  type: string;
+  severity: 'high' | 'medium' | 'low';
+  title: string;
+  description: string;
+  keywords: string[];
+  assignedTo?: string;
+  assignedToName?: string;
+  assignedToPhone?: string;
+}
+
+export async function createRiskEvent(params: CreateRiskEventParams): Promise<void> {
+  const obj = new Parse.Object('RiskEvent');
+  obj.set('groupId', params.groupId);
+  obj.set('groupName', params.groupName);
+  obj.set('communityName', params.communityName);
+  obj.set('type', params.type);
+  obj.set('severity', params.severity);
+  obj.set('title', params.title);
+  obj.set('description', params.description);
+  obj.set('keywords', params.keywords);
+  if (params.assignedTo) obj.set('assignedTo', params.assignedTo);
+  if (params.assignedToName) obj.set('assignedToName', params.assignedToName);
+  if (params.assignedToPhone) obj.set('assignedToPhone', params.assignedToPhone);
+  obj.set('status', 'pending');
+  obj.set('createdAt', new Date());
+  await obj.save(null, { useMasterKey: true });
+  console.log(`[RiskEvent] 创建成功: ${params.title} (severity=${params.severity})`);
+}
+
+export async function createNotification(params: {
+  userId: string;
+  type: string;
+  severity: string;
+  title: string;
+  description: string;
+  groupName?: string;
+  communityName?: string;
+  link?: string;
+}): Promise<void> {
+  const obj = new Parse.Object('AppNotification');
+  obj.set('userId', params.userId);
+  obj.set('type', params.type);
+  obj.set('severity', params.severity);
+  obj.set('title', params.title);
+  obj.set('description', params.description);
+  if (params.groupName) obj.set('groupName', params.groupName);
+  if (params.communityName) obj.set('communityName', params.communityName);
+  if (params.link) obj.set('link', params.link);
+  obj.set('read', false);
+  obj.set('createdAt', new Date());
+  await obj.save(null, { useMasterKey: true });
+  console.log(`[Notification] 创建成功: ${params.title}`);
+}
+
+const DEFAULT_KEYWORDS: { word: string; category: string; severity: string }[] = [
+  /* ── 投诉抱怨 (complaint) ── */
+  { word: '投诉', category: 'complaint', severity: 'high' },
+  { word: '维权', category: 'complaint', severity: 'high' },
+  { word: '退款', category: 'complaint', severity: 'high' },
+  { word: '假货', category: 'complaint', severity: 'high' },
+  { word: '诈骗', category: 'complaint', severity: 'high' },
+  { word: '举报', category: 'complaint', severity: 'high' },
+  { word: '差评', category: 'complaint', severity: 'medium' },
+  { word: '退货', category: 'complaint', severity: 'medium' },
+  { word: '延期', category: 'complaint', severity: 'medium' },
+  { word: '太差了', category: 'complaint', severity: 'medium' },
+  { word: '坑人', category: 'complaint', severity: 'high' },
+  { word: '骗子', category: 'complaint', severity: 'high' },
+  { word: '黑心', category: 'complaint', severity: 'high' },
+  { word: '索赔', category: 'complaint', severity: 'high' },
+  { word: '违约', category: 'complaint', severity: 'high' },
+  { word: '态度差', category: 'complaint', severity: 'medium' },
+  { word: '不接电话', category: 'complaint', severity: 'medium' },
+  { word: '不回复', category: 'complaint', severity: 'medium' },
+  { word: '敷衍', category: 'complaint', severity: 'medium' },
+  { word: '推诿', category: 'complaint', severity: 'medium' },
+  { word: '不见人', category: 'complaint', severity: 'medium' },
+  { word: '没回复', category: 'complaint', severity: 'low' },
+  { word: '不理人', category: 'complaint', severity: 'low' },
+
+  /* ── 质量工艺 (quality) ── */
+  { word: '质量问题', category: 'quality', severity: 'high' },
+  { word: '开裂', category: 'quality', severity: 'high' },
+  { word: '变形', category: 'quality', severity: 'high' },
+  { word: '脱胶', category: 'quality', severity: 'high' },
+  { word: '色差', category: 'quality', severity: 'medium' },
+  { word: '划痕', category: 'quality', severity: 'medium' },
+  { word: '起皮', category: 'quality', severity: 'medium' },
+  { word: '鼓包', category: 'quality', severity: 'medium' },
+  { word: '发霉', category: 'quality', severity: 'high' },
+  { word: '瑕疵', category: 'quality', severity: 'medium' },
+  { word: '异味', category: 'quality', severity: 'medium' },
+  { word: '松动', category: 'quality', severity: 'medium' },
+  { word: '不对齐', category: 'quality', severity: 'medium' },
+  { word: '缝隙大', category: 'quality', severity: 'medium' },
+  { word: '有缝', category: 'quality', severity: 'low' },
+  { word: '不平', category: 'quality', severity: 'medium' },
+  { word: '掉漆', category: 'quality', severity: 'medium' },
+  { word: '装错', category: 'quality', severity: 'high' },
+  { word: '尺寸不对', category: 'quality', severity: 'high' },
+  { word: '做错了', category: 'quality', severity: 'high' },
+
+  /* ── 敏感词 (sensitive) ── */
+  { word: '甲醛', category: 'sensitive', severity: 'high' },
+  { word: '超标', category: 'sensitive', severity: 'high' },
+  { word: '退群', category: 'sensitive', severity: 'high' },
+  { word: '曝光', category: 'sensitive', severity: 'high' },
+  { word: '不想装了', category: 'sensitive', severity: 'medium' },
+  { word: '换一家', category: 'sensitive', severity: 'medium' },
+  { word: '换人', category: 'sensitive', severity: 'medium' },
+  { word: '停工', category: 'sensitive', severity: 'high' },
+  { word: '烂尾', category: 'sensitive', severity: 'high' },
+  { word: '跑路', category: 'sensitive', severity: 'high' },
+  { word: '倒闭', category: 'sensitive', severity: 'high' },
+  { word: '闹事', category: 'sensitive', severity: 'high' },
+  { word: '拉横幅', category: 'sensitive', severity: 'high' },
+  { word: '堵门', category: 'sensitive', severity: 'high' },
+  { word: '投诉到总部', category: 'sensitive', severity: 'high' },
+  { word: '退单', category: 'sensitive', severity: 'high' },
+  { word: '取消订单', category: 'sensitive', severity: 'medium' },
+  { word: '拆掉重做', category: 'sensitive', severity: 'high' },
+  { word: '重新做', category: 'sensitive', severity: 'medium' },
+
+  /* ── 法律监管 (legal) ── */
+  { word: '315', category: 'legal', severity: 'high' },
+  { word: '工商', category: 'legal', severity: 'high' },
+  { word: '法院', category: 'legal', severity: 'high' },
+  { word: '律师函', category: 'legal', severity: 'high' },
+  { word: '媒体曝光', category: 'legal', severity: 'high' },
+  { word: '消费者协会', category: 'legal', severity: 'high' },
+  { word: '消协', category: 'legal', severity: 'high' },
+  { word: '市场监督', category: 'legal', severity: 'high' },
+  { word: '12315', category: 'legal', severity: 'high' },
+  { word: '起诉', category: 'legal', severity: 'high' },
+  { word: '打官司', category: 'legal', severity: 'high' },
+  { word: '仲裁', category: 'legal', severity: 'high' },
+  { word: '合同纠纷', category: 'legal', severity: 'high' },
+
+  /* ── 竞品对比 (competitor) ── */
+  { word: '欧派', category: 'competitor', severity: 'medium' },
+  { word: '索菲亚', category: 'competitor', severity: 'medium' },
+  { word: '尚品宅配', category: 'competitor', severity: 'medium' },
+  { word: '好莱客', category: 'competitor', severity: 'medium' },
+  { word: '金牌橱柜', category: 'competitor', severity: 'medium' },
+  { word: '志邦', category: 'competitor', severity: 'medium' },
+  { word: '皮阿诺', category: 'competitor', severity: 'medium' },
+  { word: '我乐', category: 'competitor', severity: 'medium' },
+  { word: '全友', category: 'competitor', severity: 'medium' },
+  { word: '顾家', category: 'competitor', severity: 'medium' },
+  { word: '曲美', category: 'competitor', severity: 'medium' },
+  { word: '宜家', category: 'competitor', severity: 'medium' },
+  { word: '红苹果', category: 'competitor', severity: 'medium' },
+  { word: '顶固', category: 'competitor', severity: 'medium' },
+  { word: '科凡', category: 'competitor', severity: 'medium' },
+  { word: '百得胜', category: 'competitor', severity: 'medium' },
+  { word: '客来福', category: 'competitor', severity: 'medium' },
+  { word: '别人家', category: 'competitor', severity: 'low' },
+  { word: '别家', category: 'competitor', severity: 'low' },
+
+  /* ── 价格讨论 (price) ── */
+  { word: '太贵了', category: 'price', severity: 'low' },
+  { word: '便宜点', category: 'price', severity: 'low' },
+  { word: '优惠', category: 'price', severity: 'low' },
+  { word: '打折', category: 'price', severity: 'low' },
+  { word: '砍价', category: 'price', severity: 'low' },
+  { word: '定金', category: 'price', severity: 'medium' },
+  { word: '订金', category: 'price', severity: 'medium' },
+  { word: '多少钱', category: 'price', severity: 'low' },
+  { word: '预算', category: 'price', severity: 'low' },
+  { word: '超出预算', category: 'price', severity: 'medium' },
+  { word: '隐性消费', category: 'price', severity: 'medium' },
+  { word: '多退少补', category: 'price', severity: 'low' },
+  { word: '差价', category: 'price', severity: 'medium' },
+  { word: '报价', category: 'price', severity: 'low' },
+  { word: '价格虚高', category: 'price', severity: 'medium' },
+  { word: '加钱', category: 'price', severity: 'high' },
+  { word: '增项', category: 'price', severity: 'medium' },
+
+  /* ── 负面情绪 (negative) ── */
+  { word: '后悔', category: 'negative', severity: 'medium' },
+  { word: '上当', category: 'negative', severity: 'high' },
+  { word: '坑爹', category: 'negative', severity: 'high' },
+  { word: '骗钱', category: 'negative', severity: 'high' },
+  { word: '不值', category: 'negative', severity: 'medium' },
+  { word: '垃圾', category: 'negative', severity: 'high' },
+  { word: '闹心', category: 'negative', severity: 'medium' },
+  { word: '失望', category: 'negative', severity: 'medium' },
+  { word: '忍不了', category: 'negative', severity: 'medium' },
+  { word: '不可接受', category: 'negative', severity: 'high' },
+  { word: '太过分', category: 'negative', severity: 'medium' },
+  { word: '早知', category: 'negative', severity: 'low' },
+  { word: '早知道', category: 'negative', severity: 'low' },
+  { word: '当初就该', category: 'negative', severity: 'medium' },
+  { word: '选错了', category: 'negative', severity: 'medium' },
+  { word: '牌子做砸', category: 'negative', severity: 'high' },
+  { word: '口碑差', category: 'negative', severity: 'high' },
+
+  /* ── 安装施工 (installation) ── */
+  { word: '安装师傅', category: 'installation', severity: 'low' },
+  { word: '安装不到位', category: 'installation', severity: 'high' },
+  { word: '装坏了', category: 'installation', severity: 'high' },
+  { word: '打孔打偏', category: 'installation', severity: 'medium' },
+  { word: '碰坏', category: 'installation', severity: 'medium' },
+  { word: '没装好', category: 'installation', severity: 'medium' },
+  { word: '漏装', category: 'installation', severity: 'medium' },
+  { word: '少了', category: 'installation', severity: 'medium' },
+  { word: '缺件', category: 'installation', severity: 'medium' },
+  { word: '等了好久', category: 'installation', severity: 'medium' },
+  { word: '还没来', category: 'installation', severity: 'medium' },
+  { word: '放了鸽子', category: 'installation', severity: 'medium' },
+];
+
+export async function seedDefaultKeywords(): Promise<void> {
+  // 批量查询已存在的词 → 只插入新词,避免 140 次逐条查询
+  const allWords = DEFAULT_KEYWORDS.map(k => k.word);
+  const q = new Parse.Query('RiskKeyword');
+  q.containedIn('word', allWords);
+  q.limit(5000);
+  const existingRows = await q.find({ useMasterKey: true }) as any[];
+  const existingWords = new Set(existingRows.map((r: any) => r.get('word') as string));
+
+  let added = 0;
+  for (const kw of DEFAULT_KEYWORDS) {
+    if (existingWords.has(kw.word)) continue;
+    const obj = new Parse.Object('RiskKeyword');
+    obj.set('word', kw.word);
+    obj.set('category', kw.category);
+    obj.set('severity', kw.severity);
+    obj.set('enabled', true);
+    obj.set('createdAt', new Date());
+    await obj.save(null, { useMasterKey: true });
+    added++;
+  }
+  console.log(`[Seed] RiskKeyword 写入 ${added} 条新增,${DEFAULT_KEYWORDS.length - added} 条已存在,总计 ${DEFAULT_KEYWORDS.length} 条种子词`);
+}
+
+/** 确认接单:任何人操作后阻断升级 + 批量标记关联通知已读 */
+export async function confirmRiskEvent(
+  eventId: string,
+  userId: string,
+): Promise<{ ok: boolean; error?: string }> {
+  try {
+    const q = new Parse.Query('RiskEvent');
+    const event = await q.get(eventId, { useMasterKey: true }) as any;
+    if (!event) return { ok: false, error: '事件不存在' };
+
+    const status = event.get('status');
+    if (status === 'resolved' || status === 'false_alarm') {
+      return { ok: false, error: '事件已关闭,不可接单' };
+    }
+
+    event.set('status', 'processing');
+    event.set('confirmedBy', Parse.User.createWithoutData(userId));
+    event.set('confirmedAt', new Date());
+    await event.save(null, { useMasterKey: true });
+
+    // 批量标记关联通知为已读
+    const nq = new Parse.Query('AppNotification');
+    nq.equalTo('riskEvent', event);
+    nq.equalTo('read', false);
+    nq.limit(500);
+    const notifications = await nq.find({ useMasterKey: true }) as any[];
+    if (notifications.length > 0) {
+      for (const n of notifications) {
+        n.set('read', true);
+      }
+      await Parse.Object.saveAll(notifications, { useMasterKey: true });
+    }
+
+    console.log(`[RiskEvent] 已接单: eventId=${eventId}, userId=${userId}, 标记 ${notifications.length} 条通知已读`);
+    return { ok: true };
+  } catch (err: any) {
+    console.warn('[RiskEvent] 接单失败:', err.message);
+    return { ok: false, error: err.message };
+  }
+}

+ 133 - 0
backend/src/services/room-operation.service.ts

@@ -0,0 +1,133 @@
+import { modifyRoomName, inviteRoomMember, removeRoomMember } from '../qiwi/qiwe-room.api.js';
+import Parse from '../db/parse-client.js';
+
+export interface GroupOperationResult {
+  success: boolean;
+  message: string;
+}
+
+/**
+ * 修改群名:先调企微 API → 成功后更新本地 DB
+ */
+export async function changeGroupName(
+  roomId: string,
+  newName: string,
+): Promise<GroupOperationResult> {
+  if (!newName.trim()) {
+    return { success: false, message: '群名不能为空' };
+  }
+
+  try {
+    await modifyRoomName(roomId, newName.trim());
+
+    const query = new Parse.Query('GroupChat');
+    query.equalTo('roomId', roomId);
+    const group = await query.first({ useMasterKey: true });
+    if (group) {
+      group.set('roomName', newName.trim());
+      await group.save(null, { useMasterKey: true });
+    }
+
+    return { success: true, message: '群名已更新' };
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return { success: false, message: `修改群名失败: ${msg}` };
+  }
+}
+
+/**
+ * 邀请成员入群:先调企微 API → 成功后更新本地 DB
+ */
+export async function inviteMembers(
+  roomId: string,
+  userIds: string[],
+): Promise<GroupOperationResult> {
+  if (userIds.length === 0) {
+    return { success: false, message: '请选择要邀请的成员' };
+  }
+
+  try {
+    await inviteRoomMember(roomId, userIds);
+
+    // 批量查询现有成员
+    const mQuery = new Parse.Query('GroupMember');
+    mQuery.equalTo('roomId', roomId);
+    mQuery.containedIn('userId', userIds);
+    const existingMembers = await mQuery.find({ useMasterKey: true }) as Parse.Object[];
+    const existingByUserId = new Map(existingMembers.map(m => [String(m.get('userId')), m]));
+
+    const now = new Date();
+    const toSave: Parse.Object[] = [];
+    for (const userId of userIds) {
+      const existing = existingByUserId.get(userId);
+      if (existing) {
+        if (existing.get('status') === 'left') {
+          existing.set('status', 'active');
+          existing.set('joinedAt', now);
+          toSave.push(existing);
+        }
+      } else {
+        const obj = new Parse.Object('GroupMember');
+        obj.set('roomId', roomId);
+        obj.set('userId', userId);
+        obj.set('status', 'active');
+        obj.set('joinedAt', now);
+        obj.set('role', 'member');
+        toSave.push(obj);
+      }
+    }
+
+    if (toSave.length > 0) {
+      await Parse.Object.saveAll(toSave, { useMasterKey: true });
+    }
+
+    return { success: true, message: `已邀请 ${userIds.length} 位成员` };
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return { success: false, message: `邀请成员失败: ${msg}` };
+  }
+}
+
+/**
+ * 移除群成员:先调企微 API → 成功后更新本地 DB
+ */
+export async function removeMembers(
+  roomId: string,
+  userIds: string[],
+): Promise<GroupOperationResult> {
+  if (userIds.length === 0) {
+    return { success: false, message: '请选择要移除的成员' };
+  }
+
+  try {
+    const now = new Date();
+
+    // 批量查询现有成员
+    const mQuery = new Parse.Query('GroupMember');
+    mQuery.equalTo('roomId', roomId);
+    mQuery.containedIn('userId', userIds);
+    const existingMembers = await mQuery.find({ useMasterKey: true }) as Parse.Object[];
+    const existingByUserId = new Map(existingMembers.map(m => [String(m.get('userId')), m]));
+
+    const toSave: Parse.Object[] = [];
+    for (const userId of userIds) {
+      await removeRoomMember(roomId, userId); // 外部 API,每次一个 userId
+
+      const existing = existingByUserId.get(userId);
+      if (existing) {
+        existing.set('status', 'left');
+        existing.set('leftAt', now);
+        toSave.push(existing);
+      }
+    }
+
+    if (toSave.length > 0) {
+      await Parse.Object.saveAll(toSave, { useMasterKey: true });
+    }
+
+    return { success: true, message: `已移除 ${userIds.length} 位成员` };
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    return { success: false, message: `移除成员失败: ${msg}` };
+  }
+}

+ 137 - 0
backend/src/services/seed-responsibility-assignment.ts

@@ -0,0 +1,137 @@
+import Parse from '../db/parse-client.js';
+
+/**
+ * 从已有数据回填 ResponsibilityAssignment 表:
+ * 1. Store.managerId → store 级负责人
+ * 2. GroupChat.ownerId → _User.wecomUserId → group 级负责人
+ * 3. _User(role=store_manager).storeId → store 级负责人补全
+ *
+ * 幂等:先查后插,重复运行安全
+ */
+
+async function ensureAssignment(
+  scopeType: 'district' | 'store' | 'group',
+  scopeId: string,
+  scopeName: string,
+  userId: string,
+  userName: string,
+  role: string,
+): Promise<boolean> {
+  const q = new Parse.Query('ResponsibilityAssignment');
+  q.equalTo('scopeType', scopeType);
+  q.equalTo('scopeId', scopeId);
+  q.equalTo('userId', userId);
+  const existing = await q.first({ useMasterKey: true });
+  if (existing) return false;
+
+  const obj = new Parse.Object('ResponsibilityAssignment');
+  obj.set('scopeType', scopeType);
+  obj.set('scopeId', scopeId);
+  obj.set('scopeName', scopeName);
+  obj.set('userId', userId);
+  obj.set('userName', userName);
+  obj.set('role', role);
+  obj.set('updatedAt', new Date());
+  await obj.save(null, { useMasterKey: true });
+  return true;
+}
+
+export async function seedResponsibilityAssignments(): Promise<void> {
+  let added = 0;
+
+  // ── 1. Store.managerId → store 级 ──
+  try {
+    const storeQ = new Parse.Query('Store');
+    storeQ.exists('managerId');
+    storeQ.notEqualTo('managerId', '');
+    storeQ.limit(5000);
+    const stores = await storeQ.find({ useMasterKey: true }) as any[];
+
+    for (const s of stores) {
+      const code = s.get('code') as string;
+      const name = s.get('name') as string;
+      const managerId = s.get('managerId') as string;
+      const managerName = s.get('managerName') as string;
+      if (!code || !managerId) continue;
+
+      const created = await ensureAssignment(
+        'store', code, name, managerId,
+        managerName || '', 'store_manager',
+      );
+      if (created) added++;
+    }
+    console.log(`[Seed] Store 负责人: ${added} 条`);
+  } catch (err) {
+    console.warn('[Seed] Store 负责人 failed:', (err as Error).message);
+  }
+
+  // ── 2. GroupChat.ownerId → _User.wecomUserId → group 级 ──
+  try {
+    const groupQ = new Parse.Query('GroupChat');
+    groupQ.exists('ownerId');
+    groupQ.notEqualTo('ownerId', '');
+    groupQ.limit(5000);
+    const groups = await groupQ.find({ useMasterKey: true }) as any[];
+
+    for (const g of groups) {
+      const roomId = g.get('roomId') as string;
+      const roomName = g.get('roomName') as string;
+      const ownerId = g.get('ownerId') as string; // wecomUserId
+      const ownerName = g.get('ownerName') as string;
+      if (!roomId || !ownerId) continue;
+
+      const userQ = new Parse.Query('_User');
+      userQ.equalTo('wecomUserId', ownerId);
+      userQ.limit(1);
+      const user = await userQ.first({ useMasterKey: true }) as any;
+
+      const userId = user?.id || ownerId;
+      const userName = user?.get('name') || ownerName || '';
+      if (!userId) continue;
+
+      const created = await ensureAssignment(
+        'group', roomId, roomName || '', userId,
+        userName, 'group_owner',
+      );
+      if (created) added++;
+    }
+    console.log(`[Seed] GroupChat 负责人: GroupChat=${groups.length}`);
+  } catch (err) {
+    console.warn('[Seed] GroupChat 负责人 failed:', (err as Error).message);
+  }
+
+  // ── 3. _User(role=store_manager).storeId 补全 ──
+  try {
+    const userQ = new Parse.Query('_User');
+    userQ.equalTo('role', 'store_manager');
+    userQ.exists('storeId');
+    userQ.notEqualTo('storeId', '');
+    userQ.limit(5000);
+    const users = await userQ.find({ useMasterKey: true }) as any[];
+
+    for (const u of users) {
+      const storeId = u.get('storeId') as string;
+      const storeName = u.get('storeName') as string;
+      const userId = u.id;
+      const userName = u.get('name') || u.get('username') || '';
+      if (!storeId || !userId) continue;
+
+      const created = await ensureAssignment(
+        'store', storeId, storeName || '', userId,
+        userName, 'store_manager',
+      );
+      if (created) added++;
+    }
+    console.log(`[Seed] _User store_manager 补全: _User=${users.length}`);
+  } catch (err) {
+    console.warn('[Seed] _User store_manager 补全 failed:', (err as Error).message);
+  }
+
+  console.log(`[Seed] ResponsibilityAssignment 总计新增 ${added} 条`);
+}
+
+// 直接运行
+seedResponsibilityAssignments().catch(err => {
+  console.error('[Seed] ResponsibilityAssignment 失败:', err);
+  process.exit(1);
+});

+ 456 - 0
backend/src/services/sync.service.ts

@@ -0,0 +1,456 @@
+import Parse from '../db/parse-client.js';
+import { getAllRooms, getSessionList, batchGetRoomDetails, type RoomDetailDto } from '../qiwi/qiwe-api.service.js';
+import { backfillMissingSenderNames } from './message-sync.service.js';
+
+const GUID = process.env.QIWE_GUID || '';
+
+function isPlaceholderRoomName(name: string): boolean {
+  return !name || name === '未命名群' || /^群 \d{6}$/.test(name);
+}
+
+function makePointer(className: string, objectId: string): Parse.Object | null {
+  if (!objectId) return null;
+  const ptr = new (Parse.Object as any)(className);
+  ptr.id = objectId;
+  return ptr;
+}
+
+/** 根据群名称自动匹配 Community 和 Store */
+async function autoMatchGroupAttribution(
+  roomName: string,
+): Promise<{ storeId: string; storeName: string; communityId: string; communityName: string } | null> {
+  if (!roomName || isPlaceholderRoomName(roomName)) return null;
+
+  try {
+    // 1. 尝试匹配小区名
+    const cQuery = new Parse.Query('Community');
+    cQuery.matches('name', roomName.replace(/[群组团]/g, '').trim(), 'i');
+    cQuery.limit(5);
+    const matchedCommunities = await cQuery.find({ useMasterKey: true });
+
+    if (matchedCommunities.length > 0) {
+      const best = matchedCommunities[0];
+      const communityName = (best.get('name') as string) || '';
+      const storeId = (best.get('storeId') as string) || '';
+      const storeName = (best.get('storeName') as string) || '';
+      if (storeId) {
+        return {
+          storeId,
+          storeName: storeName || '',
+          communityId: best.id,
+          communityName,
+        };
+      }
+    }
+
+    // 2. 尝试直接匹配门店名
+    const sQuery = new Parse.Query('Store');
+    sQuery.matches('name', roomName.replace(/[群组团]/g, '').trim(), 'i');
+    sQuery.limit(5);
+    const matchedStores = await sQuery.find({ useMasterKey: true });
+
+    if (matchedStores.length > 0) {
+      const best = matchedStores[0];
+      return {
+        storeId: best.id,
+        storeName: (best.get('name') as string) || '',
+        communityId: '',
+        communityName: '',
+      };
+    }
+  } catch (err) {
+    // 匹配失败不影响主流程
+  }
+
+  return null;
+}
+
+export async function syncGroupsFromQiWe(): Promise<{
+  created: number;
+  updated: number;
+  errors: string[];
+}> {
+  const result = { created: 0, updated: 0, errors: [] as string[] };
+
+  console.log('[Sync] 开始从企微平台拉取群列表...');
+  const rooms = await getAllRooms();
+  console.log(`[Sync] 共获取 ${rooms.length} 个群`);
+
+  // 预加载所有已存在的 GroupChat,避免 N+1 查询
+  const roomIds = rooms.map(r => r.roomId);
+  const existingQuery = new Parse.Query('GroupChat');
+  existingQuery.containedIn('roomId', roomIds);
+  existingQuery.equalTo('guid', GUID);
+  existingQuery.limit(roomIds.length + 50);
+  const existingObjs = await existingQuery.find({ useMasterKey: true }) as Parse.Object[];
+  const existingMap = new Map(existingObjs.map(o => [String(o.get('roomId')), o]));
+
+  const toSave: Parse.Object[] = [];
+
+  for (const room of rooms) {
+    try {
+      const existing = existingMap.get(room.roomId) as Parse.Object | undefined;
+
+      if (existing) {
+        if (!isPlaceholderRoomName(room.roomName)) {
+          existing.set('roomName', room.roomName);
+        }
+        if (!existing.get('ownerId') && room.roomOwnerId) {
+          existing.set('ownerId', room.roomOwnerId);
+        }
+        existing.set('memberCount', room.roomMemberCount);
+        existing.set('avatarUrl', room.roomAvatarUrl);
+        existing.set('status', 'active');
+        toSave.push(existing);
+        result.updated++;
+      } else {
+        const obj = new Parse.Object('GroupChat');
+        obj.set('roomId', room.roomId);
+        obj.set('guid', GUID);
+        obj.set('roomName', room.roomName);
+        obj.set('ownerId', room.roomOwnerId);
+        obj.set('memberCount', room.roomMemberCount);
+        obj.set('avatarUrl', room.roomAvatarUrl);
+        obj.set('status', 'active');
+
+        // 自动匹配归属关系
+        const attr = await autoMatchGroupAttribution(room.roomName);
+        if (attr) {
+          const storePtr = makePointer('Store', attr.storeId);
+          if (storePtr) obj.set('store', storePtr);
+          if (attr.communityId) {
+            const commPtr = makePointer('Community', attr.communityId);
+            if (commPtr) obj.set('community', commPtr);
+          }
+        }
+
+        toSave.push(obj);
+        result.created++;
+      }
+    } catch (err: any) {
+      result.errors.push(`roomId=${room.roomId}: ${err.message}`);
+    }
+  }
+
+  if (toSave.length > 0) {
+    await Parse.Object.saveAll(toSave, { useMasterKey: true });
+  }
+
+  console.log(`[Sync] 完成 — 新建 ${result.created}, 更新 ${result.updated}, 错误 ${result.errors.length}`);
+  return result;
+}
+
+/** 从会话列表补全群,新群通过 batchGetRoomDetail 确认有名称才创建 */
+export async function syncGroupSessionsFromQiWe(): Promise<{
+  created: number;
+  updated: number;
+  skipped: number;
+  noName: number;
+}> {
+  const result = { created: 0, updated: 0, skipped: 0, noName: 0 };
+  const sessions = await getSessionList(1);
+  console.log(`[Sync] 会话列表中群会话 ${sessions.length} 个`);
+
+  const toSave: Parse.Object[] = [];
+  const newRoomIds: string[] = [];
+
+  // 预加载所有已存在的 GroupChat
+  const sessionRoomIds = sessions
+    .map(s => String(s.sessionId ?? ''))
+    .filter(id => id.length >= 14 && /^\d+$/.test(id));
+  const existingQuery = new Parse.Query('GroupChat');
+  existingQuery.containedIn('roomId', sessionRoomIds);
+  existingQuery.equalTo('guid', GUID);
+  existingQuery.limit(sessionRoomIds.length + 50);
+  const existingObjsX = await existingQuery.find({ useMasterKey: true }) as Parse.Object[];
+  const existingMap = new Map(existingObjsX.map(o => [String(o.get('roomId')), o]));
+
+  for (const session of sessions) {
+    const roomId = String(session.sessionId ?? '');
+    if (!roomId || roomId.length < 14 || !/^\d+$/.test(roomId)) {
+      result.skipped++;
+      continue;
+    }
+
+    try {
+      const existing = existingMap.get(roomId);
+
+      if (existing) {
+        existing.set('status', 'active');
+        toSave.push(existing);
+        result.updated++;
+      } else {
+        newRoomIds.push(roomId);
+      }
+    } catch (err: unknown) {
+      const message = err instanceof Error ? err.message : String(err);
+      console.warn(`[Sync] 会话群 roomId=${roomId} 写入失败: ${message}`);
+    }
+  }
+
+  // 批量查询新群的详情,只创建有名称的
+  if (newRoomIds.length > 0 && GUID) {
+    const details = await batchGetRoomDetails(GUID, newRoomIds);
+    const detailMap = new Map(details.map((d) => [d.roomId, d]));
+
+    for (const roomId of newRoomIds) {
+      const detail = detailMap.get(roomId);
+      if (detail && detail.roomName) {
+        const obj = new Parse.Object('GroupChat');
+        obj.set('roomId', roomId);
+        obj.set('guid', GUID);
+        obj.set('roomName', detail.roomName);
+        obj.set('ownerId', detail.ownerId);
+        obj.set('memberCount', detail.memberCount);
+        obj.set('status', 'active');
+
+        // 自动匹配归属关系
+        const attr = await autoMatchGroupAttribution(detail.roomName);
+        if (attr) {
+          const storePtr = makePointer('Store', attr.storeId);
+          if (storePtr) obj.set('store', storePtr);
+          if (attr.communityId) {
+            const commPtr = makePointer('Community', attr.communityId);
+            if (commPtr) obj.set('community', commPtr);
+          }
+        }
+
+        toSave.push(obj);
+        result.created++;
+      } else {
+        result.noName++;
+      }
+    }
+  }
+
+  if (toSave.length > 0) {
+    await Parse.Object.saveAll(toSave, { useMasterKey: true });
+  }
+
+  console.log(`[Sync] 会话群 — 新建 ${result.created}, 更新 ${result.updated}, 跳过 ${result.skipped}, 无名称跳过 ${result.noName}`);
+  return result;
+}
+
+/** 补全占位群名 + 同步群成员(含昵称),批量写入优化 */
+export async function enrichGroupsAndMembers(): Promise<{
+  namesUpdated: number;
+  membersCreated: number;
+  membersUpdated: number;
+}> {
+  if (!GUID) return { namesUpdated: 0, membersCreated: 0, membersUpdated: 0 };
+
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('guid', GUID);
+  query.limit(500);
+  const groups = await query.find({ useMasterKey: true });
+
+  const placeholderIds: string[] = [];
+  const allIds: string[] = [];
+  for (const g of groups) {
+    const roomId = String(g.get('roomId') || '');
+    if (!roomId) continue;
+    allIds.push(roomId);
+    const name = String(g.get('roomName') || '');
+    if (isPlaceholderRoomName(name)) {
+      placeholderIds.push(roomId);
+    }
+  }
+
+  if (allIds.length === 0) return { namesUpdated: 0, membersCreated: 0, membersUpdated: 0 };
+
+  console.log(`[Sync] 开始补全群详情:${placeholderIds.length} 个占位名,${allIds.length} 个群成员同步`);
+  const details = await batchGetRoomDetails(GUID, allIds);
+  const detailMap = new Map<string, RoomDetailDto>(details.map((d) => [d.roomId, d]));
+  console.log(`[Sync] 企微返回 ${details.length} 个群详情`);
+
+  let namesUpdated = 0;
+  let membersCreated = 0;
+  let membersUpdated = 0;
+
+  const groupsToSave: Parse.Object[] = [];
+  const membersToSave: Parse.Object[] = [];
+
+  // 批量预加载所有群的现有成员,避免 N+1 查询
+  const allRoomIds = allIds.filter(id => detailMap.has(id));
+  const allMembersQuery = new Parse.Query('GroupMember');
+  allMembersQuery.containedIn('roomId', allRoomIds);
+  allMembersQuery.equalTo('guid', GUID);
+  allMembersQuery.limit(100000);
+  const allExistingMembers = await allMembersQuery.find({ useMasterKey: true });
+  const membersByRoom = new Map<string, Map<string, Parse.Object>>();
+  for (const em of allExistingMembers) {
+    const rid = String(em.get('roomId') || '');
+    const uid = String(em.get('userId') || '');
+    if (!membersByRoom.has(rid)) membersByRoom.set(rid, new Map());
+    membersByRoom.get(rid)!.set(uid, em);
+  }
+
+  for (const group of groups) {
+    const roomId = String(group.get('roomId') || '');
+    const detail = detailMap.get(roomId);
+    if (!detail) continue;
+
+    const needNameFix = placeholderIds.includes(roomId);
+    let groupChanged = false;
+    if (needNameFix && detail.roomName) {
+      group.set('roomName', detail.roomName);
+      namesUpdated++;
+      groupChanged = true;
+    }
+    if (detail.ownerId && group.get('ownerId') !== detail.ownerId) {
+      group.set('ownerId', detail.ownerId);
+      groupChanged = true;
+    }
+    if (group.get('ownerId') && !group.get('ownerName')) {
+      const ownerMember = detail.members.find(m => m.userId === String(group.get('ownerId')));
+      if (ownerMember?.nickname) {
+        group.set('ownerName', ownerMember.nickname);
+        groupChanged = true;
+      }
+    }
+    if (detail.memberCount > 0 && group.get('memberCount') !== detail.memberCount) {
+      group.set('memberCount', detail.memberCount);
+      groupChanged = true;
+    }
+    if (groupChanged) groupsToSave.push(group);
+
+    const existingMap = membersByRoom.get(roomId) || new Map();
+    const ownerId = detail.ownerId || String(group.get('ownerId') || '');
+
+    for (const m of detail.members) {
+      const isOwner = ownerId && m.userId === ownerId;
+      const existing = existingMap.get(m.userId);
+      if (existing) {
+        let changed = false;
+        if (m.nickname && existing.get('nickname') !== m.nickname) {
+          existing.set('nickname', m.nickname);
+          changed = true;
+        }
+        if (existing.get('status') !== 'active') {
+          existing.set('status', 'active');
+          existing.set('leftAt', null);
+          changed = true;
+        }
+        if (isOwner && existing.get('role') !== 'owner') {
+          existing.set('role', 'owner');
+          changed = true;
+        }
+        if (changed) {
+          membersToSave.push(existing);
+          membersUpdated++;
+        }
+      } else {
+        const obj = new Parse.Object('GroupMember');
+        obj.set('roomId', roomId);
+        obj.set('userId', m.userId);
+        obj.set('nickname', m.nickname || '');
+        obj.set('guid', GUID);
+        obj.set('status', 'active');
+        obj.set('joinedAt', new Date());
+        if (isOwner) obj.set('role', 'owner');
+        membersToSave.push(obj);
+        membersCreated++;
+      }
+    }
+  }
+
+  if (groupsToSave.length > 0) {
+    await Parse.Object.saveAll(groupsToSave, { useMasterKey: true });
+  }
+  if (membersToSave.length > 0) {
+    for (let i = 0; i < membersToSave.length; i += 100) {
+      await Parse.Object.saveAll(membersToSave.slice(i, i + 100), { useMasterKey: true });
+    }
+  }
+
+  console.log(`[Sync] 群详情补全完成 — 名 ${namesUpdated} / 成员 +${membersCreated} ~${membersUpdated}`);
+  return { namesUpdated, membersCreated, membersUpdated };
+}
+
+/** 补全群主名称:对有 ownerId 但无 ownerName 的群,从 GroupMember 或 Message 表查找昵称 */
+export async function enrichOwnerNames(): Promise<number> {
+  if (!GUID) return 0;
+
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('guid', GUID);
+  query.exists('ownerId');
+  query.notEqualTo('ownerId', '');
+  query.limit(500);
+  const groups = await query.find({ useMasterKey: true });
+
+  const needFix = groups.filter((g: Parse.Object) => {
+    const ownerName = g.get('ownerName');
+    return !ownerName || String(ownerName).trim() === '';
+  });
+
+  if (needFix.length === 0) return 0;
+
+  let fixed = 0;
+  const toSave: Parse.Object[] = [];
+
+  for (const group of needFix) {
+    const ownerId = String(group.get('ownerId') || '');
+    const roomId = String(group.get('roomId') || '');
+    if (!ownerId || !roomId) continue;
+
+    // 1. 优先从 GroupMember 表查找
+    const mq = new Parse.Query('GroupMember');
+    mq.equalTo('roomId', roomId);
+    mq.equalTo('userId', ownerId);
+    mq.equalTo('guid', GUID);
+    mq.exists('nickname');
+    mq.notEqualTo('nickname', '');
+    const member = await mq.first({ useMasterKey: true });
+    if (member?.get('nickname')) {
+      group.set('ownerName', String(member.get('nickname')));
+      toSave.push(group);
+      fixed++;
+      continue;
+    }
+
+    // 2. 从 Message 表查找该用户在该群的最近一条 senderName
+    const msgQ = new Parse.Query('Message');
+    msgQ.equalTo('roomId', roomId);
+    msgQ.equalTo('senderId', ownerId);
+    msgQ.exists('senderName');
+    msgQ.notEqualTo('senderName', '');
+    msgQ.descending('timestamp');
+    msgQ.limit(1);
+    const msg = await msgQ.first({ useMasterKey: true });
+    if (msg?.get('senderName')) {
+      group.set('ownerName', String(msg.get('senderName')));
+      toSave.push(group);
+      fixed++;
+    }
+  }
+
+  if (toSave.length > 0) {
+    await Parse.Object.saveAll(toSave, { useMasterKey: true });
+  }
+
+  if (fixed > 0) {
+    console.log(`[Sync] ownerName 补全: ${fixed} 个群`);
+  }
+  return fixed;
+}
+
+/** 一次性对齐群目录:企微群列表 + 会话 + 详情补全 */
+export async function reconcileGroupCatalog(): Promise<{
+  rooms: { created: number; updated: number; errors: string[] };
+  sessions: { created: number; updated: number; skipped: number; noName: number };
+  namesUpdated: number;
+  membersCreated: number;
+  membersUpdated: number;
+  ownerNamesFixed: number;
+}> {
+  const rooms = await syncGroupsFromQiWe();
+  const sessions = await syncGroupSessionsFromQiWe();
+  const enriched = await enrichGroupsAndMembers();
+  backfillMissingSenderNames(GUID).catch((e) =>
+    console.warn('[Sync] senderName 回填失败:', e.message));
+  const ownerNamesFixed = await enrichOwnerNames();
+  console.log(
+    `[Sync] 群目录对齐完成 rooms+${rooms.created}/${rooms.updated} sessions+${sessions.created} enrich+${enriched.namesUpdated}/${enriched.membersCreated}`,
+  );
+  return { rooms, sessions, namesUpdated: enriched.namesUpdated, membersCreated: enriched.membersCreated, membersUpdated: enriched.membersUpdated, ownerNamesFixed };
+}

+ 139 - 0
backend/src/services/users.service.ts

@@ -0,0 +1,139 @@
+import Parse from '../db/parse-client.js';
+
+export interface UserDto {
+  id: string;
+  name: string;
+  phone: string;
+  role: string;
+  storeId: string;
+  storeName: string;
+  districtId: string;
+  districtName: string;
+}
+
+function makePointer(className: string, objectId: string): Parse.Object | null {
+  if (!objectId) return null;
+  const ptr = new (Parse.Object as any)(className);
+  ptr.id = objectId;
+  return ptr;
+}
+
+function mapUser(o: any): UserDto {
+  const store = o.get?.('store');
+  const district = o.get?.('district');
+  return {
+    id: o.id ?? '',
+    name: o.get?.('name') || '',
+    phone: o.get?.('phone') || '',
+    role: o.get?.('role') || '',
+    storeId: store?.id || '',
+    storeName: store?.get?.('name') || '',
+    districtId: district?.id || '',
+    districtName: district?.get?.('name') || '',
+  };
+}
+
+/** 搜索 _User(模糊匹配 name) */
+export async function searchUsers(query: string): Promise<UserDto[]> {
+  const q = new Parse.Query('_User');
+  q.notEqualTo('isDeleted', true);
+  q.include('store');
+  q.include('district');
+  q.limit(500);
+  q.ascending('name');
+  if (query) q.matches('name', query, 'i');
+  const results = await q.find({ useMasterKey: true }) as any[];
+  return results.map(mapUser);
+}
+
+/** 按角色查询 _User */
+export async function listUsersByRole(role?: string): Promise<UserDto[]> {
+  const q = new Parse.Query('_User');
+  q.notEqualTo('isDeleted', true);
+  if (role) q.equalTo('role', role);
+  q.include('store');
+  q.include('district');
+  q.limit(500);
+  q.ascending('name');
+  const results = await q.find({ useMasterKey: true }) as any[];
+  return results.map(mapUser);
+}
+
+/** 全量查询 _User(含 isDeleted 过滤) */
+export async function listAllUsers(): Promise<UserDto[]> {
+  return listUsersByRole();
+}
+
+/** 批量查询 _User,返回 userId → { name, phone } 映射 */
+export async function batchLookupUsers(userIds: string[]): Promise<Array<{ id: string; name: string; phone: string }>> {
+  const uniqueIds = [...new Set(userIds.filter(Boolean))];
+  if (uniqueIds.length === 0) return [];
+
+  const results: Array<{ id: string; name: string; phone: string }> = [];
+  for (let i = 0; i < uniqueIds.length; i += 50) {
+    const batch = uniqueIds.slice(i, i + 50);
+    const q = new Parse.Query('_User');
+    q.containedIn('objectId', batch);
+    q.limit(50);
+    const items = await q.find({ useMasterKey: true }) as any[];
+    for (const u of items) {
+      results.push({
+        id: u.id ?? '',
+        name: u.get?.('name') || '',
+        phone: u.get?.('phone') || '',
+      });
+    }
+  }
+  return results;
+}
+
+/** 更新用户权限范围 */
+export async function updateUserScope(
+  userId: string,
+  patch: { storeId?: string; districtId?: string; role?: string },
+): Promise<void> {
+  const q = new Parse.Query('_User');
+  const user = await q.get(userId, { useMasterKey: true }) as any;
+  if (!user) throw new Error(`User ${userId} not found`);
+
+  if (patch.storeId !== undefined) {
+    if (patch.storeId) {
+      user.set('store', makePointer('Store', patch.storeId));
+    } else {
+      user.unset('store');
+    }
+  }
+  if (patch.districtId !== undefined) {
+    if (patch.districtId) {
+      user.set('district', makePointer('District', patch.districtId));
+    } else {
+      user.unset('district');
+    }
+  }
+  if (patch.role !== undefined) {
+    if (patch.role) {
+      user.set('role', patch.role);
+    } else {
+      user.unset('role');
+    }
+  }
+  await user.save(null, { useMasterKey: true });
+}
+
+/** 软删除用户(isDeleted = true) */
+export async function deleteUser(userId: string): Promise<void> {
+  const q = new Parse.Query('_User');
+  const user = await q.get(userId, { useMasterKey: true }) as any;
+  if (!user) throw new Error(`User ${userId} not found`);
+  user.set('isDeleted', true);
+  await user.save(null, { useMasterKey: true });
+}
+
+/** 管理员重置用户密码 */
+export async function adminResetPassword(userId: string, newPassword: string): Promise<void> {
+  const q = new Parse.Query('_User');
+  const user = await q.get(userId, { useMasterKey: true }) as any;
+  if (!user) throw new Error(`User ${userId} not found`);
+  user.setPassword(newPassword);
+  await user.save(null, { useMasterKey: true });
+}

+ 396 - 0
backend/src/services/webhook.service.ts

@@ -0,0 +1,396 @@
+import Parse from '../db/parse-client.js';
+import { persistWebhookChatMessage } from './message-persist.service.js';
+import { analyzeMessage } from './message-analyzer.service.js';
+
+interface CallbackEvent {
+  guid: string;
+  userId: string;
+  cmd: number;
+  msgType: number;
+  msgServerId: number;
+  msgUniqueIdentifier: string;
+  senderId: number;
+  receiverId?: number;
+  fromRoomId: number;
+  senderName: string;
+  timestamp: number;
+  msgData: any;
+  base64RawData?: string;
+  seq?: number;
+}
+
+function decodeBase64List(data: string): string[] {
+  if (!data) return [];
+  try {
+    const decoded = Buffer.from(data, 'base64').toString('utf-8');
+    return decoded.split(';').filter(Boolean);
+  } catch {
+    return [];
+  }
+}
+
+async function incrementGroupCounters(roomId: string, guid: string): Promise<void> {
+  if (!roomId) return;
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const obj = await query.first({ useMasterKey: true });
+  if (!obj) return;
+
+  obj.increment('messageCountTotal', 1);
+  obj.increment('messageCountToday', 1);
+  obj.set('lastActivityAt', new Date());
+  await obj.save(null, { useMasterKey: true });
+}
+
+async function updateGroupMemberCount(roomId: string, guid: string, delta: number): Promise<void> {
+  if (!roomId) return;
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const obj = await query.first({ useMasterKey: true });
+  if (!obj) return;
+
+  const current = (obj.get('memberCount') ?? 0) as number;
+  obj.set('memberCount', Math.max(0, current + delta));
+  await obj.save(null, { useMasterKey: true });
+}
+
+async function upsertGroupChat(roomId: string, guid: string, extra: Record<string, any> = {}) {
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  query.equalTo('guid', guid);
+  const existing = await query.first({ useMasterKey: true });
+
+  if (existing) {
+    Object.entries(extra).forEach(([k, v]) => { if (v !== undefined) existing.set(k, v); });
+    return existing.save(null, { useMasterKey: true });
+  }
+
+  const obj = new Parse.Object('GroupChat');
+  obj.set('roomId', roomId);
+  obj.set('guid', guid);
+  obj.set('status', 'active');
+  Object.entries(extra).forEach(([k, v]) => { if (v !== undefined) obj.set(k, v); });
+  return obj.save(null, { useMasterKey: true });
+}
+
+async function upsertGroupMember(roomId: string, userId: string, guid: string, extra: Record<string, any> = {}) {
+  const query = new Parse.Query('GroupMember');
+  query.equalTo('roomId', roomId);
+  query.equalTo('userId', userId);
+  query.equalTo('guid', guid);
+  const existing = await query.first({ useMasterKey: true });
+
+  if (existing) {
+    let changed = false;
+    if (existing.get('status') === 'left' && extra.status === 'active') {
+      existing.set('status', 'active');
+      existing.set('leftAt', null);
+      changed = true;
+    }
+    for (const [k, v] of Object.entries(extra)) {
+      if (v !== undefined && existing.get(k) !== v) {
+        existing.set(k, v);
+        changed = true;
+      }
+    }
+    if (changed) return existing.save(null, { useMasterKey: true });
+    return existing;
+  }
+
+  const obj = new Parse.Object('GroupMember');
+  obj.set('roomId', roomId);
+  obj.set('userId', userId);
+  obj.set('guid', guid);
+  Object.entries(extra).forEach(([k, v]) => { if (v !== undefined) obj.set(k, v); });
+  return obj.save(null, { useMasterKey: true });
+}
+
+async function batchUpsertGroupMembers(
+  roomId: string,
+  guid: string,
+  memberUpdates: Array<{ userId: string; extra: Record<string, any> }>,
+): Promise<void> {
+  if (memberUpdates.length === 0) return;
+
+  const userIds = memberUpdates.map(m => m.userId);
+  const q = new Parse.Query('GroupMember');
+  q.equalTo('roomId', roomId);
+  q.equalTo('guid', guid);
+  q.containedIn('userId', userIds);
+  const existingList = await q.find({ useMasterKey: true }) as Parse.Object[];
+  const existingMap = new Map(existingList.map(e => [String(e.get('userId')), e]));
+
+  const toSave: Parse.Object[] = [];
+  for (const { userId, extra } of memberUpdates) {
+    const existing = existingMap.get(userId);
+    if (existing) {
+      let changed = false;
+      if (existing.get('status') === 'left' && extra.status === 'active') {
+        existing.set('status', 'active');
+        existing.set('leftAt', null);
+        changed = true;
+      }
+      for (const [k, v] of Object.entries(extra)) {
+        if (v !== undefined && existing.get(k) !== v) {
+          existing.set(k, v);
+          changed = true;
+        }
+      }
+      if (changed) toSave.push(existing);
+    } else {
+      const obj = new Parse.Object('GroupMember');
+      obj.set('roomId', roomId);
+      obj.set('userId', userId);
+      obj.set('guid', guid);
+      for (const [k, v] of Object.entries(extra)) {
+        if (v !== undefined) obj.set(k, v);
+      }
+      toSave.push(obj);
+    }
+  }
+
+  if (toSave.length > 0) {
+    await Parse.Object.saveAll(toSave, { useMasterKey: true });
+  }
+}
+
+async function handleGroupCreate(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+  const timestamp = new Date(event.timestamp * 1000);
+
+  const memberIds = decodeBase64List(event.base64RawData || event.msgData?.changedMemberList || '');
+  const roomName = event.msgData?.roomName || event.msgData?.name || '';
+  const ownerId = String(event.senderId);
+
+  const extra: Record<string, any> = {
+    ownerId,
+    ownerName: event.senderName || '',
+    memberCount: memberIds.length,
+    lastActivityAt: timestamp,
+  };
+  if (roomName) extra.roomName = roomName;
+
+  await upsertGroupChat(roomId, guid, extra);
+
+  await batchUpsertGroupMembers(roomId, guid, memberIds.map(userId => ({
+    userId,
+    extra: {
+      status: 'active',
+      joinedAt: timestamp,
+      ...(userId === ownerId ? { role: 'owner' } : {}),
+    },
+  })));
+
+  console.log(`[群创建] roomId=${roomId} name="${roomName}" members=${memberIds.length} guid=${guid}`);
+}
+
+async function handleGroupNameChange(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+  const rawData = event.base64RawData || event.msgData?.base64RawData || '';
+  const roomName = rawData ? Buffer.from(rawData, 'base64').toString('utf-8') : '';
+
+  if (roomName) {
+    await upsertGroupChat(roomId, guid, { roomName });
+    console.log(`[群名变更] roomId=${roomId} name=${roomName}`);
+  }
+}
+
+async function handleMemberAdd(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+  const timestamp = new Date(event.timestamp * 1000);
+
+  const memberIds = decodeBase64List(event.base64RawData || event.msgData?.changedMemberList || '');
+  if (memberIds.length > 0) {
+    await batchUpsertGroupMembers(roomId, guid, memberIds.map(userId => ({
+      userId,
+      extra: { status: 'active', joinedAt: timestamp },
+    })));
+    await updateGroupMemberCount(roomId, guid, memberIds.length);
+  }
+
+  console.log(`[成员加入] roomId=${roomId} members=${memberIds.join(',')}`);
+}
+
+async function handleMemberRemove(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+  const timestamp = new Date(event.timestamp * 1000);
+
+  const memberIds = decodeBase64List(event.base64RawData || event.msgData?.changedMemberList || '');
+  if (memberIds.length > 0) {
+    await batchUpsertGroupMembers(roomId, guid, memberIds.map(userId => ({
+      userId,
+      extra: { status: 'left', leftAt: timestamp },
+    })));
+    await updateGroupMemberCount(roomId, guid, -memberIds.length);
+  }
+
+  console.log(`[成员移除] roomId=${roomId} members=${memberIds.join(',')}`);
+}
+
+async function handleMemberQuit(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+  const timestamp = new Date(event.timestamp * 1000);
+  const userId = String(event.senderId);
+
+  await upsertGroupMember(roomId, userId, guid, {
+    status: 'left',
+    leftAt: timestamp,
+  });
+
+  await updateGroupMemberCount(roomId, guid, -1);
+
+  console.log(`[成员退群] roomId=${roomId} userId=${userId}`);
+}
+
+async function handleGroupDismiss(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+
+  await upsertGroupChat(roomId, guid, { status: 'dismissed' });
+  console.log(`[群解散] roomId=${roomId}`);
+}
+
+async function handleOwnerTransfer(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+  const ids = decodeBase64List(event.base64RawData || event.msgData?.changedMemberList || '');
+  const newOwnerId = ids[0];
+  if (!newOwnerId) {
+    console.log(`[群主转让] roomId=${roomId} 未能解析新群主ID`);
+    return;
+  }
+
+  let newOwnerName = '';
+  try {
+    const mq = new Parse.Query('GroupMember');
+    mq.equalTo('roomId', roomId);
+    mq.equalTo('userId', newOwnerId);
+    mq.equalTo('guid', guid);
+    mq.exists('nickname');
+    mq.notEqualTo('nickname', '');
+    const member = await mq.first({ useMasterKey: true });
+    if (member?.get('nickname')) {
+      newOwnerName = String(member.get('nickname'));
+    }
+  } catch {
+    // 查找失败不阻塞
+  }
+
+  await upsertGroupChat(roomId, guid, {
+    ownerId: newOwnerId,
+    ...(newOwnerName ? { ownerName: newOwnerName } : {}),
+  });
+
+  await upsertGroupMember(roomId, newOwnerId, guid, { role: 'owner' });
+  const oldOwnerId = String(event.senderId);
+  if (oldOwnerId && oldOwnerId !== newOwnerId) {
+    await upsertGroupMember(roomId, oldOwnerId, guid, { role: 'member' });
+  }
+
+  console.log(`[群主转让] roomId=${roomId} newOwner=${newOwnerId} name=${newOwnerName}`);
+}
+
+async function handleChatMessage(event: CallbackEvent) {
+  const msgData = event.msgData || {};
+  const status = await persistWebhookChatMessage({
+    msgUniqueIdentifier: event.msgUniqueIdentifier,
+    fromRoomId: event.fromRoomId,
+    senderId: event.senderId,
+    senderName: event.senderName,
+    msgType: event.msgType,
+    timestamp: event.timestamp,
+    guid: event.guid,
+    msgData,
+    seq: event.seq,
+  });
+
+  if (status === 'created') {
+    const roomId = event.fromRoomId ? String(event.fromRoomId) : '';
+    const guid = event.guid;
+
+    if (roomId && (event.msgType ?? 0) < 1000) {
+      incrementGroupCounters(roomId, guid).catch((err) =>
+        console.warn('[Webhook] 更新群计数器失败:', (err as Error).message));
+      if (event.senderName) {
+        upsertGroupMember(roomId, String(event.senderId), guid, {
+          nickname: event.senderName,
+          status: 'active',
+        }).catch((err) => console.warn('[Webhook] 更新成员昵称失败:', (err as Error).message));
+      }
+    }
+
+    analyzeMessage({
+      msgUniqueIdentifier: event.msgUniqueIdentifier,
+      roomId,
+      senderId: String(event.senderId),
+      senderName: event.senderName || '',
+      content: (msgData.content || msgData.linkUrl || '').toString(),
+      msgType: event.msgType,
+    }).catch((err) =>
+      console.warn('[Webhook] 消息分析失败:', (err as Error).message));
+
+    const tag = roomId ? `roomId=${roomId}` : '私聊';
+    const preview = (msgData.content || msgData.linkUrl || '').toString().slice(0, 50);
+    console.log(`[消息入库] ${tag} type=${event.msgType} sender=${event.senderId} ${preview}`);
+  }
+}
+
+async function updateAccountHeartbeat(guid: string): Promise<void> {
+  if (!guid) return;
+  const query = new Parse.Query('QiWeAccount');
+  query.equalTo('guid', guid);
+  const account = await query.first({ useMasterKey: true });
+  if (account) {
+    account.set('status', 'online');
+    account.set('lastHeartbeat', new Date());
+    await account.save(null, { useMasterKey: true });
+  }
+}
+
+export async function processWebhookEvent(event: CallbackEvent): Promise<void> {
+  const { cmd, msgType } = event;
+
+  if (cmd === 15000) {
+    // 群事件 — 特殊的 msgType 需要结构化处理
+    if (msgType === 1001) { await handleGroupNameChange(event); return; }
+    if (msgType === 1002) { await handleMemberAdd(event); return; }
+    if (msgType === 1003) { await handleMemberRemove(event); return; }
+    if (msgType === 1005) { await handleMemberQuit(event); return; }
+    if (msgType === 1006) { await handleGroupCreate(event); return; }
+    if (msgType === 1022) { await handleOwnerTransfer(event); return; }
+    if (msgType === 1023) { await handleGroupDismiss(event); return; }
+    if (msgType === 1043) { console.log(`[管理员变动] roomId=${event.fromRoomId}`); return; }
+    if (msgType === 2002) { console.log(`[删除聊天]`); return; }
+    if (msgType === 2055) { console.log(`[清空聊天]`); return; }
+
+    // 其他所有 msgType 都视为聊天消息入库
+    await handleChatMessage(event);
+    return;
+  }
+
+  if (cmd === 15500) {
+    console.log(`[系统消息] msgType=${msgType} guid=${event.guid}`);
+    return;
+  }
+
+  if (cmd === 11016) {
+    console.log(`[账号状态] guid=${event.guid} code=${event.msgData?.code} status=${event.msgData?.status}`);
+    updateAccountHeartbeat(event.guid).catch((err) =>
+      console.warn('[Webhook] 更新账号心跳失败:', (err as Error).message));
+    return;
+  }
+
+  if (cmd === 20000) {
+    console.log(`[API异步] guid=${event.guid}`);
+    return;
+  }
+
+  console.log(`[未处理] cmd=${cmd} msgType=${msgType}`);
+}

+ 5 - 0
backend/src/types/parse-node.d.ts

@@ -0,0 +1,5 @@
+declare module 'parse/node' {
+  import Parse from 'parse';
+  export = Parse;
+  export default Parse;
+}

+ 22 - 0
backend/src/utils/with-timeout.ts

@@ -0,0 +1,22 @@
+export function withTimeout<T>(
+  promise: Promise<T>,
+  ms: number,
+  label = 'operation',
+): Promise<T> {
+  return new Promise<T>((resolve, reject) => {
+    const timer = setTimeout(
+      () => reject(new Error(`${label} timed out after ${ms}ms`)),
+      ms,
+    );
+    promise.then(
+      (value) => {
+        clearTimeout(timer);
+        resolve(value);
+      },
+      (error: unknown) => {
+        clearTimeout(timer);
+        reject(error);
+      },
+    );
+  });
+}

+ 30 - 0
backend/test-webhook.bat

@@ -0,0 +1,30 @@
+@echo off
+chcp 65001 >nul
+echo ========================================
+echo   企微 Webhook 测试脚本
+echo ========================================
+echo.
+
+:: 生成时间戳作为唯一标识,避免去重
+set TS=%date:~0,4%%date:~5,2%%date:~8,2%%time:~0,2%%time:~3,2%%time:~6,2%
+set TS=%TS: =0%
+
+echo [1/3] 测试群聊消息...
+curl -s -X POST http://47.96.148.66/api/qiwe/webhook -H "Content-Type: application/json" -d "{\"code\":0,\"data\":[{\"guid\":\"deploy-test\",\"cmd\":15000,\"msgType\":0,\"fromRoomId\":888777,\"senderId\":168885001,\"msgUniqueIdentifier\":\"grp-%TS%\",\"timestamp\":1716883200,\"msgData\":{\"content\":\"群聊测试消息\",\"atList\":[\"168885002\"]}}],\"msg\":\"success\"}"
+echo.
+
+echo [2/3] 测试私聊消息...
+curl -s -X POST http://47.96.148.66/api/qiwe/webhook -H "Content-Type: application/json" -d "{\"code\":0,\"data\":[{\"guid\":\"deploy-test\",\"cmd\":15000,\"msgType\":0,\"fromRoomId\":0,\"senderId\":168885001,\"receiverId\":168885002,\"msgUniqueIdentifier\":\"prv-%TS%\",\"timestamp\":1716883200,\"msgData\":{\"content\":\"私聊测试消息\"}}],\"msg\":\"success\"}"
+echo.
+
+echo [3/3] 测试群创建(含成员)...
+curl -s -X POST http://47.96.148.66/api/qiwe/webhook -H "Content-Type: application/json" -d "{\"code\":0,\"data\":[{\"guid\":\"deploy-test\",\"cmd\":15000,\"msgType\":1006,\"fromRoomId\":999888,\"senderId\":168885001,\"msgUniqueIdentifier\":\"create-%TS%\",\"timestamp\":1716883200,\"base64RawData\":\"MTY4ODg1MDAxOzE2ODg4NTAwMjsxNjg4ODUwMDM=\"}],\"msg\":\"success\"}"
+echo.
+
+echo.
+echo ========================================
+echo   测试完成!
+echo   在服务器上查看日志: pm2 logs qiwe-backend --lines 10 --nostream
+echo   查看数据库: Parse Dashboard
+echo ========================================
+pause

+ 17 - 0
backend/tsconfig.json

@@ -0,0 +1,17 @@
+{
+  "compilerOptions": {
+    "target": "ES2022",
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "esModuleInterop": true,
+    "strict": true,
+    "outDir": "dist",
+    "rootDir": "src",
+    "skipLibCheck": true,
+    "forceConsistentCasingInFileNames": true,
+    "resolveJsonModule": true,
+    "declaration": true
+  },
+  "include": ["src/**/*"],
+  "exclude": ["node_modules", "dist"]
+}

+ 1374 - 0
doc/Angular项目开发规范.md

@@ -0,0 +1,1374 @@
+# Angular 项目开发规范
+
+> 本文档定义了 Angular 项目开发过程中必须遵守的规范,以确保代码质量、一致性和可维护性。
+
+## 目录
+
+1. [组件规范](#1-组件规范)
+2. [路由规范](#2-路由规范)
+3. [服务规范](#3-服务规范)
+4. [依赖管理规范](#4-依赖管理规范)
+5. [模块规范](#5-模块规范)
+6. [命名规范](#6-命名规范)
+7. [代码风格规范](#7-代码风格规范)
+   - [7.1 TypeScript 规范](#71-typescript-规范)
+   - [7.2 RxJS 规范](#72-rxjs-规范)
+   - [7.3 Signals 响应式规范](#73-signals-响应式规范)
+   - [7.4 注释规范](#74-注释规范)
+8. [样式规范](#8-样式规范)
+9. [安全规范](#9-安全规范)
+10. [性能规范](#10-性能规范)
+11. [Git 提交规范](#11-git-提交规范)
+
+---
+
+## 1. 组件规范
+
+### 1.1 必须使用 CLI 生成组件
+
+**强制要求:所有组件必须使用 `ng g c` 命令生成,禁止手动创建单文件组件。**
+
+```bash
+# ✅ 正确:使用 Angular CLI 生成完整组件
+ng g c features/users/user-list
+
+# ❌ 错误:手动创建单文件组件
+# 禁止创建类似以下的单文件组件:
+@Component({
+  selector: 'app-user-list',
+  template: `<div>用户列表</div>`,
+  styles: [`div { color: red; }`]
+})
+export class UserListComponent {}
+```
+
+**生成后的组件结构:**
+
+```
+src/app/features/user-list/
+├── user-list.component.ts       # 组件类
+├── user-list.component.html    # 模板
+├── user-list.component.scss    # 样式
+└── user-list.component.spec.ts # 单元测试
+```
+
+### 1.2 Standalone 组件
+
+**强制要求:默认使用 Standalone 组件,不使用 NgModule。**
+
+```typescript
+// ✅ 正确:Standalone 组件
+@Component({
+  selector: 'app-user-card',
+  standalone: true,
+  imports: [CommonModule, ReactiveFormsModule],
+  templateUrl: './user-card.component.html',
+  styleUrls: ['./user-card.component.scss']
+})
+export class UserCardComponent {}
+
+// ❌ 错误:使用 NgModule 的组件(禁止新建)
+@Component({
+  selector: 'app-user-card',
+  moduleId: module.id,
+  templateUrl: './user-card.component.html'
+})
+export class UserCardComponent {}
+```
+
+### 1.3 组件职责单一
+
+**一个组件只负责一个功能,避免"万能组件"。**
+
+```typescript
+// ✅ 好:职责单一
+UserListComponent        // 只负责用户列表显示
+UserCardComponent       // 只负责用户卡片显示
+UserFormComponent       // 只负责用户表单
+
+// ❌ 差:职责过多
+UserManagementComponent  // 同时负责列表、详情、编辑、删除
+```
+
+### 1.4 组件输入输出清晰
+
+```typescript
+@Component({
+  selector: 'app-button',
+  template: `
+    <button
+      [disabled]="disabled"
+      [type]="type"
+      (click)="handleClick()">
+      {{ label }}
+    </button>
+  `
+})
+export class ButtonComponent {
+  @Input() label = '按钮';
+  @Input() disabled = false;
+  @Input() type: 'button' | 'submit' = 'button';
+
+  @Output() clicked = new EventEmitter<void>();
+
+  handleClick() {
+    this.clicked.emit();
+  }
+}
+```
+
+### 1.5 避免直接操作 DOM
+
+**使用 Angular 绑定机制,禁止使用 `document.querySelector` 等直接 DOM 操作。**
+
+```typescript
+// ✅ 正确:使用模板绑定
+template: `<div [class.active]="isActive">内容</div>`
+
+// ❌ 错误:直接操作 DOM
+ngAfterViewInit() {
+  document.querySelector('.active').classList.add('highlight');
+}
+```
+
+---
+
+## 2. 路由规范
+
+### 2.1 路由必须集中在 app.routes.ts
+
+**强制要求:所有路由必须写在 `app.routes.ts` 中,禁止在 feature 目录下创建独立的 `.routes.ts` 文件。**
+
+```typescript
+// ✅ 正确:所有路由集中在 app.routes.ts
+// src/app/app.routes.ts
+export const routes: Routes = [
+  {
+    path: '',
+    loadComponent: () => import('./layouts/main-layout/main-layout.component')
+      .then(m => m.MainLayoutComponent),
+    children: [
+      { path: '', redirectTo: 'dashboard', pathMatch: 'full' },
+      {
+        path: 'dashboard',
+        loadComponent: () => import('./features/dashboard/dashboard.component')
+          .then(m => m.DashboardComponent)
+      },
+      {
+        path: 'users',
+        loadComponent: () => import('./features/users/user-list/user-list.component')
+          .then(m => m.UserListComponent)
+      },
+      {
+        path: 'users/:id',
+        loadComponent: () => import('./features/users/user-detail/user-detail.component')
+          .then(m => m.UserDetailComponent)
+      }
+    ]
+  },
+  {
+    path: 'login',
+    loadComponent: () => import('./features/auth/login/login.component')
+      .then(m => m.LoginComponent)
+  },
+  {
+    path: '**',
+    loadComponent: () => import('./features/not-found/not-found.component')
+      .then(m => m.NotFoundComponent)
+  }
+];
+```
+
+**❌ 错误:禁止在 feature 目录下创建独立的路由文件**
+
+```typescript
+// ❌ 错误:禁止创建 features/users/users.routes.ts
+// features/users/users.routes.ts
+export const USER_ROUTES: Routes = [
+  { path: '', component: UserListComponent },
+  { path: ':id', component: UserDetailComponent }
+];
+
+// ❌ 错误:禁止在 app.routes.ts 中引用外部路由文件
+export const routes: Routes = [
+  {
+    path: 'users',
+    loadChildren: () => import('./features/users/users.routes')
+      .then(m => m.USER_ROUTES)  // ❌ 禁止
+  }
+];
+```
+
+### 2.2 必须使用懒加载
+
+**强制要求:所有功能组件必须使用懒加载。**
+
+```typescript
+// ✅ 正确:懒加载组件
+{
+  path: 'users',
+  loadComponent: () => import('./features/users/user-list/user-list.component')
+    .then(m => m.UserListComponent)
+}
+
+// ❌ 错误:直接导入(立即加载)
+import { UserListComponent } from './features/users/user-list/user-list.component';
+
+export const routes: Routes = [
+  { path: 'users', component: UserListComponent }  // ❌
+];
+```
+
+### 2.3 路由守卫保护
+
+**敏感页面必须使用路由守卫。**
+
+```typescript
+// core/guards/auth.guard.ts
+export const authGuard: CanActivateFn = (route, state) => {
+  const authService = inject(AuthService);
+  const router = inject(Router);
+
+  if (authService.isAuthenticated()) {
+    return true;
+  }
+
+  router.navigate(['/login'], {
+    queryParams: { returnUrl: state.url }
+  });
+  return false;
+};
+
+// app.routes.ts 中使用
+{
+  path: 'admin',
+  loadComponent: () => import('./features/admin/admin.component')
+    .then(m => m.AdminComponent),
+  canActivate: [authGuard, adminGuard]
+}
+```
+
+### 2.4 路由路径命名
+
+**使用 kebab-case 命名路由路径。**
+
+```typescript
+// ✅ 正确
+{ path: 'user-profile', component: UserProfileComponent }
+{ path: 'order-management', component: OrderManagementComponent }
+
+// ❌ 错误
+{ path: 'userProfile', component: UserProfileComponent }
+{ path: 'OrderManagement', component: OrderManagementComponent }
+```
+
+### 2.5 404 处理
+
+**必须配置 404 页面。**
+
+```typescript
+export const routes: Routes = [
+  // ... 其他路由
+  {
+    path: '**',
+    loadComponent: () => import('./features/not-found/not-found.component')
+      .then(m => m.NotFoundComponent)
+  }
+];
+```
+
+### 2.6 嵌套路由配置
+
+**嵌套路由也必须写在 app.routes.ts 中。**
+
+```typescript
+export const routes: Routes = [
+  {
+    path: 'products',
+    loadComponent: () => import('./features/products/product-list/product-list.component')
+      .then(m => m.ProductListComponent)
+  },
+  {
+    path: 'products/:id',
+    loadComponent: () => import('./features/products/product-detail/product-detail.component')
+      .then(m => m.ProductDetailComponent)
+  },
+  {
+    path: 'products/:id/edit',
+    loadComponent: () => import('./features/products/product-edit/product-edit.component')
+      .then(m => m.ProductEditComponent)
+  }
+];
+```
+
+---
+
+## 3. 服务规范
+
+### 3.1 使用 inject() 注入
+
+**优先使用 `inject()` 函数,替代构造函数注入。**
+
+```typescript
+// ✅ 正确:使用 inject()
+@Injectable({
+  providedIn: 'root'
+})
+export class UserService {
+  private http = inject(HttpClient);
+  private logger = inject(LoggerService);
+
+  getUsers(): Observable<User[]> {
+    return this.http.get<User[]>('/api/users');
+  }
+}
+
+// ❌ 错误:构造函数注入(仅在必须时使用)
+@Injectable({
+  providedIn: 'root'
+})
+export class UserService {
+  constructor(
+    private http: HttpClient,
+    private logger: LoggerService
+  ) {}
+}
+```
+
+### 3.2 服务作用域
+
+**根据需要选择正确的作用域。**
+
+```typescript
+// 全局服务(大多数情况)
+@Injectable({
+  providedIn: 'root'
+})
+export class UserService {}
+
+// 组件级服务(需要多个实例时)
+@Component({
+  selector: 'app-cart',
+  providers: [CartService]  // 每个组件实例都有独立的服务实例
+})
+export class CartComponent {}
+```
+
+### 3.3 服务方法返回 Observable
+
+**所有 HTTP 请求方法必须返回 Observable。**
+
+```typescript
+@Injectable({
+  providedIn: 'root'
+})
+export class UserService {
+  private http = inject(HttpClient);
+
+  // ✅ 正确
+  getUsers(): Observable<User[]> {
+    return this.http.get<User[]>('/api/users');
+  }
+
+  // ❌ 错误:返回 Promise
+  async getUsers(): Promise<User[]> {
+    return fetch('/api/users').then(res => res.json());
+  }
+}
+```
+
+### 3.4 HTTP 拦截器处理通用逻辑
+
+**使用拦截器统一处理认证、错误等。**
+
+```typescript
+// core/interceptors/auth.interceptor.ts
+@Injectable()
+export class AuthInterceptor implements HttpInterceptor {
+  intercept(req: HttpRequest<any>, next: HttpHandler) {
+    const token = inject(TokenService).getToken();
+    if (token) {
+      const authReq = req.clone({
+        headers: req.headers.set('Authorization', `Bearer ${token}`)
+      });
+      return next.handle(authReq);
+    }
+    return next.handle(req);
+  }
+}
+
+// app.config.ts
+export const appConfig: ApplicationConfig = {
+  providers: [
+    provideHttpClient(
+      withInterceptors([authInterceptor, errorInterceptor])
+    )
+  ]
+};
+```
+
+---
+
+## 4. 依赖管理规范
+
+### 4.1 及时清除未使用的依赖
+
+**安装新包后或移除功能后,必须清除未使用的依赖。**
+
+```bash
+# ✅ 正确添加依赖
+pnpm add package-name
+
+# ✅ 正确移除依赖(同时从 package.json 和 pnpm-lock.yaml 中移除)
+pnpm remove package-name
+
+# ✅ 消除重复依赖(可选)
+pnpm dedupe
+
+# ❌ 错误:直接删除 node_modules 中的包
+```
+
+**注意:使用 `pnpm remove` 是清除依赖的唯一正确方式,它会同时更新 `package.json` 和锁文件。**
+
+### 4.2 生产依赖与开发依赖区分
+
+```json
+{
+  "dependencies": {
+    "@angular/core": "^20.0.0",
+    "@angular/common/http": "^20.0.0"
+  },
+  "devDependencies": {
+    "@angular/cli": "^20.0.0",
+    "prettier": "^3.0.0",
+    "typescript": "~5.6.0"
+  }
+}
+```
+
+### 4.3 禁止安装的包
+
+**以下类型的包禁止使用:**
+
+| 禁止类型 | 示例 | 原因 |
+|---------|------|------|
+| 已弃用的包 | `@types/node@12` | 安全风险 |
+| jQuery 及插件 | `jquery`, `bootstrap` | 与 Angular 理念冲突 |
+| 原生 JS 库 | 原生 XMLHttpRequest | 应使用 HttpClient |
+
+### 4.4 版本锁定
+
+**生产环境必须锁定依赖版本。**
+
+```json
+// ✅ 正确:锁定主版本
+"dependencies": {
+  "@angular/core": "^20.0.0"
+}
+
+// ❌ 错误:使用浮动版本
+"dependencies": {
+  "@angular/core": "20"
+}
+```
+
+---
+
+## 5. 模块规范
+
+### 5.1 目录结构规范
+
+```
+src/app/
+├── core/                          # 核心服务(全局单例)
+│   ├── interceptors/               # HTTP 拦截器
+│   ├── guards/                     # 路由守卫
+│   ├── services/                   # 核心服务
+│   └── models/                     # 全局数据模型
+│
+├── shared/                         # 共享资源(Standalone)
+│   ├── components/                 # 共享组件
+│   ├── directives/                 # 共享指令
+│   └── pipes/                      # 共享管道
+│
+├── features/                       # 功能模块(懒加载)
+│   ├── dashboard/
+│   │   ├── dashboard.component.ts
+│   │   ├── dashboard.component.html
+│   │   ├── dashboard.component.scss
+│   │   └── components/             # 功能内部子组件
+│   ├── users/
+│   └── products/
+│
+├── layouts/                        # 布局组件
+│   ├── main-layout/
+│   └── auth-layout/
+│
+├── environments/                    # 环境配置
+│   ├── environment.ts
+│   └── environment.prod.ts
+│
+├── app.component.ts
+├── app.config.ts
+└── app.routes.ts
+```
+
+### 5.2 Core 服务(始终加载)
+
+**Core 服务使用 `providedIn: 'root'`,在 app.config.ts 中统一配置拦截器。**
+
+```typescript
+// core/services/auth.service.ts
+@Injectable({
+  providedIn: 'root'
+})
+export class AuthService {
+  private http = inject(HttpClient);
+  private tokenService = inject(TokenService);
+
+  isAuthenticated(): boolean {
+    return !!this.tokenService.getToken();
+  }
+
+  login(credentials: any): Observable<any> {
+    return this.http.post('/api/auth/login', credentials);
+  }
+}
+```
+
+```typescript
+// core/interceptors/auth.interceptor.ts
+@Injectable()
+export class AuthInterceptor implements HttpInterceptor {
+  intercept(req: HttpRequest<any>, next: HttpHandler) {
+    const token = inject(TokenService).getToken();
+    if (token) {
+      const authReq = req.clone({
+        headers: req.headers.set('Authorization', `Bearer ${token}`)
+      });
+      return next.handle(authReq);
+    }
+    return next.handle(req);
+  }
+}
+```
+
+```typescript
+// app.config.ts - 统一注册拦截器
+export const appConfig: ApplicationConfig = {
+  providers: [
+    provideRouter(routes),
+    provideHttpClient(
+      withInterceptors([authInterceptor, errorInterceptor])
+    )
+  ]
+};
+```
+
+**注意:在 Standalone 模式下,不再使用 NgModule,统一使用 providers 数组注册服务。**
+
+### 5.3 Shared 共享资源(按需导入)
+
+**在 Standalone 模式下,共享组件、指令、管道直接在组件中导入,不再使用 SharedModule。**
+
+```typescript
+// 在组件中按需导入
+@Component({
+  selector: 'app-user-list',
+  standalone: true,
+  imports: [
+    CommonModule,
+    ReactiveFormsModule,
+    DateFormatPipe,      // 只导入需要的
+    PermissionDirective  // 只导入需要的
+  ]
+})
+export class UserListComponent {}
+```
+
+**目录结构中的 shared 目录:**
+
+```
+src/app/
+├── shared/
+│   ├── components/                 # 共享组件(直接导入)
+│   │   ├── button/
+│   │   ├── modal/
+│   │   └── data-table/
+│   ├── directives/                  # 共享指令
+│   │   └── permission.directive.ts
+│   └── pipes/                       # 共享管道
+│       └── date-format.pipe.ts
+```
+
+**注意:不再需要 shared.module.ts,所有共享资源都是 Standalone 组件/指令/管道。**
+
+### 5.4 功能模块(必须懒加载)
+
+**每个功能模块独立,使用懒加载,路由集中在 app.routes.ts。**
+
+```typescript
+// app.routes.ts
+export const routes: Routes = [
+  {
+    path: 'users',
+    loadComponent: () => import('./features/users/user-list/user-list.component')
+      .then(m => m.UserListComponent)
+  },
+  {
+    path: 'users/:id',
+    loadComponent: () => import('./features/users/user-detail/user-detail.component')
+      .then(m => m.UserDetailComponent)
+  }
+];
+```
+
+**禁止创建独立的路由文件:**
+
+```typescript
+// ❌ 禁止:features/users/users.routes.ts
+// ❌ 禁止:features/products/products.routes.ts
+```
+
+---
+
+## 6. 命名规范
+
+### 6.1 文件命名
+
+| 类型 | 规范 | 示例 |
+|-----|------|------|
+| 组件 | `kebab-case.component.ts` | `user-profile.component.ts` |
+| 服务 | `kebab-case.service.ts` | `auth.service.ts` |
+| 指令 | `kebab-case.directive.ts` | `permission.directive.ts` |
+| 管道 | `kebab-case.pipe.ts` | `date-format.pipe.ts` |
+| 守卫 | `kebab-case.guard.ts` | `auth.guard.ts` |
+| 拦截器 | `kebab-case.interceptor.ts` | `auth.interceptor.ts` |
+| 模型 | `kebab-case.model.ts` | `user.model.ts` |
+| 组件目录 | `kebab-case/` | `user-profile/` |
+
+### 6.2 类命名
+
+| 类型 | 规范 | 示例 |
+|-----|------|------|
+| 组件类 | PascalCase + Component | `UserProfileComponent` |
+| 服务类 | PascalCase + Service | `AuthService` |
+| 指令类 | PascalCase + Directive | `PermissionDirective` |
+| 管道类 | PascalCase + Pipe | `DateFormatPipe` |
+| 守卫类 | PascalCase + Guard | `AuthGuard` |
+| 模型接口 | PascalCase | `User`, `UserProfile` |
+| 拦截器类 | PascalCase + Interceptor | `AuthInterceptor` |
+
+### 6.3 变量与函数命名
+
+```typescript
+// 变量:camelCase
+const userName = '张三';
+const isLoading = false;
+const userList: User[] = [];
+
+// 函数:camelCase
+function getUserById(id: number): User { }
+function handleClick(): void { }
+
+// 常量:UPPER_SNAKE_CASE
+const MAX_RETRY_COUNT = 3;
+const API_BASE_URL = '/api';
+```
+
+### 6.4 路由路径命名
+
+**使用 kebab-case,多层嵌套用斜杠分隔。**
+
+```typescript
+// ✅ 正确
+'/user-profile'
+'/order-management/list'
+'/admin/system-settings'
+
+// ❌ 错误
+'/userProfile'
+'/orderManagement'
+'/admin/systemSettings'
+```
+
+---
+
+## 7. 代码风格规范
+
+### 7.1 TypeScript 规范
+
+**1. 禁止使用 `any`,必须指定类型。**
+
+```typescript
+// ✅ 正确
+const user: User = { id: 1, name: '张三' };
+function getUser(): Observable<User> { }
+
+// ❌ 错误
+const user: any = { id: 1, name: '张三' };
+function getUser(): any { }
+```
+
+**2. 使用 strict 模式。**
+
+```json
+// tsconfig.json
+{
+  "compilerOptions": {
+    "strict": true,
+    "noImplicitAny": true,
+    "strictNullChecks": true
+  }
+}
+```
+
+**3. 接口和类型别名优先使用接口。**
+
+```typescript
+// ✅ 优先使用接口
+interface User {
+  id: number;
+  name: string;
+}
+
+// 仅在需要联合类型或元组时使用类型别名
+type UserRole = 'admin' | 'user' | 'guest';
+type Point = [number, number];
+```
+
+**4. 使用可选链和空值合并。**
+
+```typescript
+// ✅ 正确
+const name = user?.profile?.name ?? '匿名';
+const users = data ?? [];
+
+// ❌ 错误
+const name = user && user.profile && user.profile.name || '匿名';
+```
+
+### 7.2 RxJS 规范
+
+**1. 组件中及时取消订阅。**
+
+```typescript
+import { Component, OnDestroy } from '@angular/core';
+import { Subject } from 'rxjs';
+import { takeUntil } from 'rxjs/operators';
+
+@Component({...})
+export class UserListComponent implements OnDestroy {
+  private destroy$ = new Subject<void>();
+
+  ngOnInit() {
+    this.userService.getUsers()
+      .pipe(takeUntil(this.destroy$))
+      .subscribe(users => this.users = users);
+  }
+
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+}
+```
+
+**注意:必须导入 `takeUntil` 操作符和 `Subject`。**
+
+**2. 优先使用 async pipe。**
+
+```typescript
+@Component({
+  selector: 'app-user-list',
+  template: `
+    @for (user of users$ | async; track user.id) {
+      <div>{{ user.name }}</div>
+    }
+  `
+})
+export class UserListComponent {
+  users$ = this.userService.getUsers();
+}
+```
+
+**3. 统一错误处理。**
+
+```typescript
+// 使用 catchError 操作符
+this.http.get<User[]>('/api/users').pipe(
+  catchError(error => {
+    console.error('请求失败', error);
+    return of([]);
+  })
+);
+```
+
+### 7.3 Signals 响应式规范
+
+**Angular 20 推荐使用 Signals 替代部分 RxJS 使用场景。**
+
+**1. 创建和使用 Signals。**
+
+```typescript
+import { signal, computed, effect } from '@angular/core';
+
+// 创建可写的 signal
+const count = signal(0);
+const user = signal<User | null>(null);
+
+// 读取值(调用函数)
+console.log(count()); // 0
+
+// 更新值
+count.set(10);
+count.update(value => value + 1);
+
+// 创建计算属性
+const doubled = computed(() => count() * 2);
+
+// 副作用
+effect(() => {
+  console.log(`Count changed: ${count()}`);
+});
+```
+
+**2. 在服务中使用 Signals 替代 BehaviorSubject。**
+
+```typescript
+// ✅ 推荐:使用 Signals
+@Injectable({
+  providedIn: 'root'
+})
+export class CartService {
+  private items = signal<CartItem[]>([]);
+
+  totalItems = computed(() => this.items().length);
+  totalPrice = computed(() =>
+    this.items().reduce((sum, item) => sum + item.price, 0)
+  );
+
+  addItem(item: CartItem) {
+    this.items.update(current => [...current, item]);
+  }
+
+  removeItem(id: number) {
+    this.items.update(current => current.filter(i => i.id !== id));
+  }
+}
+
+// ❌ 不推荐:使用 BehaviorSubject
+@Injectable({
+  providedIn: 'root'
+})
+export class CartService {
+  private items$ = new BehaviorSubject<CartItem[]>([]);
+
+  totalItems$ = this.items$.pipe(map(items => items.length));
+}
+```
+
+**3. 在组件中使用 Signals。**
+
+```typescript
+@Component({
+  selector: 'app-cart',
+  template: `
+    <p>商品数量:{{ cart.totalItems() }}</p>
+    <p>总价:{{ cart.totalPrice() | currency:'¥' }}</p>
+  `
+})
+export class CartComponent {
+  cart = inject(CartService);
+}
+```
+
+**4. Signals vs RxJS 选择指南。**
+
+| 场景 | 推荐 | 原因 |
+|-----|------|------|
+| 组件内本地状态 | Signals | 简单、直接 |
+| 服务中的共享状态 | Signals | 性能更好 |
+| HTTP 请求 | RxJS | 强大的操作符支持 |
+| 复杂异步流程 | RxJS | 可取消、retry、重试 |
+| 实时数据流 | RxJS | WebSocket 等 |
+
+### 7.4 注释规范
+
+**仅在必要时添加注释,避免无意义注释。**
+
+```typescript
+// ✅ 好的注释:解释为什么
+// 使用 setTimeout 而非 setInterval,避免内存泄漏
+setTimeout(() => this.refresh(), 5000);
+
+// ✅ 好的注释:复杂逻辑说明
+/**
+ * 计算用户积分
+ * 规则:每消费1元积1分,VIP用户双倍积分
+ */
+calculatePoints(amount: number, isVip: boolean): number {
+  const basePoints = amount;
+  return isVip ? basePoints * 2 : basePoints;
+}
+
+// ❌ 坏的注释:显而易见的说明
+// 设置用户名
+this.userName = '张三';
+```
+
+---
+
+## 8. 样式规范
+
+### 8.1 使用 SCSS
+
+**强制使用 SCSS 预处理器。**
+
+```bash
+ng new my-app --style=scss
+```
+
+### 8.2 全局样式配置
+
+**全局样式使用 `src/styles.scss`,直接定义变量和混入。**
+
+```scss
+// src/styles.scss
+
+// 全局变量
+$primary-color: #1976d2;
+$border-radius: 4px;
+
+// 混入
+@mixin flex-center {
+  display: flex;
+  justify-content: center;
+  align-items: center;
+}
+
+// CSS 重置
+* {
+  margin: 0;
+  padding: 0;
+  box-sizing: border-box;
+}
+
+// 全局样式
+body {
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
+  font-size: 14px;
+  line-height: 1.5;
+  color: #333;
+}
+
+// 组件样式使用 BEM
+.user-card {
+  padding: 16px;
+
+  &__header {
+    @include flex-center;
+  }
+
+  &__avatar {
+    width: 48px;
+    border-radius: 50%;
+  }
+
+  &--featured {
+    border: 2px solid gold;
+  }
+}
+```
+
+**angular.json 配置(自动生成,无需修改):**
+
+```json
+{
+  "styles": ["src/styles.scss"],
+  "stylePreprocessorOptions": {
+    "includePaths": ["src"]
+  }
+}
+```
+
+**注意:在组件 SCSS 中可以直接使用全局变量和混入。**
+
+### 8.3 样式最佳实践
+
+**1. 使用 :host 选择器。**
+
+```scss
+:host {
+  display: block;
+}
+
+:host(.featured) {
+  border: 2px solid gold;
+}
+```
+
+**2. 禁止使用深度选择器。**
+
+```scss
+// ✅ 正确:使用固定类名
+.parent { }
+.parent__child { }
+
+// ❌ 错误:/deep/ 已废弃
+.parent /deep/ .child { }
+
+// ❌ 错误:::ng-deep 已废弃,不应使用
+.parent ::ng-deep .child { }
+```
+
+**深度选择器会破坏组件封装,应使用固定的类名或通过 @Input 传递样式配置。**
+
+**3. 响应式样式使用变量。**
+
+```scss
+// 在 styles.scss 或组件中定义断点
+$breakpoint-sm: 576px;
+$breakpoint-md: 768px;
+$breakpoint-lg: 992px;
+
+.container {
+  width: 100%;
+  @media (min-width: $breakpoint-md) {
+    width: 720px;
+  }
+}
+```
+
+---
+
+## 9. 安全规范
+
+### 9.1 XSS 防护
+
+**使用 Angular 绑定机制,禁止使用 `innerHTML` 绑定未处理的内容。**
+
+```typescript
+// ✅ 正确:自动转义
+template: `<div>{{ userContent }}</div>`
+
+// ❌ 错误:可能导致 XSS
+template: `<div [innerHTML]="userContent"></div>`
+
+// 如果必须使用 innerHTML,必须先净化
+import { DomSanitizer } from '@angular/platform-browser';
+
+constructor(private sanitizer: DomSanitizer) {}
+
+getSafeHtml(html: string): SafeHtml {
+  return this.sanitizer.bypassSecurityTrustHtml(html);
+}
+```
+
+### 9.2 CSRF 防护
+
+**使用 provideHttpClient 的 withXsrfConfiguration 配置 CSRF 防护。**
+
+```typescript
+// app.config.ts
+export const appConfig: ApplicationConfig = {
+  providers: [
+    provideHttpClient(
+      withXsrfConfiguration({
+        cookieName: 'XSRF-TOKEN',
+        headerName: 'X-XSRF-TOKEN'
+      })
+    )
+  ]
+};
+```
+
+### 9.3 敏感信息处理
+
+**禁止在代码中硬编码敏感信息。**
+
+```typescript
+// ❌ 错误
+const API_KEY = 'sk-xxxxx-xxxxx';
+const DATABASE_URL = 'mongodb://admin:password@localhost:27017';
+
+// ✅ 正确:使用环境变量
+// environment.ts
+export const environment = {
+  production: false,
+  apiUrl: 'http://localhost:3000/api'
+};
+
+// environment.prod.ts
+export const environment = {
+  production: true,
+  apiUrl: 'https://api.example.com'
+};
+```
+
+### 9.4 路由守卫
+
+**敏感路由必须添加守卫。**
+
+```typescript
+// 需要认证的路由
+{ path: 'profile', canActivate: [authGuard] }
+
+// 需要特定角色的路由
+{ path: 'admin', canActivate: [authGuard, adminGuard] }
+```
+
+---
+
+## 10. 性能规范
+
+### 10.1 懒加载
+
+**所有功能组件必须使用懒加载,使用 loadComponent。**
+
+```typescript
+// ✅ 正确
+{
+  path: 'users',
+  loadComponent: () => import('./features/users/user-list/user-list.component')
+    .then(m => m.UserListComponent)
+}
+
+// ❌ 错误:禁止使用 loadChildren
+{
+  path: 'users',
+  loadChildren: () => import('./features/users/users.routes')
+    .then(m => m.USER_ROUTES)  // ❌ 禁止
+}
+
+// ❌ 错误:直接导入
+import { UserListComponent } from './features/users/user-list/user-list.component';
+{ path: 'users', component: UserListComponent }  // ❌ 禁止
+```
+
+### 10.2 OnPush 变更检测
+
+**高频更新的组件使用 OnPush。**
+
+```typescript
+@Component({
+  selector: 'app-user-list',
+  changeDetection: ChangeDetectionStrategy.OnPush,
+  template: `...`
+})
+export class UserListComponent {}
+```
+
+### 10.3 trackBy 函数
+
+**@for 循环必须使用 track。**
+
+```html
+<!-- ✅ 正确 -->
+@for (user of users; track user.id) {
+  <div>{{ user.name }}</div>
+}
+
+<!-- ❌ 错误:缺少 track -->
+@for (user of users) {
+  <div>{{ user.name }}</div>
+}
+```
+
+### 10.4 图片优化
+
+**使用 `loading="lazy"` 延迟加载图片。**
+
+```html
+<img src="avatar.jpg" alt="头像" loading="lazy" />
+```
+
+### 10.5 避免内存泄漏
+
+**1. RxJS 订阅必须取消。**
+
+```typescript
+@Component({...})
+export class UserListComponent implements OnDestroy {
+  private destroy$ = new Subject<void>();
+
+  ngOnDestroy() {
+    this.destroy$.next();
+    this.destroy$.complete();
+  }
+}
+```
+
+**2. 推荐使用 Signals 替代 RxJS 管理组件状态,可以避免订阅管理问题。**
+
+```typescript
+// ✅ 推荐:使用 Signals,无需手动取消订阅
+@Injectable({
+  providedIn: 'root'
+})
+export class CartService {
+  private items = signal<CartItem[]>([]);
+  totalItems = computed(() => this.items().length);
+}
+
+// 组件中使用
+@Component({
+  template: `<p>{{ cart.totalItems() }}</p>`
+})
+export class CartComponent {
+  cart = inject(CartService);
+}
+// Signals 会自动管理依赖,无需担心内存泄漏
+```
+
+### 10.6 Bundle 分析
+
+**定期分析打包大小。**
+
+```bash
+# 生成打包分析
+ng build --stats-json
+npx webpack-bundle-analyzer dist/*/stats.json
+```
+
+---
+
+## 11. Git 提交规范
+
+### 11.1 提交信息格式
+
+```
+<type>(<scope>): <subject>
+
+<body>
+
+<footer>
+```
+
+### 11.2 Type 类型
+
+| 类型 | 说明 |
+|-----|------|
+| feat | 新功能 |
+| fix | 修复 bug |
+| docs | 文档更新 |
+| style | 代码格式(不影响功能) |
+| refactor | 重构 |
+| perf | 性能优化 |
+| test | 测试相关 |
+| build | 构建相关 |
+| ci | CI 相关 |
+| chore | 其他更改 |
+
+### 11.3 示例
+
+```bash
+# 功能提交
+git commit -m "feat(user): 添加用户列表分页功能"
+
+# 修复提交
+git commit -m "fix(auth): 修复登录失败后 token 未清除的问题"
+
+# 重构提交
+git commit -m "refactor(service): 重构 UserService 使用 inject()"
+
+# 提交前检查
+git commit -m "feat(cart): 添加购物车功能
+
+- 添加购物车服务
+- 添加商品加减功能
+- 添加结算页面"
+
+Closes #123
+```
+
+### 11.4 禁止提交
+
+**禁止提交以下内容:**
+
+```gitignore
+# node_modules
+node_modules/
+
+# 构建产物
+dist/
+build/
+
+# IDE 配置
+.idea/
+.vscode/
+
+# 环境配置文件
+.env
+*.local
+
+# npm 锁文件(禁止提交 npm 的 lock 文件)
+package-lock.json
+
+# 日志文件
+*.log
+
+# 操作系统文件
+.DS_Store
+Thumbs.db
+```
+
+**pnpm-lock.yaml 必须提交**,它是 pnpm 的锁文件,确保团队成员安装的依赖版本一致。
+
+---
+
+## 附录 A:规范检查清单
+
+### 新建组件
+- [ ] 使用 `ng g c` 生成
+- [ ] 文件结构完整(.ts/.html/.scss/.spec.ts)
+- [ ] 使用 standalone
+- [ ] 职责单一
+
+### 新建路由
+- [ ] 所有路由写在 app.routes.ts 中(禁止创建独立的 .routes.ts 文件)
+- [ ] 使用懒加载 `loadComponent`
+- [ ] 添加路由守卫(如需要)
+- [ ] 路径使用 kebab-case
+
+### 新增依赖
+- [ ] 确认是否为必需依赖
+- [ ] 正确区分 dependencies 和 devDependencies
+- [ ] 检查是否有替代方案
+
+### 代码审查
+- [ ] 无 `any` 类型
+- [ ] 无内存泄漏
+- [ ] 无直接 DOM 操作
+- [ ] 样式无深度选择器
+
+---
+
+## 附录 B:Prettier 配置(可选)
+
+**如果需要代码格式化,可以配置 Prettier。**
+
+```json
+// .prettierrc
+{
+  "semi": true,
+  "singleQuote": true,
+  "tabWidth": 2,
+  "trailingComma": "es5",
+  "printWidth": 100,
+  "bracketSpacing": true,
+  "arrowParens": "avoid",
+  "endOfLine": "auto"
+}
+```
+
+**安装和使用:**
+
+```bash
+pnpm add -D prettier
+pnpm exec prettier --write src/
+```
+
+---
+
+*文档版本:2.0.0 | 对应 Angular 20.x | 更新日期:2025*

+ 302 - 0
doc/M1-M2功能模块开发计划.md

@@ -0,0 +1,302 @@
+# M1+M2 功能模块开发计划
+
+> 版本 v4 | 2026-06-09 | 基于《项目功能模块梳理与进度追踪》+《社群运营优化清单》
+
+---
+
+## 一、背景
+
+常驻接口(Webhook + 定时同步)已在服务器部署完成,企微消息和群数据持续流入 PostgreSQL 数据库(Parse Server 抽象层)。当前需要实现前端可用的功能模块接口。
+
+### 数据库说明
+
+生产环境使用 **PostgreSQL**,通过 Parse Server 作为数据抽象层。Parse Schema API 和 Query API 屏蔽底层差异,冗余字段设计仍然合理(Parse 不做 SQL JOIN,冗余换读取性能)。
+
+---
+
+## 二、架构决策:后端 API
+
+| 因素 | 后端 API | 纯前端 Parse |
+|------|:--:|:--:|
+| 数据范围隔离 | ✅ 服务端判断 | ❌ 前端可绕过 |
+| 健康度/生命周期计算 | ✅ 服务端纯函数 | ❌ 逻辑分散 |
+| Parse Server URL | ✅ 不暴露 | ❌ 需暴露给前端 |
+| 基础设施 | ✅ 已有路由/控制器/服务层 | ❌ 需从头搭建 |
+
+**结论:全部功能模块走后端 API,前端不直连 Parse。**
+
+---
+
+## 三、权限体系
+
+### 3.1 三级角色 + 数据下钻
+
+```
+总监 (director)
+  └─ 全上海 → 区域 → 门店 → 社群(一个门店可能有多个群)
+
+区域督导 (regional_supervisor)  
+  └─ 管辖区域(约3个门店)→ 门店 → 社群
+
+店长 (store_manager)
+  └─ 自己的门店 → 该门店的所有社群
+```
+
+### 3.2 各角色 UI 差异
+
+| 角色 | 首页看到 | 关键元素 |
+|------|---------|---------|
+| 总监 | 全上海门店对比排行 | 门店排行表、全上海健康值分布、预警汇总 |
+| 督导 | 管辖门店对比 | 门店对比视图、生命周期社群柱状图、核心数据汇总 |
+| 店长 | 本门店全部社群 | 社群列表(按健康值排序)、QC/微信/样板间数字卡片 |
+
+### 3.3 后端实现
+
+所有列表查询接口注入 `DataScopeService.resolveDataScope()`:
+- director → 全量,可按下钻参数过滤
+- regional_supervisor → `storeId IN (regionStoreIds)`
+- store_manager → `storeId = user.storeId`
+
+### 3.4 前端实现
+
+- 群操作按钮(改名/邀请/移除)按角色显隐:总监可写,督导和店长只读
+- 数据筛选器可选范围按角色限定
+- 侧边栏已按角色过滤(settings 仅总监可见)
+
+---
+
+## 四、数据库优化
+
+### 4.1 删除死表
+
+| 表名 | 原因 |
+|------|------|
+| `MessageSyncCursor` | 本地代码未使用,游标在 `SystemMigration` 中 |
+| `AppNotification` | 全代码库零引用 |
+
+> 服务器端(`backend_Gyd8G`)的 `MessageSyncCursor` 保留不动,常驻接口在用。
+
+### 4.2 GroupChat — 删除 5 个无用字段
+
+| 删除 | 原因 |
+|------|------|
+| `lastSyncAt` | 零写入 |
+| `memberChange24h` | 仅初始化为 0,从未计算 |
+| `hasDocument` | 仅初始化为 false,M3 未实现 |
+| `documentPinned` | 同上 |
+| `documentInNotice` | 同上 |
+
+**保留的冗余字段:** `storeName`, `communityName`, `ownerName`(Parse 无 JOIN,冗余换读性能)
+
+**最终:20 → 15 字段**
+
+### 4.3 GroupMember — 删除 5 个无用字段
+
+| 删除 | 原因 |
+|------|------|
+| `displayName`, `isKOC`, `intentLevel`, `messageCount`, `lastActiveAt` | 全部零写入 |
+
+**最终:13 → 8 字段**
+
+### 4.4 Message — 删除 4 个分析字段
+
+| 删除 | 原因 |
+|------|------|
+| `hasDocumentLink`, `riskKeywords`, `intentKeywords`, `intentLevel` | 风控/意向分析暂不实现,后续模块再加 |
+
+**最终:16 → 12 字段**
+
+### 4.5 _User — 删除 email
+
+注册不再需要邮箱。
+
+---
+
+## 五、注册改造
+
+纯手机号 + 密码注册,完全移除邮箱。
+
+| 文件 | 改动 |
+|------|------|
+| `backend/src/apps/pc/auth/services/auth.service.ts` | `register()` 移除 email 参数和校验;`UserDto` 删除 email |
+| `backend/src/shared/db/schema-setup.ts` | `_User` 删除 `email: 'String'` |
+| `src/app/features/auth/register/register.component.ts` | 删除邮箱输入框和校验逻辑 |
+| `src/app/features/auth/register/register.component.html` | 删除邮箱表单项 |
+| `src/app/core/models/index.ts` | `User` 接口删除 `email` |
+
+注册表单保留:姓名、手机号、密码、确认密码。
+
+---
+
+## 六、M1 功能接口 — 企微接入与消息基础
+
+### 6.1 接口列表
+
+| 方法 | 端点 | 状态 | 说明 |
+|------|------|:--:|------|
+| GET | `/api/qiwe/accounts` | ✅ 已有 | 企微账号列表 |
+| POST | `/api/qiwe/accounts` | ✅ 已有 | 新增账号 |
+| PUT | `/api/qiwe/accounts/:id` | ✅ 已有 | 编辑账号 |
+| DELETE | `/api/qiwe/accounts/:id` | ✅ 已有 | 删除账号 |
+| POST | `/api/qiwe/accounts/:id/test` | ✅ 已有 | 测试连接 |
+| GET | `/api/qiwe/accounts/:id/status` | 🆕 新增 | 账号在线状态 |
+| GET | `/api/qiwe/messages` | ✅ 已有 | 消息分页查询 |
+| POST | `/api/qiwe/messages/send-text` | ✅ 已有 | 发送文本消息 |
+| POST | `/api/qiwe/org/sync` | ✅ 已有 | 触发组织同步 |
+| GET | `/api/qiwe/org/tree` | ✅ 已有 | 组织架构树 |
+
+### 6.2 前端页面
+
+| 页面 | 状态 | 动作 |
+|------|:--:|------|
+| P1-01 企业授权管理 | ✅ 已有组件 | 账号表格加"在线状态"列 |
+| P1-02 人员账号管理 | ❌ 新建 | 组织树页面 |
+| P1-03 账号在线状态 | 合并到 P1-01 | 不单独建页面 |
+| P1-04 消息实时流 | 复用群详情 | 群详情页消息面板 |
+| P1-05 历史消息查询 | 同上 | 加时间筛选 |
+
+---
+
+## 七、M2 功能接口 — 客户群与组织资产
+
+### 7.1 接口列表
+
+| 方法 | 端点 | 状态 | 说明 |
+|------|------|:--:|------|
+| GET | `/api/qiwe/groups` | ✅ 已有 | 群列表(分页+筛选+DataScope) |
+| GET | `/api/qiwe/groups/:roomId` | ✅ 已有 | 群详情(含健康度) |
+| GET | `/api/qiwe/groups/:roomId/members` | ✅ 已有 | 群成员列表 |
+| PUT | `/api/qiwe/groups/:roomId/name` | ✅ 已有 | 修改群名 |
+| POST | `/api/qiwe/groups/:roomId/invite` | ✅ 已有 | 邀请成员 |
+| POST | `/api/qiwe/groups/:roomId/remove` | ✅ 已有 | 移除成员 |
+| PUT | `/api/qiwe/groups/:roomId/store` | 🆕 新增 | 群绑定门店 |
+| PUT | `/api/qiwe/groups/:roomId/community` | 🆕 新增 | 群绑定小区 |
+| GET | `/api/qiwe/groups/health` | 🆕 新增 | 健康度看板汇总 |
+| GET | `/api/qiwe/communities` | ✅ 已有 | 小区列表 |
+| PUT | `/api/qiwe/communities/:id` | 🆕 新增 | 编辑小区 |
+| GET | `/api/qiwe/stores` | ✅ 已有 | 门店列表 |
+
+### 7.2 群名规范
+
+格式:`区域-门店-xxx群`
+
+在群详情页提供手动修改入口,后续考虑定时同步自动对齐。
+
+### 7.3 健康度系统
+
+**评级 S/A/B/C(来源:《社群运营优化清单》):**
+
+| 评级 | 含义 | 动作 |
+|:--:|------|------|
+| S | 优秀 | 经验萃取,复制推广 |
+| A | 良好 | 维持现有策略 |
+| B | 一般 | 关注变化趋势 |
+| C | 需关注 | 触发预警,资源倾斜 |
+
+**双维度计算(不写库,查询时纯函数计算):**
+- 运营活跃度:员工发消息频次
+- 客户活跃度:客户回复互动频次
+
+**实现:** `group-metrics.util.ts` 中 `deriveHealthMetrics()` 纯函数。
+
+### 7.4 社群生命周期
+
+基于 `Community.deliveryDate`(交房时间)分组:
+
+| 阶段 | 判定条件 | 运营频次 |
+|------|------|----------|
+| 交房前 | 距交房 > 3 个月 | 1周1次 |
+| 临交房 | 距交房 ≤ 3 个月 | 1周至少2次 |
+| 已交房 | 交房后 1 年内 | 1月2-4次 |
+| 老小区 | 交房 1 年以上 | 1月1次 |
+
+前端群列表页顶部四个 Tab 切换,每 Tab 显示数量。
+
+### 7.5 前端页面
+
+| 页面 | 当前状态 | 动作 |
+|------|:--:|------|
+| P2-01 群总览 | Mock+API 双源 | 去 Mock,纯 API。加生命周期 Tab、健康值筛选、门店筛选器 |
+| P2-02 群详情 | Mock+API 双源 | 健康度指标卡片、成员列表、消息面板、群名编辑、门店/小区关联下拉 |
+| P2-03 小区档案 | Mock+API 双源 | 去 Mock,补充编辑功能(交房时间等),加生命周期标记 |
+| P2-04 群-门店-小区关系 | ❌ 新建 | 在群详情页加下拉关联选择 |
+| P2-05 健康度看板 | ❌ 新建 | 独立页面:排行表 + S/A/B/C 分布饼图 + C 级群突出标记 |
+
+---
+
+## 八、实施步骤(一次性完成)
+
+### 步骤 1:数据库优化
+
+| 文件 | 改动 |
+|------|------|
+| `backend/src/shared/db/schema-setup.ts` | 删除 `MessageSyncCursor`、`AppNotification` 的 ensureClass;GroupChat 删 5 字段;GroupMember 删 5 字段;Message 删 4 字段;_User 删 email |
+| `backend/src/apps/pc/room/services/groups.service.ts` | 移除已删除字段的 set 调用(initNewGroupDefaults 中等) |
+| `backend/src/apps/pc/qiwe/models/schema-setup.ts` | 服务器侧同步精简 |
+
+### 步骤 2:注册改造
+
+| 文件 | 改动 |
+|------|------|
+| `backend/src/apps/pc/auth/services/auth.service.ts` | 移除 email |
+| `src/app/features/auth/register/register.component.ts` + `.html` | 删除邮箱输入 |
+| `src/app/core/models/index.ts` | User 删除 email |
+
+### 步骤 3:权限贯穿
+
+| 文件 | 改动 |
+|------|------|
+| `backend/src/apps/pc/room/services/groups.service.ts` | `listGroupChats()` 注入 DataScope 过滤 |
+| `backend/src/apps/pc/room/controllers/groups.controller.ts` | 提取用户信息传入 service |
+| 前端各页面组件 | 群操作按钮按角色显隐;筛选器按角色限定 |
+
+### 步骤 4:M2 接口完善
+
+| 文件 | 改动 |
+|------|------|
+| `backend/src/apps/pc/community/services/community.service.ts` | 新增 `updateCommunity()` |
+| `backend/src/apps/pc/community/controllers/community.controller.ts` | 新增 `handleUpdateCommunity` |
+| `backend/src/apps/pc/community/routes/community.routes.ts` | 新增 PUT `/communities/:id` |
+| `backend/src/apps/pc/room/services/groups.service.ts` | 新增 `bindStore()`、`bindCommunity()` |
+| `backend/src/apps/pc/room/controllers/groups.controller.ts` | 新增对应 handler |
+| `backend/src/apps/pc/room/routes/room.routes.ts` | 新增 PUT `/groups/:roomId/store`、`/groups/:roomId/community` |
+| `backend/src/apps/pc/room/controllers/groups.controller.ts` | 新增 `GET /groups/health` handler |
+| 前端 QiweApiService | 新增对应 HTTP 方法 |
+
+### 步骤 5:M1 接口完善
+
+| 文件 | 改动 |
+|------|------|
+| `backend/src/apps/pc/qiwei/controllers/account.controller.ts` | 新增 `GET /accounts/:id/status` |
+
+### 步骤 6:前端对接
+
+| 页面 | 文件 | 改动 |
+|------|------|------|
+| P2-01 群总览 | `group-list.component.ts` | 去 Mock 回退,加生命周期 Tab、筛选器 |
+| P2-02 群详情 | `group-detail.component.ts` | 健康度卡片、门店/小区关联下拉、群名编辑 |
+| P2-03 小区档案 | `community-list.component.ts` | 去 Mock,加编辑弹窗 |
+| P2-04 群关联 | 复用 P2-02 | 在群详情页加下拉 |
+| P2-05 健康度看板 | **新建** `group-health.component.ts` | 排行表 + 分布图 |
+| P1-01 账号管理 | `qiwe-accounts.component.ts` | 加状态列 |
+
+### 步骤 7:文档更新
+
+| 文件 | 更新内容 |
+|------|------|
+| `doc/项目功能模块梳理与进度追踪.md` | 更新 M1/M2 进度、权限设计、健康度评级、群名规范 |
+| `doc/社群运营优化清单.md` | 更新已完成项状态 |
+
+---
+
+## 九、验证清单
+
+- [ ] 手机号+密码注册成功,无邮箱字段
+- [ ] 店长只看到自己门店的群;督导看到管辖区域门店;总监看到全上海可下钻
+- [ ] 群列表生命周期 Tab 分组正确,健康值排序正常
+- [ ] 群详情健康度 S/A/B/C 计算正确
+- [ ] 群名可编辑(区域-门店-xxx群格式)
+- [ ] 门店/小区关联下拉可用
+- [ ] 健康度看板排行数据正确,C 级群高亮
+- [ ] 企微账号在线状态正确显示
+- [ ] 数据库无用字段已清除

+ 111 - 0
doc/QiWe开放平台文档/README.md

@@ -0,0 +1,111 @@
+# QiWe 开放平台文档索引
+
+> 抓取入口: https://doc.qiweapi.com/
+> 页面总数: 106
+
+- [平台介绍](md/平台介绍.md) - https://doc.qiweapi.com
+- [平台介绍](md/平台介绍 2.md) - https://doc.qiweapi.com/doc-7331301
+- [快速开始 ⭐](md/快速开始 ⭐.md) - https://doc.qiweapi.com/doc-7562288
+- [Webhook事件⭐](md/Webhook事件⭐.md) - https://doc.qiweapi.com/doc-7331303
+- [回调结构说明](md/回调结构说明.md) - https://doc.qiweapi.com/doc-7331304
+- [调试指南⭐](md/调试指南⭐.md) - https://doc.qiweapi.com/doc-8483504
+- [常见问题](md/常见问题.md) - https://doc.qiweapi.com/doc-8483505
+- [创建设备(步骤1)](md/创建设备(步骤1).md) - https://doc.qiweapi.com/api-344613850
+- [二维码-获取(步骤2)](md/二维码-获取(步骤2).md) - https://doc.qiweapi.com/api-344613856
+- [二维码状态-检测(步骤3)](md/二维码状态-检测(步骤3).md) - https://doc.qiweapi.com/api-344613857
+- [二维码-code验证(步骤4)](md/二维码-code验证(步骤4).md) - https://doc.qiweapi.com/api-344613858
+- [设置回调地址](md/设置回调地址.md) - https://doc.qiweapi.com/api-354411522
+- [用户登录(免扫码登录)](md/用户登录(免扫码登录).md) - https://doc.qiweapi.com/api-344613859
+- [用户状态](md/用户状态.md) - https://doc.qiweapi.com/api-347221662
+- [恢复设备](md/恢复设备.md) - https://doc.qiweapi.com/api-344613851
+- [停用设备](md/停用设备.md) - https://doc.qiweapi.com/api-344613852
+- [联系人详情-批量](md/联系人详情-批量.md) - https://doc.qiweapi.com/api-344613868
+- [外部联系人分页](md/外部联系人分页.md) - https://doc.qiweapi.com/api-344613869
+- [内部联系人分页](md/内部联系人分页.md) - https://doc.qiweapi.com/api-344613870
+- [联系人搜索](md/联系人搜索.md) - https://doc.qiweapi.com/api-344613871
+- [添加个微](md/添加个微.md) - https://doc.qiweapi.com/api-344613872
+- [添加企微](md/添加企微.md) - https://doc.qiweapi.com/api-344613873
+- [添加群成员好友](md/添加群成员好友.md) - https://doc.qiweapi.com/api-425758709
+- [添加企微名片](md/添加企微名片.md) - https://doc.qiweapi.com/api-344613874
+- [添加删除联系人](md/添加删除联系人.md) - https://doc.qiweapi.com/api-344613875
+- [同意申请](md/同意申请.md) - https://doc.qiweapi.com/api-344613876
+- [外部联系人信息-更新](md/外部联系人信息-更新.md) - https://doc.qiweapi.com/api-344613877
+- [内部联系人信息-更新](md/内部联系人信息-更新.md) - https://doc.qiweapi.com/api-344613878
+- [删除联系人](md/删除联系人.md) - https://doc.qiweapi.com/api-344613879
+- [Openid](md/Openid.md) - https://doc.qiweapi.com/api-344613880
+- [群分页](md/群分页.md) - https://doc.qiweapi.com/api-344613881
+- [群详情-批量](md/群详情-批量.md) - https://doc.qiweapi.com/api-344613882
+- [群成员变动查询](md/群成员变动查询.md) - https://doc.qiweapi.com/api-437674162
+- [创建群](md/创建群.md) - https://doc.qiweapi.com/api-344613883
+- [修改群名称](md/修改群名称.md) - https://doc.qiweapi.com/api-344613884
+- [修改群备注](md/修改群备注.md) - https://doc.qiweapi.com/api-344613885
+- [修改群内昵称](md/修改群内昵称.md) - https://doc.qiweapi.com/api-344613886
+- [邀请/添加成员](md/邀请_添加成员.md) - https://doc.qiweapi.com/api-344613887
+- [禁止群成员互相添加](md/禁止群成员互相添加.md) - https://doc.qiweapi.com/api-437795951
+- [移除成员](md/移除成员.md) - https://doc.qiweapi.com/api-344613888
+- [群二维码](md/群二维码.md) - https://doc.qiweapi.com/api-344613889
+- [修改群公告](md/修改群公告.md) - https://doc.qiweapi.com/api-344613890
+- [添加群管理员](md/添加群管理员.md) - https://doc.qiweapi.com/api-344613891
+- [取消群管理员](md/取消群管理员.md) - https://doc.qiweapi.com/api-344613892
+- [退群](md/退群.md) - https://doc.qiweapi.com/api-344613893
+- [转让群主](md/转让群主.md) - https://doc.qiweapi.com/api-344613894
+- [群解散](md/群解散.md) - https://doc.qiweapi.com/api-344613895
+- [OpenID](md/OpenID 2.md) - https://doc.qiweapi.com/api-344613896
+- [开启群改名](md/开启群改名.md) - https://doc.qiweapi.com/api-344613897
+- [开启群邀请确认](md/开启群邀请确认.md) - https://doc.qiweapi.com/api-344613898
+- [接受群邀请-By链接](md/接受群邀请-By链接.md) - https://doc.qiweapi.com/api-410838558
+- [引用消息发送说明](md/引用消息发送说明.md) - https://doc.qiweapi.com/doc-7331310
+- [本地文件上传](md/本地文件上传.md) - https://doc.qiweapi.com/api-344613899
+- [企微文件异步上传](md/企微文件异步上传.md) - https://doc.qiweapi.com/api-395133885
+- [企微大文件异步上传](md/企微大文件异步上传.md) - https://doc.qiweapi.com/api-389695362
+- [个微文件下载](md/个微文件下载.md) - https://doc.qiweapi.com/api-344613902
+- [企微文件下载](md/企微文件下载.md) - https://doc.qiweapi.com/api-344613901
+- [企微文件异步下载](md/企微文件异步下载.md) - https://doc.qiweapi.com/api-389691087
+- [企微大文件异步下载](md/企微大文件异步下载.md) - https://doc.qiweapi.com/api-453564016
+- [个微文件异步下载](md/个微文件异步下载.md) - https://doc.qiweapi.com/api-399776006
+- [文件上传-URL](md/文件上传-URL.md) - https://doc.qiweapi.com/api-344613900
+- [文件CDN转URL](md/文件CDN转URL.md) - https://doc.qiweapi.com/api-344613903
+- [发送纯文本消息](md/发送纯文本消息.md) - https://doc.qiweapi.com/api-344613906
+- [发送混合文本消息](md/发送混合文本消息.md) - https://doc.qiweapi.com/api-344613907
+- [发送图片消息](md/发送图片消息.md) - https://doc.qiweapi.com/api-344613908
+- [发送GIF表情消息](md/发送GIF表情消息.md) - https://doc.qiweapi.com/api-344613909
+- [发送视频消息](md/发送视频消息.md) - https://doc.qiweapi.com/api-344613910
+- [发送文件消息](md/发送文件消息.md) - https://doc.qiweapi.com/api-344613911
+- [发送语音消息](md/发送语音消息.md) - https://doc.qiweapi.com/api-344613912
+- [发送链接消息](md/发送链接消息.md) - https://doc.qiweapi.com/api-344613913
+- [发送小程序消息](md/发送小程序消息.md) - https://doc.qiweapi.com/api-344613914
+- [发送名片消息](md/发送名片消息.md) - https://doc.qiweapi.com/api-344613915
+- [发送视频号消息](md/发送视频号消息.md) - https://doc.qiweapi.com/api-344613916
+- [发送定位消息](md/发送定位消息.md) - https://doc.qiweapi.com/api-344613917
+- [撤回消息](md/撤回消息.md) - https://doc.qiweapi.com/api-344613918
+- [语音转文字-任务申请](md/语音转文字-任务申请.md) - https://doc.qiweapi.com/api-440192990
+- [语音转文字-任务查询](md/语音转文字-任务查询.md) - https://doc.qiweapi.com/api-440146240
+- [修改消息状态](md/修改消息状态.md) - https://doc.qiweapi.com/api-344613919
+- [群消息置顶-列表](md/群消息置顶-列表.md) - https://doc.qiweapi.com/api-344613920
+- [群消息置顶-添加](md/群消息置顶-添加.md) - https://doc.qiweapi.com/api-344613921
+- [群消息置顶-移除](md/群消息置顶-移除.md) - https://doc.qiweapi.com/api-344613922
+- [群发消息](md/群发消息.md) - https://doc.qiweapi.com/api-344613923
+- [群发消息-状态查询](md/群发消息-状态查询.md) - https://doc.qiweapi.com/api-344613924
+- [群发消息-规则查询](md/群发消息-规则查询.md) - https://doc.qiweapi.com/api-344613925
+- [同步历史消息分页](md/同步历史消息分页.md) - https://doc.qiweapi.com/api-344613926
+- [会话分页](md/会话分页.md) - https://doc.qiweapi.com/api-344613938
+- [会话组-编辑](md/会话组-编辑.md) - https://doc.qiweapi.com/api-344613939
+- [会话组-查询](md/会话组-查询.md) - https://doc.qiweapi.com/api-344613940
+- [朋友圈列表分页](md/朋友圈列表分页.md) - https://doc.qiweapi.com/api-344613927
+- [获取详情-批量](md/获取详情-批量.md) - https://doc.qiweapi.com/api-344613928
+- [文件上传](md/文件上传.md) - https://doc.qiweapi.com/api-344613929
+- [发送朋友圈](md/发送朋友圈.md) - https://doc.qiweapi.com/api-344613930
+- [删除朋友圈](md/删除朋友圈.md) - https://doc.qiweapi.com/api-344613931
+- [点赞/取消赞](md/点赞_取消赞.md) - https://doc.qiweapi.com/api-344613932
+- [评论/追评](md/评论_追评.md) - https://doc.qiweapi.com/api-344613933
+- [评论删除](md/评论删除.md) - https://doc.qiweapi.com/api-344613934
+- [生成二维码](md/生成二维码.md) - https://doc.qiweapi.com/api-344613861
+- [获取个人信息](md/获取个人信息.md) - https://doc.qiweapi.com/api-344613862
+- [更新个人信息](md/更新个人信息.md) - https://doc.qiweapi.com/api-344613863
+- [查询企业信息](md/查询企业信息.md) - https://doc.qiweapi.com/api-344613864
+- [账号退出](md/账号退出.md) - https://doc.qiweapi.com/api-344613865
+- [个人收藏-分页](md/个人收藏-分页.md) - https://doc.qiweapi.com/api-344613866
+- [个人收藏-添加GIF表情](md/个人收藏-添加GIF表情.md) - https://doc.qiweapi.com/api-344613867
+- [列表分页](md/列表分页.md) - https://doc.qiweapi.com/api-361694421
+- [个人标签-增删改](md/个人标签-增删改.md) - https://doc.qiweapi.com/api-344613936
+- [客户标签-增删](md/客户标签-增删.md) - https://doc.qiweapi.com/api-344613937

+ 210 - 0
doc/QiWe开放平台文档/md/OpenID 2.md

@@ -0,0 +1,210 @@
+# OpenID
+
+> 来源: https://doc.qiweapi.com/api-344613896
+
+[![图片](images/api-344613896/48f11eac-33e9-4038-9e16-4382ea98363e.png)**QiWe开放平台**](https://doc.qiweapi.com)
+[ 企微官网](http://www.qiweapi.com "企微官网")[企微报价](http://manager.qiweapi.com/order "企微报价")[控制台](http://manager.qiweapi.com/login?redirect=%2Findex "控制台")
+[企微官网](http://www.qiweapi.com "企微官网")[企微报价](http://manager.qiweapi.com/order "企微报价")[控制台](http://manager.qiweapi.com/login?redirect=%2Findex "控制台")
+[后台系统](http://manager.qiweapi.com/index)
+复制页面
+  * [平台介绍](https://doc.qiweapi.com/doc-7331301 "平台介绍")
+  * [ 快速开始 ⭐](https://doc.qiweapi.com/doc-7562288 " 快速开始 ⭐")
+  * Webhook
+    * [Webhook事件⭐](https://doc.qiweapi.com/doc-7331303)
+    * [回调结构说明](https://doc.qiweapi.com/doc-7331304)
+    * [调试指南⭐](https://doc.qiweapi.com/doc-8483504)
+    * [常见问题](https://doc.qiweapi.com/doc-8483505)
+  * API参考
+    * 登录模块
+      * [创建设备(步骤1)](https://doc.qiweapi.com/api-344613850)
+      * [二维码-获取(步骤2)](https://doc.qiweapi.com/api-344613856)
+      * [二维码状态-检测(步骤3)](https://doc.qiweapi.com/api-344613857)
+      * [二维码-code验证(步骤4)](https://doc.qiweapi.com/api-344613858)
+      * [设置回调地址](https://doc.qiweapi.com/api-354411522)
+      * [用户登录(免扫码登录)](https://doc.qiweapi.com/api-344613859)
+      * [用户状态](https://doc.qiweapi.com/api-347221662)
+      * [恢复设备](https://doc.qiweapi.com/api-344613851)
+      * [停用设备](https://doc.qiweapi.com/api-344613852)
+    * 联系人模块
+      * [联系人详情-批量](https://doc.qiweapi.com/api-344613868)
+      * [外部联系人分页](https://doc.qiweapi.com/api-344613869)
+      * [内部联系人分页](https://doc.qiweapi.com/api-344613870)
+      * [联系人搜索](https://doc.qiweapi.com/api-344613871)
+      * [添加个微](https://doc.qiweapi.com/api-344613872)
+      * [添加企微](https://doc.qiweapi.com/api-344613873)
+      * [添加群成员好友](https://doc.qiweapi.com/api-425758709)
+      * [添加企微名片](https://doc.qiweapi.com/api-344613874)
+      * [添加删除联系人](https://doc.qiweapi.com/api-344613875)
+      * [同意申请](https://doc.qiweapi.com/api-344613876)
+      * [外部联系人信息-更新](https://doc.qiweapi.com/api-344613877)
+      * [内部联系人信息-更新](https://doc.qiweapi.com/api-344613878)
+      * [删除联系人](https://doc.qiweapi.com/api-344613879)
+      * [Openid](https://doc.qiweapi.com/api-344613880)
+    * 群模块
+      * [群分页POST](https://doc.qiweapi.com/api-344613881 "群分页")
+      * [群详情-批量POST](https://doc.qiweapi.com/api-344613882 "群详情-批量")
+      * [群成员变动查询POST](https://doc.qiweapi.com/api-437674162 "群成员变动查询")
+      * [创建群POST](https://doc.qiweapi.com/api-344613883 "创建群")
+      * [修改群名称POST](https://doc.qiweapi.com/api-344613884 "修改群名称")
+      * [修改群备注POST](https://doc.qiweapi.com/api-344613885 "修改群备注")
+      * [修改群内昵称POST](https://doc.qiweapi.com/api-344613886 "修改群内昵称")
+      * [邀请/添加成员POST](https://doc.qiweapi.com/api-344613887 "邀请/添加成员")
+      * [禁止群成员互相添加POST](https://doc.qiweapi.com/api-437795951 "禁止群成员互相添加")
+      * [移除成员POST](https://doc.qiweapi.com/api-344613888 "移除成员")
+      * [群二维码POST](https://doc.qiweapi.com/api-344613889 "群二维码")
+      * [修改群公告POST](https://doc.qiweapi.com/api-344613890 "修改群公告")
+      * [添加群管理员POST](https://doc.qiweapi.com/api-344613891 "添加群管理员")
+      * [取消群管理员POST](https://doc.qiweapi.com/api-344613892 "取消群管理员")
+      * [退群POST](https://doc.qiweapi.com/api-344613893 "退群")
+      * [转让群主POST](https://doc.qiweapi.com/api-344613894 "转让群主")
+      * [群解散POST](https://doc.qiweapi.com/api-344613895 "群解散")
+      * [OpenIDPOST](https://doc.qiweapi.com/api-344613896 "OpenID")
+      * [开启群改名POST](https://doc.qiweapi.com/api-344613897 "开启群改名")
+      * [开启群邀请确认POST](https://doc.qiweapi.com/api-344613898 "开启群邀请确认")
+      * [接受群邀请-By链接POST](https://doc.qiweapi.com/api-410838558 "接受群邀请-By链接")
+    * 消息模块 
+      * [引用消息发送说明](https://doc.qiweapi.com/doc-7331310)
+      * [文件与媒体(下载/上传)]()
+        * [本地文件上传](https://doc.qiweapi.com/api-344613899)
+        * [企微文件异步上传](https://doc.qiweapi.com/api-395133885)
+        * [企微大文件异步上传](https://doc.qiweapi.com/api-389695362)
+        * [个微文件下载](https://doc.qiweapi.com/api-344613902)
+        * [企微文件下载](https://doc.qiweapi.com/api-344613901)
+        * [企微文件异步下载](https://doc.qiweapi.com/api-389691087)
+        * [企微大文件异步下载](https://doc.qiweapi.com/api-453564016)
+        * [个微文件异步下载](https://doc.qiweapi.com/api-399776006)
+        * [文件上传-URL](https://doc.qiweapi.com/api-344613900)
+        * [文件CDN转URL](https://doc.qiweapi.com/api-344613903)
+      * [发送纯文本消息](https://doc.qiweapi.com/api-344613906)
+      * [发送混合文本消息](https://doc.qiweapi.com/api-344613907)
+      * [发送图片消息](https://doc.qiweapi.com/api-344613908)
+      * [发送GIF表情消息](https://doc.qiweapi.com/api-344613909)
+      * [发送视频消息](https://doc.qiweapi.com/api-344613910)
+      * [发送文件消息](https://doc.qiweapi.com/api-344613911)
+      * [发送语音消息](https://doc.qiweapi.com/api-344613912)
+      * [发送链接消息](https://doc.qiweapi.com/api-344613913)
+      * [发送小程序消息](https://doc.qiweapi.com/api-344613914)
+      * [发送名片消息](https://doc.qiweapi.com/api-344613915)
+      * [发送视频号消息](https://doc.qiweapi.com/api-344613916)
+      * [发送定位消息](https://doc.qiweapi.com/api-344613917)
+      * [撤回消息](https://doc.qiweapi.com/api-344613918)
+      * [语音转文字-任务申请](https://doc.qiweapi.com/api-440192990)
+      * [语音转文字-任务查询](https://doc.qiweapi.com/api-440146240)
+      * [修改消息状态](https://doc.qiweapi.com/api-344613919)
+      * [群消息置顶-列表](https://doc.qiweapi.com/api-344613920)
+      * [群消息置顶-添加](https://doc.qiweapi.com/api-344613921)
+      * [群消息置顶-移除](https://doc.qiweapi.com/api-344613922)
+      * [群发消息](https://doc.qiweapi.com/api-344613923)
+      * [群发消息-状态查询](https://doc.qiweapi.com/api-344613924)
+      * [群发消息-规则查询](https://doc.qiweapi.com/api-344613925)
+      * [同步历史消息分页](https://doc.qiweapi.com/api-344613926)
+    * 会话模块
+      * [会话分页](https://doc.qiweapi.com/api-344613938)
+      * [会话组-编辑](https://doc.qiweapi.com/api-344613939)
+      * [会话组-查询](https://doc.qiweapi.com/api-344613940)
+    * 朋友圈模块
+      * [朋友圈列表分页](https://doc.qiweapi.com/api-344613927)
+      * [获取详情-批量](https://doc.qiweapi.com/api-344613928)
+      * [文件上传](https://doc.qiweapi.com/api-344613929)
+      * [发送朋友圈](https://doc.qiweapi.com/api-344613930)
+      * [删除朋友圈](https://doc.qiweapi.com/api-344613931)
+      * [点赞/取消赞](https://doc.qiweapi.com/api-344613932)
+      * [评论/追评](https://doc.qiweapi.com/api-344613933)
+      * [评论删除](https://doc.qiweapi.com/api-344613934)
+    * 个人模块
+      * [生成二维码](https://doc.qiweapi.com/api-344613861)
+      * [获取个人信息](https://doc.qiweapi.com/api-344613862)
+      * [更新个人信息](https://doc.qiweapi.com/api-344613863)
+      * [查询企业信息](https://doc.qiweapi.com/api-344613864)
+      * [账号退出](https://doc.qiweapi.com/api-344613865)
+      * [个人收藏-分页](https://doc.qiweapi.com/api-344613866)
+      * [个人收藏-添加GIF表情](https://doc.qiweapi.com/api-344613867)
+    * 标签模块
+      * [列表分页](https://doc.qiweapi.com/api-361694421)
+      * [个人标签-增删改](https://doc.qiweapi.com/api-344613936)
+      * [客户标签-增删](https://doc.qiweapi.com/api-344613937)
+
+复制页面
+# OpenID
+POST
+/api/qw/doApi
+调试Run in Apifox
+Run in Apifox
+## 请求参数
+Header 参数
+生成代码
+### 
+  *   *   * 
+
+Body 参数application/json
+生成代码
+### 
+  *   *   * 
+
+示例
+```
+    {
+        "method": "/room/openid",
+        "params": {
+            "guid": "{{guid}}",
+            "roomId": "1077**********6",
+            "corpAgentId": "56295***777700"
+        }
+    }
+```
+## 请求示例代码
+Shell
+JavaScript
+Java
+Swift
+Go
+PHP
+Python
+HTTP
+C
+C#
+Objective-C
+Ruby
+OCaml
+Dart
+R
+请求示例请求示例
+Shell
+JavaScript
+Java
+Swift
+cURLcURL-WindowsHttpiewgetPowerShell
+```
+    curl --location 'http://manager.qiweapi.com/qiwe/api/qw/doApi' \
+    --header 'X-QIWEI-TOKEN: ' \
+    --header 'Content-Type: application/json' \
+    --data '{
+        "method": "/room/openid",
+        "params": {
+            "guid": "",
+            "roomId": "1077**********6",
+            "corpAgentId": "56295***777700"
+        }
+    }'
+```
+## 返回响应
+🟢200成功
+application/json
+生成代码
+Bodyapplication/json
+生成代码
+### 
+  *   *   * 
+
+示例
+```
+    {
+        "code": 0,
+        "data": {
+            "openRoomId": "wrhNLvDA**********HskZE3dtg"
+        },
+        "msg": "成功"
+    }
+```
+修改于 2026-04-14 10:23:10
+[上一页群解散](https://doc.qiweapi.com/api-344613895)[下一页开启群改名](https://doc.qiweapi.com/api-344613897)

+ 6 - 0
doc/QiWe开放平台文档/md/Openid.md

@@ -0,0 +1,6 @@
+# Openid
+
+> 来源: https://doc.qiweapi.com/api-344613880
+
+•
+OpenID:就是官方API的 external_userid(客户的唯一身份证)

Algunos archivos no se mostraron porque demasiados archivos cambiaron en este cambio