Explorar el Código

Initial commit: 企微客户服务系统前端与后端

Co-authored-by: Cursor <cursoragent@cursor.com>
0235699曾露 hace 3 meses
commit
ec0f8b5419
Se han modificado 100 ficheros con 13733 adiciones y 0 borrados
  1. 17 0
      .editorconfig
  2. 54 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. 59 0
      README.md
  8. 112 0
      angular.json
  9. 13 0
      backend/backend/.env.example
  10. 221 0
      backend/backend/docs/backend-design.md
  11. 222 0
      backend/backend/docs/database-design.md
  12. 302 0
      backend/backend/docs/deployment-baota.md
  13. 23 0
      backend/backend/package.json
  14. 1081 0
      backend/backend/pnpm-lock.yaml
  15. 21 0
      backend/backend/src/apps/pc/app.ts
  16. 94 0
      backend/backend/src/apps/pc/auth/controllers/auth.controller.ts
  17. 23 0
      backend/backend/src/apps/pc/auth/routes/auth.routes.ts
  18. 298 0
      backend/backend/src/apps/pc/auth/services/auth.service.ts
  19. 18 0
      backend/backend/src/apps/pc/auth/utils/password.util.ts
  20. 7 0
      backend/backend/src/apps/pc/auth/utils/request.util.ts
  21. 26 0
      backend/backend/src/apps/pc/health/controllers/health.controller.ts
  22. 10 0
      backend/backend/src/apps/pc/health/routes/health.routes.ts
  23. 14 0
      backend/backend/src/apps/pc/health/server.ts
  24. 21 0
      backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts
  25. 26 0
      backend/backend/src/apps/pc/qiwe/controllers/sync.controller.ts
  26. 23 0
      backend/backend/src/apps/pc/qiwe/controllers/webhook.controller.ts
  27. 14 0
      backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts
  28. 42 0
      backend/backend/src/apps/pc/qiwe/services/groups.service.ts
  29. 108 0
      backend/backend/src/apps/pc/qiwe/services/qiwe-api.service.ts
  30. 51 0
      backend/backend/src/apps/pc/qiwe/services/sync.service.ts
  31. 226 0
      backend/backend/src/apps/pc/qiwe/services/webhook.service.ts
  32. 16 0
      backend/backend/src/index.ts
  33. 13 0
      backend/backend/src/shared/config/env.ts
  34. 8 0
      backend/backend/src/shared/db/parse-client.ts
  35. 37 0
      backend/backend/src/shared/db/parse-health.service.ts
  36. 94 0
      backend/backend/src/shared/db/schema-setup.ts
  37. 14 0
      backend/backend/src/shared/errors/app-error.ts
  38. 9 0
      backend/backend/src/shared/http/async-handler.ts
  39. 17 0
      backend/backend/src/shared/http/error-handler.ts
  40. 6 0
      backend/backend/src/shared/http/not-found.middleware.ts
  41. 37 0
      backend/backend/src/shared/http/response.ts
  42. 30 0
      backend/backend/test-webhook.bat
  43. 17 0
      backend/backend/tsconfig.json
  44. 1374 0
      doc/Angular项目开发规范.md
  45. 932 0
      doc/UI设计规范白皮书.md
  46. 331 0
      doc/业务泳道图.md
  47. 277 0
      doc/产品架构和页面索引.md
  48. 117 0
      doc/企微客户服务-功能清单(2).md
  49. 70 0
      doc/后续优化需求与流程梳理.md
  50. 434 0
      doc/流程图.md
  51. 439 0
      doc/用户画像和权责分析.md
  52. 22 0
      doc/社群运营.md
  53. 99 0
      doc/社群运营功能模块-实现可行性清单.md
  54. 77 0
      doc/跨部门对接沟通清单.md
  55. 333 0
      doc/项目现状分析与待确认事项.md
  56. 7 0
      lami-base-v1/.claude/settings.local.json
  57. 6 0
      lami-base-v1/.gitignore
  58. 48 0
      lami-base-v1/.trae/documents/learning_page_plan.md
  59. 31 0
      lami-base-v1/.trae/documents/profile_optimize_plan.md
  60. 86 0
      lami-base-v1/.trae/documents/qa_ai_logic_plan.md
  61. 32 0
      lami-base-v1/backend/.env.example
  62. 12 0
      lami-base-v1/backend/.gitignore
  63. 35 0
      lami-base-v1/backend/package.json
  64. 1030 0
      lami-base-v1/backend/pnpm-lock.yaml
  65. 24 0
      lami-base-v1/backend/src/apps/mobile/chat/app.ts
  66. 141 0
      lami-base-v1/backend/src/apps/mobile/chat/controllers/chat.controller.ts
  67. 8 0
      lami-base-v1/backend/src/apps/mobile/chat/routes/chat.routes.ts
  68. 11 0
      lami-base-v1/backend/src/apps/mobile/chat/server.ts
  69. 114 0
      lami-base-v1/backend/src/apps/pc/app.ts
  70. 155 0
      lami-base-v1/backend/src/apps/pc/community/controllers/community.controller.ts
  71. 69 0
      lami-base-v1/backend/src/apps/pc/community/models/community.model.ts
  72. 43 0
      lami-base-v1/backend/src/apps/pc/community/routes/community.routes.ts
  73. 189 0
      lami-base-v1/backend/src/apps/pc/community/services/community.service.ts
  74. 120 0
      lami-base-v1/backend/src/apps/pc/compliance/controllers/compliance.controller.ts
  75. 109 0
      lami-base-v1/backend/src/apps/pc/compliance/models/compliance.model.ts
  76. 28 0
      lami-base-v1/backend/src/apps/pc/compliance/routes/compliance.routes.ts
  77. 268 0
      lami-base-v1/backend/src/apps/pc/compliance/services/compliance.service.ts
  78. 155 0
      lami-base-v1/backend/src/apps/pc/content/controllers/content.controller.ts
  79. 94 0
      lami-base-v1/backend/src/apps/pc/content/models/content.model.ts
  80. 48 0
      lami-base-v1/backend/src/apps/pc/content/routes/content.routes.ts
  81. 246 0
      lami-base-v1/backend/src/apps/pc/content/services/content.service.ts
  82. 90 0
      lami-base-v1/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts
  83. 34 0
      lami-base-v1/backend/src/apps/pc/dashboard/routes/dashboard.routes.ts
  84. 229 0
      lami-base-v1/backend/src/apps/pc/dashboard/services/dashboard.service.ts
  85. 27 0
      lami-base-v1/backend/src/apps/pc/health/controllers/health.controller.ts
  86. 8 0
      lami-base-v1/backend/src/apps/pc/health/routes/health.routes.ts
  87. 34 0
      lami-base-v1/backend/src/apps/pc/health/server.ts
  88. 132 0
      lami-base-v1/backend/src/apps/pc/koc/controllers/koc.controller.ts
  89. 88 0
      lami-base-v1/backend/src/apps/pc/koc/models/koc.model.ts
  90. 54 0
      lami-base-v1/backend/src/apps/pc/koc/routes/koc.routes.ts
  91. 306 0
      lami-base-v1/backend/src/apps/pc/koc/services/koc.service.ts
  92. 301 0
      lami-base-v1/backend/src/apps/pc/qiwei/controllers/qiwei.controller.ts
  93. 147 0
      lami-base-v1/backend/src/apps/pc/qiwei/models/qiwei.model.ts
  94. 55 0
      lami-base-v1/backend/src/apps/pc/qiwei/routes/qiwei.routes.ts
  95. 381 0
      lami-base-v1/backend/src/apps/pc/qiwei/services/qiwei.service.ts
  96. 170 0
      lami-base-v1/backend/src/apps/pc/risk/controllers/risk.controller.ts
  97. 91 0
      lami-base-v1/backend/src/apps/pc/risk/models/risk.model.ts
  98. 42 0
      lami-base-v1/backend/src/apps/pc/risk/routes/risk.routes.ts
  99. 247 0
      lami-base-v1/backend/src/apps/pc/risk/services/risk.service.ts
  100. 188 0
      lami-base-v1/backend/src/apps/pc/room/controllers/room.controller.ts

+ 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

+ 54 - 0
.gitignore

@@ -0,0 +1,54 @@
+# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
+
+# Compiled output
+/dist
+/tmp
+/out-tsc
+/bazel-out
+
+# Node
+/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
+
+# Temp / local artifacts
+_tmp_backend_zip/
+backend/backend/node_modules/
+lami-base-v1/.venv/
+lami-base-v1/**/node_modules/

+ 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"
+          }
+        }
+      }
+    }
+  ]
+}

+ 59 - 0
README.md

@@ -0,0 +1,59 @@
+# 拉迷企微客户服务系统(PC 社群运营端)
+
+## 项目结构
+
+| 目录 | 说明 |
+|------|------|
+| `src/` | **PC 前端**(Angular 20,本仓库主应用) |
+| `lami-base-v1/backend/` | **后端 API**(Express,PC `:3101`,Mobile `:3201`) |
+| `lami-base-v1/frontend/` | 后端联调/测试用前端(销售培训 Mobile,可保留) |
+| `doc/` | 产品与设计文档 |
+| `lami-base-v1/doc/` | 后端接口与开发规范 |
+
+## 本地开发
+
+### 1. 启动后端
+
+```bash
+cd lami-base-v1/backend
+pnpm install
+pnpm dev
+```
+
+PC API:`http://localhost:3101/api`  
+Swagger:`http://localhost:3101/api-docs`
+
+### 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 |
+
+## 环境变量(后端)
+
+见 `lami-base-v1/backend/.env.example`。生产环境务必配置:
+
+- `API_KEY` — 前端请求头 `X-API-Key`
+- `QIWEI_LIVE_ALLOW_BROADCAST=1` — 才允许真实群发
+- `QIWEI_WEBHOOK_SECRET` — Webhook 校验
+
+## 构建
+
+```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"
+            ]
+          }
+        }
+      }
+    }
+  }
+}

+ 13 - 0
backend/backend/.env.example

@@ -0,0 +1,13 @@
+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
+
+# 企微 QiWe 平台(sync-groups 拉群列表需要)
+QIWE_API_BASE=https://manager.qiweapi.com/qiwe
+QIWE_TOKEN=
+QIWE_GUID=
+QIWE_USER_ID=

+ 221 - 0
backend/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/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/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`)

+ 23 - 0
backend/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/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: {}

+ 21 - 0
backend/backend/src/apps/pc/app.ts

@@ -0,0 +1,21 @@
+import express from 'express';
+import cors from 'cors';
+import authRoutes from './auth/routes/auth.routes.js';
+import webhookRoutes from './qiwe/routes/webhook.routes.js';
+import healthRoutes from './health/routes/health.routes.js';
+import { errorHandler } from '../../shared/http/error-handler.js';
+import { notFoundHandler } from '../../shared/http/not-found.middleware.js';
+
+const app = express();
+
+app.use(cors());
+app.use(express.json());
+
+app.use('/api/auth', authRoutes);
+app.use('/api/qiwe', webhookRoutes);
+app.use('/api', healthRoutes);
+
+app.use(notFoundHandler);
+app.use(errorHandler);
+
+export default app;

+ 94 - 0
backend/backend/src/apps/pc/auth/controllers/auth.controller.ts

@@ -0,0 +1,94 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import * as authService from '../services/auth.service.js';
+import { getBearerToken } from '../utils/request.util.js';
+
+async function requireUser(req: Request): Promise<authService.UserDto> {
+  const token = getBearerToken(req);
+  if (!token) {
+    throw new AppError(401, 'UNAUTHORIZED', '未登录');
+  }
+  const user = await authService.getUserByToken(token);
+  if (!user) {
+    throw new AppError(401, 'SESSION_EXPIRED', '登录已过期,请重新登录');
+  }
+  return user;
+}
+
+export async function handleLogin(req: Request, res: Response): Promise<void> {
+  const { email, password, role, rememberMe } = req.body ?? {};
+  if (!email || !password || !role) {
+    throw new AppError(400, 'INVALID_BODY', '请提供 email、password、role');
+  }
+
+  const result = await authService.loginWithRemember(
+    String(email),
+    String(password),
+    String(role),
+    Boolean(rememberMe),
+  );
+  sendSuccess(res, result);
+}
+
+export async function handleRegister(req: Request, res: Response): Promise<void> {
+  const { name, email, phone, password, role } = req.body ?? {};
+  if (!name || !email || !phone || !password) {
+    throw new AppError(400, 'INVALID_BODY', '请填写完整注册信息');
+  }
+
+  const user = await authService.register({
+    name: String(name),
+    email: String(email),
+    phone: String(phone),
+    password: String(password),
+      role: (role as authService.UserRole) || 'single_group',
+  });
+  sendSuccess(res, { user }, 201);
+}
+
+export async function handleForgotPassword(req: Request, res: Response): Promise<void> {
+  const { email } = req.body ?? {};
+  if (!email) {
+    throw new AppError(400, 'INVALID_BODY', '请提供邮箱');
+  }
+
+  await authService.forgotPassword(String(email));
+  sendSuccess(res, { message: '若该邮箱已注册,请联系管理员重置密码' });
+}
+
+export async function handleMe(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  sendSuccess(res, { user });
+}
+
+export async function handleChangePassword(req: Request, res: Response): Promise<void> {
+  const user = await requireUser(req);
+  const { currentPassword, newPassword } = req.body ?? {};
+  if (!currentPassword || !newPassword) {
+    throw new AppError(400, 'INVALID_BODY', '请提供当前密码和新密码');
+  }
+
+  await authService.changePassword(user.id, String(currentPassword), String(newPassword));
+  sendSuccess(res, { message: '密码已更新' });
+}
+
+export async function handleUpdateProfile(req: Request, res: Response): Promise<void> {
+  const current = await requireUser(req);
+  const { name, phone, position } = req.body ?? {};
+
+  const user = await authService.updateProfile(current.id, {
+    name: name !== undefined ? String(name) : undefined,
+    phone: phone !== undefined ? String(phone) : undefined,
+    position: position !== undefined ? String(position) : undefined,
+  });
+  sendSuccess(res, { user });
+}
+
+export async function handleLogout(req: Request, res: Response): Promise<void> {
+  const token = getBearerToken(req);
+  if (token) {
+    await authService.logoutByToken(token);
+  }
+  sendSuccess(res, null);
+}

+ 23 - 0
backend/backend/src/apps/pc/auth/routes/auth.routes.ts

@@ -0,0 +1,23 @@
+import { Router } from 'express';
+import { asyncHandler } from '../../../../shared/http/async-handler.js';
+import {
+  handleLogin,
+  handleRegister,
+  handleForgotPassword,
+  handleMe,
+  handleChangePassword,
+  handleUpdateProfile,
+  handleLogout,
+} from '../controllers/auth.controller.js';
+
+const router = Router();
+
+router.post('/login', asyncHandler(handleLogin));
+router.post('/register', asyncHandler(handleRegister));
+router.post('/forgot-password', asyncHandler(handleForgotPassword));
+router.get('/me', asyncHandler(handleMe));
+router.post('/logout', asyncHandler(handleLogout));
+router.put('/profile', asyncHandler(handleUpdateProfile));
+router.put('/password', asyncHandler(handleChangePassword));
+
+export default router;

+ 298 - 0
backend/backend/src/apps/pc/auth/services/auth.service.ts

@@ -0,0 +1,298 @@
+import { randomBytes } from 'crypto';
+import Parse from '../../../../shared/db/parse-client.js';
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { hashPassword, verifyPassword } from '../utils/password.util.js';
+
+export type UserRole = 'director' | 'regional_supervisor' | 'store_manager' | 'single_group';
+
+export interface UserDto {
+  id: string;
+  name: string;
+  email: string;
+  phone: string;
+  role: UserRole;
+  storeId: string;
+  storeName: string;
+  department: string;
+  position: string;
+  createdAt: string;
+}
+
+const VALID_ROLES: UserRole[] = ['director', 'regional_supervisor', 'store_manager', 'single_group'];
+
+const LEGACY_ROLE_MAP: Record<string, UserRole> = {
+  admin: 'director',
+  operator: 'single_group',
+  designer: 'single_group',
+  sales: 'single_group',
+  store_manager: 'store_manager',
+};
+const SESSION_DAYS_REMEMBER = 30;
+const SESSION_DAYS_DEFAULT = 1;
+
+function toUserDto(obj: Parse.Object): UserDto {
+  return {
+    id: obj.id!,
+    name: obj.get('name') || '',
+    email: obj.get('email') || '',
+    phone: obj.get('phone') || '',
+    role: obj.get('role') as UserRole,
+    storeId: obj.get('storeId') || 's1',
+    storeName: obj.get('storeName') || '',
+    department: obj.get('department') || '',
+    position: obj.get('position') || '',
+    createdAt: (obj.get('createdAt') as Date)?.toISOString?.() || new Date().toISOString(),
+  };
+}
+
+function newToken(): string {
+  return randomBytes(32).toString('hex');
+}
+
+function normalizeRole(role: string): UserRole | null {
+  if (VALID_ROLES.includes(role as UserRole)) {
+    return role as UserRole;
+  }
+  return LEGACY_ROLE_MAP[role] ?? null;
+}
+
+async function ensureUserRole(user: Parse.Object): Promise<UserRole> {
+  const rawRole = user.get('role') as string;
+  const normalized = normalizeRole(rawRole);
+  if (!normalized) {
+    throw new AppError(500, 'INVALID_USER_ROLE', `账号角色数据异常:${rawRole}`);
+  }
+  if (normalized !== rawRole) {
+    user.set('role', normalized);
+    await user.save(null, { useMasterKey: true });
+  }
+  return normalized;
+}
+
+async function findUserByEmail(email: string): Promise<Parse.Object | undefined> {
+  const query = new Parse.Query('AppUser');
+  query.equalTo('email', email.trim().toLowerCase());
+  return (await query.first({ useMasterKey: true })) ?? undefined;
+}
+
+async function createSession(userId: string, rememberMe: boolean): Promise<string> {
+  const token = newToken();
+  const days = rememberMe ? SESSION_DAYS_REMEMBER : SESSION_DAYS_DEFAULT;
+  const expiresAt = new Date(Date.now() + days * 24 * 60 * 60 * 1000);
+
+  const session = new Parse.Object('AuthSession');
+  session.set('token', token);
+  session.set('userId', userId);
+  session.set('expiresAt', expiresAt);
+  await session.save(null, { useMasterKey: true });
+  return token;
+}
+
+export async function loginWithRemember(
+  email: string,
+  password: string,
+  role: string,
+  rememberMe: boolean,
+): Promise<{ user: UserDto; token: string }> {
+  const normalizedEmail = email.trim().toLowerCase();
+  const userObj = await findUserByEmail(normalizedEmail);
+  if (!userObj) {
+    throw new AppError(401, 'AUTH_FAILED', '邮箱或密码错误');
+  }
+
+  const hash = userObj.get('passwordHash') as string;
+  const salt = userObj.get('passwordSalt') as string;
+  if (!verifyPassword(password, hash, salt)) {
+    throw new AppError(401, 'AUTH_FAILED', '邮箱或密码错误');
+  }
+
+  const storedRole = await ensureUserRole(userObj);
+  if (storedRole !== role) {
+    throw new AppError(401, 'ROLE_MISMATCH', '角色不匹配,请选择正确的登录角色');
+  }
+
+  const token = await createSession(userObj.id!, rememberMe);
+  return { user: toUserDto(userObj), token };
+}
+
+export async function logoutByToken(token: string): Promise<void> {
+  const sessionQuery = new Parse.Query('AuthSession');
+  sessionQuery.equalTo('token', token);
+  const session = await sessionQuery.first({ useMasterKey: true });
+  if (session) {
+    await session.destroy({ useMasterKey: true });
+  }
+}
+
+export async function register(data: {
+  name: string;
+  email: string;
+  phone: string;
+  password: string;
+  role: UserRole;
+}): Promise<UserDto> {
+  const email = data.email.trim().toLowerCase();
+  if (!VALID_ROLES.includes(data.role)) {
+    throw new AppError(400, 'INVALID_ROLE', '无效的角色');
+  }
+
+  const existing = await findUserByEmail(email);
+  if (existing) {
+    throw new AppError(400, 'EMAIL_EXISTS', '该邮箱已被注册');
+  }
+
+  const { hash, salt } = hashPassword(data.password);
+  const obj = new Parse.Object('AppUser');
+  obj.set('email', email);
+  obj.set('passwordHash', hash);
+  obj.set('passwordSalt', salt);
+  obj.set('name', data.name.trim());
+  obj.set('phone', data.phone.trim());
+  obj.set('role', data.role);
+  obj.set('storeId', 's1');
+  obj.set('storeName', '上海总部');
+  obj.set('department', '运营部');
+  obj.set('position', '新注册用户');
+  await obj.save(null, { useMasterKey: true });
+  return toUserDto(obj);
+}
+
+export async function forgotPassword(email: string): Promise<void> {
+  const user = await findUserByEmail(email.trim().toLowerCase());
+  if (!user) {
+    throw new AppError(404, 'EMAIL_NOT_FOUND', '该邮箱未注册');
+  }
+  // 暂未接入邮件服务,仅校验邮箱存在
+}
+
+export async function getUserByToken(token: string): Promise<UserDto | null> {
+  const sessionQuery = new Parse.Query('AuthSession');
+  sessionQuery.equalTo('token', token);
+  const session = await sessionQuery.first({ useMasterKey: true });
+  if (!session) return null;
+
+  const expiresAt = session.get('expiresAt') as Date;
+  if (expiresAt && expiresAt.getTime() < Date.now()) {
+    await session.destroy({ useMasterKey: true });
+    return null;
+  }
+
+  const userQuery = new Parse.Query('AppUser');
+  const user = await userQuery.get(session.get('userId') as string, { useMasterKey: true });
+  return user ? toUserDto(user) : null;
+}
+
+export async function changePassword(
+  userId: string,
+  currentPassword: string,
+  newPassword: string,
+): Promise<void> {
+  const user = await new Parse.Query('AppUser').get(userId, { useMasterKey: true });
+  const hash = user.get('passwordHash') as string;
+  const salt = user.get('passwordSalt') as string;
+  if (!verifyPassword(currentPassword, hash, salt)) {
+    throw new AppError(400, 'PASSWORD_CHANGE_FAILED', '当前密码不正确');
+  }
+  const next = hashPassword(newPassword);
+  user.set('passwordHash', next.hash);
+  user.set('passwordSalt', next.salt);
+  await user.save(null, { useMasterKey: true });
+}
+
+export async function updateProfile(
+  userId: string,
+  patch: { name?: string; phone?: string; position?: string },
+): Promise<UserDto> {
+  const user = await new Parse.Query('AppUser').get(userId, { useMasterKey: true });
+  if (patch.name !== undefined) user.set('name', patch.name.trim());
+  if (patch.phone !== undefined) user.set('phone', patch.phone.trim());
+  if (patch.position !== undefined) user.set('position', patch.position.trim());
+  await user.save(null, { useMasterKey: true });
+  return toUserDto(user);
+}
+
+const SEED_USERS: Array<{
+  email: string;
+  password: string;
+  name: string;
+  phone: string;
+  role: UserRole;
+  storeId: string;
+  storeName: string;
+  department: string;
+  position: string;
+}> = [
+  { email: 'admin@lami.com', password: '123456', name: '张总监', phone: '13800000001', role: 'director', storeId: 's1', storeName: '上海总部', department: '总部', position: '总监' },
+  { email: 'dudao@lami.com', password: '123456', name: '周督导', phone: '13800000002', role: 'regional_supervisor', storeId: 's1', storeName: '上海总部', department: '督导部', position: '区域督导' },
+  { email: 'lidian@lami.com', password: '123456', name: '李店长', phone: '13800000003', role: 'store_manager', storeId: 's1', storeName: '上海总部', department: '直营店', position: '店长' },
+  { email: 'wangying@lami.com', password: '123456', name: '王单群', phone: '13800000004', role: 'single_group', storeId: 's1', storeName: '上海总部', department: '运营部', position: '单群' },
+];
+
+async function migrateLegacyRoles(): Promise<void> {
+  const query = new Parse.Query('AppUser');
+  query.limit(1000);
+  const users = await query.find({ useMasterKey: true });
+  let migrated = 0;
+
+  for (const user of users) {
+    const role = user.get('role') as string;
+    const normalized = normalizeRole(role);
+    if (normalized && normalized !== role) {
+      user.set('role', normalized);
+      await user.save(null, { useMasterKey: true });
+      migrated++;
+    }
+  }
+
+  if (migrated > 0) {
+    console.log(`[Auth] 已迁移 ${migrated} 个账号到新版角色`);
+  }
+}
+
+async function upsertSeedUsers(): Promise<void> {
+  let created = 0;
+  let updated = 0;
+
+  for (const seed of SEED_USERS) {
+    const existing = await findUserByEmail(seed.email);
+    const { hash, salt } = hashPassword(seed.password);
+
+    if (existing) {
+      existing.set('passwordHash', hash);
+      existing.set('passwordSalt', salt);
+      existing.set('name', seed.name);
+      existing.set('phone', seed.phone);
+      existing.set('role', seed.role);
+      existing.set('storeId', seed.storeId);
+      existing.set('storeName', seed.storeName);
+      existing.set('department', seed.department);
+      existing.set('position', seed.position);
+      await existing.save(null, { useMasterKey: true });
+      updated++;
+      continue;
+    }
+
+    const obj = new Parse.Object('AppUser');
+    obj.set('email', seed.email);
+    obj.set('passwordHash', hash);
+    obj.set('passwordSalt', salt);
+    obj.set('name', seed.name);
+    obj.set('phone', seed.phone);
+    obj.set('role', seed.role);
+    obj.set('storeId', seed.storeId);
+    obj.set('storeName', seed.storeName);
+    obj.set('department', seed.department);
+    obj.set('position', seed.position);
+    await obj.save(null, { useMasterKey: true });
+    created++;
+  }
+
+  if (created > 0 || updated > 0) {
+    console.log(`[Auth] 演示账号已同步:新增 ${created},更新 ${updated}`);
+  }
+}
+
+export async function bootstrapAuth(): Promise<void> {
+  await migrateLegacyRoles();
+  await upsertSeedUsers();
+}

+ 18 - 0
backend/backend/src/apps/pc/auth/utils/password.util.ts

@@ -0,0 +1,18 @@
+import { randomBytes, scryptSync, timingSafeEqual } from 'crypto';
+
+export function hashPassword(password: string): { hash: string; salt: string } {
+  const salt = randomBytes(16).toString('hex');
+  const hash = scryptSync(password, salt, 64).toString('hex');
+  return { hash, salt };
+}
+
+export function verifyPassword(password: string, hash: string, salt: string): boolean {
+  try {
+    const derived = scryptSync(password, salt, 64);
+    const stored = Buffer.from(hash, 'hex');
+    if (derived.length !== stored.length) return false;
+    return timingSafeEqual(derived, stored);
+  } catch {
+    return false;
+  }
+}

+ 7 - 0
backend/backend/src/apps/pc/auth/utils/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;
+}

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

@@ -0,0 +1,26 @@
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { checkParseDatabase } from '../../../../shared/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',
+  });
+}

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

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

+ 14 - 0
backend/backend/src/apps/pc/health/server.ts

@@ -0,0 +1,14 @@
+import app from '../app.js';
+import { getNumberEnv } from '../../../shared/config/env.js';
+
+const PORT = getNumberEnv('PC_PORT', 3101);
+
+app.listen(PORT, () => {
+  console.log(`[PC Server] 启动成功,端口: ${PORT}`);
+  console.log(`[PC Server] 健康检查:   http://localhost:${PORT}/api/health`);
+  console.log(`[PC Server] 版本信息:   http://localhost:${PORT}/api/version`);
+  console.log(`[PC Server] Webhook:     http://localhost:${PORT}/api/qiwe/webhook`);
+  console.log(`[PC Server] 群同步:      http://localhost:${PORT}/api/qiwe/sync-groups`);
+  console.log(`[PC Server] 登录:        http://localhost:${PORT}/api/auth/login`);
+  console.log(`[PC Server] Parse:       ${process.env.PARSE_SERVER_URL}`);
+});

+ 21 - 0
backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts

@@ -0,0 +1,21 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { getGroupChatByRoomId, listGroupChats } from '../services/groups.service.js';
+
+export async function handleListGroups(_req: Request, res: Response): Promise<void> {
+  const groups = await listGroupChats();
+  sendSuccess(res, { groups, total: groups.length });
+}
+
+export async function handleGetGroup(req: Request, res: Response): Promise<void> {
+  const roomId = req.params.roomId;
+  if (!roomId) {
+    throw new AppError(400, 'INVALID_PARAMS', '缺少 roomId');
+  }
+  const group = await getGroupChatByRoomId(roomId);
+  if (!group) {
+    throw new AppError(404, 'GROUP_NOT_FOUND', '群不存在');
+  }
+  sendSuccess(res, { group });
+}

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

@@ -0,0 +1,26 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { checkParseDatabase } from '../../../../shared/db/parse-health.service.js';
+import { syncGroupsFromQiWe } from '../services/sync.service.js';
+import { getQiWeConfig } from '../services/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 sync = await syncGroupsFromQiWe();
+  const dbAfter = await checkParseDatabase();
+
+  sendSuccess(res, { sync, database: dbAfter });
+  if (sync.errors.length > 0) {
+    console.warn(`[Sync] 部分群写入失败: ${sync.errors.length} 条`);
+  }
+}

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

@@ -0,0 +1,23 @@
+import type { Request, Response } from 'express';
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { sendSuccess } from '../../../../shared/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);
+    }
+  }
+}

+ 14 - 0
backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts

@@ -0,0 +1,14 @@
+import { Router } from 'express';
+import { asyncHandler } from '../../../../shared/http/async-handler.js';
+import { handleWebhook } from '../controllers/webhook.controller.js';
+import { handleSyncGroups } from '../controllers/sync.controller.js';
+import { handleListGroups, handleGetGroup } from '../controllers/groups.controller.js';
+
+const router = Router();
+
+router.get('/groups', asyncHandler(handleListGroups));
+router.get('/groups/:roomId', asyncHandler(handleGetGroup));
+router.post('/webhook', asyncHandler(handleWebhook));
+router.post('/sync-groups', asyncHandler(handleSyncGroups));
+
+export default router;

+ 42 - 0
backend/backend/src/apps/pc/qiwe/services/groups.service.ts

@@ -0,0 +1,42 @@
+import Parse from '../../../../shared/db/parse-client.js';
+
+export interface GroupChatDto {
+  id: string;
+  roomId: string;
+  roomName: string;
+  ownerId: string;
+  memberCount: number;
+  avatarUrl: string;
+  status: string;
+  guid: string;
+  updatedAt: string;
+}
+
+function toDto(obj: Parse.Object): GroupChatDto {
+  return {
+    id: obj.id!,
+    roomId: obj.get('roomId') || '',
+    roomName: obj.get('roomName') || '未命名群',
+    ownerId: obj.get('ownerId') || '',
+    memberCount: obj.get('memberCount') ?? 0,
+    avatarUrl: obj.get('avatarUrl') || '',
+    status: obj.get('status') || 'active',
+    guid: obj.get('guid') || '',
+    updatedAt: (obj.get('updatedAt') as Date)?.toISOString?.() || new Date().toISOString(),
+  };
+}
+
+export async function listGroupChats(limit = 500): Promise<GroupChatDto[]> {
+  const query = new Parse.Query('GroupChat');
+  query.descending('updatedAt');
+  query.limit(limit);
+  const results = await query.find({ useMasterKey: true });
+  return results.map(toDto);
+}
+
+export async function getGroupChatByRoomId(roomId: string): Promise<GroupChatDto | null> {
+  const query = new Parse.Query('GroupChat');
+  query.equalTo('roomId', roomId);
+  const obj = await query.first({ useMasterKey: true });
+  return obj ? toDto(obj) : null;
+}

+ 108 - 0
backend/backend/src/apps/pc/qiwe/services/qiwe-api.service.ts

@@ -0,0 +1,108 @@
+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;
+}
+
+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;
+}
+
+async function callApi<T = any>(method: string, params: Record<string, any> = {}): Promise<QiWeResponse<T>> {
+  const resp = await fetch(`${API_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;
+}
+
+/** 获取创建的群列表(含群名称、群主、人数等完整信息) */
+export async function getRoomList(page = 1, pageSize = 50): Promise<{
+  rooms: RoomInfo[];
+  hasMore: boolean;
+  total: number;
+}> {
+  const resp = await callApi<{
+    hasMore: boolean;
+    nextStartIndex: number;
+    roomCount: number;
+    roomList: RoomInfo[];
+  }>('/room/getRoomList', { guid: GUID, page, pageSize });
+
+  return {
+    rooms: resp.data.roomList || [],
+    hasMore: resp.data.hasMore || false,
+    total: resp.data.roomCount || 0,
+  };
+}
+
+/** 获取所有群(遍历分页) */
+export async function getAllRooms(): Promise<RoomInfo[]> {
+  const all: RoomInfo[] = [];
+  let page = 1;
+
+  while (true) {
+    const { rooms, hasMore } = await getRoomList(page, 50);
+    all.push(...rooms);
+    if (!hasMore) break;
+    page++;
+  }
+
+  return all;
+}
+
+/** 获取会话列表(含群会话和个人会话) */
+export async function getSessionList(sessionType?: number): Promise<SessionInfo[]> {
+  const resp = await callApi<{
+    collectList: SessionInfo[];
+    shieldList: SessionInfo[];
+    topList: SessionInfo[];
+  }>('/session/getSessionList', { 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;
+}
+
+/** 获取 Token 和 Guid 的可用性 */
+export function getQiWeConfig() {
+  return { apiBase: API_BASE, token: TOKEN, guid: GUID };
+}

+ 51 - 0
backend/backend/src/apps/pc/qiwe/services/sync.service.ts

@@ -0,0 +1,51 @@
+import Parse from '../../../../shared/db/parse-client.js';
+import { getAllRooms } from './qiwe-api.service.js';
+
+const GUID = process.env.QIWE_GUID || '';
+
+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} 个群`);
+
+  for (const room of rooms) {
+    try {
+      const query = new Parse.Query('GroupChat');
+      query.equalTo('roomId', room.roomId);
+      query.equalTo('guid', GUID);
+      const existing = await query.first({ useMasterKey: true });
+
+      if (existing) {
+        existing.set('roomName', room.roomName);
+        existing.set('ownerId', room.roomOwnerId);
+        existing.set('memberCount', room.roomMemberCount);
+        existing.set('avatarUrl', room.roomAvatarUrl);
+        existing.set('status', 'active');
+        await existing.save(null, { useMasterKey: true });
+        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');
+        await obj.save(null, { useMasterKey: true });
+        result.created++;
+      }
+    } catch (err: any) {
+      result.errors.push(`roomId=${room.roomId}: ${err.message}`);
+    }
+  }
+
+  console.log(`[Sync] 完成 — 新建 ${result.created}, 更新 ${result.updated}, 错误 ${result.errors.length}`);
+  return result;
+}

+ 226 - 0
backend/backend/src/apps/pc/qiwe/services/webhook.service.ts

@@ -0,0 +1,226 @@
+import Parse from '../../../../shared/db/parse-client.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 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();
+
+  if (existing) {
+    if (existing.get('status') === 'left' && extra.status === 'active') {
+      existing.set('status', 'active');
+      existing.set('leftAt', null);
+    }
+    Object.entries(extra).forEach(([k, v]) => { if (v !== undefined) existing.set(k, v); });
+    return existing.save(null, { useMasterKey: true });
+  }
+
+  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 handleGroupCreate(event: CallbackEvent) {
+  const roomId = String(event.fromRoomId);
+  const guid = event.guid;
+  const timestamp = new Date(event.timestamp * 1000);
+
+  await upsertGroupChat(roomId, guid, {
+    ownerId: String(event.senderId),
+    memberCount: 0,
+  });
+
+  const memberIds = decodeBase64List(event.base64RawData || event.msgData?.changedMemberList || '');
+  for (const userId of memberIds) {
+    await upsertGroupMember(roomId, userId, guid, {
+      status: 'active',
+      joinedAt: timestamp,
+    });
+  }
+
+  console.log(`[群创建] roomId=${roomId} 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 || '');
+  for (const userId of memberIds) {
+    await upsertGroupMember(roomId, userId, guid, {
+      status: 'active',
+      joinedAt: timestamp,
+    });
+  }
+
+  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 || '');
+  for (const userId of memberIds) {
+    await upsertGroupMember(roomId, userId, guid, {
+      status: 'left',
+      leftAt: timestamp,
+    });
+  }
+
+  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,
+  });
+
+  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 handleTextMessage(event: CallbackEvent) {
+  const query = new Parse.Query('Message');
+  query.equalTo('msgUniqueIdentifier', event.msgUniqueIdentifier);
+  const existing = await query.first({ useMasterKey: true });
+  if (existing) return;
+
+  const isGroup = event.fromRoomId && event.fromRoomId !== 0;
+
+  const msgData = event.msgData || {};
+  const obj = new Parse.Object('Message');
+  obj.set('msgUniqueIdentifier', event.msgUniqueIdentifier);
+  obj.set('roomId', isGroup ? String(event.fromRoomId) : null);
+  obj.set('senderId', String(event.senderId));
+  obj.set('receiverId', event.receiverId ? String(event.receiverId) : null);
+  obj.set('content', msgData.content || '');
+  obj.set('atList', msgData.atList || []);
+  obj.set('msgType', event.msgType);
+  obj.set('isGroupChat', isGroup ? 1 : 0);
+  obj.set('timestamp', new Date(event.timestamp * 1000));
+  obj.set('guid', event.guid);
+  await obj.save(null, { useMasterKey: true });
+
+  const tag = isGroup ? `roomId=${event.fromRoomId}` : '私聊';
+  console.log(`[文本消息] ${tag} sender=${event.senderId} content=${(msgData.content || '').slice(0, 50)}`);
+}
+
+export async function processWebhookEvent(event: CallbackEvent): Promise<void> {
+  const { cmd, msgType } = event;
+
+  // 普通消息
+  if (cmd === 15000) {
+    if (msgType === 0 || msgType === 2) {
+      await handleTextMessage(event);
+      return;
+    }
+    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) { console.log(`[群主转让] roomId=${event.fromRoomId}`); 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; }
+  }
+
+  // 系统消息 — 暂不处理明细,只记录日志
+  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}`);
+    return;
+  }
+
+  // API异步消息
+  if (cmd === 20000) {
+    console.log(`[API异步] guid=${event.guid}`);
+    return;
+  }
+
+  console.log(`[未处理] cmd=${cmd} msgType=${msgType}`);
+}

+ 16 - 0
backend/backend/src/index.ts

@@ -0,0 +1,16 @@
+import 'dotenv/config';
+
+import './shared/db/parse-client.js';
+import { ensureSchemas } from './shared/db/schema-setup.js';
+import { bootstrapAuth } from './apps/pc/auth/services/auth.service.js';
+
+async function bootstrap(): Promise<void> {
+  console.log('[Setup] 检查 Parse Schema...');
+  await ensureSchemas();
+  console.log('[Setup] Schema 检查完毕');
+
+  await bootstrapAuth();
+}
+
+await bootstrap();
+await import('./apps/pc/health/server.js');

+ 13 - 0
backend/backend/src/shared/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;
+}

+ 8 - 0
backend/backend/src/shared/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;

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

@@ -0,0 +1,37 @@
+import Parse from './parse-client.js';
+import { getStringEnv } from '../config/env.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';
+
+  try {
+    const [groupChat, groupMember, message] = await Promise.all([
+      new Parse.Query('GroupChat').count({ useMasterKey: true }),
+      new Parse.Query('GroupMember').count({ useMasterKey: true }),
+      new Parse.Query('Message').count({ useMasterKey: true }),
+    ]);
+
+    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 };
+  }
+}

+ 94 - 0
backend/backend/src/shared/db/schema-setup.ts

@@ -0,0 +1,94 @@
+import Parse from './parse-client.js';
+
+function isSchemaExistsError(msg: string): boolean {
+  return (
+    msg.includes('already exists')
+    || msg.includes('Class already exists')
+    || msg.includes('exists, cannot update')
+  );
+}
+
+async function ensureClass(
+  className: string,
+  fields: Record<string, string>,
+  indexName?: string,
+  indexDef?: Record<string, number>,
+): Promise<void> {
+  const schema = new Parse.Schema(className);
+  for (const [name, type] of Object.entries(fields)) {
+    schema.addField(name, type as 'String' | 'Number' | 'Date' | 'Array');
+  }
+  if (indexName && indexDef) {
+    schema.addIndex(indexName, indexDef);
+  }
+
+  try {
+    await schema.update({ useMasterKey: true });
+    console.log(`[Schema] ${className} — updated`);
+  } catch (err: unknown) {
+    const msg = err instanceof Error ? err.message : String(err);
+    if (isSchemaExistsError(msg)) {
+      console.log(`[Schema] ${className} — already exists`);
+      return;
+    }
+    try {
+      await schema.save({ useMasterKey: true });
+      console.log(`[Schema] ${className} — created`);
+    } catch (saveErr: unknown) {
+      const saveMsg = saveErr instanceof Error ? saveErr.message : String(saveErr);
+      if (isSchemaExistsError(saveMsg)) {
+        console.log(`[Schema] ${className} — already exists`);
+      } else {
+        console.error(`[Schema] ${className} error:`, saveMsg);
+      }
+    }
+  }
+}
+
+export async function ensureSchemas(): Promise<void> {
+  await ensureClass(
+    'GroupChat',
+    { roomId: 'String', roomName: 'String', ownerId: 'String', memberCount: 'Number', avatarUrl: 'String', status: 'String', guid: 'String' },
+    'roomId_guid',
+    { roomId: 1, guid: 1 },
+  );
+
+  await ensureClass(
+    'GroupMember',
+    { roomId: 'String', userId: 'String', nickname: 'String', status: 'String', joinedAt: 'Date', leftAt: 'Date', guid: 'String' },
+    'roomId_userId_guid',
+    { roomId: 1, userId: 1, guid: 1 },
+  );
+
+  await ensureClass(
+    'Message',
+    { msgUniqueIdentifier: 'String', roomId: 'String', senderId: 'String', receiverId: 'String', content: 'String', atList: 'Array', msgType: 'Number', isGroupChat: 'Number', timestamp: 'Date', guid: 'String' },
+    'msgUniqueIdentifier',
+    { msgUniqueIdentifier: 1 },
+  );
+
+  await ensureClass(
+    'AppUser',
+    {
+      email: 'String',
+      passwordHash: 'String',
+      passwordSalt: 'String',
+      name: 'String',
+      phone: 'String',
+      role: 'String',
+      storeId: 'String',
+      storeName: 'String',
+      department: 'String',
+      position: 'String',
+    },
+    'email_unique',
+    { email: 1 },
+  );
+
+  await ensureClass(
+    'AuthSession',
+    { token: 'String', userId: 'String', expiresAt: 'Date' },
+    'token_unique',
+    { token: 1 },
+  );
+}

+ 14 - 0
backend/backend/src/shared/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/backend/src/shared/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/backend/src/shared/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/backend/src/shared/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/backend/src/shared/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);
+}

+ 30 - 0
backend/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/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*

+ 932 - 0
doc/UI设计规范白皮书.md

@@ -0,0 +1,932 @@
+# UI 设计规范白皮书
+
+> **版本**:v1.0
+> **适用范围**:B 端后台管理系统 / SaaS 平台 / 数据可视化产品
+> **核心理念**:一致性、效率、可控、可访问
+
+---
+
+## 目录
+
+1. [设计原则](#1-设计原则)
+2. [设计方法论:原子化设计](#2-设计方法论原子化设计)
+3. [色彩系统](#3-色彩系统)
+4. [字体系统](#4-字体系统)
+5. [间距与栅格系统](#5-间距与栅格系统)
+6. [圆角与边框](#6-圆角与边框)
+7. [按钮](#7-按钮)
+8. [导航体系](#8-导航体系)
+9. [页面层级与布局](#9-页面层级与布局)
+10. [数据录入](#10-数据录入)
+11. [数据展示](#11-数据展示)
+12. [表格设计](#12-表格设计)
+13. [反馈系统](#13-反馈系统)
+14. [数据可视化](#14-数据可视化)
+15. [附录:设计稿交付规范](#15-附录设计稿交付规范)
+
+---
+
+## 1. 设计原则
+
+### 1.1 一致性(Consistency)
+
+- **视觉一致性**:颜色、字体、间距、图标等设计基础元素在全局范围内保持统一。
+- **交互一致性**:同类操作的行为预期保持一致。例如,所有弹窗的关闭方式、所有表格的排序方式应当遵循相同模式。
+- **文案一致性**:相同功能的命名、提示文案在不同页面中保持一致。
+- **结构一致性**:页面中相同层级、相同类型的元素在结构上保持一致。
+
+**价值**:一致性确保多设计师协作时代码复用高效,用户体验连贯,产品形象专业。当设计组件库建成后,设计师只需关注业务逻辑差异,而非重复定义基础样式。
+
+### 1.2 效率优先(Efficiency)
+
+- **简洁至上**:简化操作流程,减少非必要步骤。
+- **信息明确**:文案表达准确直白,让用户快速理解当前状态和可执行的操作。
+- **页面简洁直接**:用户无需记忆即可识别功能位置。面包屑导航、Tab 切换等方式帮助用户时刻清楚"我在哪里"。
+- **高频操作突出**:将用户使用频率最高的操作置于最易触达的位置。
+
+### 1.3 可控性(Controllability)
+
+- **用户掌控**:不应代替用户做决策。重要操作必须有明确的确认步骤。
+- **安全提示**:删除、下架等破坏性操作必须二次确认。
+- **可撤销**:尽量提供撤销能力,降低用户焦虑。
+- **状态反馈**:用户的操作应得到即时、清晰的系统反馈。
+
+### 1.4 状态可见性(Status Visibility)
+
+系统应当始终让用户知晓当前状态:
+
+- **操作前**:明确告知当前所处状态,即将发生什么。
+- **操作中**:操作过程给予即时反馈(如 loading、进度条)。
+- **操作后**:通过页面元素变化展示当前结果状态(如提交成功提示、列表更新)。
+- **页面级**:通过全局提示(Toast)、页面内元素变化展示当前状态。
+
+---
+
+## 2. 设计方法论:原子化设计
+
+本规范采用 **原子化设计(Atomic Design)** 作为方法论基础,将设计系统按粒度分为五个层级:
+
+```
+原子(Atom) →  分子(Molecule) →  组织(Organism) →  模板(Template) →  页面(Page)
+```
+
+| 层级 | 定义 | 示例 |
+|------|------|------|
+| **原子** | 最小不可分割的 UI 单元 | 颜色、字体、图标、圆角、分割线 |
+| **分子** | 由原子组合而成的简单组件 | 按钮、输入框、选择器、标签 |
+| **组织** | 由分子组合而成的功能模块 | 表格工具栏、表单区域、导航菜单 |
+| **模板** | 由组织组合而成的页面骨架 | 列表页模板、详情页模板 |
+| **页面** | 填充真实数据后的最终形态 | 用户列表页、订单详情页 |
+
+### 通用层 vs 业务层
+
+- **通用层**:适用于所有产品的原子/分子级元素(颜色、字体、按钮、输入框、栅格等),是规范的核心。
+- **业务层**:特定业务场景下形成的模板和页面规范(如插画体系、业务标签、Banner 等)。
+
+**核心原则**:通用层的东西需要严格遵循,不建议随意重新定义。优先使用成熟的组件库(如 Ant Design、Element UI),在此基础上进行定制化调整,而非从零造轮子。
+
+---
+
+## 3. 色彩系统
+
+### 3.1 色彩架构
+
+```
+ 品牌色/主色
+    └── 浅色变体(不同透明度层级)
+    └── 深色变体(不同明度层级)
+ 功能色
+    ├── 成功色(绿)
+    ├── 警告色(橙/黄)
+    ├── 错误色(红)
+    └── 信息色(蓝)
+ 中性色
+    ├── 文字色(不同灰阶)
+    ├── 背景色
+    ├── 分割线色
+    └── 边框色
+```
+
+### 3.2 品牌色/主色选择
+
+- **推荐冷色系**(蓝色系为最常用选择)。
+- 避免过于高饱和度的颜色——B 端产品用户长时间使用,高饱和度会导致视觉疲劳。
+- 避免过于低饱和度——过于暗淡会导致页面沉闷压抑。
+- 品牌色应当**适度饱和、明度适中**。
+
+**使用场景**:
+- 主按钮(Primary Button)
+- 链接文字
+- 选中态(Tab 选中、Checkbox/Radio 选中、开关开启态)
+- 关键操作提示
+- 标签页选中下划线
+
+**全局替换能力**:在 Sketch / Figma 中使用 Symbol 或 Color Style 管理颜色。当需要整体更换主色时,可通过"编辑 → 查找替换颜色"功能,一键替换所有页面中该颜色的所有透明度实例。
+
+### 3.3 功能色
+
+| 功能 | 推荐色相 | 使用场景 |
+|------|---------|----------|
+| **成功** | 绿色 | 操作成功提示、状态标签-已完成、通过状态 |
+| **警告** | 橙色/黄色 | 即将到期提醒、注意事项、待处理状态 |
+| **错误** | 红色 | 操作失败、表单验证错误、删除确认、危险操作 |
+| **信息** | 蓝色 | 普通信息提示、帮助说明 |
+
+**记忆口诀**:绿成功、橙警告、红错误——对应日常生活中的交通信号灯逻辑,直观易记。
+
+### 3.4 中性色
+
+中性色是页面中使用最多的色系,主要包括:
+
+| 用途 | 说明 |
+|------|------|
+| **文字色** | 主文字(#333 左右深度)、次要文字、禁用/占位文字 |
+| **背景色** | 页面底色、卡片底色、表头底色 |
+| **分割线/边框色** | 表格分割线、卡片边框、输入框边框 |
+| **浅底色** | hover 态底色、选中行底色 |
+
+### 3.5 颜色推导方法
+
+**同色系浅色**:以品牌色为基础,叠加不同透明度的白色(#FFFFFF),得到不同深浅层次。
+
+**同色系中性色文字**:以纯黑(#000000)为基础,叠加不同透明度,得到不同灰度的文字颜色。此方法可保证所有文字色同属一个色系,视觉上更加统一干净。
+
+**色彩微调技巧**:中性色(尤其是背景色和分割线色)可略微偏向冷色调,使页面视觉上更加清爽干净。如灰色偏蓝比偏暖更显洁净。
+
+### 3.6 颜色格式注意事项
+
+- 设计软件中的颜色模式务必设置为 **sRGB**(而非默认的"非托管"模式)。
+- 在 Sketch 中:文件 → 更改颜色描述文件 → 选择 sRGB。
+- 若不做此设置,导出的颜色值与设计稿中的颜色值会出现明显偏差。
+
+### 3.7 暗色模式注意事项
+
+- 暗色模式下的品牌色需要适当提高明度/饱和度,保证在深色背景上的可读性。
+- 文字与背景的对比度必须满足 WCAG AA 级标准。
+
+---
+
+## 4. 字体系统
+
+### 4.1 字体家族
+
+| 平台 | 中文 | 英文/数字 |
+|------|------|-----------|
+| **macOS** | 苹方(PingFang SC) | SF Pro / San Francisco |
+| **Windows** | 微软雅黑(Microsoft YaHei) | Segoe UI |
+
+**CSS Fallback 建议**:
+```css
+font-family: -apple-system, BlinkMacSystemFont, "PingFang SC",
+             "Microsoft YaHei", "Helvetica Neue", Arial, sans-serif;
+```
+(注:macOS 系统下 Regular 对应 font-weight 400,Medium 对应 500,Semibold 对应 600)
+
+### 4.2 字号规范
+
+| 层级 | 字号 | 用途 |
+|------|------|------|
+| **大标题** | 20px | 页面大标题(极少使用) |
+| **页面标题** | 18px | 页面级标题 |
+| **内容标题** | 16px | 区块标题、卡片标题 |
+| **正文** | **14px**(主推荐) | 表格正文、表单标签、正文内容 |
+| **辅助文字** | 12px | 说明文字、水印、标签 |
+
+**核心建议**:
+- 正文以 **14px** 为主,这是 B 端产品的基准字号。该字号在各种分辨率屏幕上有较好的可读性。
+- 同一页面中**字号不超过 3 种**,通过颜色、粗细、间距来区分层级。
+- 16px 可用于信息密度较低的页面(如 Dashboard 概览页)。
+
+### 4.3 行高
+
+| 字号 | 推荐行高 | 说明 |
+|------|---------|------|
+| 20px | 1.3 ~ 1.5 | 标题 |
+| 18px | 1.3 ~ 1.5 | 页面标题 |
+| 16px | 1.5 | 内容标题、卡片内正文 |
+| **14px** | **1.5** | 基准正文行高 |
+| 12px | 1.3 ~ 1.5 | 辅助文字 |
+
+- **推荐行高 1.5 倍**作为通用标准,在大多数场景下视觉舒适,且字数完整可见。
+- 若 1.5 倍行高无法完全显示时,可微调到恰好包含完整一行,保持参考值 1.5 不被破坏。
+
+### 4.4 字重
+
+- 常规正文:Regular(400)
+- 强调/小标题:Medium(500)
+- 标题/重点突出:Semibold(600)
+- 避免在同一页面中混用过多字重。
+
+---
+
+## 5. 间距与栅格系统
+
+### 5.1 基准间距:8px 体系
+
+采用 **8px 间距体系**(以 8 为步长递增):
+
+```
+4px    — 极小间距(紧密关联元素)
+8px    — 基础间距(最常用)
+12px   — 辅助间距
+16px   — 中等间距
+24px   — 大间距(模块分割)
+32px   — 超大间距
+48px   — 页面级间距
+```
+
+- 8 能被当前主流屏幕宽度(1920、1440、1366、1280、1024)整除,在不同分辨率下布局规整。
+- 4px 间距过于细密,视觉上显杂乱,8px 恰到好处。
+- Sketch/Figma 中可将 Nudge Amount 设为 8px(按住 Shift + 方向键每次移动 8px)。
+
+### 5.2 24 栅格系统
+
+**推荐使用 24 栅格(24-column Grid)**。
+
+```
+页面宽度 = 24 × 列宽 + 23 × 水槽宽 + 2 × 页边距
+```
+
+**为什么是 24 栅格?**
+- 24 可以轻易实现 2 等分、3 等分、4 等分、6 等分、8 等分、12 等分。
+- 12 栅格无法实现 8 等分,灵活度不如 24 栅格。
+- 更细的栅格粒度可以应对更复杂的模块划分场景。
+
+**栅格术语**:
+- **列(Column)**:内容区域
+- **水槽(Gutter)**:列与列之间的间距
+- **页边距(Margin)**:栅格容器与页面边缘的距离
+
+### 5.3 栅格的分层定位
+
+**栅格属于内容层(Content Layer)**,而非全局层。它约束的是数据内容的布局,不包括全局导航、侧边栏等固定元素。
+
+```
+ Z 轴层级(由底向上):
+   ├── 背景层(Background)
+   ├── 全局控制层(Global Control) — 顶部导航、左侧菜单
+   ├── 栅格约束下的内容层(Content with Grid)
+   │    ├── 数据过滤区
+   │    ├── 数据展示区(表格/卡片)
+   │    └── 分页区
+   └── 弹窗/浮层(Modal/Overlay)
+```
+
+### 5.4 模块划分规范
+
+在 24 栅格下推荐模块拆分方式:
+
+| 布局模式 | 栅格分配 | 适用场景 |
+|---------|---------|---------|
+| 2 等分 | 12 + 12 | 对照展示 |
+| 3 等分 | 8 + 8 + 8 | 卡片列表 |
+| 4 等分 | 6 + 6 + 6 + 6 | 数据卡片 |
+| 左窄右宽 | 6 + 18 或 8 + 16 | 筛选 + 内容 |
+| 左宽右窄 | 18 + 6 | 内容 + 辅助信息 |
+| 不等分 | 9 + 15 | 表格 + 详情面板 |
+
+**灵活的模块划分不只是等分**——根据信息量和业务需要,可以使用不同比例的组合,通过栅格规范控制视觉秩序。
+
+### 5.5 响应式策略
+
+**B 端后台产品推荐做法**:
+- **定宽布局为主**:内容区设置固定宽度(如 1200px / 1440px),两侧自适应留白。
+- 当屏幕宽度缩小时,先收缩两侧页边距,内容区宽度保持不变。
+- 当页边距收缩到最小值后,再考虑内容区的弹性变化。
+- 对于 B 端复杂业务系统,不建议采用完全弹性响应式——实现成本高且移动端操作效率低。
+
+**设计稿设计宽度建议**:**1440px**(兼顾主流分辨率 1366、1440、1920,向上向下缩放均不会过于紧张或空旷)。
+
+---
+
+## 6. 圆角与边框
+
+### 6.1 圆角规范
+
+| 元素类型 | 推荐圆角 | 说明 |
+|---------|---------|------|
+| 小按钮/标签 | 2px ~ 4px | 精致克制 |
+| 标准按钮/输入框 | 4px | 通用标准 |
+| 卡片容器 | 8px | 柔和过渡 |
+| 弹窗 | 8px ~ 12px | 与卡片保持一致 |
+
+**B 端产品建议**:
+- 避免使用大圆角(如 16px+ 或全圆角 999px),过大的圆角在 B 端严谨场景下会产生不协调感。
+- 避免使用全圆角按钮(Pill Button)——识别效率不如微圆角,在 PC 后台大量出现时影响专业感。
+- 圆角的一致性要保持全局统一——同一类元素使用相同圆角。
+
+### 6.2 边框
+
+- 边框统一设为 **1px**。
+- 边框颜色使用浅灰中性色,不宜过深。
+- 主要使用场景:输入框、卡片、表格单元格边界。
+
+---
+
+## 7. 按钮
+
+### 7.1 按钮尺寸
+
+| 尺寸 | 高度 | 适用场景 |
+|------|------|---------|
+| **小按钮(Small)** | 24px | 表格内操作、紧凑区域 |
+| **标准按钮(Default)** | 32px | 表单区域、工具栏 |
+| **大按钮(Large)** | 40px | 页面主操作、登录页、空状态引导 |
+
+- 按钮宽度适配文字内容 + 固定内边距(通常左右 16px)。
+- 按钮文字**不宜过长**(控制在 2~4 个字),过长会影响操作效率。
+- 最小宽度应保证至少能容纳 2 个中文字的宽度。
+
+### 7.2 按钮类型与层级
+
+| 类型 | 视觉权重 | 使用场景 | 每屏建议数量 |
+|------|---------|---------|------------|
+| **主按钮(Primary)** | 最高 | 页面最主要操作(新建、保存、提交) | 1 个 |
+| **次按钮(Default)** | 中等 | 辅助操作(取消、重置、导出) | 不限 |
+| **文字按钮(Text)** | 最低 | 表格内操作、次要功能入口 | 不限 |
+| **危险按钮(Danger)** | 特殊 | 删除、下架等破坏性操作 | 按需 |
+| **虚线按钮(Dashed)** | 低 | 新增项、占位入口 | 按需 |
+| **图标按钮(Icon-only)** | 低 | 空间受限的操作(表格行操作) | 建议少用 |
+
+**推荐做法**:
+- 每个操作区域**仅设置一个主按钮**,其余为次按钮或文字按钮,形成清晰的视觉层级。
+- 谨慎使用纯图标按钮——尽量保留文字标签,降低认知成本。仅在空间极度受限的场景(如表格行内操作)使用图标按钮。
+- 当按钮数量较多时,按视觉权重排列。
+
+### 7.3 按钮状态
+
+必须涵盖以下状态:
+
+| 状态 | 说明 |
+|------|------|
+| **默认态(Default)** | 正常可见状态 |
+| **悬停态(Hover)** | 鼠标悬停时(叠加黑色遮罩层,透明度通常 10%~20%) |
+| **点击态(Active)** | 鼠标按下时(叠加更深遮罩层或色相微调) |
+| **禁用态(Disabled)** | 不可点击时(降低透明度或灰度化) |
+| **加载态(Loading)** | 提交/保存过程中(显示 loading 动画,同时禁用点击) |
+
+> **Hover 态推导方法**:在默认色上叠加一层黑色遮罩(10%~20% 透明度),适用于所有按钮类型,无需为每种按钮单独设计 hover 色。
+
+### 7.4 按钮位置与阅读顺序
+
+- **页面级操作按钮**:通常位于页面内容区左上角或右上角。
+- **表单底部按钮**:靠左或靠右排列均可。
+- **阅读顺序**:从左到右,用户先看到主操作(左/右对齐均可,但需要在该对齐方式下按重要性排列)。
+- **弹窗按钮**:通常右对齐。
+
+**关键原则**:同一产品中,按钮排列方式应当统一。当页面同时存在多个按钮时,最重要的按钮是第一眼能看到的。
+
+---
+
+## 8. 导航体系
+
+### 8.1 导航模式对比
+
+| 导航模式 | 适用场景 | 优点 | 缺点 |
+|---------|---------|------|------|
+| **顶部导航** | 功能简单(≤7 个一级菜单) | 不占内容空间,阅读流自然 | 扩展性差,不支持多级 |
+| **侧边导航** | 后台系统(最常用) | 扩展性强,层级清晰,支持深层级 | 占用横向空间 |
+| **混合导航** | 超大型系统(一级菜单 5~6 个以上、模块众多) | 最强扩展性 | 视觉复杂,开发难度高 |
+
+### 8.2 导航模式选择指南
+
+```
+功能数量少、结构简单
+  └── 顶部导航
+
+功能多、多层级(大多数 B 端后台)
+  └── 侧边导航(推荐)
+
+超大型 SaaS 平台(一级模块 6 个以上,每模块下数十子级)
+  └── 混合导航(L 型:顶栏一级 + 侧栏二级/三级)
+```
+
+### 8.3 侧边导航设计规范
+
+**展开与折叠**:
+- 默认展开,支持折叠为图标+文字提示的模式。
+- 折叠后 hover 时展开完整菜单(浮层方式)。
+- 展开状态和折叠状态需明确切换入口(通常为底部折叠按钮)。
+
+**菜单层级**:
+- 最多 3 级,超过 3 级时考虑重新组织信息架构。
+- 选中态:文字颜色变化 + 背景色高亮(浅色底),在当前层级深入时展开子级。
+
+**视觉风格**:
+- 浅色侧边栏:白色/浅灰底色,菜单项通过文字颜色 + 浅底色区分状态。
+- 深色侧边栏:深色底色(如 #001529),白色/浅色文字,适合信息密度高的大型系统。
+
+**侧边栏宽度**:
+- 展开态:200px ~ 240px(过宽浪费内容空间,过窄容纳不下菜单文字)。
+- 折叠态:64px(仅展示图标)。
+
+### 8.4 面包屑导航
+
+- **使用场景**:二级及以上页面,帮助用户定位当前位置和返回路径。
+- **位置**:页面内容区顶部,标题上方或标题同一行。
+- **交互**:除当前页外,各级均可点击跳转。
+- 如果系统层级扁平(仅两层),可以不使用面包屑,直接使用返回按钮。
+
+### 8.5 标签页导航
+
+- **使用场景**:同级数据视图切换、详情页多 Tab 信息展示。
+- **位置**:内容区顶部(最常见),可在侧边放置(较少见)。
+- **状态**:选中态使用品牌色下划线 + 品牌色文字。
+- **溢出处理**:Tab 数量过多时可横向滚动或折叠为下拉。
+
+---
+
+## 9. 页面层级与布局
+
+### 9.1 Z 轴层级架构
+
+```
+ ┌────────────────────────────┐
+ │   弹窗/浮层 (Modal)         │  ← 最高层
+ ├────────────────────────────┤
+ │   局部弹窗/抽屉 (Drawer)    │
+ ├────────────────────────────┤
+ │   内容区 (Content Area)     │
+ │   ├── 数据过滤区            │
+ │   ├── 数据展示区            │
+ │   └── 分页区               │
+ ├────────────────────────────┤
+ │   全局控制层 (Global Nav)   │  ← 固定
+ │   ├── 顶部导航              │
+ │   └── 侧边导航              │
+ └────────────────────────────┘
+```
+
+### 9.2 全局控制层
+
+- 包含顶部导航栏、侧边导航菜单、用户信息区。
+- 该层在页面切换时保持不变。
+- 内容区为该层让出空间,两者不重叠。
+
+### 9.3 局部弹层
+
+| 类型 | 层级 | 使用场景 |
+|------|------|---------|
+| **全局弹窗(Modal)** | 全局控制层之上 | 跨页面操作、重要确认 |
+| **局部弹窗(Popover)** | 内容区之上 | 表格内编辑、快速查看 |
+| **抽屉(Drawer)** | 内容区之上 | 详情查看、复杂表单编辑 |
+
+### 9.4 分页
+
+- **位置**:表格/列表内容区的底部,通常右对齐或居中对齐。
+- **默认每页条数**:10~20 条(推荐 15 条),用户可自定义。
+- **分页器包含**:总条数、每页条数选择、页码跳转、上一页/下一页。
+- **为何使用分页而非无限滚动**:分页减少首次加载的数据量,降低前后端压力;且支持用户跨页选择数据的场景。
+- **何时可增大每页条数**:批量操作场景(如需要全选当前页做批量处理),可设 50~100 条/页。
+
+---
+
+## 10. 数据录入
+
+### 10.1 录入组件决策树
+
+逐层判断选择最合适的录入组件:
+
+```
+需要录入什么?
+├── 纯文本/数字
+│   └── 输入框(Input)
+│       ├── 单行 → Input
+│       ├── 多行 → Textarea
+│       └── 数字范围 → InputNumber(或滑块)
+│
+├── 从已有选项中选择
+│   ├── 选项是否分组?
+│   │   ├── 无分组
+│   │   │   ├── 选项少(≤5)→ Radio(单选)/ Checkbox(多选)
+│   │   │   ├── 选项多(>5)→ Select 下拉
+│   │   │   └── 选项极多(>20)→ Select + 搜索
+│   │   └── 有分组
+│   │       ├── 组少 + 每组选项少 → 平铺 Radio/Checkbox
+│   │       ├── 组少 + 每组选项多 → 级联选择(Cascader)
+│   │       ├── 组多 + 每组选项少 → 树选择(TreeSelect)
+│   │       └── 组多 + 每组选项多 + 多层级 → 穿梭框(Transfer)
+│   │
+│   └── 是否/开关类
+│       └── Switch 开关(比 Checkbox 更直觉)
+│
+├── 日期/时间
+│   ├── 选择日期 → DatePicker
+│   ├── 选择日期范围 → RangePicker
+│   ├── 选择时间 → TimePicker
+│   └── 选择日期+时间 → DateTimePicker
+│
+├── 数值范围
+│   └── Slider(滑块)/ InputNumber 范围
+│
+└── 文件/图片
+    └── Upload(上传组件)
+```
+
+### 10.2 表单(Form)设计规范
+
+**标签对齐**:推荐**右对齐(冒号对齐)**的标签方式。在 B 端表单中,右对齐的标签使标签与输入框的距离最紧凑,视觉扫描效率最高。
+
+**字段间距**:
+- 标签与输入框之间:8px
+- 字段与字段之间(垂直):24px
+- 相关字段组之间:16px
+
+**表单按钮位置**:
+- 通常置于表单底部。
+- 主按钮(提交/保存)在左或右均可,需全局统一。
+
+**必填标识**:必填字段标签前加红色星号 `*`,或在标签后标注"(必填)"。
+
+**表单分步**:当表单字段过多时,使用**分步表单(Steps)**将录入过程拆分为多个步骤。
+
+### 10.3 选择器规范
+
+- **单选(Radio)/ 多选(Checkbox)**:选项较少(≤8 个)时,直接平铺展示,减少用户点击次数。
+- **下拉选择(Select)**:选项较多(>8 个)时使用。超过可视范围可滚动。支持搜索过滤。
+- **级联选择(Cascader)**:选项具有明确层级关系时使用。
+- **穿梭框(Transfer)**:选项多且带分组、需要对比"已选/未选"时使用。
+- **单选支持取消选中**:Radio 组默认支持点击已选项取消选中(提升操作容错率)。
+
+### 10.4 开关(Switch)
+
+- 适用于即时生效的布尔型设置(如开启/关闭某功能)。
+- 交互直觉比下拉选择"是/否"更直接。
+- 状态变化即时生效,不额外需要"确认"操作。
+
+### 10.5 滑块(Slider)
+
+- 适用于数值范围选择(如价格区间、评分区间)。
+- 可带刻度标记、输入框联动。
+
+### 10.6 文件上传
+
+| 状态 | 说明 |
+|------|------|
+| **上传前** | 默认区域 + 上传按钮 |
+| **上传中** | 进度条展示 |
+| **上传完成** | 缩略图/文件名展示 + 删除按钮 |
+| **上传失败** | 错误提示 + 重试按钮 |
+| **拖拽上传** | 支持拖拽文件到指定区域(开发成本高,可按需实现) |
+
+---
+
+## 11. 数据展示
+
+### 11.1 数据展示组件层次
+
+从小到大,数据展示容器逐级扩展:
+
+```
+徽标数(Badge)           → 最小信息单元,用于计数
+  └── 标签(Tag)         → 状态/分类标记
+       └── 数据卡片(Card)   → 结构化展示一组相关信息
+            └── 表格(Table)        → 大规模结构化数据展示
+```
+
+### 11.2 标签(Tag)
+
+**类型**:
+- **实心标签**:彩色填充,视觉权重高,用于强状态标识。
+- **线框标签**:仅边框+文字,视觉权重低,适合辅助标记。
+- **可删除标签**:带关闭图标,用于用户自定义标签场景。
+
+**尺寸**:小/中/大,与按钮的尺寸体系保持一致。
+
+**颜色**:使用品牌色、功能色的浅色变体作为填充色,深色或品牌色作为边框/文字色。
+
+### 11.3 提示(Tooltip)
+
+- **触发方式**:hover 显示,移开消失。
+- **内容**:辅助说明文字,不长于 2~3 行。
+- **位置**:跟随触发元素,12px 间距。
+- **用途**:解释专业术语、补充字段说明、展开被截断的文本。
+
+### 11.4 数据卡片(Card)
+
+- **用途**:Dashboard 指标卡、列表项卡、详情信息卡。
+- **内容**:标题 + 数值 + 辅助描述 + 可选操作入口。
+- **交互**:hover 时可显示更多操作入口。
+- **宽度**:建议固定宽度或按栅格自适应。
+
+### 11.5 空状态(Empty State)
+
+- 无数据时必须展示空状态插图和引导文案。
+- 引导文案应包含:为什么是空的 + 可以做什么(如"暂无数据,点击新建")。
+
+---
+
+## 12. 表格设计
+
+### 12.1 表格基础结构
+
+```
+┌──────────────────────────────────────────┐
+│  表格工具栏(Toolbar)                     │
+│  ┌──────────┬──────────┬──────────┐      │
+│  │  表头1   │  表头2   │  表头3   │      │
+│  ├──────────┼──────────┼──────────┤      │
+│  │  单元格  │  单元格  │  单元格  │      │
+│  ├──────────┼──────────┼──────────┤      │
+│  │  单元格  │  单元格  │  单元格  │      │
+│  └──────────┴──────────┴──────────┘      │
+│  分页器                                  │
+└──────────────────────────────────────────┘
+```
+
+**术语定义**:
+- **表头(Header)**:每列顶部标题行。
+- **行(Row)**:横向数据单元。
+- **列(Column)**:纵向数据字段。
+- **单元格(Cell)**:行与列交叉的基本单元。
+
+### 12.2 表格视觉样式
+
+| 样式类型 | 特点 | 适用场景 |
+|---------|------|---------|
+| **极简样式** | 无竖分割线,仅横向浅色分割线 | 最常用,视觉干净,信息干扰少 |
+| **斑马纹样式** | 奇偶行背景色交替 | 列数多、数据密集时辅助横线阅读 |
+| **带边框样式** | 全网格线(横+竖) | 数据极为复杂、需要精确对位时 |
+| **无分割线样式** | 仅靠间距区分行 | 数据列少、行数少时(极度简洁) |
+
+**推荐**:以极简样式(横向浅色分割线)为默认方案,满足大多数场景。hover 时高亮当前行(浅底色)。
+
+**分割线设计原则**:"轻盈"——分割线颜色要浅,不要抢夺用户对数据的注意力。它的作用是"引导视线,区分行",而非"成为视觉焦点"。
+
+### 12.3 表头设计
+
+- **分组表头**:当列之间存在包含关系时,使用表头分组(父表头 + 子表头),加上较明显的边框线分隔组。
+- **表头文字**:简洁、专业。避免冗长描述,必要时使用 Tooltip 补充说明。
+- **专业术语的处理**:复杂表头可自定义简短名称,hover 时通过 Tooltip 展示完整解释。
+- **排序图标**:表头支持排序时显示排序箭头,默认大小排序,支持从小到大/从大到小切换。
+
+### 12.4 单元格设计
+
+**单元格高度**:
+- 由字号 + 行高 + 上下内边距组成。
+- 单行数据:一行文字高度 + 上下内边距(14px 字号 + 1.5 行高 + 上下各约 8~10px padding)。
+- 多行数据:内容行数 × 行高 + 上下内边距。
+
+**单元格宽度**:
+- 根据内容类型和长度设置合理宽度。
+- 内容过长时的处理方式:
+  - 省略号截断 + hover Tooltip 展示完整内容。
+  - 自动换行(多行展示)。
+  - 固定宽度 + 省略号(列宽固定不变)。
+
+**内容对齐**:
+| 内容类型 | 对齐方式 | 原因 |
+|---------|---------|------|
+| 文本(名称、标题、描述等) | **左对齐** | 符合从左到右的阅读习惯 |
+| 数字(金额、数量、占比) | **右对齐** | 方便数字大小直观对比 |
+| 固定长度标识(ID、编码、手机号) | **居中对齐** | 视觉规整,无需对比 |
+| 状态标签 | **左对齐**或**居中** | 根据内容长度决定 |
+| 操作列 | **右对齐** | 视线终点自然落位操作区 |
+
+**单元格空值处理**:
+- 有数据但为空时:显示 `-`(短横杠),不要留空白。
+- 无数据(整行无意义)时:显示为空行,不作为 "0" 或 "-" 处理。
+- 关键是保持信息透明——"没有数据"和"数据是零"传达的信息完全不同。
+
+### 12.5 操作列
+
+- 操作列通常固定在表格最右侧。
+- 高频操作用文字按钮直接展示(如"编辑""查看")。
+- 低频操作收起到"更多"下拉菜单中。
+- 删除操作通常需要二次确认(弹出确认弹窗)。
+
+**操作列触发方式**:
+| 方式 | 适用场景 |
+|------|---------|
+| 行 hover 显示操作 | 操作较少时 |
+| 操作列固定展示 | 操作多且重要时 |
+| 点击行触发详情 | 行数据以"查看详情"为主操作时 |
+
+### 12.6 列宽自适应策略
+
+- **固定列宽 + 省略号**:大部分列采用此策略。
+- **弹性列**:选 1~2 个不太重要的列设为弹性宽度,填充剩余空间。
+- **最小宽度**:每列设定最小宽度(min-width),确保窗口缩小时内容不被完全挤压。
+- **固定首/尾列**:重要标识列(名称/ID)和操作列可固定在左侧或右侧,横向滚动时不移动。
+
+### 12.7 表格工具栏
+
+表格上方工具栏通常包含:
+| 区域 | 内容 |
+|------|------|
+| **左侧** | 批量操作按钮(批量删除、批量导出等) |
+| **右侧** | 新建按钮、搜索框、筛选器、导出按钮 |
+
+### 12.8 数据操作交互方式
+
+| 交互方式 | 适用场景 | 说明 |
+|---------|---------|------|
+| **行内编辑** | 单字段快速修改 | 点击单元格进入编辑态 |
+| **弹窗编辑** | 多字段编辑 | 弹出 Modal 表单 |
+| **抽屉编辑** | 复杂表单编辑 | 右侧/底部弹出 Drawer |
+| **新页面编辑** | 极复杂表单 | 跳转独立编辑页 |
+| **行 hover 操作** | 简单操作 | hover 显示操作入口 |
+
+---
+
+## 13. 反馈系统
+
+### 13.1 反馈类型总览
+
+| 类型 | 打扰程度 | 停留时间 | 是否可手动关闭 | 内容承载量 |
+|------|---------|---------|--------------|----------|
+| **即时提示(Toast)** | 低 | 短(自动消失) | 否(或可选关闭) | 少 |
+| **通知提示(Notification)** | 中 | 较长(可自动消失/手动关闭) | 是 | 较多 |
+| **告警提示(Alert)** | 中高 | 需手动关闭 | 是 | 较多 |
+| **确认弹窗(Confirm Modal)** | 高 | 停留在页面直到操作 | 是 | 最多 |
+
+### 13.2 即时提示(Toast / Message)
+
+- **位置**:页面顶部居中。在特殊情况下可放在其他位置(如下方),但需确保不遮挡关键信息。
+- **内容**:简短操作反馈(1~2 行文字)。
+- **类型**:成功(绿)、警告(橙)、错误(红)、信息(蓝)。
+- **消失时间**:自动消失,时长约 2~3 秒。
+- **触发来源**:**系统主动发出的反馈**(如保存成功、提交失败)。这类反馈的来源是系统,告知用户操作结果。
+
+### 13.3 通知提示(Notification)
+
+- **位置**:页面右上角(最典型),或根据页面内容灵活调位。
+- **内容**:较 Toast 更丰富,可包含标题、正文、操作入口。
+- **触发来源**:**系统推送的主动通知**(如"审批已通过""库存预警")。用户事先不知道,系统主动告知。
+- **关闭**:可手动关闭,也可设 n 秒后自动消失。可设置为不可关闭(需用户处理)。
+
+### 13.4 告警提示(Alert)
+
+- **位置**:通常嵌入在页面内容区顶部,或相关模块上方。
+- **样式**:可包含图标、标题、正文、操作按钮。
+- **类型**:成功、警告、错误、信息。
+- **关闭**:可设关闭按钮,也可固定展示。
+- **用途**:全局性提示(如"系统将于某时升级维护")。
+
+### 13.5 确认弹窗(Confirm Modal)
+
+- **触发方式**:用户执行破坏性操作(删除、下架等)时弹出。
+- **内容**:标题 + 说明文字 + 确认/取消按钮。
+- **核心原则**:不要让用户猜测——清晰说明操作后果。
+- **关闭**:点击"取消"或右上角关闭按钮可关闭。
+
+### 13.6 加载与进度反馈
+
+| 类型 | 使用场景 |
+|------|---------|
+| **局部 Loading** | 组件级数据加载(如表格刷新、下拉框查询) |
+| **全局 Loading** | 页面级数据加载(首次进入页面) |
+| **骨架屏(Skeleton)** | 页面框架先展示占位,数据逐步填充 |
+| **进度条(Progress)** | 文件上传、任务执行等可量化进度场景 |
+
+- B 端后台不推荐设计过于复杂炫目的 loading 动画——清晰表达"正在加载"即可。
+
+---
+
+## 14. 数据可视化
+
+### 14.1 图表选用方法论
+
+**三步骤**:
+
+1. **明确业务目标**:需要回答什么问题?比较、构成、分布、趋势、还是关系?
+2. **确定分析维度和指标**:产品/业务方给出的维度和指标是什么?
+3. **选择匹配的图表类型**:根据分析目标选择最合适的图表。
+
+### 14.2 图表选择速查表
+
+| 分析目标 | 首选图表 | 次选 | 说明 |
+|---------|---------|------|------|
+| **比较大小** | 柱状图(Bar) | 条形图(水平柱状) | 柱状图通过柱子的长短最直观地比较数值大小 |
+| **比较占比** | 饼图/环形图 | 堆叠柱状图 | 饼图适合展示各部分占整体的比例 |
+| **趋势变化** | 折线图(Line) | 面积图 | 折线图最适合展示随时间变化的趋势 |
+| **构成分布** | 饼图/环形图 | 堆叠柱状图、瀑布图 | 展示整体中各部分的构成 |
+| **排名** | 条形图(水平柱状) | 柱状图 | 条形图适合展示排名(尤其是移动端或名称较长场景) |
+| **多维度对比** | 分组柱状图/分组条形图 | 雷达图 | 同时比较多个维度/类别的数值 |
+| **相关性/分布** | 散点图 | 气泡图 | 展示两个变量之间的关系 |
+| **流程转化** | 漏斗图(Funnel) | 桑基图 | 展示业务流程中各阶段的转化率 |
+| **目标 vs 实际** | 仪表盘图(Gauge) | 子弹图 | 展示实际完成情况与目标的差距 |
+| **多指标综合评估** | 雷达图(Radar) | — | 多维度综合表现评估(常用于竞品对比、能力评估) |
+| **地理数据** | 地图(Map) | — | 带有地理属性的数据 |
+
+### 14.3 常用图表详细说明
+
+#### 柱状图(Bar Chart)
+
+- **适用场景**:类别间的数值比较。
+- **X 轴**:分类维度(品类、地区、部门等)。
+- **Y 轴**:数值指标。
+- **方向**:垂直柱状图(类别多时优先)、水平条形图(名称较长或移动端优先)。
+- **注意事项**:
+  - Y 轴**必须从 0 开始**——不从 0 开始会夸大数据差异,造成误导。
+  - 柱子数量建议不超过 30 个,超过时考虑使用条形图或数据处理。
+  - 避免使用大圆角柱状图——影响数据读取准确性。
+
+#### 折线图(Line Chart)
+
+- **适用场景**:展示数据随时间的变化趋势。
+- **X 轴**:时间维度(连续且均匀的)。
+- **Y 轴**:数值指标。
+- **注意事项**:
+  - X 轴数据必须是连续的(时间序列)。
+  - 当需要同时展示趋势 + 累计值时,使用双轴图(柱状 + 折线)。
+  - 双轴图中确保两条轴在视觉上有区分,避免混淆。
+
+#### 饼图 / 环形图(Pie / Donut Chart)
+
+- **适用场景**:展示各部分占整体的比例。
+- **注意事项**:
+  - 分类数量建议不超过 6~8 个。
+  - 各部分之和必须等于 100%(构成完整整体)。
+  - 不适合用于精确比较大小——比较各部分大小时,用柱状图更直观。
+  - 环形图中心区域可展示总量数值。
+
+#### 条形图(Horizontal Bar Chart)
+
+- **适用场景**:排名展示、分类名称较长的场景。
+- **本质**:柱状图旋转 90 度。
+- **特别适用**:移动端屏幕、Top N 排名。
+
+#### 漏斗图(Funnel Chart)
+
+- **适用场景**:业务流程各阶段转化率分析(如电商下单路径)。
+- **注意事项**:
+  - 仅适用于有**明确的、线性的业务流程**。
+  - 各阶段之间必须有先后逻辑顺序。
+  - 多分支、反流程的场景不适合使用漏斗图。
+  - 漏斗的斜率越大,说明该阶段流失率越高。
+
+#### 雷达图(Radar Chart)
+
+- **适用场景**:多维度能力/指标的综合评估与对比。
+- **特点**:
+  - 多个轴(通常 5~8 个)呈放射状分布。
+  - 越靠近外边缘表示表现越好。
+  - 可叠加多个数据集进行对比。
+- **注意事项**:
+  - 轴的数量控制在 5~8 个。
+  - 轴的刻度等分(角度均等),但半径长度代表数值大小。
+  - 雷达图的本质是将柱状图沿圆周展开排列——比较的是半径(数值),而非角度或面积。
+
+#### 散点图(Scatter Chart)
+
+- **适用场景**:展示两个变量之间的相关关系。
+- **两个轴**:各代表一个连续变量。
+- **每个点**:代表一个数据项在两个维度上的取值。
+
+#### 仪表盘图(Gauge Chart)
+
+- **适用场景**:目标完成度、进度展示。
+- **注意事项**:
+  - 视觉上有一定的"欺骗性"——半径长度在视觉上不是线性比例。
+  - 适用于非精确比较的场景(如给高层领导做汇报展示)。
+
+### 14.4 图表设计通用规范
+
+**交互规范**:
+- hover 时展示该数据项的详细数值(Tooltip)。
+- 支持图例点击显/隐对应数据系列。
+- 图表标题应简洁明确地描述图表内容。
+
+**视觉规范**:
+- 柱状图/折线图中的数据点避免使用大圆角。
+- 颜色不要超过 4~5 种。超过时使用同色系不同深浅,或在图例中分组。
+- 坐标轴标签保持清晰可读(字号不宜过小)。
+- 网络线使用浅色细线,避免抢夺数据本身的视觉权重。
+
+**应避免的常见错误**:
+- 饼图用于比较多组大小。
+- 折线图的 X 轴不是连续时间序列。
+- Y 轴不从 0 开始(刻意夸大差异,误导读者)。
+- 雷达图直接比较面积而非半径。
+- 在需要精确比较的场景使用 3D 效果图表。
+
+---
+
+## 15. 附录:设计稿交付规范
+
+### 15.1 UI 设计师的交付物
+
+- 所有通用组件必须已规范化(颜色、字体、按钮、输入框、栅格、导航、弹窗等)。
+- 局部组件规范完成(面包屑、标签页、分步条、分页器等)。
+- 典型页面设计稿完成(至少包含列表页、表单页、详情页)。
+
+### 15.2 设计稿制作建议
+
+- **设计稿宽度**:**1440px**(兼容性好,既能模拟 1920 屏幕内容区,又不至于在 1366/1024 屏幕上过于拥挤)。
+- 若公司统一采购特定分辨率的显示器(如 1366 或 1920),则以实际采购的显示器分辨率作为设计稿宽度。
+- 使用栅格系统约束页面布局。
+- Sketch/Figma 中的组件化(Symbol/Component)要贯穿始终,确保全局更新能力。
+
+### 15.3 前端协作要点
+
+- 设计师需要理解基本的前端工作流:前端 90% 的时间在写业务逻辑和对接接口,UI 样式的开发时间占比不到 10%。
+- 设计师应优先使用成熟的组件库(Ant Design、Element UI 等)中已有的组件,在此基础上进行品牌化定制。
+- 避免在非必要处创新——"可复用"比"独特"更有价值。
+- 设计师交付标注时,应遵循开发熟悉的命名规范(如组件名 + 状态 + 尺寸),降低沟通成本。
+
+---
+
+> **文档维护**:本白皮书应随产品迭代持续更新。所有新增组件和交互模式需经过设计评审后方可纳入规范。
+>
+> **参考资源**:Ant Design、Element UI、Arco Design、SAP Fiori Design Guidelines、Material Design。

+ 331 - 0
doc/业务泳道图.md

@@ -0,0 +1,331 @@
+# 业务泳道图
+
+> 基于《企微客户服务-功能清单》《社群运营功能模块-实现可行性清单》与运营老师访谈纪要梳理
+
+---
+
+## 一、群消息监控与风控预警泳道图
+
+```mermaid
+sequenceDiagram
+    autonumber
+    actor 客户 as 👤 客户(群成员)
+    participant 企微 as 企业微信
+    participant 系统 as 系统(消息监听)
+    participant 运营 as 👤 运营
+    participant 店长 as 👤 店长/主管
+    participant 管理员 as 👤 管理员
+
+    客户->>企微: 在客户群发送消息
+    企微->>系统: 实时同步群消息
+    系统->>系统: 消息解析与关键词匹配
+
+    alt 命中风险关键词
+        系统->>系统: 自动生成预警事件
+        系统->>系统: 匹配预警等级(高/中/低)
+        系统->>运营: 企微强提醒(红字跳显)
+        系统->>店长: 高风险事件同步通知
+        系统->>系统: 自动生成干预工单
+        运营->>系统: 查看预警详情
+        运营->>运营: 判断严重程度
+        alt 需立即处理
+            运营->>系统: 创建处理工单,指定处理人
+            系统->>运营: 通知处理人
+            运营->>系统: 记录处理过程
+            系统->>运营: 工单关闭,归档案例
+        else 误报/无需处理
+            运营->>系统: 标记「误报」并关闭
+            系统->>系统: 记录误报,优化关键词库
+        end
+    else 未命中关键词
+        系统->>系统: 归档为普通消息
+    end
+
+    opt 阈值异常(人数骤降等)
+        系统->>系统: 检测群人数/互动量异常
+        系统->>运营: 阈值异常预警
+        系统->>店长: 自动抄送主管
+        运营->>系统: 确认异常原因并处理
+    end
+
+    系统->>管理员: 按天/周汇总风控报表
+```
+
+---
+
+## 二、沟通记录合规检查泳道图
+
+```mermaid
+sequenceDiagram
+    autonumber
+    actor 设计师 as 👤 设计师
+    participant 企微 as 企业微信
+    participant 系统 as 系统
+    participant 运营 as 👤 运营
+    participant 店长 as 👤 店长
+
+    Note over 设计师,店长: === 阶段一:文档登记 ===
+
+    设计师->>企微: 在群里发送沟通记录在线文档
+    企微->>系统: 同步群消息(含文档链接)
+    系统->>系统: 自动识别文档链接
+    system->>系统: 登记「群 ↔ 文档」对应关系
+
+    alt 同一群多份文档
+        系统->>运营: 异常提醒(多表冲突)
+        运营->>系统: 确认以哪份为准
+    end
+
+    alt 链接无法识别
+        系统->>运营: 标为异常待人工处理
+        运营->>系统: 手动关联或忽略
+    end
+
+    Note over 设计师,店长: === 阶段二:合规自动检查 ===
+
+    系统->>系统: 对比全部客户群 vs 已登记文档
+    系统->>系统: 识别「缺表群」清单
+
+    系统->>系统: 检查群公告是否含文档链接
+    系统->>系统: 检查文档是否已置顶(待确认)
+
+    opt 能读取文档内容时
+        系统->>系统: 检查必填项是否为空
+        系统->>系统: 检查日期/格式是否规范
+        系统->>系统: 检查内容是否过于简略
+        系统->>系统: 检查是否长期未更新
+    end
+
+    系统->>系统: 群有新消息但表未同步更新 → 疑似漏记
+
+    Note over 设计师,店长: === 阶段三:整改闭环 ===
+
+    系统->>设计师: 企微推送整改通知(含群名+问题类型)
+    系统->>店长: 主管汇总通知(按天/周)
+
+    设计师->>系统: 查看整改要求
+    设计师->>企微: 修改沟通记录文档
+    企微->>系统: 检测到文档更新
+
+    系统->>系统: 复检(重新走合规检查)
+    alt 整改通过
+        系统->>系统: 标记「已处理」
+        系统->>运营: 关闭整改任务
+    else 仍未达标
+        系统->>设计师: 再次提醒整改
+    end
+
+    系统->>系统: 生成合规汇总报表
+```
+
+---
+
+## 三、运营内容策划与执行泳道图
+
+```mermaid
+sequenceDiagram
+    autonumber
+    actor 运营 as 👤 运营
+    participant 系统 as 系统
+    participant 企微 as 企业微信
+    actor 客户 as 👤 客户(群成员)
+    actor 店长 as 👤 店长
+
+    Note over 运营,店长: === 阶段一:计划制定 ===
+
+    运营->>系统: 录入周度运营计划<br/>(内容/日期/覆盖群)
+    系统->>系统: 存储计划,设置执行时间窗
+    系统->>店长: 计划同步(主管可见)
+
+    Note over 运营,店长: === 阶段二:内容执行 ===
+
+    运营->>系统: 从素材库选择话术/海报
+    运营->>系统: 选择目标群,一键群发
+
+    alt 系统内群发
+        系统->>企微: 批量发送运营内容
+        企微->>客户: 各群收到运营内容
+        系统->>系统: 记录群发状态(成功/失败)
+    else 手动在群内发送
+        运营->>企微: 手动在群内发运营内容
+        企微->>系统: 同步群消息
+        系统->>系统: 自动识别运营内容发布
+    end
+
+    Note over 运营,店长: === 阶段三:效果追踪 ===
+
+    客户->>企微: 群内互动(回复/@/表情)
+    企微->>系统: 同步互动数据
+
+    系统->>系统: 统计发布后消息量/回复数
+    系统->>系统: 计算互动效果(粗统计)
+
+    系统->>系统: 根据历史活跃时段<br/>推荐最佳发布时间
+
+    Note over 运营,店长: === 阶段四:对照检查 ===
+
+    系统->>系统: 自动比对「计划 vs 执行」
+    system->>系统: 识别未完成项
+
+    alt 有未完成项
+        系统->>运营: 企微提醒(未完成事项)
+        运营->>运营: 补执行或调整计划
+    end
+
+    系统->>店长: 周度执行率汇总
+```
+
+---
+
+## 四、意向客户识别与跟进泳道图
+
+```mermaid
+sequenceDiagram
+    autonumber
+    actor 客户 as 👤 客户
+    participant 企微 as 企业微信
+    participant 系统 as 系统
+    actor 销售 as 👤 销售
+    actor 运营 as 👤 运营
+    actor 店长 as 👤 店长
+
+    Note over 客户,店长: === 阶段一:意向识别 ===
+
+    客户->>企微: 发送咨询类消息<br/>(问价格/量房/方案等)
+    企微->>系统: 同步群消息
+    系统->>系统: NLP 识别意向话术
+    系统->>系统: 关键词/规则初筛分级<br/>(高/中/低意向)
+
+    系统->>销售: 企微通知(高意向新线索)
+
+    Note over 客户,店长: === 阶段二:意向确认与跟进 ===
+
+    销售->>系统: 查看意向客户详情
+    system->>系统: 自动生成跟进待办
+
+    销售->>销售: 人工复核意向等级
+    alt 确认高意向
+        销售->>系统: 确认等级,开始跟进
+        销售->>企微: 在群内(或私聊)触达客户
+        销售->>系统: 登记跟进记录
+    else 误判/低意向
+        销售->>系统: 降级或关闭
+    end
+
+    Note over 客户,店长: === 阶段三:KOC 识别 ===
+
+    系统->>系统: 按发言次数/互动规则<br/>筛选 KOC 候选人
+    系统->>运营: KOC 候选人名单
+
+    运营->>运营: 人工审核确认
+    运营->>系统: 标记为正式 KOC
+    系统->>企微: 打 KOC 标签
+    系统->>系统: 更新外部联系人档案
+
+    Note over 客户,店长: === 阶段四:数据追踪 ===
+
+    系统->>系统: 统计各渠道拉群效果
+    系统->>系统: 统计样板间/KOC 完成情况
+
+    销售->>系统: 手动填报添加客户数
+    销售->>系统: 手动填报订单与转化
+
+    系统->>店长: 转化漏斗看板更新
+```
+
+---
+
+## 五、数据看板生成与经营复盘泳道图
+
+```mermaid
+sequenceDiagram
+    autonumber
+    participant 系统 as 系统(数据处理)
+    participant 企微 as 企业微信
+    actor 销售 as 👤 销售
+    actor 运营 as 👤 运营
+    actor 店长 as 👤 店长
+    actor 管理员 as 👤 管理员
+
+    Note over 系统,管理员: === 阶段一:自动数据采集 ===
+
+    企微->>系统: 群基础数据(成员/人数/消息)
+    系统->>系统: 计算群活跃度指数
+    系统->>系统: 计算异常预警响应时间
+    系统->>系统: 计算风控事件处理率
+
+    Note over 系统,管理员: === 阶段二:手工数据补录 ===
+
+    销售->>系统: 填报添加客户数
+    销售->>系统: 填报订单与转化数据
+    运营->>系统: 填报直播观看数据
+    运营->>系统: 维护小区总户数
+
+    系统->>系统: 计算小区触达率<br/>(群人数 ÷ 总户数)
+    系统->>系统: 计算客户链接率<br/>(已添加 ÷ 群人数)
+    系统->>系统: 计算订单转化率<br/>(订单数 ÷ 已添加)
+
+    Note over 系统,管理员: === 阶段三:看板生成 ===
+
+    系统->>系统: 生成总览看板(全局指标)
+    系统->>系统: 生成小区看板(按小区汇总)
+    系统->>系统: 生成门店看板(按门店汇总)
+    系统->>系统: 生成转化漏斗图
+
+    系统->>店长: 看板数据更新
+    系统->>管理员: 全维度看板可见
+
+    Note over 系统,管理员: === 阶段四:复盘报表 ===
+
+    系统->>系统: 自动汇总已有指标<br/>(群/活跃/预警/合规)
+    系统->>管理员: 生成周月报
+
+    opt 需转化数据时
+        店长->>系统: 查看周报
+        管理员->>管理员: 结合转化补录数据<br/>完成经营复盘
+    end
+
+    管理员->>系统: 图表下钻:总览 → 小区 → 群 → 人
+```
+
+---
+
+## 六、新客户群从创建到纳入管理全流程泳道图
+
+```mermaid
+sequenceDiagram
+    autonumber
+    actor 运营 as 👤 运营
+    actor 店长 as 👤 店长
+    participant 企微 as 企业微信
+    participant 系统 as 系统
+    actor 设计师 as 👤 设计师
+
+    运营->>企微: 新建外部客户群
+    system->>企微: 自动拉取群名单
+    系统->>系统: 登记新群基本信息
+
+    运营->>系统: 录入小区基础档案<br/>(总户数/房价/交房时间)
+    运营->>系统: 维护「小区→群→门店」关联
+
+    系统->>系统: 新群自动纳入合规检查范围
+
+    设计师->>企微: 在群内发送沟通记录文档
+    企微->>系统: 识别并登记文档链接
+
+    系统->>系统: 检查:群是否有文档?是否置顶?是否写入公告?
+
+    alt 合规通过
+        系统->>系统: 群状态 = 「合规」
+    else 存在不合规项
+        系统->>设计师: 推送整改通知
+        系统->>店长: 汇总通知
+    end
+
+    系统->>系统: 持续监控群消息 + 群健康度
+    系统->>运营: 异常时预警
+```
+
+---
+
+*文档版本:v1.0 | 最后更新:2026-05-20*

+ 277 - 0
doc/产品架构和页面索引.md

@@ -0,0 +1,277 @@
+# 产品架构和页面索引
+
+> 基于《企微客户服务-功能清单》与《社群运营功能模块-实现可行性清单》梳理
+
+---
+
+## 一、产品整体架构
+
+```mermaid
+graph TB
+    subgraph 展示层["展示层 —— 统一工作台"]
+        direction LR
+        W1["总览工作台<br/>(管理者)"]
+        W2["门店工作台<br/>(店长)"]
+        W3["运营工作台<br/>(运营)"]
+        W4["设计师工作台<br/>(设计师)"]
+        W5["销售工作台<br/>(销售)"]
+    end
+
+    subgraph 业务层["业务层 —— 11 大功能模块"]
+        direction TB
+        M1["模块1<br/>企微接入与消息基础"]
+        M2["模块2<br/>客户群与组织资产"]
+        M3["模块3<br/>沟通记录在线文档"]
+        M4["模块4<br/>沟通记录合规检查"]
+        M5["模块5<br/>群风控与异常干预"]
+        M6["模块6<br/>社群内容与运营执行"]
+        M7["模块7<br/>拉群/KOC/意向客户"]
+        M8["模块8<br/>数据看板与经营复盘"]
+        M9["模块9<br/>经营数据手工补录"]
+        M10["模块10<br/>统一工作台与通知闭环"]
+        M11["模块11<br/>扩展能力(二期)"]
+    end
+
+    subgraph 数据层["数据层"]
+        direction LR
+        D1["群消息库"]
+        D2["客户群资产库"]
+        D3["沟通记录库"]
+        D4["风控事件库"]
+        D5["运营内容库"]
+        D6["经营指标库"]
+        D7["用户权限库"]
+    end
+
+    subgraph 接入层["接入层"]
+        direction LR
+        A1["企业微信 API"]
+        A2["企微在线文档 API"]
+        A3["手工录入终端"]
+    end
+
+    A1 --> D1
+    A1 --> D2
+    A2 --> D3
+    A3 --> D6
+
+    D1 --> M1
+    D1 --> M4
+    D1 --> M5
+    D1 --> M7
+    D2 --> M2
+    D3 --> M3
+    D3 --> M4
+    D4 --> M5
+    D5 --> M6
+    D6 --> M8
+    D6 --> M9
+    D4 --> M10
+    D7 --> M10
+
+    M1 --> W1
+    M2 --> W2
+    M3 --> W3
+    M4 --> W2
+    M5 --> W2
+    M6 --> W3
+    M7 --> W4
+    M8 --> W1
+    M9 --> W5
+    M10 --> W1
+    M11 --> W1
+```
+
+---
+
+## 二、模块架构关系图
+
+```mermaid
+graph LR
+    subgraph 基础能力["基础能力层"]
+        A["企微接入与消息基础<br/>(模块1)"]
+    end
+
+    subgraph 资产管理["资产管理层"]
+        B["客户群与组织资产<br/>(模块2)"]
+        C["沟通记录在线文档<br/>(模块3)"]
+    end
+
+    subgraph 业务执行["业务执行层"]
+        D["沟通记录合规检查<br/>(模块4)"]
+        E["群风控与异常干预<br/>(模块5)"]
+        F["社群内容与运营执行<br/>(模块6)"]
+        G["拉群/KOC/意向客户<br/>(模块7)"]
+    end
+
+    subgraph 数据驱动["数据驱动层"]
+        H["数据看板与经营复盘<br/>(模块8)"]
+        I["经营数据手工补录<br/>(模块9)"]
+    end
+
+    subgraph 协同闭环["协同与闭环层"]
+        J["统一工作台与通知闭环<br/>(模块10)"]
+        K["扩展能力二期<br/>(模块11)"]
+    end
+
+    A --> B
+    A --> C
+    B --> D
+    B --> E
+    C --> D
+    B --> F
+    B --> G
+    D --> H
+    E --> H
+    F --> H
+    G --> H
+    I --> H
+    D --> J
+    E --> J
+    J --> K
+```
+
+---
+
+## 三、页面索引总表
+
+### 3.1 页面清单(按模块)
+
+| 页面编号 | 所属模块 | 页面名称 | 页面路由(建议) | 主要使用者 | 页面类型 |
+|:--------:|----------|----------|------------------|-----------|:--------:|
+| P1-01 | 模块1·企微接入与消息基础 | 企业授权管理 | `/access/auth` | 管理员 | 配置页 |
+| P1-02 | 模块1·企微接入与消息基础 | 人员账号管理 | `/access/accounts` | 管理员 | 管理页 |
+| P1-03 | 模块1·企微接入与消息基础 | 账号在线状态监控 | `/access/online-status` | 管理员 | 监控页 |
+| P1-04 | 模块1·企微接入与消息基础 | 群消息实时流 | `/access/message-stream` | 运营/管理员 | 监控页 |
+| P1-05 | 模块1·企微接入与消息基础 | 历史消息补查任务 | `/access/history-sync` | 管理员 | 任务页 |
+| P2-01 | 模块2·客户群与组织资产 | 客户群总览 | `/assets/group-list` | 运营/店长 | 列表页 |
+| P2-02 | 模块2·客户群与组织资产 | 群详情页 | `/assets/group-detail/:id` | 运营/店长 | 详情页 |
+| P2-03 | 模块2·客户群与组织资产 | 小区档案管理 | `/assets/community-archive` | 运营 | 管理页 |
+| P2-04 | 模块2·客户群与组织资产 | 小区-群-门店关系维护 | `/assets/relationship` | 运营 | 配置页 |
+| P2-05 | 模块2·客户群与组织资产 | 群健康度看板 | `/assets/health-dashboard` | 运营/店长 | 看板页 |
+| P3-01 | 模块3·沟通记录在线文档 | 文档登记列表 | `/doc/registry` | 运营/设计师 | 列表页 |
+| P3-02 | 模块3·沟通记录在线文档 | 文档异常处理 | `/doc/anomaly` | 运营 | 任务页 |
+| P3-03 | 模块3·沟通记录在线文档 | 群公告与置顶检查 | `/doc/pinned-check` | 运营 | 检查页 |
+| P4-01 | 模块4·沟通记录合规检查 | 合规总览 | `/compliance/overview` | 运营/店长 | 看板页 |
+| P4-02 | 模块4·沟通记录合规检查 | 缺表群清单 | `/compliance/missing-doc` | 运营 | 列表页 |
+| P4-03 | 模块4·沟通记录合规检查 | 格式与必填项检查 | `/compliance/format-check` | 运营 | 检查页 |
+| P4-04 | 模块4·沟通记录合规检查 | 长期未更新预警 | `/compliance/stale-warning` | 运营 | 预警页 |
+| P4-05 | 模块4·沟通记录合规检查 | 疑似漏记清单 | `/compliance/suspected-miss` | 运营/店长 | 列表页 |
+| P4-06 | 模块4·沟通记录合规检查 | 合规汇总报表 | `/compliance/report` | 管理员/店长 | 报表页 |
+| P5-01 | 模块5·群风控与异常干预 | 风险关键词库 | `/risk/keyword-library` | 管理员 | 配置页 |
+| P5-02 | 模块5·群风控与异常干预 | 实时预警大屏 | `/risk/alert-dashboard` | 运营/管理员 | 监控页 |
+| P5-03 | 模块5·群风控与异常干预 | 预警工单列表 | `/risk/work-order` | 运营/店长 | 任务页 |
+| P5-04 | 模块5·群风控与异常干预 | 工单处理详情 | `/risk/work-order/:id` | 运营/店长 | 详情页 |
+| P5-05 | 模块5·群风控与异常干预 | 异常案例知识库 | `/risk/case-library` | 全员 | 知识页 |
+| P6-01 | 模块6·社群内容与运营执行 | 素材库管理 | `/content/material-library` | 运营 | 管理页 |
+| P6-02 | 模块6·社群内容与运营执行 | 一键群发 | `/content/batch-send` | 运营 | 操作页 |
+| P6-03 | 模块6·社群内容与运营执行 | 内容发布识别记录 | `/content/publish-log` | 运营 | 列表页 |
+| P6-04 | 模块6·社群内容与运营执行 | 互动效果统计 | `/content/interaction-stats` | 运营 | 分析页 |
+| P6-05 | 模块6·社群内容与运营执行 | 周度运营计划 | `/content/weekly-plan` | 运营 | 计划页 |
+| P6-06 | 模块6·社群内容与运营执行 | 计划执行对照 | `/content/plan-vs-exec` | 运营/店长 | 对比页 |
+| P7-01 | 模块7·拉群/KOC/意向客户 | 拉群渠道管理 | `/acquisition/channel` | 运营 | 管理页 |
+| P7-02 | 模块7·拉群/KOC/意向客户 | 渠道效果分析 | `/acquisition/channel-stats` | 运营/店长 | 分析页 |
+| P7-03 | 模块7·拉群/KOC/意向客户 | KOC 候选人列表 | `/acquisition/koc-candidates` | 运营 | 列表页 |
+| P7-04 | 模块7·拉群/KOC/意向客户 | 外部联系人档案 | `/acquisition/contact-profile/:id` | 运营/销售 | 详情页 |
+| P7-05 | 模块7·拉群/KOC/意向客户 | 样板间目标看板 | `/acquisition/showroom-board` | 运营/店长 | 看板页 |
+| P7-06 | 模块7·拉群/KOC/意向客户 | 意向客户识别列表 | `/acquisition/intent-leads` | 销售/设计师 | 列表页 |
+| P7-07 | 模块7·拉群/KOC/意向客户 | 跟进待办 | `/acquisition/followup-tasks` | 销售 | 任务页 |
+| P8-01 | 模块8·数据看板与经营复盘 | 总览看板 | `/dashboard/overview` | 管理员/店长 | 看板页 |
+| P8-02 | 模块8·数据看板与经营复盘 | 小区看板 | `/dashboard/community` | 运营/店长 | 看板页 |
+| P8-03 | 模块8·数据看板与经营复盘 | 门店看板 | `/dashboard/store` | 店长/管理员 | 看板页 |
+| P8-04 | 模块8·数据看板与经营复盘 | 群活跃度分析 | `/dashboard/activity` | 运营 | 分析页 |
+| P8-05 | 模块8·数据看板与经营复盘 | 小区触达率分析 | `/dashboard/reach-rate` | 运营/店长 | 分析页 |
+| P8-06 | 模块8·数据看板与经营复盘 | 转化漏斗 | `/dashboard/conversion-funnel` | 管理员/店长 | 分析页 |
+| P8-07 | 模块8·数据看板与经营复盘 | 周月报 | `/dashboard/report` | 管理员 | 报表页 |
+| P9-01 | 模块9·经营数据手工补录 | 销售添加客户填报 | `/data-entry/customer-add` | 销售 | 录入页 |
+| P9-02 | 模块9·经营数据手工补录 | 订单与转化填报 | `/data-entry/orders` | 销售 | 录入页 |
+| P9-03 | 模块9·经营数据手工补录 | 客户档案与跟进登记 | `/data-entry/customer-profile` | 销售 | 录入页 |
+| P9-04 | 模块9·经营数据手工补录 | 直播观看数据填报 | `/data-entry/live-data` | 运营 | 录入页 |
+| P10-01 | 模块10·统一工作台与通知闭环 | 工作台首页 | `/workspace` | 全员 | 工作台 |
+| P10-02 | 模块10·统一工作台与通知闭环 | 整改通知列表 | `/workspace/notifications` | 设计师/运营 | 任务页 |
+| P10-03 | 模块10·统一工作台与通知闭环 | 主管汇总通知 | `/workspace/summary` | 店长/管理员 | 通知页 |
+| P10-04 | 模块10·统一工作台与通知闭环 | 问题分类看板 | `/workspace/issue-board` | 运营/店长 | 看板页 |
+| P10-05 | 模块10·统一工作台与通知闭环 | 整改复检 | `/workspace/recheck` | 运营 | 检查页 |
+| P10-06 | 模块10·统一工作台与通知闭环 | 操作日志 | `/workspace/audit-log` | 管理员 | 日志页 |
+| P11-01 | 模块11·扩展能力(二期) | 竞品与话题词库 | `/extend/competitor-keywords` | 管理员 | 配置页 |
+| P11-02 | 模块11·扩展能力(二期) | 话题与舆情周报 | `/extend/sentiment-report` | 管理员 | 报表页 |
+| P11-03 | 模块11·扩展能力(二期) | 小区勘探与户型方案库 | `/extend/floorplan-library` | 运营/设计师 | 管理页 |
+| P11-04 | 模块11·扩展能力(二期) | 小区开拓进度 | `/extend/progress` | 运营 | 录入页 |
+
+---
+
+## 四、页面导航结构
+
+```mermaid
+graph TB
+    Home["登录页"] --> Workspace["统一工作台 /workspace"]
+
+    Workspace --> Nav1["企微接入管理"]
+    Workspace --> Nav2["客户群资产"]
+    Workspace --> Nav3["沟通记录文档"]
+    Workspace --> Nav4["合规检查"]
+    Workspace --> Nav5["群风控预警"]
+    Workspace --> Nav6["内容运营"]
+    Workspace --> Nav7["拉群与意向"]
+    Workspace --> Nav8["数据看板"]
+    Workspace --> Nav9["数据补录"]
+    Workspace --> Nav10["通知与闭环"]
+    Workspace --> Nav11["扩展能力"]
+
+    Nav1 --> P1-01 & P1-02 & P1-03 & P1-04 & P1-05
+    Nav2 --> P2-01 & P2-02 & P2-03 & P2-04 & P2-05
+    Nav3 --> P3-01 & P3-02 & P3-03
+    Nav4 --> P4-01 & P4-02 & P4-03 & P4-04 & P4-05 & P4-06
+    Nav5 --> P5-01 & P5-02 & P5-03 & P5-04 & P5-05
+    Nav6 --> P6-01 & P6-02 & P6-03 & P6-04 & P6-05 & P6-06
+    Nav7 --> P7-01 & P7-02 & P7-03 & P7-04 & P7-05 & P7-06 & P7-07
+    Nav8 --> P8-01 & P8-02 & P8-03 & P8-04 & P8-05 & P8-06 & P8-07
+    Nav9 --> P9-01 & P9-02 & P9-03 & P9-04
+    Nav10 --> P10-01 & P10-02 & P10-03 & P10-04 & P10-05 & P10-06
+    Nav11 --> P11-01 & P11-02 & P11-03 & P11-04
+```
+
+---
+
+## 五、角色-页面访问矩阵
+
+| 页面分类 | 管理员 | 店长 | 运营 | 设计师 | 销售 |
+|----------|:------:|:----:|:----:|:------:|:----:|
+| 企微接入管理(P1-xx) | ● | ○ | ○ | - | - |
+| 客户群资产(P2-xx) | ● | ● | ● | ○ | - |
+| 沟通记录文档(P3-xx) | ● | ● | ● | ● | - |
+| 合规检查(P4-xx) | ● | ● | ● | ○ | - |
+| 群风控预警(P5-xx) | ● | ● | ● | ○ | - |
+| 内容运营(P6-xx) | ● | ○ | ● | ○ | - |
+| 拉群与意向(P7-xx) | ● | ● | ● | ○ | ● |
+| 数据看板(P8-xx) | ● | ● | ● | ○ | ○ |
+| 数据补录(P9-xx) | ● | ○ | ○ | - | ● |
+| 通知闭环(P10-xx) | ● | ● | ● | ● | ● |
+| 扩展能力(P11-xx) | ● | ○ | ● | ○ | - |
+
+> ● 全部可见  ○ 部分可见(本门店/本人相关)  - 不可见
+
+---
+
+## 六、一期与二期功能边界
+
+```mermaid
+graph LR
+    subgraph 一期["一期建设(P0 + P1)"]
+        direction TB
+        P0["P0 核心功能<br/>━━━━━━━━<br/>· 企微接入与消息<br/>· 客户群资产管理<br/>· 沟通记录文档<br/>· 合规检查<br/>· 群风控预警<br/>· 内容运营执行<br/>· 拉群/意向识别<br/>· 数据看板<br/>· 手工补录<br/>· 统一工作台"]
+        P1["P1 增强功能<br/>━━━━━━━━<br/>· 舆情/话题/竞品分析<br/>· 小区信息采集与进度"]
+    end
+
+    subgraph 二期["二期建设"]
+        direction TB
+        F2["· 竞品与话题词库深化<br/>· 话题与舆情周报<br/>· 小区勘探与户型方案库<br/>· 小区开拓进度填报<br/>· 更深度的话题聚类<br/>· 行业级舆情研判"]
+    end
+
+    P0 --> P1
+    P1 --> F2
+```
+
+---
+
+*文档版本:v1.0 | 最后更新:2026-05-20*

+ 117 - 0
doc/企微客户服务-功能清单(2).md

@@ -0,0 +1,117 @@
+# 企微客户服务 — 功能清单
+
+> 面向业务与管理人员的说明:下表为 **一张总表**,四栏列出全部功能;文首 **大模块一览** 为目录。模块划分见 [企微客户服务-功能模块划分.md](./企微客户服务-功能模块划分.md)。  
+> **「部分可以实现」** 在「是否能实现」列标明 **可实现 / 不可实现** 分别指什么。  
+> **为什么能够实现:** 见 [企微客户服务-功能可实现性说明.md](./企微客户服务-功能可实现性说明.md)
+
+---
+
+## 大模块一览
+
+| 序号 | 模块名称 | 大模块说明(所含小模块概要) |
+|:----:|----------|------------------------------|
+| **1** | **企微接入与消息基础模块** | 企业对接授权、店长/设计师/运营账号纳入管理、账号在线提醒、客户群消息实时接收、历史群消息补查、按人员区分群与消息归属 |
+| **2** | **客户群与组织资产模块** | 外部客户群登记与创建、自动拉取群名单、群名称与人数、进群与退群监测、小区基础档案、小区—群—门店对应关系、群健康度评估 |
+| **3** | **沟通记录在线文档模块** | 每群一份沟通记录在线文档(规范要求)、群内文档链接识别与登记、一群一表、历史消息补登记、多份文档异常提醒、群公告文档链接、文档置顶(规范项) |
+| **4** | **沟通记录合规检查模块** | 全量群与已登记文档对比(缺表识别)、是否曾发过记录表、置顶与公告检查、记录表内容读取、必填项与格式与版式检查、长期未更新预警、内容简略提示、群里聊过但表未更新(疑似漏记)、逐条对表检查(试点)、群外沟通覆盖说明、合规汇总报表 |
+| **5** | **群风控与异常干预模块** | 群消息实时监听、风险关键词库、敏感词命中预警、人数骤降等阈值异常、企微强提醒、预警自动生成工单、工单处理闭环、异常处理案例知识库 |
+| **6** | **社群内容与运营执行模块** | 话术与案例素材库、多群一键群发、运营内容发布识别、内容互动效果统计、发布时间建议、周度运营计划、计划执行对照、未完成项提醒 |
+| **7** | **拉群、KOC 与意向客户模块** | 拉群渠道登记、各渠道拉群效果、KOC 候选人筛选与人工确认、企微 KOC 标签、外部联系人档案、样板间与激励目标维护、群内意向话术识别、意向等级、跟进待办与通知、群内添加好友(可选) |
+| **8** | **数据看板与经营复盘模块** | 总览/小区/门店看板、群活跃度、小区触达率、图表下钻到群与人、添加微信与链接率展示、签单与转化率展示、沟通合规情况统计、周月报(自动指标汇总 + 转化类补录) |
+| **9** | **经营数据手工补录模块** | 销售添加客户数填报、订单与转化数据填报、简化版客户档案与跟进登记、直播观看数据填报 |
+| **10** | **统一工作台与通知闭环模块** | 按角色/部门定制界面、监控预警与日常业务同一屏、设计师/运营整改通知、主管汇总通知、问题分类(缺表/未置顶/风控/格式/漏记等)、整改复检与关闭、操作与检查留痕 |
+| **11** | **扩展能力模块(二期)** | 舆情/话题/竞品词库与周报、小区实地勘探与户型方案库、小区开拓进度填报 |
+
+---
+
+## 功能清单总表
+
+| 功能模块 | 功能模块具体功能 | 具体功能说明 | 是否能实现 |
+|----------|------------------|--------------|------------|
+| 企微接入与消息基础模块 | 开通与企业企微的对接权限 | 由企业授权后,系统才能合法读取店长、设计师、运营等人员企微上的群聊、联系人等相关信息。 | 可以实现 |
+|  | 人员企微账号纳入管理 | 把店长、设计师、运营等角色的企微账号纳入系统,确保能收到群消息、能查到有哪些客户群。 | 可以实现 |
+| 企微接入与消息基础模块 | 账号是否在线提醒 | 检查或统计前先看账号是否在线;若掉线,提醒重新登录,避免漏看消息。 | 可以实现 |
+| 企微接入与消息基础模块 | 客户群新消息及时收到 | 客户群里有人发文字、图片、链接等,系统能尽快收到并保存,供后续检查与运营使用。 | 可以实现 |
+| 企微接入与消息基础模块 | 补查历史群聊天记录 | 可分批把以前的群消息补进系统,用于统计、对账和合规检查,减少遗漏。 | 可以实现 |
+| 企微接入与消息基础模块 | 按人员区分群与消息归属 | 多人同时使用时,能分清每个群、每条消息属于哪位店长、设计师或运营。 | 可以实现 |
+| 客户群与组织资产模块 | 新建或登记外部客户群 | 系统可协助创建客户群,或把已有客户群登记进来,纳入统一管理。 | 可以实现 |
+| 客户群与组织资产模块 | 自动拉取企微客户群名单 | 自动获取需纳入管理的客户群列表,不用人工逐个抄群名。 | 可以实现 |
+| 客户群与组织资产模块 | 自动查看群名与群人数 | 自动显示每个群的名称、当前人数等基本信息,方便对照和报表。 | 可以实现 |
+| 客户群与组织资产模块 | 自动发现进群与退群 | 监测群成员进出变化,用于人数统计、异常预警和拉群效果分析。 | 可以实现 |
+| 客户群与组织资产模块 | 录入小区基础档案 | 在系统里维护小区总户数、房价、交房时间、归属门店等,作为触达率等指标的基础。 | 可以实现(需先录入小区档案) |
+| 客户群与组织资产模块 | 维护「小区—群—门店」对应关系 | 明确每个群属于哪个小区、哪个门店;需运营导入或手工维护,系统不能自动猜归属。 | 可以实现(需维护群与小区的关联) |
+| 客户群与组织资产模块 | 群健康度评估 | 按规则给每个群打分(如长期无人说话、人数骤降等),便于优先关注问题群。 | 可以实现(评分规则可配置) |
+| 沟通记录在线文档模块 | 每个客户群有一份沟通记录在线文档 | 要求每个外部客户群里都有一份企微在线文档,用来记录跟客户的沟通(由设计师等在群里发出文档)。 | 可以实现(需配合管理制度,按规范操作) |
+| 沟通记录在线文档模块 | 认出群里发的在线文档链接 | 在群里发企微在线文档时,系统能自动认出并识别为沟通记录表。 | 可以实现 |
+| 沟通记录在线文档模块 | 自动登记「哪个群对应哪张表」 | 发现文档后,系统自动记下「某群 = 某张沟通记录表」,并坚持一群一表,减少人工登记。 | 可以实现 |
+| 沟通记录在线文档模块 | 一个群里出现多张表时提醒 | 同一群里出现多份不同记录表时,标为异常,提醒确认以哪一份为准。 | 可以实现 |
+| 沟通记录在线文档模块 | 漏记时用历史聊天补登记 | 若个别消息当时没记上,可用补查的历史聊天把漏掉的群和文档关系补上。 | 可以实现 |
+| 沟通记录在线文档模块 | 链接异常时提醒人工处理 | 看起来像文档链接但系统认不出来时,标为异常,请相关人员处理。 | 可以实现 |
+| 沟通记录在线文档模块 | 在群公告里写上文档链接 | 在群公告中放置沟通记录表链接,方便成员查找(作为置顶之外的补充)。 | 可以实现 |
+| 沟通记录在线文档模块 | 把沟通记录文档在群里置顶 | 把沟通记录表固定在群聊顶部,打开群即可看到。 | 需实际试用后确认(不可承诺项:试用前不宜写死「一定能自动验置顶」) |
+| 沟通记录合规检查模块 | 列出全部外部客户群 | 调出需要纳入管理的所有外部客户群名单。 | 可以实现 |
+| 沟通记录合规检查模块 | 找出「还没有沟通记录表」的群 | 对比全部客户群与已登记有表的群,列出还缺记录表的群。 | 可以实现 |
+| 沟通记录合规检查模块 | 查群里是否曾经发过记录表 | 根据历史聊天判断该群是否曾发过沟通记录文档。 | 可以实现 |
+| 沟通记录合规检查模块 | 查记录表是否已置顶 | 自动查看群置顶区是否包含已登记的那份沟通记录表。 | 需实际试用后确认(不可承诺项:自动验置顶能力待真实环境验证) |
+| 沟通记录合规检查模块 | 查群公告里是否有文档链接 | 查看群公告是否写了记录表链接,作为置顶检查的补充。 | 可以实现 |
+| 沟通记录合规检查模块 | 读取在线文档里的文字和表格 | 取出文档内容,供系统按公司规范自动检查。 | 待确认(不可承诺项:平台是否开放「读文档正文」尚未最终确认) |
+| 沟通记录合规检查模块 | 必填项是否都填了 | 在能读到文档内容且公司已统一模板的前提下,检查关键字段是否漏填。 | 部分可以实现|**可实现:** 按模板查必填项是否为空|**不可实现:** 未统一模板前无法验收;**不能** 自动判断写得是否专业、到位 |
+| 沟通记录合规检查模块 | 日期、格式与表格版式是否规范 | 检查日期写法、先后顺序、表头列数、版面等是否符合公司模板。 | 部分可以实现|**可实现:** 固定格式类规则检查(在能读到文档内容时)|**不可实现:** 读不到文档正文则无法查;**不能** 100% 判断表述质量与业务合理性 |
+| 沟通记录合规检查模块 | 记录表是否很久没更新 | 超过约定天数无人更新时,系统自动预警。 | 可以实现 |
+| 沟通记录合规检查模块 | 内容写得是否太简略 | 对过于简单、缺重点的写法做初步提示。 | 部分可以实现|**可实现:** 规则/粗筛提示(如字数过少、缺关键字段)|**不可实现:** 复杂表述是否合格 **不能** 全自动代替人工复核 |
+| 沟通记录合规检查模块 | 内容不合规时提醒整改 | 发现漏填、格式不对、长期不更新等,提醒相关人员去修改记录表。 | 可以实现 |
+| 沟通记录合规检查模块 | 群里聊过但表没更新时提示 | 群里有新聊天但记录表同期没更新时,标为「可能忘了记」,提醒补记。 | 部分可以实现|**可实现:** 群内可见聊天与表更新时间的对比提醒|**不可实现:** 电话/线下/私聊等 **群外沟通** 是否记入表;**不能** 证明「每一次沟通都已记录」 |
+| 沟通记录合规检查模块 | 新建客户群自动纳入检查 | 新开的客户群若尚未登记沟通记录表,自动进入待处理名单并提醒。 | 可以实现 |
+| 沟通记录合规检查模块 | 客户每说一句话都自动对表检查 | 客户每发一条群消息,系统尝试与记录表对照是否已有对应记录。 | 测试中(试点验证)|**不可承诺项:** 客户「每句话」与表中「每一行」逐字一致,试跑通过前不宜满额承诺 |
+| 沟通记录合规检查模块 | 群外沟通是否记入表 | 电话、线下、个人微信等群外沟通,系统无法自动判断是否已记入表中。 | 暂无法实现 |
+| 沟通记录合规检查模块 | 合规情况汇总报表 | 统计已达标、待整改、待确认的客户群数量与明细。 | 可以实现 |
+| 群风控与异常干预模块 | 实时监听群消息 | 对各客户群消息持续监听,为敏感词与异常行为提供数据基础。 | 可以实现 |
+| 群风控与异常干预模块 | 风险关键词库配置 | 由公司配置投诉、竞品、敏感话题等关键词,命中即触发预警。 | 可以实现(需先配置词库) |
+| 群风控与异常干预模块 | 命中敏感词自动预警 | 群内出现配置的风险词时,自动生成预警事件。 | 可以实现 |
+| 群风控与异常干预模块 | 人数骤降等阈值异常 | 如短时间内退群过多、长期零互动等,按阈值自动标为异常。 | 可以实现 |
+| 群风控与异常干预模块 | 异常红字提醒到企微 | 通过企微消息把预警推给相关人员,含群名、小区、问题类型,便于一眼看到风险。 | 可以实现 |
+| 群风控与异常干预模块 | 预警自动生成干预工单 | 出现预警后自动形成待办,指定处理人与完成时间。 | 可以实现 |
+| 群风控与异常干预模块 | 工单处理与关闭 | 记录处理过程,支持确认「已处理」,便于统计响应时效。 | 可以实现 |
+| 群风控与异常干预模块 | 异常处理案例知识库 | 沉淀典型异常及处理办法,供新人学习和检索。 | 可以实现(案例内容需运营维护) |
+| 社群内容与运营执行模块 | 话术与案例素材库 | 集中存放各阶段运营话术、海报、优秀案例,辅助新人带教。 | 可以实现 |
+| 社群内容与运营执行模块 | 向多个群一键群发 | 把同一条运营内容一次发到多个客户群,并可查发送状态。 | 可以实现 |
+| 社群内容与运营执行模块 | 识别群内是否已发规定运营内容 | 根据群消息判断本期是否已执行计划中的发帖或群发。 | 部分可以实现|**可实现:** 按关键词、链接、群发记录等识别「是否发过」|**不可实现:** 非标准话术、线下发放物料等 **无法自动识别** 为已执行 |
+| 社群内容与运营执行模块 | 运营内容互动效果统计 | 统计发布后一段时间内的消息量、回复情况等。 | 部分可以实现|**可实现:** 发布后群消息量、回复数等 **粗统计**|**不可实现:** 精确归因「每一条互动都由该条内容带来」 |
+| 社群内容与运营执行模块 | 发布时间建议 | 根据历史活跃时段推荐较合适的发布时间。 | 部分可以实现|**可实现:** 有足够历史数据后给活跃时段参考|**不可实现:** 系统刚上线、数据不足时 **无法** 给出可靠建议 |
+| 社群内容与运营执行模块 | 录入周度运营计划 | 运营填写本周计划:发什么内容、哪天、覆盖哪些群。 | 可以实现 |
+| 社群内容与运营执行模块 | 对照计划检查是否已执行 | 自动比对计划时间窗内是否出现对应群发或群消息。 | 部分可以实现|**可实现:** 对照群发记录、群内可识别的运营消息|**不可实现:** 仅在线下完成、群内无痕迹的动作 **无法自动判定** 已执行 |
+| 社群内容与运营执行模块 | 未完成项提醒 | 对未按计划执行的事项提醒责任人。 | 可以实现 |
+| 拉群、KOC 与意向客户模块 | 拉群渠道登记 | 登记地推、物业、老带新等拉群渠道及负责人。 | 可以实现 |
+| 拉群、KOC 与意向客户模块 | 各渠道拉群效果统计 | 结合进群记录与消息数据分析各渠道效果。 | 部分可以实现|**可实现:** 有进群时间、人数等数据时的统计与对比|**不可实现:** 进群 **无法自动区分渠道来源** 时,须人工标注,否则不能自动算准各渠道效果 |
+| 拉群、KOC 与意向客户模块 | 自动筛选 KOC 候选人 | 按发言次数、互动等规则列出疑似 KOC。 | 部分可以实现|**可实现:** 规则筛出 **候选人名单**|**不可实现:** **不能** 不经人工确认就自动认定为正式 KOC |
+| 拉群、KOC 与意向客户模块 | 在企微给客户打 KOC 标签 | 对认定的 KOC 在企微侧打标签,便于后续运营。 | 可以实现 |
+| 拉群、KOC 与意向客户模块 | 查看外部联系人档案 | 查看 KOC 等外部联系人的基本资料。 | 可以实现 |
+| 拉群、KOC 与意向客户模块 | 样板间与激励目标维护 | 维护样板间数量目标、KOC 激励政策等,并在看板展示完成情况。 | 可以实现(目标值需手工维护) |
+| 拉群、KOC 与意向客户模块 | 识别群内咨询类话术 | 自动抓取带价格、量房、方案等意图的群消息。 | 可以实现 |
+| 拉群、KOC 与意向客户模块 | 意向高/中/低分级 | 对意向客户做等级划分。 | 部分可以实现|**可实现:** 关键词/规则或辅助手段做 **初筛分级**|**不可实现:** 复杂语境、反讽、多轮对话的 **100% 自动准确分级**(重要客户须人工复核) |
+| 拉群、KOC 与意向客户模块 | 生成跟进待办并通知销售 | 对高意向客户生成待办,并通过企微提醒对应销售跟进。 | 可以实现 |
+| 拉群、KOC 与意向客户模块 | 从群内发起添加好友 | 对意向成员发起加好友申请。 | 需实际试用后确认(不可承诺项:是否启用、是否合规,须贵司确认后试点) |
+| 数据看板与经营复盘模块 | 总览看板 | 展示群总数、总人数、活跃群占比等全局指标。 | 可以实现 |
+| 数据看板与经营复盘模块 | 小区看板 | 按小区汇总群人数、活跃度、触达情况等。 | 可以实现 |
+| 数据看板与经营复盘模块 | 门店看板 | 按门店汇总多个小区与群的核心指标,支持排名对比。 | 可以实现 |
+| 数据看板与经营复盘模块 | 群活跃度指数 | 根据消息量、发言人数等自动计算群是否活跃。 | 可以实现 |
+| 数据看板与经营复盘模块 | 小区触达率 | 用「群人数 ÷ 小区总户数」衡量覆盖程度。 | 部分可以实现|**可实现:** 录入小区总户数后 **自动计算** 触达率|**不可实现:** **不能** 从企微自动获取「小区总户数」(须先录入档案) |
+| 数据看板与经营复盘模块 | 图表下钻到群与人 | 点击图表可查看具体小区、具体群、相关责任人明细。 | 可以实现 |
+| 数据看板与经营复盘模块 | 展示添加微信与链接率 | 在看板展示添加数、链接率等。 | 部分可以实现|**可实现:** 销售 **填报后** 在看板展示与计算链接率|**不可实现:** **自动统计**「谁从哪个群加了客户微信」 |
+| 数据看板与经营复盘模块 | 展示签单与转化率 | 在看板展示签单数、转化率等。 | 部分可以实现|**可实现:** 订单/签单 **填报或对接 CRM 后** 展示转化率|**不可实现:** **不能** 仅从企微群聊自动得出签单与转化 |
+| 数据看板与经营复盘模块 | 周月报自动汇总与转化补录 | 自动汇总群、活跃、预警、合规等已有指标,并形成复盘报告。 | 部分可以实现|**可实现:** 自动汇总 **群、活跃、预警、合规** 等已有数据|**不可实现:** **不录入转化数据** 却生成「含签单/添加数」的 **完整自动复盘** |
+| 经营数据手工补录模块 | 销售添加客户数填报 | 由销售登记添加人数,支撑链接率等指标。 | 可以实现 |
+| 经营数据手工补录模块 | 订单与转化数据填报 | 登记签单、转化等经营结果,补全看板与复盘。 | 可以实现 |
+| 经营数据手工补录模块 | 简化版客户档案与跟进登记 | 维护客户基本信息和手工登记的跟进记录。 | 可以实现 |
+| 经营数据手工补录模块 | 直播观看数据填报 | 登记直播观看人数等,补全看板。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 按角色与部门定制界面 | 店长、设计师、运营、新人等不同角色登录后看到各自关注的菜单与指标。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 监控预警与日常业务同一屏 | 异常预警与日常运营、合规检查入口在同一工作台,减少来回切换。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 给相关人员发整改通知 | 通过企微发送问题说明:哪个群、什么问题,必要时附上记录表链接。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 给主管发汇总通知 | 按天或按周汇总不合规群数量、风险事件等主要问题类型。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 问题分类展示 | 区分缺表、未置顶、格式不对、风控、疑似漏记等类型,一目了然。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 整改后复检与问题关闭 | 改完后可再查一遍;确认无误后,该条提醒可标记为「已处理」。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 操作与检查记录可追溯 | 保留何时收到消息、何时检查、何时发过提醒等记录,方便事后查证。 | 可以实现 |
+| 扩展能力模块(二期) | 竞品与话题词库 | 配置竞品名、敏感话题等,用于群内监测。 | 可以实现(建议二期建设) |
+| 扩展能力模块(二期) | 话题与舆情周报 | 定期汇总群内热点话题与舆情倾向。 | 部分可以实现(建议二期)|**可实现:** 基于词库与消息量的 **基础周报**|**不可实现:** 深度话题聚类、行业级舆情研判 **不宜一期满额承诺** |
+| 扩展能力模块(二期) | 小区实地勘探与户型方案库 | 上传勘探记录、户型图、方案文档等档案。 | 可以实现(以人工录入为主) |
+| 扩展能力模块(二期) | 小区开拓进度填报 | 填报各小区开拓、样板间等进度百分比。 | 可以实现 |

+ 70 - 0
doc/后续优化需求与流程梳理.md

@@ -0,0 +1,70 @@
+# 企微客户服务系统 - 需求与流程优化建议文档
+
+## 一、 系统当前状态分析
+
+目前系统已经完成了基础架构搭建、UI 设计规范的落地以及核心页面(如工作台、Dashboard 总览)的前端静态实现。界面视觉上符合 SAP Fiori 企业级应用设计规范,具备良好的层级结构、色彩管理、组件交互和响应式支持。
+
+但由于目前主要是前端 Mock 数据驱动,深入到具体的业务模块时,存在流程不闭环、数据项缺失、角色权限模糊等问题。为了推动系统走向真实的生产环境,需要对各个业务模块进行深度梳理。
+
+## 二、 各模块流程完善建议及缺失需求梳理
+
+### 1. 客户群管理 (Customer Group Management)
+**当前问题**:只展示了列表和简单的统计,缺乏生命周期管理。
+**缺失流程/数据需求**:
+- **建群与认领流程**:建群是由系统自动发起还是销售手动创建?群主权限如何交接?
+- **群标签与画像体系**:缺少给群打标签(如“高潜群”、“售后群”、“死群”)的功能设计。
+- **数据需求**:
+  - 企微原生数据接口(如每日群成员进出记录、群聊活跃度趋势接口)。
+  - 需要打通小区的地理位置数据和楼盘交付状态。
+**责任人**:企微运营主管、后端开发(企微 API 对接)
+
+### 2. 风控预警 (Risk Control)
+**当前问题**:仅有“事件标题”和“严重等级”,缺乏风控工单的流转机制。
+**缺失流程/数据需求**:
+- **工单流转审批流**:风控事件触发 -> 自动派发给对应门店店长 -> 门店店长跟进 -> 风控专员审核结案。
+- **敏感词词库管理机制**:谁有权限增加、删除敏感词?敏感词命中后的系统自动动作(如踢人、警告)是什么?
+- **数据需求**:
+  - 聊天记录实时风控审计接口(需确认企微会话存档功能的开通情况及合规性)。
+  - 员工违规操作日志。
+**责任人**:法务合规专员、门店店长
+
+### 3. 内容运营 (Content Operations)
+**当前问题**:素材库和一键群发模块暂无真实的资源管理和发送记录。
+**缺失流程/数据需求**:
+- **SOP 任务下发流**:总部运营制定周计划 -> 自动生成任务下发给门店导购 -> 导购确认执行。
+- **素材生命周期**:素材上传 -> 审核 -> 标签化 -> 上架可用 -> 统计使用次数和转化效果。
+- **数据需求**:
+  - 素材在不同群内的点击率、转化率回传机制。
+  - 企微群发助手的 API 调用权限。
+**责任人**:总部内容运营、数据分析师
+
+### 4. 数据补录与业务转化 (Data Entry & Acquisition)
+**当前问题**:这是打通业务闭环的关键,目前只有入口没有字段。
+**缺失流程/数据需求**:
+- **客户身份打通**:企微客户如何与公司内部的 CRM/ERP 系统客户 ID 进行绑定?
+- **订单转化归因**:导购在企微群内促成的转化,如何关联到具体的销售业绩?
+- **数据需求**:
+  - 内部 ERP 系统的商品数据、订单数据接口。
+  - 需要获取客户在小程序的浏览行为埋点数据(意向评分依据)。
+**责任人**:销售总监、ERP 产研团队
+
+## 三、 角色视角信息诉求梳理
+
+系统必须为不同角色提供针对性的信息视图:
+
+| 角色 | 核心关注点 | 需要拿到的核心信息 | 待完善功能 |
+|------|-----------|--------------------|------------|
+| **超级管理员** | 系统整体运行状态、权限分配 | 全局数据、所有门店概况、系统操作日志 | 权限配置台、操作日志审计模块 |
+| **总部运营** | 内容转化率、活动参与度、群健康度 | 素材使用数据、SOP执行率、大盘指标 | 细粒度的漏斗分析报表、活动 ROI 报表 |
+| **门店店长** | 本店业绩、导购考核、风控事件处理 | 本店群活跃度、本店待办工单、导购拉新数 | 门店维度的数据看板、员工绩效考核面板 |
+| **一线导购/销售** | 客户跟进、线索转化、日常任务 | 待联系的高意向客户、今日SOP任务、客户画像 | 移动端/企微侧边栏界面的支持 |
+
+## 四、 下一步行动计划 (Action Items)
+
+1. **接口对齐会议**:由产品经理牵头,组织后端开发与企微开放平台对接人,明确哪些数据能拿到,哪些数据有延迟(T+1),修正产品预期。
+2. **账号权限体系设计**:明确 RBAC (Role-Based Access Control) 权限模型,输出详细的权限点矩阵表。
+3. **补充业务规则说明**:如“群健康分”的具体计算公式(权重占比)、风控“严重等级”的判定条件等,这些需要业务部门提供确切的业务逻辑。
+4. **移动端/企微侧边栏规划**:B 端系统虽然主要在 PC,但对于一线导购,必须提供嵌在企业微信聊天侧边栏的移动端 H5 页面。需要启动移动端的 UI 设计和交互规划。
+
+---
+> 备注:本系统高度依赖企业微信原生接口能力(特别是会话存档和客户联系功能),需尽快确认公司企业微信的主体认证资质及接口调用额度,避免后期开发受阻。

+ 434 - 0
doc/流程图.md

@@ -0,0 +1,434 @@
+# 流程图
+
+> 基于《企微客户服务-功能清单》《社群运营功能模块-实现可行性清单》与运营老师访谈纪要梳理
+
+---
+
+## 一、企微接入与授权流程
+
+```mermaid
+flowchart TD
+    A["开始:企业管理员发起接入"] --> B["企业微信开放平台<br/>创建第三方应用"]
+    B --> C{"企业是否<br/>完成授权?"}
+    C -->|否| D["联系企业管理员<br/>完成企微授权"]
+    D --> C
+    C -->|是| E["系统获取 Access Token<br/>及群/联系人读取权限"]
+
+    E --> F["导入人员企微账号"]
+    F --> G{"账号是否<br/>在线?"}
+    G -->|否| H["企微提醒重新登录"]
+    H --> G
+    G -->|是| I["开始接收群消息"]
+
+    I --> J["启动历史消息补查任务<br/>(分批拉取)"]
+    J --> K{"历史消息<br/>补查完成?"}
+    K -->|否| L["继续下一批"]
+    L --> K
+    K -->|是| M["接入完成<br/>进入日常运行"]
+```
+
+---
+
+## 二、客户群生命周期管理流程
+
+```mermaid
+flowchart TD
+    A["新客户群创建/登记"] --> B["系统自动拉取群名单"]
+    B --> C["获取群基本信息<br/>(群名/人数/成员列表)"]
+
+    C --> D{"已有小区档案?"}
+    D -->|否| E["运营录入小区基础档案<br/>(总户数/房价/交房时间等)"]
+    E --> F["维护「小区→群→门店」对应关系"]
+    D -->|是| F
+
+    F --> G["群进入日常监控状态"]
+    G --> H["监测成员进出变化"]
+    G --> I["监测群消息活跃度"]
+    G --> J["监测群健康度评分"]
+
+    H --> K{"触发异常?<br/>(人数骤降/长期零互动)"}
+    K -->|是| L["生成异常预警"]
+    L --> M["运营介入处理"]
+    M --> G
+    K -->|否| G
+
+    J --> N{"健康度低于<br/>阈值?"}
+    N -->|是| O["标记为「需关注群」"]
+    O --> P["运营制定激活/维护方案"]
+    N -->|否| Q["群状态正常"]
+```
+
+---
+
+## 三、沟通记录合规检查全流程
+
+```mermaid
+flowchart TD
+    A["开始:定时/手动触发合规检查"] --> B["获取全部外部客户群列表"]
+
+    B --> C["获取已登记沟通记录文档的群列表"]
+
+    C --> D["交叉比对<br/>找出「缺表群」"]
+
+    D --> E{"缺表群是否为空?"}
+    E -->|否| F["生成缺表群清单<br/>推送整改通知"]
+    E -->|是| G["全部群已登记文档"]
+
+    F --> H["通知设计师补发文档"]
+    G --> I["对有文档的群逐一检查"]
+
+    I --> J{"查群公告<br/>是否含文档链接?"}
+    J -->|否| K["标记「公告缺文档链接」"]
+    J -->|是| L["通过"]
+
+    I --> M{"查文档是否<br/>已置顶?<br/>(待确认能力)"}
+    M -->|否| N["标记「未置顶」"]
+    M -->|是| L
+
+    I --> O{"能否读取<br/>文档正文?"}
+    O -->|是| P["读取文档内容"]
+    O -->|否| Q["仅做表层检查"]
+    P --> R["检查必填项是否为空"]
+    P --> S["检查日期/格式是否规范"]
+    P --> T["检查内容是否过于简略"]
+    P --> U["检查是否长期未更新"]
+
+    R --> V{"发现不合规项?"}
+    S --> V
+    T --> V
+    U --> V
+
+    V -->|是| W["生成不合规明细<br/>分类推送整改通知"]
+    V -->|否| X["合规"]
+
+    W --> Y["设计师整改"]
+    Y --> Z["整改后复检"]
+    Z --> V
+
+    I --> AA["群有新消息<br/>但表未同步更新?"]
+    AA -->|是| AB["标记「疑似漏记」<br/>提醒补记"]
+    AA -->|否| AC["记录正常"]
+
+    F --> AD["生成合规汇总报表"]
+    W --> AD
+    X --> AD
+    AB --> AD
+    AC --> AD
+    Q --> AD
+    L --> AD
+    K --> AD
+    N --> AD
+```
+
+---
+
+## 四、风控预警处理工单流程
+
+```mermaid
+flowchart TD
+    A["群消息实时流入"] --> B["消息解析引擎"]
+
+    B --> C{"命中风险<br/>关键词?"}
+    C -->|是| D["确定预警等级<br/>(高/中/低)"]
+    C -->|否| E["正常归档"]
+
+    B --> F{"触发阈值<br/>异常?<br/>(人数骤降等)"}
+    F -->|是| D
+    F -->|否| E
+
+    D --> G["生成预警事件"]
+    G --> H["企微强提醒推送给运营"]
+    G --> I["高风险同步推送给店长"]
+
+    H --> J["自动生成干预工单"]
+    J --> K{"运营评估<br/>严重程度"}
+
+    K -->|"需立即处理"| L["指定处理人<br/>设定完成时限"]
+    K -->|"误报"| M["标记误报<br/>关闭工单"]
+    K -->|"低风险观察"| N["加入观察列表<br/>暂不派单"]
+
+    L --> O["处理人接收工单"]
+    O --> P["执行处理动作"]
+    P --> Q["记录处理过程"]
+    Q --> R{"问题是否<br/>已解决?"}
+
+    R -->|是| S["关闭工单"]
+    R -->|否| T["升级给主管"]
+    T --> L
+
+    S --> U["归档到案例知识库"]
+    M --> U
+    N --> U
+
+    U --> V["定期汇总:<br/>风控事件处理率<br/>预警响应时间"]
+```
+
+---
+
+## 五、运营计划执行与对照流程
+
+```mermaid
+flowchart TD
+    A["运营录入周度运营计划"] --> B["填写计划内容:<br/>· 发什么内容<br/>· 哪天发送<br/>· 覆盖哪些群"]
+
+    B --> C["计划存入系统<br/>设定执行时间窗"]
+    C --> D["系统推送给主管可见"]
+
+    D --> E{"执行方式?"}
+    E -->|"系统群发"| F["从素材库选择内容"]
+    F --> G["选择目标群"]
+    G --> H["一键群发"]
+    H --> I["记录发送状态"]
+
+    E -->|"手动群发"| J["在企微群内手动发送"]
+    J --> K["系统同步群消息"]
+    K --> L["自动识别运营内容发布"]
+
+    I --> M["收集互动数据"]
+    L --> M
+
+    M --> N["统计发布后消息量/回复数"]
+    N --> O["计算内容互动效果"]
+
+    O --> P["时间窗结束后<br/>自动比对计划 vs 执行"]
+
+    P --> Q{"是否有<br/>未完成项?"}
+    Q -->|是| R["企微提醒责任人"]
+    R --> S["补执行或调整计划"]
+    Q -->|否| T["标记全部完成"]
+
+    T --> U["生成周度执行率报表"]
+    S --> U
+```
+
+---
+
+## 六、意向客户跟进流程
+
+```mermaid
+flowchart TD
+    A["群内客户发送消息"] --> B["消息同步到系统"]
+
+    B --> C{"NLP 识别<br/>是否含咨询意图?<br/>(价格/量房/方案等)"}
+    C -->|否| D["正常归档"]
+    C -->|是| E["提取意图关键词"]
+
+    E --> F["规则初筛分级"]
+
+    F --> G{"意向等级?"}
+    G -->|"高意向"| H["红色标记"]
+    G -->|"中意向"| I["黄色标记"]
+    G -->|"低意向"| J["蓝色标记"]
+
+    H --> K["自动生成跟进待办"]
+    K --> L["企微通知对应销售"]
+    L --> M["销售查看意向详情"]
+
+    I --> N["加入意向池<br/>人工判断"]
+    J --> O["记录观察<br/>暂不主动跟进"]
+
+    M --> P{"销售人工<br/>复核意向等级"}
+    P -->|"确认高意向"| Q["立即跟进"]
+    P -->|"降级"| R["调整等级<br/>重新分流"]
+    P -->|"误判关闭"| S["关闭线索"]
+
+    Q --> T["销售触达客户<br/>(群内或私聊)"]
+    T --> U["登记跟进记录"]
+    U --> V{"是否促成<br/>量房/签单?"}
+
+    V -->|是| W["手动填报订单数据<br/>更新转化漏斗"]
+    V -->|否| X["继续跟进<br/>或标记流失"]
+
+    W --> Y["看板数据更新"]
+    X --> Y
+```
+
+---
+
+## 七、KOC 识别与管理流程
+
+```mermaid
+flowchart TD
+    A["系统持续分析群成员行为"] --> B["统计维度:<br/>· 发言次数<br/>· 互动频率<br/>· @次数<br/>· 内容质量"]
+
+    B --> C{"达到 KOC<br/>候选阈值?"}
+    C -->|否| D["继续观察"]
+    C -->|是| E["生成 KOC 候选人名单"]
+
+    E --> F["运营查看候选人列表"]
+    F --> G{"运营人工<br/>审核判断"}
+
+    G -->|"确认为 KOC"| H["标记为正式 KOC"]
+    G -->|"不确定"| I["保持候选状态<br/>继续观察"]
+    G -->|"不符合"| J["移除候选名单"]
+
+    H --> K["系统在企微侧<br/>打 KOC 标签"]
+    K --> L["更新外部联系人档案"]
+
+    L --> M["维护 KOC 激励政策"]
+    M --> N["KOC 激励执行<br/>与效果统计"]
+
+    N --> O["在看板展示:<br/>· KOC 数量<br/>· KOC 占比<br/>· 样板间数量"]
+```
+
+---
+
+## 八、数据补录与看板闭环流程
+
+```mermaid
+flowchart TD
+    subgraph 自动采集["系统自动采集"]
+        A1["群基础数据<br/>(成员/人数/消息)"] --> A2["计算自动指标<br/>· 群活跃度<br/>· 预警响应时间<br/>· 风控处理率<br/>· 内容覆盖率"]
+    end
+
+    subgraph 手工补录["手工数据补录"]
+        B1["销售填报<br/>添加客户数"]
+        B2["销售填报<br/>订单与转化"]
+        B3["运营填报<br/>直播观看数据"]
+        B4["运营维护<br/>小区总户数"]
+    end
+
+    subgraph 指标计算["指标计算引擎"]
+        A2 --> C1["小区触达率<br/>= 群人数 ÷ 小区总户数"]
+        B4 --> C1
+        B1 --> C2["客户链接率<br/>= 已添加 ÷ 群人数"]
+        B2 --> C3["订单转化率<br/>= 订单数 ÷ 已添加"]
+    end
+
+    subgraph 看板生成["看板生成"]
+        C1 --> D1["总览看板"]
+        C2 --> D1
+        C3 --> D1
+        A2 --> D1
+
+        D1 --> D2["小区看板<br/>(按小区汇总)"]
+        D1 --> D3["门店看板<br/>(按门店汇总)"]
+        D1 --> D4["转化漏斗<br/>(添加→签单)"]
+
+        D2 --> E["支持下钻:<br/>总览 → 小区 → 群 → 人"]
+        D3 --> E
+        D4 --> E
+    end
+
+    subgraph 复盘["经营复盘"]
+        E --> F1["周月报自动生成"]
+        F1 --> F2{"含转化数据?"}
+        F2 -->|"是(有补录)"| F3["完整复盘报告"]
+        F2 -->|"否(无补录)"| F4["部分复盘<br/>(仅自动指标)"]
+        F4 --> F5["管理者结合线下数据<br/>完成最终复盘"]
+    end
+```
+
+---
+
+## 九、整改通知闭环流程
+
+```mermaid
+flowchart TD
+    A["系统检测到不合规项"] --> B["问题自动分类"]
+
+    B --> C{"问题类型?"}
+    C -->|"缺表"| D1["缺沟通记录表"]
+    C -->|"未置顶"| D2["文档未置顶"]
+    C -->|"格式"| D3["格式/必填项不合规"]
+    C -->|"风控"| D4["风控事件"]
+    C -->|"漏记"| D5["疑似漏记"]
+    C -->|"长期未更新"| D6["记录表超期未更新"]
+
+    D1 --> E["系统生成整改通知"]
+    D2 --> E
+    D3 --> E
+    D4 --> E
+    D5 --> E
+    D6 --> E
+
+    E --> F["企微推送给责任人(设计师/运营)"]
+    E --> G["主管收到汇总通知(按天/周)"]
+
+    F --> H["责任人查看问题详情"]
+    H --> I["执行整改"]
+
+    I --> J["系统自动复检"]
+    J --> K{"整改是否<br/>通过?"}
+
+    K -->|"通过"| L["标记「已处理」<br/>关闭整改任务"]
+    K -->|"未通过"| M["再次推送整改通知<br/>追加提醒次数"]
+
+    L --> N["记录操作日志<br/>(可追溯)"]
+    M --> N
+
+    N --> O["更新合规汇总报表"]
+    O --> P["统计整改完成率<br/>平均整改耗时"]
+```
+
+---
+
+## 十、系统整体数据流
+
+```mermaid
+flowchart LR
+    subgraph 数据源["数据源"]
+        S1["企微群消息"]
+        S2["企微群列表"]
+        S3["企微在线文档"]
+        S4["手工录入终端"]
+    end
+
+    subgraph 数据存储["数据存储"]
+        DB1[("群消息库")]
+        DB2[("客户群资产库")]
+        DB3[("沟通记录库")]
+        DB4[("风控事件库")]
+        DB5[("运营内容库")]
+        DB6[("经营指标库")]
+    end
+
+    subgraph 业务处理["业务处理"]
+        P1["消息解析<br/>+ 关键词匹配"]
+        P2["文档识别<br/>+ 群表关联"]
+        P3["合规规则引擎"]
+        P4["风控规则引擎"]
+        P5["意向识别 NLP"]
+        P6["指标计算引擎"]
+        P7["通知推送服务"]
+    end
+
+    subgraph 输出["输出"]
+        O1["预警工单"]
+        O2["合规报表"]
+        O3["数据看板"]
+        O4["企微通知"]
+        O5["周月报"]
+    end
+
+    S1 --> DB1
+    S2 --> DB2
+    S3 --> DB3
+    S4 --> DB6
+
+    DB1 --> P1
+    DB1 --> P4
+    DB1 --> P5
+    DB2 --> P3
+    DB3 --> P2
+    DB3 --> P3
+
+    P1 --> DB4
+    P2 --> DB3
+    P3 --> O2
+    P4 --> DB4
+    P5 --> DB6
+
+    DB4 --> O1
+    DB6 --> P6
+    P6 --> O3
+    P6 --> O5
+
+    P3 --> P7
+    P4 --> P7
+    P7 --> O4
+```
+
+---
+
+*文档版本:v1.0 | 最后更新:2026-05-20*

+ 439 - 0
doc/用户画像和权责分析.md

@@ -0,0 +1,439 @@
+# 用户画像和权责分析
+
+> 基于《企微客户服务-功能清单》《社群运营功能模块-实现可行性清单》与运营老师访谈纪要梳理
+
+---
+
+## 一、用户画像
+
+### 1.1 管理员(总部/区域负责人)
+
+```mermaid
+mindmap
+  root((管理员))
+    角色定位
+      总部或区域管理层
+      系统最高权限者
+      战略决策者
+    核心关注
+      全局经营数据
+      各门店对比排名
+      转化漏斗全貌
+      合规整体情况
+      风控总体态势
+    日常工作
+      查看总览看板
+      审批系统配置
+      查看周月报
+      制定考核标准
+      二期功能规划
+    痛点
+      数据分散在各门店
+      跨店对比困难
+      复盘依赖人工汇总
+      看不到全貌
+    期望
+      一屏看全局
+      自动汇总报表
+      图表下钻到人
+```
+
+### 1.2 店长(门店管理者)
+
+```mermaid
+mindmap
+  root((店长))
+    角色定位
+      门店第一负责人
+      管设计师+销售
+      对门店业绩负责
+    核心关注
+      本店群健康度
+      设计师合规情况
+      销售转化数据
+      风控事件处理
+      门店排名
+    日常工作
+      查看门店看板
+      处理主管汇总通知
+      督促整改
+      审核运营计划
+      复盘门店数据
+    痛点
+      不知道设计师有没有写记录
+      不知道群有没有人管
+      异常发现滞后
+      数据对不上
+    期望
+      异常红字跳到眼前
+      一键看到谁没做
+      自动对比排名
+```
+
+### 1.3 运营(社群运营专员)
+
+```mermaid
+mindmap
+  root((运营))
+    角色定位
+      社群运营执行者
+      内容策划与分发
+      群资产管理者
+    核心关注
+      群数量与健康
+      运营内容效果
+      小区触达率
+      KOC 培养
+      拉群渠道效果
+    日常工作
+      维护小区档案
+      制定周度运营计划
+      管理素材库
+      执行一键群发
+      识别运营内容
+      筛选 KOC 候选人
+      监控群健康度
+    痛点
+      数据统计耗时极长
+      跨表人工统计易出错
+      不知道内容发没发
+      不知道发了效果怎样
+      新人带教耗时巨大
+    期望
+      自动统计数据
+      计划执行自动对照
+      知识沉淀辅助新人
+      内容效果自动追踪
+```
+
+### 1.4 设计师(社群服务提供者)
+
+```mermaid
+mindmap
+  root((设计师))
+    角色定位
+      客户沟通执行者
+      群内服务提供者
+      沟通记录撰写者
+    核心关注
+      我的群列表
+      沟通记录是否合规
+      整改通知
+      意向客户信号
+    日常工作
+      在群内与客户沟通
+      撰写沟通记录在线文档
+      响应整改通知
+      关注意向客户
+    痛点
+      不知道记录表是否合规
+      忘记更新记录表
+      整改要求不明确
+      不知道哪些群缺表
+    期望
+      清晰的问题提示
+      明确的整改要求
+      自动检查减轻负担
+```
+
+### 1.5 销售(销售顾问)
+
+```mermaid
+mindmap
+  root((销售))
+    角色定位
+      客户转化执行者
+      意向客户跟进者
+      经营数据填报者
+    核心关注
+      意向客户线索
+      跟进待办
+      添加客户数
+      签单转化
+    日常工作
+      查看意向客户列表
+      跟进高意向客户
+      登记跟进记录
+      填报添加人数
+      填报订单数据
+    痛点
+      不知道谁有意向
+      跟进容易遗漏
+      数据填报繁琐
+    期望
+      自动推送意向线索
+      待办提醒不遗漏
+      简化填报操作
+```
+
+### 1.6 新人(新入职员工)
+
+```mermaid
+mindmap
+  root((新人))
+    角色定位
+      新入职设计师/运营/销售
+      需要快速上手
+      需要带教支持
+    核心关注
+      学习标准流程
+      参考优秀案例
+      了解运营节奏
+      理解合规要求
+    日常工作
+      学习素材库案例
+      参考异常案例知识库
+      跟随计划执行
+      逐步独立操作
+    痛点
+      不知道怎么做
+      不知道标准是什么
+      依赖老员工手把手教
+    期望
+      知识沉淀可自学
+      案例库辅助上手
+      系统引导减少犯错
+```
+
+---
+
+## 二、用户画像对比总表
+
+| 维度 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 新人 |
+|------|--------|------|------|--------|------|------|
+| **系统权限** | 全部 | 本门店 | 本门店/全部群 | 本人负责群 | 本人客户 | 受限 |
+| **登录频率** | 每天 1-2 次 | 每天多次 | 全天在线 | 每天多次 | 每天多次 | 每天 |
+| **核心页面** | 总览看板/周月报 | 门店看板/预警 | 运营工作台 | 整改通知 | 意向列表 | 素材库 |
+| **关键指标** | 全局转化率 | 门店排名 | 触达率/覆盖率 | 合规率 | 签单数 | 学习进度 |
+| **通知偏好** | 汇总通知 | 汇总+高风险 | 实时预警+汇总 | 整改通知 | 意向通知 | - |
+| **移动端需求** | 中 | 高 | 高 | 高 | 高 | 中 |
+
+---
+
+## 三、权责矩阵(RACI)
+
+> **R** = Responsible 执行者 | **A** = Accountable 负责人 | **C** = Consulted 咨询方 | **I** = Informed 知会方
+
+### 3.1 企微接入与资产管理层
+
+| 功能/决策事项 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 系统 |
+|:-------------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 企业企微授权 | A/R | I | I | - | - | C |
+| 人员账号纳入管理 | A | C | R | I | I | C |
+| 新建/登记客户群 | I | I | R | C | - | C |
+| 小区档案录入 | I | A | R | - | - | C |
+| 小区-群-门店关联维护 | I | A | R | - | - | C |
+| 群健康度评估规则配置 | A | C | R | - | - | C |
+
+### 3.2 沟通记录与合规检查层
+
+| 功能/决策事项 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 系统 |
+|:-------------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 在群内发沟通记录文档 | I | I | C | R | - | I |
+| 文档链接识别与登记 | - | - | I | - | - | R |
+| 缺表群识别 | I | I | R | I | - | C |
+| 合规检查(格式/必填项) | I | I | R | I | - | C |
+| 整改通知推送 | I | I | R | I | - | C |
+| 整改执行 | - | - | I | R | - | I |
+| 整改复检与关闭 | I | I | R | I | - | C |
+| 合规汇总报表 | I | A | R | I | - | C |
+
+### 3.3 风控预警与异常干预层
+
+| 功能/决策事项 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 系统 |
+|:-------------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 风险关键词库配置 | A | C | R | - | - | C |
+| 预警阈值配置 | A | C | R | - | - | C |
+| 群消息实时监听 | - | - | I | - | - | R |
+| 预警事件处理 | I | A | R | I | - | C |
+| 工单派发与跟踪 | I | A | R | I | - | C |
+| 异常案例知识库维护 | I | C | R | I | - | C |
+
+### 3.4 内容运营与执行层
+
+| 功能/决策事项 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 系统 |
+|:-------------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 素材库管理 | I | C | R | C | - | C |
+| 一键群发 | - | I | R | - | - | C |
+| 周度运营计划制定 | I | A | R | I | - | C |
+| 计划执行对照 | I | A | R | - | - | C |
+| 运营内容发布识别 | - | I | I | - | - | R |
+| 互动效果统计 | I | I | R | - | - | C |
+| 发布时间建议 | - | - | I | - | - | R |
+
+### 3.5 拉群、KOC 与意向客户层
+
+| 功能/决策事项 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 系统 |
+|:-------------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 拉群渠道登记 | I | I | R | - | - | C |
+| 渠道效果分析 | I | A | R | - | - | C |
+| KOC 候选人筛选 | - | - | I | - | - | R |
+| KOC 人工确认 | I | C | R | - | - | I |
+| KOC 标签打标 | - | I | R | - | - | C |
+| 样板间目标维护 | I | A | R | - | - | C |
+| 意向客户识别 | - | - | I | - | - | R |
+| 意向等级确认 | - | I | C | - | R | C |
+| 跟进待办执行 | - | A | I | - | R | C |
+
+### 3.6 数据看板与经营复盘层
+
+| 功能/决策事项 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 系统 |
+|:-------------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 总览看板 | A | I | I | I | I | R |
+| 小区/门店看板 | I | A | R | I | I | C |
+| 群活跃度计算 | - | I | I | - | - | R |
+| 触达率/链接率计算 | I | A | R | - | - | C |
+| 转化漏斗展示 | A | A | I | - | I | C |
+| 添加客户数填报 | I | A | I | - | R | C |
+| 订单与转化填报 | I | A | I | - | R | C |
+| 直播数据填报 | I | I | R | - | - | C |
+| 周月报生成 | A | A | R | I | I | C |
+
+### 3.7 统一工作台与通知闭环层
+
+| 功能/决策事项 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 系统 |
+|:-------------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 角色权限配置 | A | C | I | - | - | C |
+| 工作台定制 | A | C | R | I | I | C |
+| 整改通知推送 | - | I | R | I | - | C |
+| 主管汇总通知 | I | R | I | - | - | C |
+| 操作日志审计 | A | I | I | - | - | R |
+
+---
+
+## 四、权限层级设计
+
+```mermaid
+graph TD
+    subgraph 数据权限["数据权限范围"]
+        L1["Level 1: 全局<br/>管理员 — 全部门店/全部数据"]
+        L2["Level 2: 门店<br/>店长 — 本门店及下属全部数据"]
+        L3["Level 3: 群组<br/>运营 — 负责的小区/群数据"]
+        L4["Level 4: 个人<br/>设计师/销售 — 本人负责的群/客户"]
+        L5["Level 5: 受限<br/>新人 — 学习资源 + 受限操作"]
+    end
+
+    L1 --> L2 --> L3 --> L4 --> L5
+```
+
+---
+
+## 五、功能权限矩阵
+
+| 功能操作 | 管理员 | 店长 | 运营 | 设计师 | 销售 | 新人 |
+|----------|:------:|:----:|:----:|:------:|:----:|:----:|
+| 系统配置修改 | ● | ○ | - | - | - | - |
+| 人员账号管理 | ● | ○ | - | - | - | - |
+| 查看全部数据 | ● | - | - | - | - | - |
+| 查看本门店数据 | ● | ● | ● | - | - | - |
+| 查看本人数据 | ● | ● | ● | ● | ● | ● |
+| 创建/编辑小区档案 | ● | ● | ● | - | - | - |
+| 管理素材库 | ● | ● | ● | - | - | - |
+| 制定运营计划 | ● | ● | ● | - | - | - |
+| 执行一键群发 | ● | ● | ● | - | - | - |
+| 审核 KOC | ● | ● | ● | - | - | - |
+| 处理预警工单 | ● | ● | ● | - | - | - |
+| 编辑沟通记录文档 | ● | ● | ● | ● | - | - |
+| 关闭整改任务 | ● | ● | ● | - | - | - |
+| 填报经营数据 | ● | ● | ● | - | ● | - |
+| 查看意向客户 | ● | ● | ● | ● | ● | - |
+| 跟进意向客户 | ● | ● | - | - | ● | - |
+| 查看素材库 | ● | ● | ● | ● | ● | ● |
+| 查看知识库 | ● | ● | ● | ● | ● | ● |
+
+> ● 有权  ○ 受限(仅本门店/本人)  - 无权
+
+---
+
+## 六、关键业务场景与角色映射
+
+### 6.1 日常监控场景
+
+```mermaid
+flowchart LR
+    subgraph 早会["每日早会"]
+        A1["管理员:查看总览看板<br/>了解全局指标"]
+        A2["店长:查看门店看板<br/>关注异常与排名"]
+        A3["运营:查看预警大屏<br/>处理待办工单"]
+    end
+
+    subgraph 日常["日常工作"]
+        B1["设计师:查看整改通知<br/>补写沟通记录"]
+        B2["销售:查看意向列表<br/>跟进高意向客户"]
+        B3["运营:执行运营计划<br/>管理群资产"]
+    end
+
+    subgraph 收尾["日/周收尾"]
+        C1["销售:填报添加+订单数据"]
+        C2["运营:检查计划执行对照"]
+        C3["店长:查看汇总通知<br/>督促未完成项"]
+    end
+
+    A1 --> B1
+    A2 --> B2
+    A3 --> B3
+    B1 --> C1
+    B2 --> C2
+    B3 --> C3
+```
+
+### 6.2 异常处理场景
+
+| 步骤 | 动作 | 执行角色 | 负责角色 | 知会角色 |
+|:----:|------|:--------:|:--------:|:--------:|
+| 1 | 系统检测异常 | 系统 | - | - |
+| 2 | 预警推送到企微 | 系统 | - | 运营 + 店长 |
+| 3 | 评估严重程度 | 运营 | 店长 | - |
+| 4 | 派发处理工单 | 运营 | 店长 | 设计师(如涉及) |
+| 5 | 执行处理动作 | 设计师/运营 | 店长 | - |
+| 6 | 关闭工单 | 运营 | 店长 | 管理员 |
+| 7 | 归档案例 | 运营 | - | 全员(知识库共享) |
+
+### 6.3 合规整改场景
+
+| 步骤 | 动作 | 执行角色 | 负责角色 | 知会角色 |
+|:----:|------|:--------:|:--------:|:--------:|
+| 1 | 系统生成合规检查结果 | 系统 | - | - |
+| 2 | 推送整改通知 | 系统 | - | 设计师 |
+| 3 | 推送主管汇总 | 系统 | - | 店长 |
+| 4 | 查看问题明细 | 设计师 | - | 运营 |
+| 5 | 修改沟通记录文档 | 设计师 | 店长 | - |
+| 6 | 系统自动复检 | 系统 | - | - |
+| 7 | 确认关闭 | 运营 | 店长 | 管理员 |
+| 8 | 更新合规报表 | 系统 | - | 店长 + 管理员 |
+
+---
+
+## 七、角色协作关系图
+
+```mermaid
+graph TB
+    Admin["管理员<br/>━━━━━━<br/>全局管控<br/>战略决策"] -->|"下达目标"| Manager["店长<br/>━━━━━━<br/>门店管理<br/>业绩负责"]
+    Manager -->|"督促执行"| Operator["运营<br/>━━━━━━<br/>社群运营<br/>内容策划"]
+    Manager -->|"督促执行"| Designer["设计师<br/>━━━━━━<br/>客户沟通<br/>记录撰写"]
+    Manager -->|"督促执行"| Sales["销售<br/>━━━━━━<br/>客户转化<br/>数据填报"]
+
+    Operator -->|"推送内容"| Designer
+    Operator -->|"推送线索"| Sales
+    Operator -->|"带教指导"| Newbie["新人<br/>━━━━━━<br/>学习成长"]
+
+    Designer -->|"识别意向"| Sales
+    Sales -->|"转化结果反馈"| Manager
+
+    Admin -..->|"汇总报表"| System["系统"]
+    Manager -..->|"预警通知"| System
+    Operator -..->|"运营执行"| System
+    Designer -..->|"沟通记录"| System
+    Sales -..->|"数据补录"| System
+
+    System -->|"数据看板"| Admin
+    System -->|"门店看板"| Manager
+    System -->|"预警工单"| Operator
+    System -->|"整改通知"| Designer
+    System -->|"意向待办"| Sales
+    System -->|"知识库"| Newbie
+```
+
+---
+
+*文档版本:v1.0 | 最后更新:2026-05-20*

+ 22 - 0
doc/社群运营.md

@@ -0,0 +1,22 @@
+根据您提供的访谈原文和可行性清单,我为您梳理了运营老师的核心意见、目前需要匹配实现的模块,以及期望的 UI 展示效果。
+
+**运营老师的核心意见与痛点**
+- **最大痛点是数据统计**:目前高度依赖人工跨表统计数据,耗时极长且容易出错(“数据对不上”),严重影响管理和复盘效率。
+- **首要需求是预警与风控**:如果只能先解决一个问题,老师明确表示最希望“更快速发现异常和风险”,以便及时介入处理。
+- **极度关注知识沉淀与新人带教**:大量时间(最耗时的工作之一)花在手把手教新人上。希望系统能沉淀优秀的开拓、运营、活动方法论和案例,辅助新人快速上手和能力培养。
+- **重视关键业务节点的转化数据**:重点关注资源开拓情况、目标群人数、添加微信数、QOC(KOC)占比、样板间数量以及最终的签单转化率。
+
+**目前需要优先实现的功能模块**
+结合老师的需求和《实现可行性清单》,以下模块是目前必须重点实现的(主要集中在 P0 优先级):
+
+- **异常预警与干预派单模块(模块4)& 群风控与预警模块(模块3)**:直接解决老师“最快发现异常和风险”的核心诉求。需要配置预警阈值,实现异常情况的自动识别和提醒。
+- **多层级数据看板模块(模块5)**:解决人工统计数据的痛点。需包含总览、小区、门店看板,展示群人数、覆盖率等核心指标。
+- **小区与群资产管理模块(模块1)& 小区拉群与KOC管理模块(模块6)**:满足老师对“资源开拓情况”和“QOC(KOC)目标、样板间目标”的管理需求。
+- **社群内容规划与运营模块(模块2)**:初步满足老师对“知识沉淀”的需求,提供内容素材库,辅助新人了解各阶段的运营动作。
+- **需特别注意的妥协模块(模块11、12、14)**:老师非常看重“添加微信数”和“签单转化率”,但根据清单,这些动作**目前无法自动获取**。系统设计时必须保留销售手动回填的入口,以支撑数据看板的完整性。
+
+**UI的展示效果期望**
+- **千人千面的定制化视图**:不同部门(如直营赋能部、小区运营部)和不同角色(店长、运营、新人)登录后,应看到符合自身关注点的定制化工作台。
+- **视觉强提醒**:对于异常和风险信息,必须有极其醒目的提示(老师原话:“异常风险跳红到你眼前”)。
+- **图表为主,支持下钻明细**:看板必须采用直观的图表形式(如趋势图、排名图、漏斗图等),但点击图表后,必须能够下钻查看具体的明细数据(具体到某个人、某个小区)。
+- **二合一的综合工作台**:UI 布局上应将“监控预警”与“日常运营工作台”结合在同一个界面中,避免用户在多个系统或页面间来回切换。

+ 99 - 0
doc/社群运营功能模块-实现可行性清单.md

@@ -0,0 +1,99 @@
+# 社群运营功能模块 - 实现可行性清单
+
+---
+
+## 一、当前可以实现的功能模块
+
+### P0 优先级(核心功能)
+
+| 模块编号 | 模块名称 | 功能简述 | 需手动录入的基础数据 |
+|---------|---------|---------|------------------|
+| 1 | 小区与群资产管理模块 | 小区信息管理、群生命周期管理、多门店协同经营、群健康度评估 | 小区基础信息(总户数、房价、交房时间等)、小区与群的关联关系 |
+| 2 | 社群内容规划与运营模块 | 内容类型策略、发布时机推荐、内容素材库、内容效果追踪 | 内容素材(初期) |
+| 3 | 群风控与预警模块 | 群消息实时监控、风险关键词识别、危机处理流程 | 风险关键词库(初期) |
+| 4 | 异常预警与干预派单模块 | 群异常自动识别、任务派单、异常处理知识库 | 预警阈值配置、异常处理知识库(初期) |
+| 5 | 多层级数据看板模块 | 总览看板、小区看板、门店看板、核心指标展示 | 小区基础信息 |
+| 6 | 小区拉群与KOC管理模块 | 拉群渠道管理、KOC自动识别、KOC激励与协作 | KOC激励政策、拉群渠道信息 |
+| 7 | 群内意向客户识别模块 | 基于群聊智能识别意向客户、意向等级评估、跟进任务触发 | - |
+| 8 | 社群运营动作记录模块 | 周度运营计划、执行状态跟踪、自动识别运营内容发布 | 运营计划 |
+
+### P1 优先级(增强功能)
+
+| 模块编号 | 模块名称 | 功能简述 | 需手动录入的基础数据 |
+|---------|---------|---------|------------------|
+| 9 | 舆情/话题/竞品分析模块 | 群内话题分析、竞品动态追踪、舆情监控报告 | 竞品关键词库(初期) |
+| 10 | 小区信息采集与进度管理模块 | 小区基础信息采集、进度追踪、户型与方案库 | 小区实地勘探记录、户型图、方案库 |
+
+---
+
+## 二、当前无法实现的功能模块
+
+| 模块编号 | 模块名称 | 无法实现原因 | 替代方案 |
+|---------|---------|------------|---------|
+| 11 | 销售添加动作与链接率跟踪模块 | 无法自动获取销售个人账号的添加客户数据 | 销售手动回填添加结果 |
+| 12 | 私域客户跟进与转化模块 | 无法获取销售与客户的一对一沟通记录、跟进数据 | 简化为基础客户信息管理 |
+| 13 | 直播策划与执行模块(完整功能) | 无法自动获取直播观看数据、互动数据 | 可分析群内直播相关讨论,观看数据需手动回填 |
+| 14 | 周月报与复盘沉淀模块(完整功能) | 转化数据、添加数据无法自动获取 | 可自动汇总部分数据,缺失部分需手动补充 |
+
+---
+
+## 三、核心指标实现情况
+
+### 完全自动统计的指标
+
+| 指标名称 | 说明 |
+|---------|------|
+| 群活跃度指数 | 基于群消息数据综合评估 |
+| 异常预警响应时间 | 从预警到处理的时间 |
+| 风控事件处理率 | 风控事件的闭环处理 |
+
+### 需部分手动数据支持的指标(计算公式可自动)
+
+| 指标名称 | 说明 | 需手动提供的数据 |
+|---------|------|----------------|
+| 小区触达率 | 群人数/小区总户数 | 小区总户数 |
+| 运营内容覆盖率 | 有运营内容的群占比 | 小区与群的关联关系 |
+
+### 需完全手动数据支持的指标
+
+| 指标名称 | 说明 |
+|---------|------|
+| 客户链接率 | 已添加通过/群人数 |
+| 订单转化率 | 订单数/已添加客户数 |
+| 小区总转化率 | 订单数/小区总户数 |
+
+---
+
+## 四、数据获取说明
+
+### 完全自动获取的数据(无需人工干预)
+
+- 群基础数据:群成员列表、群人数变化、进群时间
+- 群消息数据:所有群聊消息、发送时间、发送者、消息内容
+- 群互动数据:@消息、回复消息、表情反应
+- 运营内容数据:发布的运营内容(可自动识别)
+- 潜在信号数据:业主询问、讨论话题、关注点识别
+
+### 需一次性初始化录入的数据(配置类)
+
+- 小区基础信息:小区总户数、房价、交房时间等
+- 小区与群的关联关系
+- 风险关键词库
+- 竞品关键词库
+- 预警阈值配置
+- 内容素材(初期)
+- KOC激励政策
+
+### 需持续手动录入的数据(业务类)
+
+- 销售添加客户记录
+- 客户跟进记录
+- 订单转化数据
+- 直播观看数据
+- 小区进度更新
+- 周度运营计划
+
+---
+
+*文档版本:v1.0*
+*最后更新:2026-05-10*

+ 77 - 0
doc/跨部门对接沟通清单.md

@@ -0,0 +1,77 @@
+# 企微客户服务系统 - 跨部门对接沟通与确权清单
+
+> 本清单基于系统当前前端页面和基础架构提取,用于协助项目负责人(PM)与后端开发、业务部门、合规法务等进行具体需求的落地确认。
+
+---
+
+## 1. 企微接口能力与数据对齐(后端开发 & 企微管理员)
+
+由于系统大量业务依赖企业微信的开放能力,需要优先确认以下接口的可行性和资质:
+
+- [ ] **会话存档接口(核心风控能力)**:
+  - 是否已购买并开通企业微信“会话存档”功能?
+  - 后端是否已实现消息的解密和存储?
+  - **影响模块**:风控预警、聊天记录审查。
+- [ ] **群管理原生 API**:
+  - 能否通过 API 获取每日群成员进出记录、群内发言活跃度统计?
+  - 若企微原生接口不支持,是否考虑引入第三方 RPA 工具或让导购在侧边栏手动打卡?
+  - **影响模块**:客户群管理、Dashboard 活跃度统计。
+- [ ] **群发助手 API**:
+  - 系统“一键群发”功能调用企微 API 时的频率限制和额度是多少?
+  - 能否通过接口获取导购是否执行了群发任务的回执状态?
+  - **影响模块**:内容运营 - 一键群发。
+- [ ] **客户身份绑定**:
+  - 企微客户的 `external_userid` 如何与公司内部 CRM 系统的会员 ID 映射?
+  - **影响模块**:意向客户、订单转化。
+
+---
+
+## 2. 业务规则与 SOP 明确(业务主管 & 门店运营)
+
+前端目前采用了 Mock 数据,真实环境需要业务部门提供确切的判定逻辑:
+
+- [ ] **群健康分计算模型**:
+  - 满分 100 分,各个维度的权重占比是多少?(如:每日活跃人数占 40%,客服响应速度占 30%,退群率占 30%)
+- [ ] **风控严重等级判定标准**:
+  - 什么情况算“严重”风控?(如:发送竞品链接、辱骂客户)
+  - 什么情况算“轻微”预警?(如:客户提问超过 30 分钟未回复)
+- [ ] **敏感词库管理规范**:
+  - 目前需要内置哪些行业通用敏感词?
+  - 词库的维护权限是归总部法务,还是各区域总监可以自行添加?
+- [ ] **SOP 任务下发流程**:
+  - 总部下发的“周度计划”是强制执行还是建议执行?
+  - 未执行的考核惩罚机制是什么?是否需要系统提供红黑榜?
+
+---
+
+## 3. 内部系统打通与资源诉求(ERP / 数据团队)
+
+- [ ] **地理位置与楼盘数据**:
+  - 客户群需要挂靠到“小区”,目前公司是否有现成的小区楼盘数据库(含经纬度、交房时间)?通过什么接口获取?
+  - **影响模块**:客户群管理 - 小区档案。
+- [ ] **订单转化数据回传**:
+  - 导购在企微转化客户后,ERP 订单中是否会记录该企微线索的来源?
+  - 财务在核算业绩时,是否认可该系统抓取的转化数据?
+- [ ] **素材库存储**:
+  - 图片/视频等营销素材存在哪里的 OSS/CDN?前端上传和拉取的鉴权机制是什么?
+
+---
+
+## 4. 权限与角色矩阵 (RBAC) 确权(产品经理)
+
+目前预设了 admin, store_manager, operator, designer, sales 角色,需要进一步确权:
+
+- [ ] **门店店长数据隔离**:
+  - 店长是否只能看自己门店的群和员工数据?跨店调拨员工时数据如何交接?
+- [ ] **一线导购视图**:
+  - 导购平时主要用手机(企微客户端),目前系统以 PC 端后台为主。是否需要紧急立项开发“企微侧边栏 H5”供导购日常打卡和快速发素材?
+- [ ] **超级管理员审计**:
+  - 是否需要记录所有用户的登录时间、敏感操作(导出报表、删除群)的日志以备安全审查?
+
+---
+
+## 下一步建议执行路径
+
+1. **第 1 步(技术摸底)**:后端拿着第 1 模块与企微官方文档核对,输出一份《企微接口可行性评估报告》。
+2. **第 2 步(业务碰头)**:PM 拿着第 2、3 模块与运营主管、销售总监开会,敲定数据计算公式和考核制度。
+3. **第 3 步(产品规划)**:根据上述两步的反馈,修正原型图,输出“企微侧边栏”的原型设计,并推进后端数据库表结构的设计。

+ 333 - 0
doc/项目现状分析与待确认事项.md

@@ -0,0 +1,333 @@
+# 拉迷企微客户服务系统 — 项目现状分析与待确认事项
+
+> 生成日期:2026-05-21 | 基于对项目全部 49 个组件、4 个服务、19 个数据模型、9 份文档的完整审查
+
+---
+
+## 一、项目整体进度总览
+
+### 1.1 已完成的基础设施
+
+| 项目 | 状态 | 说明 |
+|------|------|------|
+| Angular 20 项目骨架 | 完成 | Standalone 组件,lazy loading 路由 |
+| Tailwind CSS v4 + Fiori 色彩体系 | 完成 | CSS 变量化,暗色模式 `@custom-variant dark` |
+| 认证系统 (登录/注册/忘记密码) | 完成 | Mock 数据 + localStorage 持久化 |
+| AuthStore (信号式状态管理) | 完成 | 角色、权限判断 |
+| 5 个布局组件 | 完成 | ShellBar, SideNav, MainLayout, UserMenu, NotificationCenter |
+| 9 个共享组件 | 完成 | DataTable, StatCard, ChartCard, PageHeader, FilterBar, StatusBadge, EmptyState, ConfirmDialog, MessageStrip |
+| 暗色模式切换 | 完成 | ThemeService + CSS 变量全量适配 |
+| 路由守卫 | 完成 | AuthGuard + RoleGuard |
+| 滚动条闪烁修复 | 完成 | 移除 fixed 遮罩,改用 HostListener 点击外部检测 |
+
+### 1.2 功能模块实现程度
+
+| 模块 | 路由数 | 状态 | 说明 |
+|------|--------|------|------|
+| Dashboard | 1 | **已实现** | 统计卡片 + 图表 + 表格,含角色重定向 |
+| Workspace | 3 | **已实现** | 千人千面工作台(按角色定制卡片/快捷操作/待办) |
+| Group Management | 3 | **已实现** | 群列表 + 小区档案 + 群详情 |
+| Documents | 2 | **已实现** | 文档登记列表 + 文档异常检测 |
+| Compliance | 4 | **部分实现** | 合规总览/缺表/格式检查/报表均有页面 |
+| Risk Control | 4 | **已实现** | 预警大屏 + 关键词库 + 工单 + 案例库 |
+| Content Ops | 4 | **部分实现** | 素材库/周计划/群发/效果分析 |
+| Acquisition | 4 | **部分实现** | 渠道管理/KOC/意向客户/样板间 |
+| Data Entry | 3 | **待确认** | 客户/订单/直播补录(表单是否完整?) |
+| Reports | 2 | **待确认** | 周报/月报(内容是否充分?) |
+| Settings | 2 | **已实现** | 系统设置 + 个人设置 |
+
+---
+
+## 二、需要确认的核心问题(按优先级排列)
+
+### P0 — 阻塞性问题
+
+#### 2.1 通知系统:通知是如何产生的?通知逻辑是什么?
+
+**当前状态:**
+- `AppNotification` 模型定义了 5 种类型(risk/compliance/plan/system/info)和 4 种严重级别(high/medium/low/info)
+- `MockDataService` 在初始化时生成 8 条静态模拟通知
+- 下拉通知中心(notification-center)使用 `NotificationService` 的 BehaviorSubject
+- 全屏通知页面(workspace-notifications)直接使用 `MockDataService`,**绕过了 NotificationService**
+- 这意味着:在通知中心标记已读 → 在通知页面不会同步,反之亦然
+
+**需要确认:**
+1. 通知的**真实产生机制**是什么?
+   - [ ] 是企微 Webhook 推送到后端,后端生成通知?
+   - [ ] 是系统定时扫描数据库,自动生成通知?
+   - [ ] 是人工在后台创建通知?
+   - [ ] 是以上混合?
+
+2. 通知的**推送渠道**是什么?
+   - [ ] 仅系统内消息中心?
+   - [ ] 通过企微消息推送(如红字提醒)?
+   - [ ] 短信/邮件?
+   - [ ] 混合方式?
+
+3. 通知的**分发规则**是什么?
+   - 谁收到什么类型的通知?按角色?按门店?按群归属?
+   - 主管如何收到汇总通知(每日/每周?实时?)?
+   - 整改通知发给设计师还是运营?
+
+4. 通知的**生命周期**:
+   - 通知创建后是否可以修改?
+   - 是否有"已处理"vs"已读"的区分?(当前模型有 `actionable` 和 `actionTaken` 字段但完全未使用)
+   - 通知是否会自动过期/清理?
+
+5. 通知中心的**列表信息展示**需要看到哪些字段?
+   - 当前显示:标题 + 描述 + 时间 + 严重级别标记
+   - 是否需要显示:所属群名、小区名、责任人、处理状态?
+
+#### 2.2 合规整改工作流:具体操作步骤是什么?
+
+**当前状态:**
+- 合规检查模块有缺表识别、格式检查、文档异常三个子页面
+- StatusBadge 定义了整改流程的 5 个状态:`not_notified → notified → fixing → rechecked → recheck_failed`
+- MissingDocs 页面有"通知全部"按钮,点击后通过 `NotificationService.addNotification()` 创建通知
+- **但通知不会持久化**(addNotification 只更新 BehaviorSubject,不写 localStorage)
+
+**需要确认:**
+1. 发现合规问题后的**完整操作流程**:
+   - 运营人员发现问题 → 点击"通知" → 设计师收到通知 → 设计师修改文档 → 运营复检 → 通过/驳回?
+   - 设计师在哪里查看待整改列表?是否有专门的"我的待整改"视图?
+   - 整改是否有截止时间?超期如何处理?
+
+2. 整改通知的**具体内容**应该包含什么?
+   - 群名称 + 小区名称(已有)
+   - 具体问题描述(如"缺沟通记录表"、"第X行日期格式错误")(当前只有通用标题)
+   - 问题截图/链接?(当前无)
+   - 操作按钮(如"前往修改"跳转到文档链接)?
+
+3. **复检机制**:
+   - 复检是自动的(系统重新扫描文档)还是人工的(运营手动确认)?
+   - 复检不通过时,是回到"整改中"状态还是新建一条通知?
+   - 复检通过后,该合规问题是否从列表中移除?
+
+4. **批量操作**:
+   - "通知全部"是否确认可用?需要二次确认对话框吗?
+   - 是否需要"按门店通知"、"按类型通知"的筛选操作?
+
+#### 2.3 数据录入表单:需要录入哪些字段?
+
+**当前状态:**
+- 3 个数据录入路由已配置:customer-entry, order-entry, live-entry
+- 需要确认这 3 个页面目前是完整实现还是占位符
+
+**需要确认:**
+1. **客户补录**:需要录入哪些字段?
+   - 客户姓名、电话、微信号?
+   - 来源群(哪个群来的客户)?
+   - 意向等级?
+   - 跟进状态?
+
+2. **订单/转化补录**:需要录入哪些字段?
+   - 订单金额?签单日期?
+   - 关联客户?关联群?
+   - 转化来源(线上/线下/转介绍)?
+
+3. **直播数据补录**:需要录入哪些字段?
+   - 直播主题?日期?
+   - 观看人数?互动次数?
+   - 产生的意向客户数?
+
+4. **表单验证规则**是什么?
+5. 数据补录后是否需要**审批流程**?
+
+---
+
+### P1 — 重要问题
+
+#### 2.4 角色权限:各角色具体能看什么、做什么?
+
+**当前状态:**
+- AuthStore 有 `hasRole()` 方法,RoleGuard 实现了基础角色拦截
+- 侧导航按角色过滤菜单项
+- 工作台按角色显示不同卡片和快捷操作
+
+**需要确认:**
+1. **5 个角色的具体数据权限范围**:
+   - 管理员:全局数据(所有门店/所有群)?
+   - 店长:仅本门店数据?
+   - 运营:所负责的门店/群?
+   - 设计师:所负责的群?
+   - 销售:所分配的意向客户?
+
+2. **功能权限的细粒度**:
+   - 设计师能否查看风控预警?(当前菜单不显示,但路由未加 RoleGuard)
+   - 销售能否查看合规报表?
+   - 运营能否修改系统设置?
+   - 哪些页面需要**双重 RoleGuard**?
+
+3. **数据可见性**:
+   - 不同角色看同一页面(如 Dashboard)时,是否显示不同数据?
+   - MockDataService 目前返回全部数据,未做按角色/门店过滤
+
+#### 2.5 企微对接:真实数据如何进入系统?
+
+**当前状态:**
+- 全部使用 MockDataService 生成静态数据 + localStorage 持久化
+- 无 HTTP 服务、无 API 调用、无 WebSocket
+
+**需要确认:**
+1. **对接方案**:
+   - 是否有后端 API 服务?技术栈是什么?
+   - API 接口文档在哪里?
+   - 是否需要 API 层(HttpClient + Interceptor)?
+   - 是否使用 WebSocket 接收实时群消息?
+
+2. **数据同步方式**:
+   - 群列表是定时拉取还是 Webhook 推送?
+   - 群消息是实时接收还是批量导入?
+   - 文档合规检查是定时任务还是手动触发?
+
+3. **Mock 数据迁移计划**:
+   - MockDataService 是否需要保留作为开发/演示模式?
+   - 是否需要抽象一层 DataService 接口方便切换?
+
+#### 2.6 群发功能:具体交互流程是什么?
+
+**当前状态:**
+- 路由 `/content/batch-send` 已配置
+- 素材库(material-library)和批量发送(batch-send)是两个独立页面
+
+**需要确认:**
+1. 群发的**操作流程**:
+   - 选择内容 → 选择目标群 → 预览 → 确认发送?
+   - 是否需要定时发送(计划发送)?
+   - 发送后如何知道是否成功?
+
+2. 群发的**内容来源**:
+   - 从素材库选择已有素材?
+   - 支持即时编辑(修改话术以适应不同群)?
+   - 支持图片/视频/文件吗?
+
+3. 群发的**限制**:
+   - 一次最多发多少个群?
+   - 是否需要间隔时间(防企微风控)?
+   - 谁有权群发?(仅运营?店长也可以?)
+
+#### 2.7 报表系统:周报和月报的内容和格式?
+
+**当前状态:**
+- 路由 `/reports/weekly` 和 `/reports/monthly` 已配置
+- 需要确认当前页面内容
+
+**需要确认:**
+1. **周报**应包含哪些内容?
+   - 新增群数、群总数、活跃率?
+   - 合规达标率、缺表率?
+   - 风控事件数量?
+   - 内容执行率(计划 vs 实际)?
+   - 意向客户统计?
+   - 需要手动填写的转化数据部分?
+
+2. **月报**应包含哪些内容?
+   - 月度趋势图?
+   - 门店对比排名?
+   - KOC 发展情况?
+   - 样板间完成进度?
+
+3. 报表的**导出格式**?
+   - PDF?Excel?还是仅在线查看?
+
+---
+
+### P2 — UI/UX 细节确认
+
+#### 2.8 通知中心 UI 需要确认的细节
+
+| 问题 | 选项 | 
+|------|------|
+| 通知列表是否需要**分类 Tab**(全部/未读/风险/合规)? | 当前为混合列表 |
+| 通知是否需要**批量操作**(全部已读/批量删除)? | 当前有"全部已读"按钮 |
+| 通知点击后行为? | 当前:有 link 则跳转,无 link 则仅标记已读 |
+| 是否需要**通知偏好设置**? | Settings 页面有通知开关 UI 但未实际生效 |
+
+#### 2.9 表格列对齐
+
+当前所有表格列都是左对齐。需要确认:
+- 数字列(人数、消息数、健康分、金额)→ 应为**右对齐**
+- 百分比列(活跃率、达标率、转化率)→ 应为**右对齐**
+- 日期列 → **左对齐**(保持当前)
+- 操作列 → **居中**(保持当前)
+
+#### 2.10 图表颜色规范
+
+当前图表使用硬编码颜色。需确认是否使用 Fiori 语义色:
+- 高活跃 / 通过 / 已处理:`#188918` (positive)
+- 中活跃 / 待处理 / 进行中:`#0070F2` (primary)
+- 低活跃 / 警告 / 待整改:`#E76500` (critical)
+- 高风险 / 不活跃 / 严重:`#D9364B` (negative)
+- 中性 / 未知:`#8D8D90` (surface-500)
+
+#### 2.11 移动端适配
+
+`UI设计规范白皮书` 明确说"定宽布局为主,设计稿宽度 1440px"。但需确认:
+- 是否需要在平板(768-1024px)上可用?
+- 是否需要手机端适配?
+- 侧边栏在小屏上的行为(自动折叠?)
+
+---
+
+### P3 — 技术债务 & 代码优化
+
+#### 2.12 数据层不一致
+
+| 问题 | 严重度 | 说明 |
+|------|--------|------|
+| `addNotification` 不持久化 | 高 | 刷新页面后通知丢失 |
+| notification-center 和 workspace-notifications 数据源不同 | 高 | 标记已读不同步 |
+| MockDataService 缺少写方法 | 中 | 无 create/update/delete |
+| 部分组件直接使用 MockDataService 而非 Service 层 | 中 | 未来切换 API 困难 |
+| `mock-data.service.ts` 使用 `any` 类型 | 低 | 丧失 TS 类型安全 |
+
+#### 2.13 未使用的代码
+
+| 文件 | 说明 |
+|------|------|
+| `AppNotification.actionable` / `actionTaken` | 字段已定义但从未读写 |
+| `NotificationService` 与 `WorkspaceNotificationsComponent` 不一致 | 两套数据流 |
+| Settings 页面的通知偏好开关 | 写了 UI 但未实际接入通知过滤逻辑 |
+
+#### 2.14 技术文档缺失
+
+- 无 API 接口文档(因为尚无后端)
+- 无状态管理架构说明(哪些用 Signal、哪些用 BehaviorSubject)
+- 无测试覆盖(仅 1 个 spec 文件)
+
+---
+
+## 三、涉及外部能力的待确认事项(来自功能清单文档)
+
+以下事项标注为"待确认"或"不可承诺",需要实际环境验证:
+
+| 序号 | 功能 | 状态 | 影响范围 |
+|------|------|------|----------|
+| 1 | 自动验证沟通记录表是否在群内置顶 | 需实际试用后确认 | 合规检查-置顶检查 |
+| 2 | 读取在线文档的正文文字和表格 | 待确认 | 合规检查-格式检查/必填项检查 |
+| 3 | 客户每句话与记录表逐句对照 | 测试中(不可承诺) | 合规检查-疑似漏记 |
+| 4 | 自动区分进群成员的渠道来源 | 不可实现 | 获客-渠道效果统计 |
+| 5 | NLP 自动确认 KOC(不经人工审核) | 不可实现 | KOC 管理 |
+| 6 | 100% 自动准确分级意向客户 | 不可实现 | 意向客户 |
+| 7 | 从群内发起添加好友 | 需试用后确认 | 意向客户跟进 |
+| 8 | 群外沟通(电话/线下/私聊)是否记入表 | 暂无法实现 | 合规检查 |
+
+---
+
+## 四、建议的下一步行动
+
+1. **优先确认 P0 问题**(通知逻辑、合规整改流程、数据录入字段)— 这些决定后续开发方向
+2. **确认 P1 问题**(权限细粒度、企微对接方案、群发流程、报表内容)— 影响功能完整性
+3. **逐一确认 P2 问题**(UI 细节、表格对齐、图表颜色、移动端需求)— 可在开发过程中迭代
+4. **建立 API 层** — 确认后端技术栈后,抽象 DataService 接口,逐步替换 MockDataService
+5. **统一通知数据流** — 修复 notification-center 和 workspace-notifications 数据不同步问题
+
+---
+
+## 五、文档引用缺失
+
+`企微客户服务-功能清单(2).md` 引用了以下两个不存在的文档:
+- `企微客户服务-功能模块划分.md`
+- `企微客户服务-功能可实现性说明.md`
+
+请确认这两个文档是否存在其他位置,或需要补充编写。

+ 7 - 0
lami-base-v1/.claude/settings.local.json

@@ -0,0 +1,7 @@
+{
+  "permissions": {
+    "allow": [
+      "Bash(npx tsc *)"
+    ]
+  }
+}

+ 6 - 0
lami-base-v1/.gitignore

@@ -0,0 +1,6 @@
+node_modules/
+*.local
+*.log
+package-lock.json
+.DS_Store
+Thumbs.db

+ 48 - 0
lami-base-v1/.trae/documents/learning_page_plan.md

@@ -0,0 +1,48 @@
+# 学习页面优化计划(简化版)
+
+## 问题分析
+
+1. 课程卡片点击无效果 — 缺少课程详情弹窗逻辑
+2. `learning.ts` import 了已删除的组件,编译报错
+3. 缺少"开始学习"→ 视频页 → 答题页的完整流程
+
+## 企业级组件拆分原则
+
+| 组件 | 策略 | 原因 |
+|------|------|------|
+| learning 页面内容 | ✅ 一个组件搞定 | 单页面内容,无需拆分 |
+| 课程详情 Sheet | ✅ 内嵌到 learning.html | 仅此一处使用,无需独立组件 |
+| video-player | ✅ 已有独立路由页面 | 全屏页面,独立路由 |
+| quiz | ✅ 已有独立路由页面 | 全屏页面,独立路由 |
+| quiz-result | ✅ 已有独立路由页面 | 全屏页面,独立路由 |
+
+> **核心原则:只有多处复用才抽离为共享组件,单一用途直接内嵌。**
+
+## 实现方案
+
+### 唯一需要改的文件
+
+| 文件 | 操作 |
+|------|------|
+| `features/learning/learning/learning.html` | 重写:学习路线 + 课程网格 + 全部模块 + 课程详情 Sheet(内嵌) |
+| `features/learning/learning/learning.ts` | 重写:所有逻辑(showCourseDetail、openCourseDetail、closeCourseDetail、startLearning 等) |
+
+### 功能流程
+
+```
+点击课程卡片 → showCourseDetail = true → 显示课程详情 Sheet
+  ↓ 点击"开始学习"
+closeCourseDetail() → router.navigate(['/video-player'])
+  ↓
+video-player 页面(已有的独立路由页面)
+  ↓ 点击"进入答题"
+router.navigate(['/quiz'])
+```
+
+### 关键实现
+
+- 课程详情 Sheet 内嵌在 learning.html 底部,用 `[class.hidden]="!showCourseDetail()"` 控制
+- 使用 signals 管理状态(showCourseDetail、selectedCourseId)
+- 课程数据在 ts 中维护
+- 视频页和答题页使用 Router 导航(已有路由配置)
+- 严格按照 demo 的 HTML 结构和 CSS 类名

+ 31 - 0
lami-base-v1/.trae/documents/profile_optimize_plan.md

@@ -0,0 +1,31 @@
+# Profile 页面优化计划
+
+## 问题分析
+当前 profile 页面与 demo 对比,存在的问题:
+1. 使用了多个子组件(`user-info-card`、`level-progress`、`settings-list`)导致可能的布局差异
+2. 深色模式切换按钮的实现和 demo 不一致
+3. 整体结构可能存在嵌套问题
+
+## 优化方案
+### 1. 重构 Profile 组件为单个文件
+- 将所有子组件的 HTML 合并到 `profile.html`
+- 更新 `profile.ts` 包含所有必要的逻辑和图标导入
+- 移除对中间组件的依赖
+
+### 2. 严格匹配 Demo 结构
+- 使用与 demo 完全相同的 HTML 结构
+- 保持一致的 CSS 类名
+- 保持一致的深色模式切换实现
+
+### 3. 清理
+- 移除不再需要的 `profile/components` 文件夹
+
+## 需要修改的文件
+1. `src/app/features/profile/profile/profile.html` - 完全重写
+2. `src/app/features/profile/profile/profile.ts` - 更新逻辑和导入
+3. 删除 `src/app/features/profile/components/` - 整个文件夹
+
+## 最终效果
+- Profile 页面完全符合 demo 的视觉效果
+- 布局结构、按钮样式、图标位置完全一致
+- 深色模式切换功能正常

+ 86 - 0
lami-base-v1/.trae/documents/qa_ai_logic_plan.md

@@ -0,0 +1,86 @@
+# QA 页面接入 AI 逻辑 + Markdown 渲染 + 图片处理计划
+
+## 现状分析
+
+| 模块 | 状态 | 说明 |
+|------|------|------|
+| 后端 `/api/chat` | ✅ 已实现 | 完整的 DashScope SSE 代理 |
+| 前端 `qa.ts` sendMessage | ❌ Mock | setTimeout 模拟 |
+| Markdown 渲染 | ❌ 无 | 当前纯文本,没有 Markdown |
+| 图片处理 | ❌ 无 | demo 中有 MCP 图片提取逻辑 |
+| `marked` 库 | ✅ 已安装 | 但未在 QA 页面使用 |
+
+## 实现方案
+
+### 第一步:Markdown 渲染
+使用 `ngx-markdown` 库(npm install ngx-markdown),通过 `<markdown>` 组件或 `| markdown` pipe 渲染。
+- `provideMarkdown()` 在 app.config.ts 全局配置
+- QA 页面的 AI 消息用 `[data]` 绑定渲染 Markdown
+- 高亮代码使用 `highlight.js`(已安装)
+
+### 第二步:图片处理
+从 demo 迁移 MCP 图片提取逻辑:
+- 解析 `output.thoughts` 中的 `action_type === 'mcp'` 节点
+- 从 `observation.content[].text` 中提取 image URLs(`results[]` 数组)
+- **不直接渲染图片**,而是在 Markdown 渲染后将 `![alt](url)` 替换为 `<img>` 标签,统一添加样式类、懒加载、点击放大等
+
+### 第三步:AI 流式逻辑
+从 demo 迁移到 Angular:
+- `ai-chat.service.ts` — SSE 流式请求 + 图片提取
+- `qa.ts` — 替换 sendMessage,接入真实流式逻辑
+- `QAMessage` 模型扩展 — 添加 `thoughts`、`images` 字段
+
+### 第四步:后端 .env
+基于 `.env.example` 创建 `.env` 文件
+
+---
+
+## 需要修改的文件
+
+| 文件 | 操作 | 说明 |
+|------|------|------|
+| `frontend/package.json` | 添加依赖 | `ngx-markdown` |
+| `frontend/src/app/app.config.ts` | 更新 | 添加 `provideMarkdown()` |
+| `frontend/src/app/core/services/ai-chat.service.ts` | **新建** | SSE 流 + 图片提取 |
+| `frontend/src/app/core/models/qa.model.ts` | 更新 | 扩展 QAMessage 接口 |
+| `frontend/src/app/features/qa/qa/qa.ts` | 重写 | 接入 AI 流式 + Markdown |
+| `frontend/src/app/features/qa/qa/qa.html` | 更新 | 使用 `<markdown>` 组件 |
+| `backend/.env` | 新建 | 基于 .env.example |
+
+## 图片处理策略
+
+demo 中的图片提取逻辑:
+```javascript
+// thought.action_type === 'mcp'
+// → observation.content[].type === 'text'
+// → JSON.parse(item.text).results[] → 图片 URL 数组
+```
+
+Angular 中:
+1. 在 `ai-chat.service.ts` 中提取 imageUrls
+2. API 返回 `{ text, thoughts, images }` 给组件
+3. 组件将 images 拼接到 Markdown 内容末尾(用 `![图片](url)` 格式)
+4. `ngx-markdown` 自动渲染为 `<img>`,配合 CSS 控制样式
+
+## 数据流
+
+```
+用户输入 → qa.ts sendMessage()
+  → aiChatService.stream(prompt, sessionId)
+    → fetch('/api/chat')
+      → SSE 逐帧解析
+        → output.text → 追加到 Markdown 内容
+        → output.thoughts[].thought → 思考过程
+        → output.thoughts[].mcp → 提取图片 URLs
+        → output.session_id → 保存会话
+          → qa.ts 实时更新 messages signal
+            → <markdown> 组件渲染
+            → 图片自动生成 <img> 标签
+```
+
+## 关键实现
+
+1. **ngx-markdown 配置**:需要 `provideHttpClient()`(已提供)+ `provideMarkdown()`
+2. **安全渲染**:使用 `[data]` 绑定而非 `[src]` 加载外部文件
+3. **图片样式**:CSS 统一控制 `.qa-markdown img { max-width: 100%; border-radius: 8px; }`
+4. **流式更新**:发送时插入空 AI 消息,收到 chunk 后更新 content 字段,ngx-markdown 自动重新渲染

+ 32 - 0
lami-base-v1/backend/.env.example

@@ -0,0 +1,32 @@
+# 运行环境
+NODE_ENV=development
+
+# PC 端统一端口(所有模块共用一个 Express App)
+PC_PORT=3101
+
+# Mobile 端端口
+MOBILE_CHAT_PORT=3201
+
+# API 鉴权(生产环境必填;开发环境未配置时放行)
+# API_KEY=your_api_key_here
+# CORS_ORIGIN=http://localhost:4200
+
+# QiWe(企微)开放平台
+# QIWEI_BASE_URL=http://manager.qiweapi.com/qiwe
+# QIWEI_TOKEN=your_token_here
+# QIWEI_GUID=your_device_guid_here
+# QIWEI_ALLOW_PROXY=0
+# QIWEI_WEBHOOK_SECRET=your_webhook_secret
+# ↑ GUID 从控制台「节点管理」复制:https://manager.qiweapi.com/nodes (与 Token 不是同一个值)
+# QIWEI_ROOM_IDS=10865515454476837,10941673770342441
+# ↑ 可选。从 getSessionList 里 sessionType=1 的 sessionId 复制,逗号分隔多个群(getRoomList 为空时用这个)
+# QIWEI_LIVE_ALLOW_BROADCAST=1
+# ↑ 可选。设为 1 时联调会向上述群发送一条测试群发(默认关闭)
+# QIWEI_LIVE_NOTIFY=1
+# ↑ 可选。设为 1 时 POST /notifications/send 会调 QiWe sendText(默认发到 QIWEI_ROOM_IDS 第一个群)
+# QIWEI_NOTIFY_TO_ID=
+# ↑ 可选。指定 sendText 的 toId(群 roomId 或用户 userid),不填则用 QIWEI_ROOM_IDS 第一个
+
+# DashScope(阿里云百炼)AI
+DASHSCOPE_API_KEY=your_api_key_here
+DASHSCOPE_APP_ID=your_app_id_here

+ 12 - 0
lami-base-v1/backend/.gitignore

@@ -0,0 +1,12 @@
+node_modules/
+dist/
+.env
+tests_python/report*.html
+tests_python/live_data_snapshot.json
+tests_python/live_data_report.md
+tests_python/.pytest_cache/
+*.local
+*.log
+package-lock.json
+.DS_Store
+Thumbs.db

+ 35 - 0
lami-base-v1/backend/package.json

@@ -0,0 +1,35 @@
+{
+  "name": "backend",
+  "version": "1.0.0",
+  "description": "Express 5 Backend API",
+  "type": "module",
+  "scripts": {
+    "dev": "tsx watch src/index.ts",
+    "build": "tsc",
+    "start": "node dist/index.js",
+    "test:py": "cd tests_python && python -m pytest",
+    "test:py:report": "cd tests_python && python -m pytest --html=report.html --self-contained-html",
+    "test:py:all": "cd tests_python && python run_all.py",
+    "test:py:live": "cd tests_python && python run_live_only.py",
+    "test:py:section-1-2": "cd tests_python && python run_section_1_2_live.py",
+    "test:py:section-3": "cd tests_python && python run_section_3_live.py",
+    "test:py:section-4": "cd tests_python && python run_section_4_live.py",
+    "test:py:section-5-8": "cd tests_python && python run_section_5_8_live.py",
+    "test:py:section-9-12": "cd tests_python && python run_section_9_12_live.py",
+    "test:py:export": "cd tests_python && python export_live_data.py"
+  },
+  "dependencies": {
+    "express": "^5.0.0",
+    "cors": "^2.8.5",
+    "dotenv": "^16.4.5",
+    "swagger-ui-express": "^5.0.1"
+  },
+  "devDependencies": {
+    "@types/express": "^5.0.0",
+    "@types/cors": "^2.8.17",
+    "@types/node": "^22.10.0",
+    "@types/swagger-ui-express": "^4.1.8",
+    "typescript": "^5.6.0",
+    "tsx": "^4.19.0"
+  }
+}

+ 1030 - 0
lami-base-v1/backend/pnpm-lock.yaml

@@ -0,0 +1,1030 @@
+lockfileVersion: '9.0'
+
+settings:
+  autoInstallPeers: true
+  excludeLinksFromLockfile: false
+
+importers:
+
+  .:
+    dependencies:
+      cors:
+        specifier: ^2.8.5
+        version: 2.8.6
+      dotenv:
+        specifier: ^16.4.5
+        version: 16.6.1
+      express:
+        specifier: ^5.0.0
+        version: 5.2.1
+      swagger-ui-express:
+        specifier: ^5.0.1
+        version: 5.0.1(express@5.2.1)
+    devDependencies:
+      '@types/cors':
+        specifier: ^2.8.17
+        version: 2.8.19
+      '@types/express':
+        specifier: ^5.0.0
+        version: 5.0.6
+      '@types/swagger-ui-express':
+        specifier: ^4.1.8
+        version: 4.1.8
+      tsx:
+        specifier: ^4.19.0
+        version: 4.21.0
+      typescript:
+        specifier: ^5.6.0
+        version: 5.9.3
+
+packages:
+
+  '@esbuild/aix-ppc64@0.27.7':
+    resolution: {integrity: sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [aix]
+
+  '@esbuild/android-arm64@0.27.7':
+    resolution: {integrity: sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [android]
+
+  '@esbuild/android-arm@0.27.7':
+    resolution: {integrity: sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [android]
+
+  '@esbuild/android-x64@0.27.7':
+    resolution: {integrity: sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [android]
+
+  '@esbuild/darwin-arm64@0.27.7':
+    resolution: {integrity: sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [darwin]
+
+  '@esbuild/darwin-x64@0.27.7':
+    resolution: {integrity: sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [darwin]
+
+  '@esbuild/freebsd-arm64@0.27.7':
+    resolution: {integrity: sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [freebsd]
+
+  '@esbuild/freebsd-x64@0.27.7':
+    resolution: {integrity: sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [freebsd]
+
+  '@esbuild/linux-arm64@0.27.7':
+    resolution: {integrity: sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [linux]
+
+  '@esbuild/linux-arm@0.27.7':
+    resolution: {integrity: sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA==}
+    engines: {node: '>=18'}
+    cpu: [arm]
+    os: [linux]
+
+  '@esbuild/linux-ia32@0.27.7':
+    resolution: {integrity: sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [linux]
+
+  '@esbuild/linux-loong64@0.27.7':
+    resolution: {integrity: sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q==}
+    engines: {node: '>=18'}
+    cpu: [loong64]
+    os: [linux]
+
+  '@esbuild/linux-mips64el@0.27.7':
+    resolution: {integrity: sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw==}
+    engines: {node: '>=18'}
+    cpu: [mips64el]
+    os: [linux]
+
+  '@esbuild/linux-ppc64@0.27.7':
+    resolution: {integrity: sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ==}
+    engines: {node: '>=18'}
+    cpu: [ppc64]
+    os: [linux]
+
+  '@esbuild/linux-riscv64@0.27.7':
+    resolution: {integrity: sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ==}
+    engines: {node: '>=18'}
+    cpu: [riscv64]
+    os: [linux]
+
+  '@esbuild/linux-s390x@0.27.7':
+    resolution: {integrity: sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw==}
+    engines: {node: '>=18'}
+    cpu: [s390x]
+    os: [linux]
+
+  '@esbuild/linux-x64@0.27.7':
+    resolution: {integrity: sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [linux]
+
+  '@esbuild/netbsd-arm64@0.27.7':
+    resolution: {integrity: sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [netbsd]
+
+  '@esbuild/netbsd-x64@0.27.7':
+    resolution: {integrity: sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [netbsd]
+
+  '@esbuild/openbsd-arm64@0.27.7':
+    resolution: {integrity: sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openbsd]
+
+  '@esbuild/openbsd-x64@0.27.7':
+    resolution: {integrity: sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [openbsd]
+
+  '@esbuild/openharmony-arm64@0.27.7':
+    resolution: {integrity: sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [openharmony]
+
+  '@esbuild/sunos-x64@0.27.7':
+    resolution: {integrity: sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [sunos]
+
+  '@esbuild/win32-arm64@0.27.7':
+    resolution: {integrity: sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA==}
+    engines: {node: '>=18'}
+    cpu: [arm64]
+    os: [win32]
+
+  '@esbuild/win32-ia32@0.27.7':
+    resolution: {integrity: sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw==}
+    engines: {node: '>=18'}
+    cpu: [ia32]
+    os: [win32]
+
+  '@esbuild/win32-x64@0.27.7':
+    resolution: {integrity: sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg==}
+    engines: {node: '>=18'}
+    cpu: [x64]
+    os: [win32]
+
+  '@scarf/scarf@1.4.0':
+    resolution: {integrity: sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==}
+
+  '@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@25.6.0':
+    resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==}
+
+  '@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==}
+
+  '@types/swagger-ui-express@4.1.8':
+    resolution: {integrity: sha512-AhZV8/EIreHFmBV5wAs0gzJUNq9JbbSXgJLQubCC0jtIo6prnI9MIRRxnU4MZX9RB9yXxF1V4R7jtLl/Wcj31g==}
+
+  accepts@2.0.0:
+    resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==}
+    engines: {node: '>= 0.6'}
+
+  body-parser@2.2.2:
+    resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
+    engines: {node: '>=18'}
+
+  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@1.1.0:
+    resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==}
+    engines: {node: '>=18'}
+
+  content-type@1.0.5:
+    resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==}
+    engines: {node: '>= 0.6'}
+
+  cookie-signature@1.2.2:
+    resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==}
+    engines: {node: '>=6.6.0'}
+
+  cookie@0.7.2:
+    resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==}
+    engines: {node: '>= 0.6'}
+
+  cors@2.8.6:
+    resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==}
+    engines: {node: '>= 0.10'}
+
+  debug@4.4.3:
+    resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
+    engines: {node: '>=6.0'}
+    peerDependencies:
+      supports-color: '*'
+    peerDependenciesMeta:
+      supports-color:
+        optional: true
+
+  depd@2.0.0:
+    resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==}
+    engines: {node: '>= 0.8'}
+
+  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.1:
+    resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==}
+    engines: {node: '>= 0.4'}
+
+  esbuild@0.27.7:
+    resolution: {integrity: sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==}
+    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@5.2.1:
+    resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==}
+    engines: {node: '>= 18'}
+
+  finalhandler@2.1.1:
+    resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==}
+    engines: {node: '>= 18.0.0'}
+
+  forwarded@0.2.0:
+    resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
+    engines: {node: '>= 0.6'}
+
+  fresh@2.0.0:
+    resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
+    engines: {node: '>= 0.8'}
+
+  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'}
+
+  get-tsconfig@4.14.0:
+    resolution: {integrity: sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==}
+
+  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.7.2:
+    resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==}
+    engines: {node: '>=0.10.0'}
+
+  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'}
+
+  is-promise@4.0.0:
+    resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==}
+
+  math-intrinsics@1.1.0:
+    resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
+    engines: {node: '>= 0.4'}
+
+  media-typer@1.1.0:
+    resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==}
+    engines: {node: '>= 0.8'}
+
+  merge-descriptors@2.0.0:
+    resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==}
+    engines: {node: '>=18'}
+
+  mime-db@1.54.0:
+    resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==}
+    engines: {node: '>= 0.6'}
+
+  mime-types@3.0.2:
+    resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
+    engines: {node: '>=18'}
+
+  ms@2.1.3:
+    resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
+
+  negotiator@1.0.0:
+    resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
+    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'}
+
+  once@1.4.0:
+    resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
+
+  parseurl@1.3.3:
+    resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
+    engines: {node: '>= 0.8'}
+
+  path-to-regexp@8.4.2:
+    resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==}
+
+  proxy-addr@2.0.7:
+    resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
+    engines: {node: '>= 0.10'}
+
+  qs@6.15.1:
+    resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
+    engines: {node: '>=0.6'}
+
+  range-parser@1.2.1:
+    resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
+    engines: {node: '>= 0.6'}
+
+  raw-body@3.0.2:
+    resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==}
+    engines: {node: '>= 0.10'}
+
+  resolve-pkg-maps@1.0.0:
+    resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==}
+
+  router@2.2.0:
+    resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==}
+    engines: {node: '>= 18'}
+
+  safer-buffer@2.1.2:
+    resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==}
+
+  send@1.2.1:
+    resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
+    engines: {node: '>= 18'}
+
+  serve-static@2.2.1:
+    resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==}
+    engines: {node: '>= 18'}
+
+  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'}
+
+  swagger-ui-dist@5.32.6:
+    resolution: {integrity: sha512-75ttZNaYCLoFPnozPZcTUU6mS3wKT8l7WLjU5zJSHFeJa23i5vtnze6IiCl4jDMPeQTXVXIgovq4M11NNfQvSA==}
+
+  swagger-ui-express@5.0.1:
+    resolution: {integrity: sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==}
+    engines: {node: '>= v0.10.32'}
+    peerDependencies:
+      express: '>=4.0.0 || >=5.0.0-beta'
+
+  toidentifier@1.0.1:
+    resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==}
+    engines: {node: '>=0.6'}
+
+  tsx@4.21.0:
+    resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
+    engines: {node: '>=18.0.0'}
+    hasBin: true
+
+  type-is@2.0.1:
+    resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==}
+    engines: {node: '>= 0.6'}
+
+  typescript@5.9.3:
+    resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
+    engines: {node: '>=14.17'}
+    hasBin: true
+
+  undici-types@7.19.2:
+    resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==}
+
+  unpipe@1.0.0:
+    resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==}
+    engines: {node: '>= 0.8'}
+
+  vary@1.1.2:
+    resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==}
+    engines: {node: '>= 0.8'}
+
+  wrappy@1.0.2:
+    resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==}
+
+snapshots:
+
+  '@esbuild/aix-ppc64@0.27.7':
+    optional: true
+
+  '@esbuild/android-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/android-arm@0.27.7':
+    optional: true
+
+  '@esbuild/android-x64@0.27.7':
+    optional: true
+
+  '@esbuild/darwin-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/darwin-x64@0.27.7':
+    optional: true
+
+  '@esbuild/freebsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/freebsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-arm@0.27.7':
+    optional: true
+
+  '@esbuild/linux-ia32@0.27.7':
+    optional: true
+
+  '@esbuild/linux-loong64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-mips64el@0.27.7':
+    optional: true
+
+  '@esbuild/linux-ppc64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-riscv64@0.27.7':
+    optional: true
+
+  '@esbuild/linux-s390x@0.27.7':
+    optional: true
+
+  '@esbuild/linux-x64@0.27.7':
+    optional: true
+
+  '@esbuild/netbsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/netbsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/openbsd-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/openbsd-x64@0.27.7':
+    optional: true
+
+  '@esbuild/openharmony-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/sunos-x64@0.27.7':
+    optional: true
+
+  '@esbuild/win32-arm64@0.27.7':
+    optional: true
+
+  '@esbuild/win32-ia32@0.27.7':
+    optional: true
+
+  '@esbuild/win32-x64@0.27.7':
+    optional: true
+
+  '@scarf/scarf@1.4.0': {}
+
+  '@types/body-parser@1.19.6':
+    dependencies:
+      '@types/connect': 3.4.38
+      '@types/node': 25.6.0
+
+  '@types/connect@3.4.38':
+    dependencies:
+      '@types/node': 25.6.0
+
+  '@types/cors@2.8.19':
+    dependencies:
+      '@types/node': 25.6.0
+
+  '@types/express-serve-static-core@5.1.1':
+    dependencies:
+      '@types/node': 25.6.0
+      '@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@25.6.0':
+    dependencies:
+      undici-types: 7.19.2
+
+  '@types/qs@6.15.1': {}
+
+  '@types/range-parser@1.2.7': {}
+
+  '@types/send@1.2.1':
+    dependencies:
+      '@types/node': 25.6.0
+
+  '@types/serve-static@2.2.0':
+    dependencies:
+      '@types/http-errors': 2.0.5
+      '@types/node': 25.6.0
+
+  '@types/swagger-ui-express@4.1.8':
+    dependencies:
+      '@types/express': 5.0.6
+      '@types/serve-static': 2.2.0
+
+  accepts@2.0.0:
+    dependencies:
+      mime-types: 3.0.2
+      negotiator: 1.0.0
+
+  body-parser@2.2.2:
+    dependencies:
+      bytes: 3.1.2
+      content-type: 1.0.5
+      debug: 4.4.3
+      http-errors: 2.0.1
+      iconv-lite: 0.7.2
+      on-finished: 2.4.1
+      qs: 6.15.1
+      raw-body: 3.0.2
+      type-is: 2.0.1
+    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@1.1.0: {}
+
+  content-type@1.0.5: {}
+
+  cookie-signature@1.2.2: {}
+
+  cookie@0.7.2: {}
+
+  cors@2.8.6:
+    dependencies:
+      object-assign: 4.1.1
+      vary: 1.1.2
+
+  debug@4.4.3:
+    dependencies:
+      ms: 2.1.3
+
+  depd@2.0.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.1:
+    dependencies:
+      es-errors: 1.3.0
+
+  esbuild@0.27.7:
+    optionalDependencies:
+      '@esbuild/aix-ppc64': 0.27.7
+      '@esbuild/android-arm': 0.27.7
+      '@esbuild/android-arm64': 0.27.7
+      '@esbuild/android-x64': 0.27.7
+      '@esbuild/darwin-arm64': 0.27.7
+      '@esbuild/darwin-x64': 0.27.7
+      '@esbuild/freebsd-arm64': 0.27.7
+      '@esbuild/freebsd-x64': 0.27.7
+      '@esbuild/linux-arm': 0.27.7
+      '@esbuild/linux-arm64': 0.27.7
+      '@esbuild/linux-ia32': 0.27.7
+      '@esbuild/linux-loong64': 0.27.7
+      '@esbuild/linux-mips64el': 0.27.7
+      '@esbuild/linux-ppc64': 0.27.7
+      '@esbuild/linux-riscv64': 0.27.7
+      '@esbuild/linux-s390x': 0.27.7
+      '@esbuild/linux-x64': 0.27.7
+      '@esbuild/netbsd-arm64': 0.27.7
+      '@esbuild/netbsd-x64': 0.27.7
+      '@esbuild/openbsd-arm64': 0.27.7
+      '@esbuild/openbsd-x64': 0.27.7
+      '@esbuild/openharmony-arm64': 0.27.7
+      '@esbuild/sunos-x64': 0.27.7
+      '@esbuild/win32-arm64': 0.27.7
+      '@esbuild/win32-ia32': 0.27.7
+      '@esbuild/win32-x64': 0.27.7
+
+  escape-html@1.0.3: {}
+
+  etag@1.8.1: {}
+
+  express@5.2.1:
+    dependencies:
+      accepts: 2.0.0
+      body-parser: 2.2.2
+      content-disposition: 1.1.0
+      content-type: 1.0.5
+      cookie: 0.7.2
+      cookie-signature: 1.2.2
+      debug: 4.4.3
+      depd: 2.0.0
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      etag: 1.8.1
+      finalhandler: 2.1.1
+      fresh: 2.0.0
+      http-errors: 2.0.1
+      merge-descriptors: 2.0.0
+      mime-types: 3.0.2
+      on-finished: 2.4.1
+      once: 1.4.0
+      parseurl: 1.3.3
+      proxy-addr: 2.0.7
+      qs: 6.15.1
+      range-parser: 1.2.1
+      router: 2.2.0
+      send: 1.2.1
+      serve-static: 2.2.1
+      statuses: 2.0.2
+      type-is: 2.0.1
+      vary: 1.1.2
+    transitivePeerDependencies:
+      - supports-color
+
+  finalhandler@2.1.1:
+    dependencies:
+      debug: 4.4.3
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      on-finished: 2.4.1
+      parseurl: 1.3.3
+      statuses: 2.0.2
+    transitivePeerDependencies:
+      - supports-color
+
+  forwarded@0.2.0: {}
+
+  fresh@2.0.0: {}
+
+  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.1
+      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.1
+
+  get-tsconfig@4.14.0:
+    dependencies:
+      resolve-pkg-maps: 1.0.0
+
+  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.7.2:
+    dependencies:
+      safer-buffer: 2.1.2
+
+  inherits@2.0.4: {}
+
+  ipaddr.js@1.9.1: {}
+
+  is-promise@4.0.0: {}
+
+  math-intrinsics@1.1.0: {}
+
+  media-typer@1.1.0: {}
+
+  merge-descriptors@2.0.0: {}
+
+  mime-db@1.54.0: {}
+
+  mime-types@3.0.2:
+    dependencies:
+      mime-db: 1.54.0
+
+  ms@2.1.3: {}
+
+  negotiator@1.0.0: {}
+
+  object-assign@4.1.1: {}
+
+  object-inspect@1.13.4: {}
+
+  on-finished@2.4.1:
+    dependencies:
+      ee-first: 1.1.1
+
+  once@1.4.0:
+    dependencies:
+      wrappy: 1.0.2
+
+  parseurl@1.3.3: {}
+
+  path-to-regexp@8.4.2: {}
+
+  proxy-addr@2.0.7:
+    dependencies:
+      forwarded: 0.2.0
+      ipaddr.js: 1.9.1
+
+  qs@6.15.1:
+    dependencies:
+      side-channel: 1.1.0
+
+  range-parser@1.2.1: {}
+
+  raw-body@3.0.2:
+    dependencies:
+      bytes: 3.1.2
+      http-errors: 2.0.1
+      iconv-lite: 0.7.2
+      unpipe: 1.0.0
+
+  resolve-pkg-maps@1.0.0: {}
+
+  router@2.2.0:
+    dependencies:
+      debug: 4.4.3
+      depd: 2.0.0
+      is-promise: 4.0.0
+      parseurl: 1.3.3
+      path-to-regexp: 8.4.2
+    transitivePeerDependencies:
+      - supports-color
+
+  safer-buffer@2.1.2: {}
+
+  send@1.2.1:
+    dependencies:
+      debug: 4.4.3
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      etag: 1.8.1
+      fresh: 2.0.0
+      http-errors: 2.0.1
+      mime-types: 3.0.2
+      ms: 2.1.3
+      on-finished: 2.4.1
+      range-parser: 1.2.1
+      statuses: 2.0.2
+    transitivePeerDependencies:
+      - supports-color
+
+  serve-static@2.2.1:
+    dependencies:
+      encodeurl: 2.0.0
+      escape-html: 1.0.3
+      parseurl: 1.3.3
+      send: 1.2.1
+    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: {}
+
+  swagger-ui-dist@5.32.6:
+    dependencies:
+      '@scarf/scarf': 1.4.0
+
+  swagger-ui-express@5.0.1(express@5.2.1):
+    dependencies:
+      express: 5.2.1
+      swagger-ui-dist: 5.32.6
+
+  toidentifier@1.0.1: {}
+
+  tsx@4.21.0:
+    dependencies:
+      esbuild: 0.27.7
+      get-tsconfig: 4.14.0
+    optionalDependencies:
+      fsevents: 2.3.3
+
+  type-is@2.0.1:
+    dependencies:
+      content-type: 1.0.5
+      media-typer: 1.1.0
+      mime-types: 3.0.2
+
+  typescript@5.9.3: {}
+
+  undici-types@7.19.2: {}
+
+  unpipe@1.0.0: {}
+
+  vary@1.1.2: {}
+
+  wrappy@1.0.2: {}

+ 24 - 0
lami-base-v1/backend/src/apps/mobile/chat/app.ts

@@ -0,0 +1,24 @@
+import express from 'express';
+import cors from 'cors';
+import type { Express } from 'express';
+import { getStringEnv } from '../../../shared/config/env.js';
+import { apiKeyAuth } from '../../../shared/http/auth.middleware.js';
+import { errorHandler } from '../../../shared/http/error-handler.js';
+import { notFoundHandler } from '../../../shared/http/not-found.middleware.js';
+import { mobileChatApiRouter } from './routes/chat.routes.js';
+
+export function createMobileChatApp(): Express {
+  const app = express();
+
+  const corsOrigin = getStringEnv('CORS_ORIGIN') ?? 'http://localhost:4200';
+  app.use(cors({ origin: corsOrigin.split(',').map((o) => o.trim()), credentials: true }));
+  app.use(express.json({ limit: '1mb' }));
+  app.use(apiKeyAuth);
+
+  app.use('/api', mobileChatApiRouter);
+
+  app.use(notFoundHandler);
+  app.use(errorHandler);
+
+  return app;
+}

+ 141 - 0
lami-base-v1/backend/src/apps/mobile/chat/controllers/chat.controller.ts

@@ -0,0 +1,141 @@
+import type { Request, Response } from 'express';
+
+interface ChatRequestBody {
+  prompt: string;
+  sessionId?: string;
+}
+
+interface DashScopeRequestBody {
+  input: {
+    prompt: string;
+    session_id?: string;
+  };
+  parameters: {
+    incremental_output: boolean;
+    has_thoughts: boolean;
+  };
+  debug: Record<string, unknown>;
+}
+
+interface DashScopeErrorResponse {
+  message?: string;
+}
+
+export async function postChat(req: Request, res: Response) {
+  const { prompt, sessionId } = req.body as ChatRequestBody;
+
+  if (!prompt) {
+    res.status(400).json({ error: { message: 'Prompt is required' } });
+    return;
+  }
+
+  const apiKey = process.env.DASHSCOPE_API_KEY;
+  const appId = process.env.DASHSCOPE_APP_ID;
+
+  if (!apiKey || !appId) {
+    res.status(500).json({ error: { message: 'API configuration missing' } });
+    return;
+  }
+
+  const apiUrl = `https://dashscope.aliyuncs.com/api/v1/apps/${appId}/completion`;
+
+  const requestBody: DashScopeRequestBody = {
+    input: { prompt },
+    parameters: {
+      incremental_output: true,
+      has_thoughts: true
+    },
+    debug: {}
+  };
+
+  if (sessionId) {
+    requestBody.input.session_id = sessionId;
+  }
+
+  try {
+    const response = await fetch(apiUrl, {
+      method: 'POST',
+      headers: {
+        'Authorization': `Bearer ${apiKey}`,
+        'Content-Type': 'application/json',
+        'X-DashScope-SSE': 'enable'
+      },
+      body: JSON.stringify(requestBody)
+    });
+
+    if (!response.ok) {
+      const errorData = await response.json().catch(() => ({})) as DashScopeErrorResponse;
+      res
+        .status(response.status)
+        .json({ error: { message: errorData.message || `Request failed: ${response.status}` } });
+      return;
+    }
+
+    res.setHeader('Content-Type', 'text/event-stream');
+    res.setHeader('Cache-Control', 'no-cache');
+    res.setHeader('Connection', 'keep-alive');
+
+    const reader = response.body?.getReader();
+    const decoder = new TextDecoder();
+
+    if (!reader) {
+      res.status(500).json({ error: { message: 'Failed to read response stream' } });
+      return;
+    }
+
+    let buffer = '';
+
+    const readStream = async () => {
+      try {
+        while (true) {
+          const { done, value } = await reader.read();
+          if (done) {
+            if (buffer) {
+              const lines = buffer.split('\n');
+              for (const line of lines) {
+                if (line.startsWith('data:')) {
+                  const data = line.slice(5).trim();
+                  if (data) {
+                    res.write(`data: ${data}\n\n`);
+                  }
+                }
+              }
+            }
+            res.end();
+            break;
+          }
+
+          buffer += decoder.decode(value, { stream: true });
+          const lines = buffer.split('\n');
+          buffer = lines.pop() || '';
+
+          for (const line of lines) {
+            if (line.startsWith('data:')) {
+              const data = line.slice(5).trim();
+              if (data) {
+                res.write(`data: ${data}\n\n`);
+              }
+            }
+          }
+        }
+      } catch (error) {
+        console.error('Stream error:', error);
+        res.end();
+      }
+    };
+
+    readStream();
+
+    req.on('close', () => {
+      reader.cancel();
+    });
+  } catch (error) {
+    console.error('Chat error:', error);
+    res.status(500).json({ error: { message: error instanceof Error ? error.message : 'Internal server error' } });
+  }
+}
+
+export function getHealth(_req: Request, res: Response) {
+  res.json({ status: 'ok', timestamp: new Date().toISOString() });
+}
+

+ 8 - 0
lami-base-v1/backend/src/apps/mobile/chat/routes/chat.routes.ts

@@ -0,0 +1,8 @@
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import { getHealth, postChat } from '../controllers/chat.controller.js';
+
+export const mobileChatApiRouter: ExpressRouter = Router();
+
+mobileChatApiRouter.post('/chat', postChat);
+mobileChatApiRouter.get('/health', getHealth);

+ 11 - 0
lami-base-v1/backend/src/apps/mobile/chat/server.ts

@@ -0,0 +1,11 @@
+import { createMobileChatApp } from './app.js';
+import { getNumberEnv } from '../../../shared/config/env.js';
+
+export function startMobileChatServer() {
+  const app = createMobileChatApp();
+  const port = getNumberEnv('MOBILE_CHAT_PORT', 3201);
+
+  app.listen(port, () => {
+    console.log(`mobile-chat server running on http://localhost:${port}`);
+  });
+}

+ 114 - 0
lami-base-v1/backend/src/apps/pc/app.ts

@@ -0,0 +1,114 @@
+/**
+ * PC 端统一 Express 应用装配层
+ *
+ * 职责:
+ *   - 创建唯一的 Express 实例
+ *   - 挂载通用中间件(cors、json 解析、请求日志)
+ *   - 注册所有 PC 模块的路由
+ *   - 挂载统一错误处理
+ *   - 挂载全局健康检查
+ *
+ * 模块路由一览(共 11 个业务模块):
+ *   /api/health         健康检查
+ *   /api/qiwei          企微 API 代理网关        [模块 1]
+ *   /api/staff          人员管理                  [模块 1]
+ *   /api/communities    小区管理                  [模块 2]
+ *   /api/community-room-bindings  群-小区绑定     [模块 2]
+ *   /api/rooms          群管理                    [模块 2]
+ *   /api/compliance     合规检查                  [模块 4]
+ *   /api/risk           群风控                    [模块 5]
+ *   /api/content        内容运营                  [模块 6]
+ *   /api/channels       渠道 / 联系人 / KOC       [模块 7]
+ *   /api/contacts       外部联系人                [模块 7]
+ *   /api/koc            KOC 管理                  [模块 7]
+ *   /api/intent-leads   意向客户                  [模块 7]
+ *   /api/intent-tasks   跟进待办                  [模块 7]
+ *   /api/dashboard      数据看板                  [模块 8]
+ *   /api/sales          经营数据补录              [模块 9]
+ *   /api/workbench      统一工作台                [模块 10]
+ *   /api/notifications  通知管理                  [模块 10]
+ *   /api/audit-logs     审计日志                  [模块 10]
+ */
+
+import express from 'express';
+import cors from 'cors';
+import type { Express, Request, Response, NextFunction } from 'express';
+import { getStringEnv } from '../../shared/config/env.js';
+import { errorHandler } from '../../shared/http/error-handler.js';
+import { apiKeyAuth, qiweiProxyGuard } from '../../shared/http/auth.middleware.js';
+import { notFoundHandler } from '../../shared/http/not-found.middleware.js';
+import { setupSwagger } from '../../shared/swagger/setup-swagger.js';
+
+// ---- 导入所有 PC 模块路由 ----
+import { pcHealthApiRouter } from './health/routes/health.routes.js';
+import { pcQiWeApiRouter } from './qiwei/routes/qiwei.routes.js';
+import { pcStaffApiRouter } from './staff/routes/staff.routes.js';
+import { pcCommunityApiRouter } from './community/routes/community.routes.js';
+import { pcRoomApiRouter } from './room/routes/room.routes.js';
+import { pcComplianceApiRouter } from './compliance/routes/compliance.routes.js';
+import { pcRiskApiRouter } from './risk/routes/risk.routes.js';
+import { pcContentApiRouter } from './content/routes/content.routes.js';
+import { pcKocApiRouter } from './koc/routes/koc.routes.js';
+import { pcDashboardApiRouter } from './dashboard/routes/dashboard.routes.js';
+import { pcSalesApiRouter } from './sales/routes/sales.routes.js';
+import { pcWorkbenchApiRouter } from './workbench/routes/workbench.routes.js';
+
+/**
+ * 请求日志中间件
+ * 记录每个请求的方法、路径、状态码和耗时
+ */
+function requestLogger(req: Request, res: Response, next: NextFunction): void {
+  const start = Date.now();
+  res.on('finish', () => {
+    const duration = Date.now() - start;
+    const level = res.statusCode >= 400 ? 'WARN' : 'INFO';
+    console.log(
+      `[PC:${level}] ${req.method} ${req.originalUrl} → ${res.statusCode} (${duration}ms)`,
+    );
+  });
+  next();
+}
+
+/**
+ * 创建 PC 端统一 Express 应用
+ *
+ * 所有 PC 模块的路由都挂载在同一个 app 实例上,
+ * 共享同一套中间件(cors、json 解析、日志、错误处理)。
+ */
+export function createPcApp(): Express {
+  const app = express();
+
+  // ---- 通用中间件 ----
+  const corsOrigin = getStringEnv('CORS_ORIGIN') ?? 'http://localhost:4200';
+  app.use(cors({ origin: corsOrigin.split(',').map((o) => o.trim()), credentials: true }));
+  app.use(express.json({ limit: '2mb' }));
+  app.use(requestLogger);
+  app.use(apiKeyAuth);
+  app.use(qiweiProxyGuard);
+
+  // ---- Swagger UI 接口测试台(领导要求的「能点着测」) ----
+  setupSwagger(app);
+
+  // ---- 注册所有模块路由 ----
+  // 注意:每个模块的路由文件已定义好各自的前缀(如 /staff、/rooms 等),
+  // 统一挂在 /api 下即可
+  app.use('/api', pcHealthApiRouter);
+  app.use('/api', pcQiWeApiRouter);
+  app.use('/api', pcStaffApiRouter);
+  app.use('/api', pcCommunityApiRouter);
+  app.use('/api', pcRoomApiRouter);
+  app.use('/api', pcComplianceApiRouter);
+  app.use('/api', pcRiskApiRouter);
+  app.use('/api', pcContentApiRouter);
+  app.use('/api', pcKocApiRouter);
+  app.use('/api', pcDashboardApiRouter);
+  app.use('/api', pcSalesApiRouter);
+  app.use('/api', pcWorkbenchApiRouter);
+
+  app.use(notFoundHandler);
+
+  // ---- 统一错误处理(必须放在所有路由之后) ----
+  app.use(errorHandler);
+
+  return app;
+}

+ 155 - 0
lami-base-v1/backend/src/apps/pc/community/controllers/community.controller.ts

@@ -0,0 +1,155 @@
+/**
+ * 小区管理模块 — 控制器层
+ *
+ * 对应功能:
+ *   §5.5「录入小区基础档案」
+ *   §5.6「维护小区—群—门店对应关系」
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../../../../shared/http/response.js';
+import {
+  createCommunity,
+  listCommunities,
+  getCommunityById,
+  updateCommunity,
+  deleteCommunity,
+  createBinding,
+  listBindings,
+  deleteBinding,
+  getBindingByRoomId,
+} from '../services/community.service.js';
+
+// ============================================================
+// 小区 CRUD
+// ============================================================
+
+/** POST /api/communities — 录入小区档案 */
+export async function create(req: Request, res: Response): Promise<void> {
+  try {
+    const { name, totalHouseholds, storeId } = req.body as {
+      name?: string;
+      totalHouseholds?: number;
+      storeId?: number;
+    };
+
+    if (!name || !totalHouseholds || !storeId) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:name, totalHouseholds, storeId');
+      return;
+    }
+
+    const community = createCommunity(req.body);
+    sendSuccess(res, community, 201);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/communities — 查询小区列表 */
+export async function list(req: Request, res: Response): Promise<void> {
+  try {
+    const { storeId } = req.query;
+    const result = listCommunities(storeId ? Number(storeId) : undefined);
+    sendSuccess(res, result);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/communities/:id — 查询小区详情 */
+export async function getById(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const community = getCommunityById(id);
+    sendSuccess(res, community);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** PUT /api/communities/:id — 更新小区信息 */
+export async function update(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const community = updateCommunity(id, req.body ?? {});
+    sendSuccess(res, community);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** DELETE /api/communities/:id — 删除小区 */
+export async function remove(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    deleteCommunity(id);
+    sendSuccess(res, { deleted: true });
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// 小区-群-门店绑定
+// ============================================================
+
+/** POST /api/community-room-bindings — 绑定群到小区 */
+export async function bind(req: Request, res: Response): Promise<void> {
+  try {
+    const { communityId, roomId, storeId } = req.body as {
+      communityId?: number;
+      roomId?: string;
+      storeId?: number;
+    };
+
+    if (!communityId || !roomId || !storeId) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:communityId, roomId, storeId');
+      return;
+    }
+
+    const binding = createBinding({ communityId, roomId, storeId });
+    sendSuccess(res, binding, 201);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/community-room-bindings — 查询绑定列表 */
+export async function listBindingList(req: Request, res: Response): Promise<void> {
+  try {
+    const { communityId, storeId } = req.query;
+    const result = listBindings(
+      typeof communityId === 'string' ? Number(communityId) : undefined,
+      typeof storeId === 'string' ? Number(storeId) : undefined,
+    );
+    sendSuccess(res, result);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/community-room-bindings/by-room/:roomId — 按群 ID 查询绑定 */
+export async function getBindingByRoom(req: Request, res: Response): Promise<void> {
+  try {
+    const roomId = String(req.params.roomId);
+    const binding = getBindingByRoomId(roomId);
+    if (!binding) {
+      sendError(res, 404, 'BINDING_NOT_FOUND', `群 ${roomId} 未绑定任何小区`);
+      return;
+    }
+    sendSuccess(res, binding);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** DELETE /api/community-room-bindings/:id — 解绑 */
+export async function unbind(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    deleteBinding(id);
+    sendSuccess(res, { deleted: true });
+  } catch (error) {
+    throw error;
+  }
+}

+ 69 - 0
lami-base-v1/backend/src/apps/pc/community/models/community.model.ts

@@ -0,0 +1,69 @@
+/**
+ * 小区管理模块 — 数据模型定义
+ *
+ * 对应规范文档:
+ *   §5.5「录入小区基础档案」
+ *   §5.6「维护小区—群—门店对应关系」
+ *
+ * 核心概念:
+ *   - 小区 (Community):楼盘的档案信息(户数、房价、交房时间等)
+ *   - 门店 (Store):所属门店
+ *   - 群 (Room):企微外部客户群
+ *   - 三者通过 CommunityRoomBinding 表关联:communityId ↔ roomId ↔ storeId
+ */
+
+/** 小区基础档案 */
+export interface Community {
+  /** 主键 */
+  id: number;
+  /** 小区名称 */
+  name: string;
+  /** 总户数(触达率计算的分母,无法从 QiWe 自动获取) */
+  totalHouseholds: number;
+  /** 参考均价(元/㎡) */
+  avgPrice: number;
+  /** 交房年份 */
+  deliveryYear: number;
+  /** 所属门店 ID */
+  storeId: number;
+  /** 详细地址 */
+  address: string;
+  /** 备注 */
+  remark: string;
+  /** 创建时间 */
+  createdAt: string;
+  /** 更新时间 */
+  updatedAt: string;
+}
+
+/** 创建小区请求体 */
+export interface CreateCommunityRequest {
+  name: string;
+  totalHouseholds: number;
+  avgPrice?: number;
+  deliveryYear?: number;
+  storeId: number;
+  address?: string;
+  remark?: string;
+}
+
+/** 小区-群-门店绑定关系 */
+export interface CommunityRoomBinding {
+  /** 主键 */
+  id: number;
+  /** 小区 ID */
+  communityId: number;
+  /** 群 ID(企微 roomId) */
+  roomId: string;
+  /** 门店 ID(冗余,方便查询) */
+  storeId: number;
+  /** 绑定时间 */
+  createdAt: string;
+}
+
+/** 创建绑定请求体 */
+export interface CreateBindingRequest {
+  communityId: number;
+  roomId: string;
+  storeId: number;
+}

+ 43 - 0
lami-base-v1/backend/src/apps/pc/community/routes/community.routes.ts

@@ -0,0 +1,43 @@
+/**
+ * 小区管理模块 — 路由层
+ *
+ * 接口一览:
+ *   POST   /api/communities                            录入小区档案
+ *   GET    /api/communities                            查询小区列表
+ *   GET    /api/communities/:id                        查询小区详情
+ *   PUT    /api/communities/:id                        更新小区信息
+ *   DELETE /api/communities/:id                        删除小区
+ *   POST   /api/community-room-bindings                绑定群到小区
+ *   GET    /api/community-room-bindings                查询绑定列表
+ *   GET    /api/community-room-bindings/by-room/:roomId 按群查询绑定
+ *   DELETE /api/community-room-bindings/:id             解绑
+ */
+
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import {
+  create,
+  list,
+  getById,
+  update,
+  remove,
+  bind,
+  listBindingList,
+  getBindingByRoom,
+  unbind,
+} from '../controllers/community.controller.js';
+
+export const pcCommunityApiRouter: ExpressRouter = Router();
+
+// 小区档案
+pcCommunityApiRouter.post('/communities', create);
+pcCommunityApiRouter.get('/communities', list);
+pcCommunityApiRouter.get('/communities/:id', getById);
+pcCommunityApiRouter.put('/communities/:id', update);
+pcCommunityApiRouter.delete('/communities/:id', remove);
+
+// 绑定关系
+pcCommunityApiRouter.post('/community-room-bindings', bind);
+pcCommunityApiRouter.get('/community-room-bindings', listBindingList);
+pcCommunityApiRouter.get('/community-room-bindings/by-room/:roomId', getBindingByRoom);
+pcCommunityApiRouter.delete('/community-room-bindings/:id', unbind);

+ 189 - 0
lami-base-v1/backend/src/apps/pc/community/services/community.service.ts

@@ -0,0 +1,189 @@
+/**
+ * 小区管理模块 — 业务服务层
+ *
+ * 对应规范文档:
+ *   §5.5「录入小区基础档案」
+ *   §5.6「维护小区—群—门店对应关系」
+ *
+ * 业务规则:
+ *   1. 小区户数无法从 QiWe 自动获取,需人工录入
+ *   2. 群与小区/门店的绑定关系禁止自动猜测——必须人工确认
+ *   3. 未绑定群在看板中标记为「待维护」
+ *   4. 报表按小区/门店聚合时,通过绑定表 INNER JOIN
+ */
+
+import { AppError } from '../../../../shared/errors/app-error.js';
+import type {
+  Community,
+  CreateCommunityRequest,
+  CommunityRoomBinding,
+  CreateBindingRequest,
+} from '../models/community.model.js';
+
+// ---------- 内存存储(TODO: 替换为数据库)----------
+const communityStore = new Map<number, Community>();
+const bindingStore = new Map<number, CommunityRoomBinding>();
+let nextCommunityId = 1;
+let nextBindingId = 1;
+
+// ============================================================
+// 小区档案 CRUD
+// ============================================================
+
+/**
+ * 录入小区基础档案
+ *
+ * 户数是触达率计算的分母,必须准确。
+ * 房价、交房时间等字段供看板和经营分析使用。
+ */
+export function createCommunity(data: CreateCommunityRequest): Community {
+  const now = new Date().toISOString();
+  const community: Community = {
+    id: nextCommunityId++,
+    name: data.name,
+    totalHouseholds: data.totalHouseholds,
+    avgPrice: data.avgPrice || 0,
+    deliveryYear: data.deliveryYear || 0,
+    storeId: data.storeId,
+    address: data.address || '',
+    remark: data.remark || '',
+    createdAt: now,
+    updatedAt: now,
+  };
+
+  communityStore.set(community.id, community);
+  return community;
+}
+
+/**
+ * 查询小区列表(支持按门店筛选)
+ */
+export function listCommunities(storeId?: number): Community[] {
+  let result = Array.from(communityStore.values());
+  if (storeId !== undefined) {
+    result = result.filter((c) => c.storeId === storeId);
+  }
+  return result;
+}
+
+/**
+ * 查询单个小区详情
+ */
+export function getCommunityById(id: number): Community {
+  const community = communityStore.get(id);
+  if (!community) {
+    throw new AppError(404, 'COMMUNITY_NOT_FOUND', `小区 ID=${id} 不存在`);
+  }
+  return community;
+}
+
+/**
+ * 更新小区信息
+ */
+export function updateCommunity(
+  id: number,
+  data: Partial<CreateCommunityRequest> = {},
+): Community {
+  const community = getCommunityById(id);
+
+  if (data.name !== undefined) community.name = data.name;
+  if (data.totalHouseholds !== undefined) community.totalHouseholds = data.totalHouseholds;
+  if (data.avgPrice !== undefined) community.avgPrice = data.avgPrice;
+  if (data.deliveryYear !== undefined) community.deliveryYear = data.deliveryYear;
+  if (data.storeId !== undefined) community.storeId = data.storeId;
+  if (data.address !== undefined) community.address = data.address;
+  if (data.remark !== undefined) community.remark = data.remark;
+  community.updatedAt = new Date().toISOString();
+
+  communityStore.set(id, community);
+  return community;
+}
+
+/**
+ * 删除小区
+ */
+export function deleteCommunity(id: number): void {
+  getCommunityById(id);
+  communityStore.delete(id);
+
+  // 同时清理关联的绑定关系
+  for (const [key, binding] of bindingStore) {
+    if (binding.communityId === id) {
+      bindingStore.delete(key);
+    }
+  }
+}
+
+// ============================================================
+// 小区-群-门店绑定
+// ============================================================
+
+/**
+ * 创建小区-群-门店绑定关系
+ *
+ * 业务约束:
+ *   - 同一个 roomId 不可重复绑定到不同的小区
+ *   - 绑定禁止自动猜测,必须人工确认
+ *
+ * @throws {AppError} 当 roomId 已被绑定时
+ */
+export function createBinding(data: CreateBindingRequest): CommunityRoomBinding {
+  // 检查 roomId 是否已绑定
+  for (const binding of bindingStore.values()) {
+    if (binding.roomId === data.roomId) {
+      throw new AppError(
+        409,
+        'BINDING_DUPLICATE',
+        `群 ${data.roomId} 已绑定到小区 ID=${binding.communityId},不可重复绑定`,
+      );
+    }
+  }
+
+  const binding: CommunityRoomBinding = {
+    id: nextBindingId++,
+    communityId: data.communityId,
+    roomId: data.roomId,
+    storeId: data.storeId,
+    createdAt: new Date().toISOString(),
+  };
+
+  bindingStore.set(binding.id, binding);
+  return binding;
+}
+
+/**
+ * 查询绑定关系列表(按小区或门店筛选)
+ */
+export function listBindings(communityId?: number, storeId?: number): CommunityRoomBinding[] {
+  let result = Array.from(bindingStore.values());
+  if (communityId !== undefined) {
+    result = result.filter((b) => b.communityId === communityId);
+  }
+  if (storeId !== undefined) {
+    result = result.filter((b) => b.storeId === storeId);
+  }
+  return result;
+}
+
+/**
+ * 删除绑定关系(解绑)
+ */
+export function deleteBinding(id: number): void {
+  const binding = bindingStore.get(id);
+  if (!binding) {
+    throw new AppError(404, 'BINDING_NOT_FOUND', `绑定 ID=${id} 不存在`);
+  }
+  bindingStore.delete(id);
+}
+
+/**
+ * 按 roomId 查询绑定(用于合规等模块关联查询)
+ */
+export function getBindingByRoomId(roomId: string): CommunityRoomBinding | undefined {
+  for (const binding of bindingStore.values()) {
+    if (binding.roomId === roomId) {
+      return binding;
+    }
+  }
+  return undefined;
+}

+ 120 - 0
lami-base-v1/backend/src/apps/pc/compliance/controllers/compliance.controller.ts

@@ -0,0 +1,120 @@
+/**
+ * 合规检查模块 — 控制器层
+ *
+ * 对应规范文档 §七「模块 4:沟通记录合规检查模块」
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../../../../shared/http/response.js';
+import {
+  runComplianceScan,
+  listIssues,
+  getIssueById,
+  updateIssueStatus,
+  generateReport,
+  getLastScan,
+} from '../services/compliance.service.js';
+import type { ComplianceIssueType, IssueStatus } from '../models/compliance.model.js';
+
+/**
+ * POST /api/compliance/scan
+ *
+ * 触发合规巡检(手动)。
+ *
+ * Body:
+ *   { "roomIds": ["10791082xxxx", "10791083xxxx"] }
+ */
+export async function scan(req: Request, res: Response): Promise<void> {
+  try {
+    const { roomIds } = req.body as { roomIds?: string[] };
+
+    if (!roomIds || !Array.isArray(roomIds) || roomIds.length === 0) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 roomIds 参数(须为非空数组)');
+      return;
+    }
+
+    const result = runComplianceScan(roomIds);
+    sendSuccess(res, result);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * GET /api/compliance/issues
+ *
+ * 查询合规问题列表(支持按 type/status/severity 筛选)。
+ */
+export async function listIssueList(req: Request, res: Response): Promise<void> {
+  try {
+    const { type, status, severity } = req.query;
+    const issues = listIssues(
+      type as ComplianceIssueType | undefined,
+      status as IssueStatus | undefined,
+      severity ? Number(severity) : undefined,
+    );
+    sendSuccess(res, issues);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * GET /api/compliance/issues/:id
+ *
+ * 查询单个合规问题详情。
+ */
+export async function getIssue(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const issue = getIssueById(id);
+    sendSuccess(res, issue);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * PUT /api/compliance/issues/:id
+ *
+ * 更新合规问题状态(认领/处理/关闭)。
+ *
+ * Body:
+ *   { "status": "in_progress", "note": "已开始整改" }
+ */
+export async function updateIssue(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const { status, note } = req.body as { status?: IssueStatus; note?: string };
+
+    const validStatuses: IssueStatus[] = ['open', 'in_progress', 'resolved', 'closed'];
+    if (!status || !validStatuses.includes(status)) {
+      sendError(res, 400, 'VALIDATION_ERROR', `无效的状态:${status},可选:${validStatuses.join(', ')}`);
+      return;
+    }
+
+    const issue = updateIssueStatus(id, status, note);
+    sendSuccess(res, issue);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * GET /api/compliance/reports
+ *
+ * 获取合规汇总报表(§7.16)。
+ *
+ * Query params:
+ *   totalRooms - 纳入统计的总群数(必填)
+ */
+export async function report(req: Request, res: Response): Promise<void> {
+  try {
+    const totalRooms = Number(req.query.totalRooms) || 0;
+    const report = generateReport(totalRooms);
+    const lastScan = getLastScan();
+    sendSuccess(res, { report, lastScan });
+  } catch (error) {
+    throw error;
+  }
+}

+ 109 - 0
lami-base-v1/backend/src/apps/pc/compliance/models/compliance.model.ts

@@ -0,0 +1,109 @@
+/**
+ * 合规检查模块 — 数据模型定义
+ *
+ * 对应规范文档 §七「模块 4:沟通记录合规检查模块」
+ *
+ * 核心概念:
+ *   - compliance_issue:合规问题记录(缺表、未置顶、格式错误、内容太简等)
+ *   - compliance_scan:巡检任务记录
+ *   - audit_log:操作审计日志
+ *
+ * 问题类型枚举(issue type):
+ *   - missing_doc       :群还没有沟通记录表
+ *   - not_pinned        :记录表未在群内置顶
+ *   - no_doc_in_notice  :群公告中无文档链接
+ *   - multi_doc         :群内出现多张表
+ *   - required_empty    :必填项为空
+ *   - format_error      :日期/格式/版式不规范
+ *   - stale_doc         :记录表很久没更新
+ *   - too_brief         :内容过于简略
+ *   - suspected_miss_log:群里聊过但表没更新
+ */
+
+/** 合规问题类型 */
+export type ComplianceIssueType =
+  | 'missing_doc'
+  | 'not_pinned'
+  | 'no_doc_in_notice'
+  | 'multi_doc'
+  | 'required_empty'
+  | 'format_error'
+  | 'stale_doc'
+  | 'too_brief'
+  | 'suspected_miss_log';
+
+/** 合规问题类型中文标签 */
+export const ComplianceIssueTypeLabel: Record<ComplianceIssueType, string> = {
+  missing_doc: '缺少沟通记录表',
+  not_pinned: '记录表未置顶',
+  no_doc_in_notice: '群公告无文档链接',
+  multi_doc: '群内存在多张表',
+  required_empty: '必填项为空',
+  format_error: '格式或版式不规范',
+  stale_doc: '记录表长期未更新',
+  too_brief: '内容过于简略',
+  suspected_miss_log: '疑似漏记(群内聊天但表未更新)',
+};
+
+/** 合规问题状态 */
+export type IssueStatus = 'open' | 'in_progress' | 'resolved' | 'closed';
+
+/** 合规问题记录 */
+export interface ComplianceIssue {
+  /** 主键 */
+  id: number;
+  /** 群 ID */
+  roomId: string;
+  /** 群名称(冗余) */
+  roomName: string;
+  /** 问题类型 */
+  type: ComplianceIssueType;
+  /** 问题描述 */
+  description: string;
+  /** 严重程度:1=低, 2=中, 3=高 */
+  severity: 1 | 2 | 3;
+  /** 状态 */
+  status: IssueStatus;
+  /** 负责人 ID */
+  assigneeId: number;
+  /** 关联的文档 ID(如有) */
+  docId: string;
+  /** 发现时间 */
+  foundAt: string;
+  /** 解决时间 */
+  resolvedAt: string;
+  /** 创建时间 */
+  createdAt: string;
+}
+
+/** 合规巡检任务 */
+export interface ComplianceScan {
+  /** 主键 */
+  id: number;
+  /** 巡检时间 */
+  scannedAt: string;
+  /** 巡检群数 */
+  totalRooms: number;
+  /** 新发现问题数 */
+  newIssues: number;
+  /** 已自动关闭的问题数 */
+  resolvedIssues: number;
+  /** 巡检状态 */
+  status: 'running' | 'completed' | 'failed';
+}
+
+/** 合规汇总报表 */
+export interface ComplianceReport {
+  /** 总群数 */
+  totalRooms: number;
+  /** 达标群数 */
+  compliantRooms: number;
+  /** 待整改群数 */
+  pendingRooms: number;
+  /** 按问题类型分组统计 */
+  issuesByType: Record<ComplianceIssueType, number>;
+  /** 按严重程度分组统计 */
+  issuesBySeverity: Record<number, number>;
+  /** 报表生成时间 */
+  generatedAt: string;
+}

+ 28 - 0
lami-base-v1/backend/src/apps/pc/compliance/routes/compliance.routes.ts

@@ -0,0 +1,28 @@
+/**
+ * 合规检查模块 — 路由层
+ *
+ * 接口一览:
+ *   POST /api/compliance/scan         触发合规巡检
+ *   GET  /api/compliance/issues       查询合规问题列表
+ *   GET  /api/compliance/issues/:id   查询问题详情
+ *   PUT  /api/compliance/issues/:id   更新问题状态
+ *   GET  /api/compliance/reports      获取合规汇总报表
+ */
+
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import {
+  scan,
+  listIssueList,
+  getIssue,
+  updateIssue,
+  report,
+} from '../controllers/compliance.controller.js';
+
+export const pcComplianceApiRouter: ExpressRouter = Router();
+
+pcComplianceApiRouter.post('/compliance/scan', scan);
+pcComplianceApiRouter.get('/compliance/issues', listIssueList);
+pcComplianceApiRouter.get('/compliance/issues/:id', getIssue);
+pcComplianceApiRouter.put('/compliance/issues/:id', updateIssue);
+pcComplianceApiRouter.get('/compliance/reports', report);

+ 268 - 0
lami-base-v1/backend/src/apps/pc/compliance/services/compliance.service.ts

@@ -0,0 +1,268 @@
+/**
+ * 合规检查模块 — 业务服务层
+ *
+ * 对应规范文档 §七「模块 4:沟通记录合规检查模块」
+ *
+ * 巡检流程(compliance_scan)由以下子任务组成:
+ *   §7.1  列出全部外部客户群(调用 room 模块数据)
+ *   §7.2  找出还没有沟通记录表的群
+ *   §7.3  查群里是否曾经发过记录表
+ *   §7.4  查记录表是否已置顶(API-12)
+ *   §7.5  查群公告里是否有文档链接(API-08 冗余字段)
+ *   §7.7  必填项是否都填了(依赖 §7.6 正文读取)
+ *   §7.8  日期、格式与表格版式是否规范
+ *   §7.9  记录表是否很久没更新
+ *   §7.10 内容写得是否太简略
+ *   §7.12 群里聊过但表没更新时提示
+ *
+ * 定时巡检通过 compliance_scan 统一调度,每次扫描生成一份报告。
+ */
+
+import { AppError } from '../../../../shared/errors/app-error.js';
+import type {
+  ComplianceIssue,
+  ComplianceIssueType,
+  IssueStatus,
+  ComplianceScan,
+  ComplianceReport,
+} from '../models/compliance.model.js';
+
+// ---------- 内存存储(TODO: 替换为数据库)----------
+const issueStore = new Map<number, ComplianceIssue>();
+const scanStore = new Map<number, ComplianceScan>();
+let nextIssueId = 1;
+let nextScanId = 1;
+
+// ============================================================
+// 合规巡检任务
+// ============================================================
+
+/**
+ * 执行合规巡检扫描
+ *
+ * 遍历所有外部客户群,逐项检查合规要求,生成问题记录。
+ *
+ * 本方法模拟巡检流程,实际生产环境应:
+ *   1. 从消息表查询是否有链接消息
+ *   2. 调用 API-12 检查置顶状态
+ *   3. 读取群详情中的公告字段
+ *   4. 【占位】读取在线文档正文
+ *
+ * @param roomIds - 需要巡检的群 ID 列表
+ * @returns 巡检结果
+ */
+export function runComplianceScan(roomIds: string[]): ComplianceScan {
+  let newIssues = 0;
+  let resolvedIssues = 0;
+
+  for (const roomId of roomIds) {
+    // ---- §7.2:检查是否缺沟通记录表 ----
+    // TODO: 从 room_doc 台账判断是否存在 docId
+    const hasDoc = true; // 示例:默认有文档
+    if (!hasDoc) {
+      createIssue({
+        roomId,
+        roomName: `群-${roomId}`,
+        type: 'missing_doc',
+        description: '该群尚未登记沟通记录在线文档链接',
+        severity: 3,
+      });
+      newIssues++;
+      continue; // 缺表则跳过后续检查
+    }
+
+    // ---- §7.4:检查记录表是否置顶 ----
+    // TODO: 调 API-12 检查
+    const isPinned = true; // 示例
+    if (!isPinned) {
+      createIssue({
+        roomId,
+        roomName: `群-${roomId}`,
+        type: 'not_pinned',
+        description: '沟通记录表在群内未置顶',
+        severity: 2,
+      });
+      newIssues++;
+    }
+
+    // ---- §7.9:检查记录表是否长期未更新 ----
+    // TODO: 比较文档 updated_at 与当前时间的差距
+    const daysSinceUpdate = 5; // 示例
+    if (daysSinceUpdate > 14) {
+      createIssue({
+        roomId,
+        roomName: `群-${roomId}`,
+        type: 'stale_doc',
+        description: `记录表已 ${daysSinceUpdate} 天未更新`,
+        severity: 2,
+      });
+      newIssues++;
+    }
+  }
+
+  // ---- 自动关闭已修复的问题 ----
+  // TODO: 遍历 open 状态的问题,检查条件是否已满足,是则自动 close
+
+  const scan: ComplianceScan = {
+    id: nextScanId++,
+    scannedAt: new Date().toISOString(),
+    totalRooms: roomIds.length,
+    newIssues,
+    resolvedIssues,
+    status: 'completed',
+  };
+
+  scanStore.set(scan.id, scan);
+  return scan;
+}
+
+// ============================================================
+// 合规问题 CRUD
+// ============================================================
+
+/** 创建问题记录的参数 */
+interface CreateIssueParams {
+  roomId: string;
+  roomName: string;
+  type: ComplianceIssueType;
+  description: string;
+  severity: 1 | 2 | 3;
+  assigneeId?: number;
+  docId?: string;
+}
+
+/**
+ * 创建合规问题记录
+ */
+export function createIssue(params: CreateIssueParams): ComplianceIssue {
+  const now = new Date().toISOString();
+  const issue: ComplianceIssue = {
+    id: nextIssueId++,
+    roomId: params.roomId,
+    roomName: params.roomName,
+    type: params.type,
+    description: params.description,
+    severity: params.severity,
+    status: 'open',
+    assigneeId: params.assigneeId || 0,
+    docId: params.docId || '',
+    foundAt: now,
+    resolvedAt: '',
+    createdAt: now,
+  };
+
+  issueStore.set(issue.id, issue);
+  return issue;
+}
+
+/**
+ * 查询合规问题列表
+ *
+ * @param type   - 问题类型筛选(可选)
+ * @param status - 状态筛选(可选)
+ * @param severity - 严重程度筛选(可选)
+ * @returns 问题列表
+ */
+export function listIssues(
+  type?: ComplianceIssueType,
+  status?: IssueStatus,
+  severity?: number,
+): ComplianceIssue[] {
+  let result = Array.from(issueStore.values());
+
+  if (type) result = result.filter((i) => i.type === type);
+  if (status) result = result.filter((i) => i.status === status);
+  if (severity) result = result.filter((i) => i.severity === severity);
+
+  return result.sort(
+    (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+  );
+}
+
+/**
+ * 查询单个问题详情
+ */
+export function getIssueById(id: number): ComplianceIssue {
+  const issue = issueStore.get(id);
+  if (!issue) {
+    throw new AppError(404, 'ISSUE_NOT_FOUND', `合规问题 ID=${id} 不存在`);
+  }
+  return issue;
+}
+
+/**
+ * 更新问题状态
+ *
+ * 状态流转:open → in_progress → resolved → closed
+ *
+ * @param id     - 问题 ID
+ * @param status - 新状态
+ * @param note   - 处理备注
+ */
+export function updateIssueStatus(id: number, status: IssueStatus, note?: string): ComplianceIssue {
+  const issue = getIssueById(id);
+  issue.status = status;
+
+  if (status === 'resolved' || status === 'closed') {
+    issue.resolvedAt = new Date().toISOString();
+  }
+
+  issueStore.set(id, issue);
+
+  // 记录审计日志
+  console.log(`[Compliance] 问题 #${id} 状态变更: ${status}${note ? ` (${note})` : ''}`);
+
+  return issue;
+}
+
+// ============================================================
+// 合规报表(§7.16)
+// ============================================================
+
+/**
+ * 生成合规汇总报表
+ *
+ * 统计达标/待整改/待确认数量与明细。
+ * 供工作台首页和主管汇总通知使用。
+ *
+ * @param totalRooms - 需要纳入统计的总群数
+ * @returns 合规报表
+ */
+export function generateReport(totalRooms: number): ComplianceReport {
+  const allIssues = Array.from(issueStore.values());
+  const openIssues = allIssues.filter((i) => i.status === 'open' || i.status === 'in_progress');
+
+  // 按问题类型分组计数
+  const issuesByType = {} as Record<ComplianceIssueType, number>;
+  for (const issue of openIssues) {
+    issuesByType[issue.type] = (issuesByType[issue.type] || 0) + 1;
+  }
+
+  // 按严重程度分组计数
+  const issuesBySeverity: Record<number, number> = {};
+  for (const issue of openIssues) {
+    issuesBySeverity[issue.severity] = (issuesBySeverity[issue.severity] || 0) + 1;
+  }
+
+  // 有未解决合规问题的群 ID 集合(去重)
+  const problemRoomIds = new Set(openIssues.map((i) => i.roomId));
+
+  return {
+    totalRooms,
+    compliantRooms: totalRooms - problemRoomIds.size,
+    pendingRooms: problemRoomIds.size,
+    issuesByType,
+    issuesBySeverity,
+    generatedAt: new Date().toISOString(),
+  };
+}
+
+/**
+ * 获取最近一次巡检记录
+ */
+export function getLastScan(): ComplianceScan | undefined {
+  const scans = Array.from(scanStore.values());
+  return scans.sort(
+    (a, b) => new Date(b.scannedAt).getTime() - new Date(a.scannedAt).getTime(),
+  )[0];
+}

+ 155 - 0
lami-base-v1/backend/src/apps/pc/content/controllers/content.controller.ts

@@ -0,0 +1,155 @@
+/**
+ * 社群内容与运营执行模块 — 控制器层
+ *
+ * 对应规范文档 §九「模块 6:社群内容与运营执行模块」
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../../../../shared/http/response.js';
+import {
+  createMaterial, listMaterials, updateMaterial, deleteMaterial,
+  createBroadcast, listBroadcasts, getBroadcastStatus,
+  createPlan, listPlans, addPlanItem, listPlanItems, checkPlanExecution,
+} from '../services/content.service.js';
+
+// ============================================================
+// 素材库
+// ============================================================
+
+export async function createMat(req: Request, res: Response): Promise<void> {
+  try {
+    const { title, type, content } = req.body;
+    if (!title || !type || !content) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:title, type, content');
+      return;
+    }
+    const m = createMaterial({
+      title, type, content,
+      tags: req.body.tags || [],
+      creatorId: req.body.creatorId || 0,
+      enabled: 1,
+    });
+    sendSuccess(res, m, 201);
+  } catch (error) { throw error; }
+}
+
+export async function listMat(req: Request, res: Response): Promise<void> {
+  try {
+    const { type, tag } = req.query;
+    const result = listMaterials(
+      typeof type === 'string' ? type : undefined,
+      typeof tag === 'string' ? tag : undefined,
+    );
+    sendSuccess(res, result);
+  } catch (error) { throw error; }
+}
+
+export async function updateMat(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const m = updateMaterial(id, req.body);
+    sendSuccess(res, m);
+  } catch (error) { throw error; }
+}
+
+export async function deleteMat(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    deleteMaterial(id);
+    sendSuccess(res, { deleted: true });
+  } catch (error) { throw error; }
+}
+
+// ============================================================
+// 群发任务
+// ============================================================
+
+export async function createBc(req: Request, res: Response): Promise<void> {
+  try {
+    const { guid, sendType, toIdList, msgList } = req.body;
+    if (!guid || sendType === undefined || !toIdList || !msgList) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:guid, sendType, toIdList, msgList');
+      return;
+    }
+    const task = await createBroadcast(guid, sendType, toIdList, msgList);
+    sendSuccess(res, task, 201);
+  } catch (error) { throw error; }
+}
+
+export async function listBc(_req: Request, res: Response): Promise<void> {
+  try {
+    const result = listBroadcasts();
+    sendSuccess(res, result);
+  } catch (error) { throw error; }
+}
+
+export async function getBcStatus(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const task = getBroadcastStatus(id);
+    sendSuccess(res, task);
+  } catch (error) { throw error; }
+}
+
+// ============================================================
+// 运营计划
+// ============================================================
+
+export async function createPl(req: Request, res: Response): Promise<void> {
+  try {
+    const { name, startDate, endDate, ownerId } = req.body;
+    if (!name || !startDate || !endDate) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:name, startDate, endDate');
+      return;
+    }
+    const plan = createPlan({
+      name, startDate, endDate,
+      status: 'draft',
+      ownerId: ownerId || 0,
+    });
+    sendSuccess(res, plan, 201);
+  } catch (error) { throw error; }
+}
+
+export async function listPl(req: Request, res: Response): Promise<void> {
+  try {
+    const { status } = req.query;
+    const result = listPlans(typeof status === 'string' ? status : undefined);
+    sendSuccess(res, result);
+  } catch (error) { throw error; }
+}
+
+export async function addItem(req: Request, res: Response): Promise<void> {
+  try {
+    const { planId, content, scheduledDate, targetRoomIds, materialId } = req.body;
+    if (!planId || !content || !scheduledDate) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:planId, content, scheduledDate');
+      return;
+    }
+    const item = addPlanItem({
+      planId, content, scheduledDate,
+      targetRoomIds: targetRoomIds || [],
+      materialId: materialId || 0,
+      status: 'pending',
+      contentDetected: false,
+      engagementCount: 0,
+    });
+    sendSuccess(res, item, 201);
+  } catch (error) { throw error; }
+}
+
+export async function listItems(req: Request, res: Response): Promise<void> {
+  try {
+    const planId = Number(req.params.planId);
+    const items = listPlanItems(planId);
+    sendSuccess(res, items);
+  } catch (error) { throw error; }
+}
+
+export async function checkExecution(req: Request, res: Response): Promise<void> {
+  try {
+    const planId = Number(req.params.planId);
+    const result = checkPlanExecution(planId);
+    sendSuccess(res, result);
+  } catch (error) { throw error; }
+}

+ 94 - 0
lami-base-v1/backend/src/apps/pc/content/models/content.model.ts

@@ -0,0 +1,94 @@
+/**
+ * 社群内容与运营执行模块 — 数据模型定义
+ *
+ * 对应规范文档 §九「模块 6:社群内容与运营执行模块」
+ *
+ * 核心表:
+ *   - material        :话术与案例素材库
+ *   - broadcast_task  :群发任务
+ *   - ops_plan        :运营计划
+ *   - ops_plan_item   :计划执行项
+ */
+
+/** 素材库条目 */
+export interface Material {
+  id: number;
+  /** 标题 */
+  title: string;
+  /** 类型:script=话术, case=案例, image=图片, video=视频, file=文件 */
+  type: 'script' | 'case' | 'image' | 'video' | 'file';
+  /** 正文内容 */
+  content: string;
+  /** 分类标签 */
+  tags: string[];
+  /** 创建人 ID */
+  creatorId: number;
+  /** 状态:1=启用, 0=停用 */
+  enabled: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+/** 群发任务 */
+export interface BroadcastTask {
+  id: number;
+  /** QiWe 返回的群发任务 ID(groupMsgId) */
+  groupMsgId: string;
+  /** 发送类型:0=外部联系人, 1=外部群 */
+  sendType: 0 | 1;
+  /** 目标群 ID 列表 */
+  toIdList: string[];
+  /** 消息列表 */
+  msgList: Array<{ type: number; msgData: Record<string, unknown> }>;
+  /** 执行人员 guid */
+  guid: string;
+  /** 总发送数 */
+  total: number;
+  /** 已发送数 */
+  hasSend: number;
+  /** 是否完成 */
+  isEnd: boolean;
+  /** 创建人 ID */
+  creatorId: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+/** 运营计划 */
+export interface OpsPlan {
+  id: number;
+  /** 计划名称 */
+  name: string;
+  /** 计划周期起始 */
+  startDate: string;
+  /** 计划周期截止 */
+  endDate: string;
+  /** 状态:draft=草稿, active=执行中, completed=已完成 */
+  status: 'draft' | 'active' | 'completed';
+  /** 负责人 ID */
+  ownerId: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+/** 运营计划项 */
+export interface OpsPlanItem {
+  id: number;
+  /** 所属计划 ID */
+  planId: number;
+  /** 内容描述 */
+  content: string;
+  /** 计划执行日期 */
+  scheduledDate: string;
+  /** 目标群范围(群 ID 列表或小区 ID 列表) */
+  targetRoomIds: string[];
+  /** 关联素材 ID */
+  materialId: number;
+  /** 执行状态:pending=待执行, executed=已执行, skipped=已跳过 */
+  status: 'pending' | 'executed' | 'skipped';
+  /** 是否已识别群内相关内容(§9.3) */
+  contentDetected: boolean;
+  /** 互动效果统计(消息数、回复数) */
+  engagementCount: number;
+  createdAt: string;
+}

+ 48 - 0
lami-base-v1/backend/src/apps/pc/content/routes/content.routes.ts

@@ -0,0 +1,48 @@
+/**
+ * 社群内容与运营执行模块 — 路由层
+ *
+ * 接口一览:
+ *   素材库:
+ *     POST   /api/content/materials          创建素材
+ *     GET    /api/content/materials          查询素材列表
+ *     PUT    /api/content/materials/:id      更新素材
+ *     DELETE /api/content/materials/:id      删除素材
+ *   群发:
+ *     POST   /api/content/broadcasts         创建群发任务
+ *     GET    /api/content/broadcasts         查询群发任务列表
+ *     GET    /api/content/broadcasts/:id     查询群发状态
+ *   运营计划:
+ *     POST   /api/content/plans              创建运营计划
+ *     GET    /api/content/plans              查询计划列表
+ *     POST   /api/content/plans/:planId/items      添加计划项
+ *     GET    /api/content/plans/:planId/items      查询计划项
+ *     GET    /api/content/plans/:planId/execution  检查执行情况
+ */
+
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import {
+  createMat, listMat, updateMat, deleteMat,
+  createBc, listBc, getBcStatus,
+  createPl, listPl, addItem, listItems, checkExecution,
+} from '../controllers/content.controller.js';
+
+export const pcContentApiRouter: ExpressRouter = Router();
+
+// 素材库
+pcContentApiRouter.post('/content/materials', createMat);
+pcContentApiRouter.get('/content/materials', listMat);
+pcContentApiRouter.put('/content/materials/:id', updateMat);
+pcContentApiRouter.delete('/content/materials/:id', deleteMat);
+
+// 群发
+pcContentApiRouter.post('/content/broadcasts', createBc);
+pcContentApiRouter.get('/content/broadcasts', listBc);
+pcContentApiRouter.get('/content/broadcasts/:id', getBcStatus);
+
+// 运营计划
+pcContentApiRouter.post('/content/plans', createPl);
+pcContentApiRouter.get('/content/plans', listPl);
+pcContentApiRouter.post('/content/plans/:planId/items', addItem);
+pcContentApiRouter.get('/content/plans/:planId/items', listItems);
+pcContentApiRouter.get('/content/plans/:planId/execution', checkExecution);

+ 246 - 0
lami-base-v1/backend/src/apps/pc/content/services/content.service.ts

@@ -0,0 +1,246 @@
+/**
+ * 社群内容与运营执行模块 — 业务服务层
+ *
+ * 对应规范文档 §九「模块 6:社群内容与运营执行模块」
+ *
+ * 业务流程:
+ *   §9.1 话术与案例素材库:CMS 上传/分类/检索话术与案例文件
+ *   §9.2 向多个群一键群发:选群与内容 → API-15 创建任务 → 轮询 API-16
+ *   §9.3 识别群内是否已发规定运营内容:时间窗内查群发记录 + 群内关键词匹配
+ *   §9.4 运营内容互动效果统计:以发布时间为起点统计后续消息量/回复数
+ *   §9.5 发布时间建议:按历史群消息聚合活跃小时
+ *   §9.6 录入周度运营计划:ops_plan 表录入计划项
+ *   §9.7 对照计划检查是否已执行:计划时间窗对比 §9.3 识别结果
+ *   §9.8 未完成项提醒:未执行计划项 → API-14 通知责任人
+ */
+
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { getStringEnv } from '../../../../shared/config/env.js';
+import { callQiWeApi } from '../../../../shared/qiwei/client.js';
+import type {
+  Material,
+  BroadcastTask,
+  OpsPlan,
+  OpsPlanItem,
+} from '../models/content.model.js';
+
+// ---------- 内存存储(TODO: 替换为数据库)----------
+const materialStore = new Map<number, Material>();
+const broadcastStore = new Map<number, BroadcastTask>();
+const planStore = new Map<number, OpsPlan>();
+const planItemStore = new Map<number, OpsPlanItem>();
+let nextMaterialId = 1;
+let nextBroadcastId = 1;
+let nextPlanId = 1;
+let nextPlanItemId = 1;
+
+// ============================================================
+// 素材库(§9.1)
+// ============================================================
+
+/** 创建素材 */
+export function createMaterial(data: Omit<Material, 'id' | 'createdAt' | 'updatedAt'>): Material {
+  const now = new Date().toISOString();
+  const m: Material = { id: nextMaterialId++, ...data, createdAt: now, updatedAt: now };
+  materialStore.set(m.id, m);
+  return m;
+}
+
+/** 查询素材列表 */
+export function listMaterials(type?: string, tag?: string): Material[] {
+  let result = Array.from(materialStore.values());
+  if (type) result = result.filter((m) => m.type === type);
+  if (tag) result = result.filter((m) => m.tags.includes(tag));
+  return result.filter((m) => m.enabled === 1);
+}
+
+/** 更新素材 */
+export function updateMaterial(id: number, data: Partial<Material>): Material {
+  const m = materialStore.get(id);
+  if (!m) throw new AppError(404, 'MATERIAL_NOT_FOUND', `素材 ID=${id} 不存在`);
+  Object.assign(m, data, { updatedAt: new Date().toISOString() });
+  materialStore.set(id, m);
+  return m;
+}
+
+/** 删除素材 */
+export function deleteMaterial(id: number): void {
+  if (!materialStore.has(id)) throw new AppError(404, 'MATERIAL_NOT_FOUND', `素材 ID=${id} 不存在`);
+  materialStore.delete(id);
+}
+
+// ============================================================
+// 群发任务(§9.2)
+// ============================================================
+
+/**
+ * 创建群发任务
+ *
+ * 步骤:
+ *  1. 调用 API-15 /msg/sendGroupMsg 创建群发
+ *  2. 获取返回的 groupMsgId
+ *  3. 轮询 API-16 /msg/sendGroupMsgStatus 检查进度
+ *
+ * @param guid      - 执行人员 guid
+ * @param sendType   - 发送类型:0=外部联系人, 1=外部群
+ * @param toIdList   - 目标 ID 列表
+ * @param msgList    - 消息内容列表
+ * @returns 群发任务记录
+ */
+export async function createBroadcast(
+  guid: string,
+  sendType: 0 | 1,
+  toIdList: string[],
+  msgList: Array<{ type: number; msgData: Record<string, unknown> }>,
+): Promise<BroadcastTask> {
+  const now = new Date().toISOString();
+  const live = getStringEnv('QIWEI_LIVE_ALLOW_BROADCAST') === '1';
+
+  let groupMsgId = `dry-run-${Date.now()}`;
+
+  if (live) {
+    const data = await callQiWeApi<{ groupMsgId: string }>('/msg/sendGroupMsg', {
+      guid,
+      sendType,
+      toIdList,
+      msgList,
+    });
+    groupMsgId = data.groupMsgId;
+  }
+
+  const task: BroadcastTask = {
+    id: nextBroadcastId++,
+    groupMsgId,
+    sendType,
+    toIdList,
+    msgList,
+    guid,
+    total: toIdList.length,
+    hasSend: 0,
+    isEnd: false,
+    creatorId: 0,
+    createdAt: now,
+    updatedAt: now,
+  };
+
+  broadcastStore.set(task.id, task);
+
+  if (live) {
+    pollBroadcastStatus(task).catch((err) =>
+      console.error(`[Content] 群发状态轮询失败: taskId=${task.id}`, err),
+    );
+  }
+
+  return task;
+}
+
+/**
+ * 轮询群发状态(内部方法)
+ */
+async function pollBroadcastStatus(task: BroadcastTask): Promise<void> {
+  // 简化的单次查询(生产环境应使用定时任务轮询)
+  const statusData = await callQiWeApi<{
+    hasSend: number;
+    isEnd: boolean;
+    total: number;
+  }>('/msg/sendGroupMsgStatus', {
+    guid: task.guid,
+    groupMsgId: task.groupMsgId,
+    endDetailId: 2,
+  });
+
+  task.hasSend = statusData.hasSend;
+  task.isEnd = statusData.isEnd;
+  task.total = statusData.total;
+  task.updatedAt = new Date().toISOString();
+  broadcastStore.set(task.id, task);
+}
+
+/** 查询群发任务列表 */
+export function listBroadcasts(): BroadcastTask[] {
+  return Array.from(broadcastStore.values()).sort(
+    (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
+  );
+}
+
+/** 查询群发任务状态 */
+export function getBroadcastStatus(id: number): BroadcastTask {
+  const task = broadcastStore.get(id);
+  if (!task) throw new AppError(404, 'BROADCAST_NOT_FOUND', `群发任务 ID=${id} 不存在`);
+  return task;
+}
+
+// ============================================================
+// 运营计划(§9.6 ~ §9.7)
+// ============================================================
+
+/** 创建运营计划 */
+export function createPlan(data: Omit<OpsPlan, 'id' | 'createdAt' | 'updatedAt'>): OpsPlan {
+  const now = new Date().toISOString();
+  const plan: OpsPlan = { id: nextPlanId++, ...data, createdAt: now, updatedAt: now };
+  planStore.set(plan.id, plan);
+  return plan;
+}
+
+/** 查询运营计划列表 */
+export function listPlans(status?: string): OpsPlan[] {
+  let result = Array.from(planStore.values());
+  if (status) result = result.filter((p) => p.status === status);
+  return result.sort((a, b) => new Date(b.startDate).getTime() - new Date(a.startDate).getTime());
+}
+
+/** 添加计划项 */
+export function addPlanItem(data: Omit<OpsPlanItem, 'id' | 'createdAt'>): OpsPlanItem {
+  const plan = planStore.get(data.planId);
+  if (!plan) throw new AppError(404, 'PLAN_NOT_FOUND', `计划 ID=${data.planId} 不存在`);
+
+  const item: OpsPlanItem = {
+    id: nextPlanItemId++,
+    ...data,
+    createdAt: new Date().toISOString(),
+  };
+  planItemStore.set(item.id, item);
+  return item;
+}
+
+/** 查询计划项列表 */
+export function listPlanItems(planId: number): OpsPlanItem[] {
+  return Array.from(planItemStore.values())
+    .filter((item) => item.planId === planId)
+    .sort((a, b) => a.scheduledDate.localeCompare(b.scheduledDate));
+}
+
+/**
+ * 检查计划执行情况(§9.7)
+ *
+ * 对计划项的时间窗和群发记录做对比,更新执行状态。
+ *
+ * @param planId - 计划 ID
+ * @returns 未执行的项目列表(供 §9.8 提醒使用)
+ */
+export function checkPlanExecution(planId: number): { unexecuted: OpsPlanItem[]; executed: OpsPlanItem[] } {
+  const items = listPlanItems(planId);
+  const now = new Date();
+
+  const unexecuted: OpsPlanItem[] = [];
+  const executed: OpsPlanItem[] = [];
+
+  for (const item of items) {
+    if (item.status === 'skipped') continue;
+
+    // 判断是否已过计划日期
+    const scheduled = new Date(item.scheduledDate);
+    if (scheduled < now && item.status === 'pending') {
+      // 检查是否有群发记录(TODO: 实际查询 broadcast 记录)
+      if (item.contentDetected) {
+        item.status = 'executed';
+        planItemStore.set(item.id, item);
+        executed.push(item);
+      } else {
+        unexecuted.push(item);
+      }
+    }
+  }
+
+  return { unexecuted, executed };
+}

+ 90 - 0
lami-base-v1/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts

@@ -0,0 +1,90 @@
+/**
+ * 数据看板与经营复盘模块 — 控制器层
+ *
+ * 对应规范文档 §十一「模块 8:数据看板与经营复盘模块」
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import {
+  getOverviewDashboard,
+  getCommunityDashboard,
+  getStoreDashboard,
+  getActivityIndex,
+  getActivityRanking,
+  generatePeriodicReport,
+} from '../services/dashboard.service.js';
+
+/** GET /api/dashboard/overview — 总览看板(§11.1) */
+export async function overview(_req: Request, res: Response): Promise<void> {
+  try {
+    const data = getOverviewDashboard();
+    sendSuccess(res, data);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/dashboard/community/:communityId — 小区看板(§11.2) */
+export async function communityDashboard(req: Request, res: Response): Promise<void> {
+  try {
+    const communityId = Number(req.params.communityId);
+    const data = getCommunityDashboard(communityId);
+    sendSuccess(res, data);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/dashboard/store/:storeId — 门店看板(§11.3) */
+export async function storeDashboard(req: Request, res: Response): Promise<void> {
+  try {
+    const storeId = Number(req.params.storeId);
+    const data = getStoreDashboard(storeId);
+    sendSuccess(res, data);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/dashboard/activity/:roomId — 群活跃度指数(§11.4) */
+export async function activityIndex(req: Request, res: Response): Promise<void> {
+  try {
+    const roomId = String(req.params.roomId);
+    const data = getActivityIndex(roomId);
+    sendSuccess(res, data);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/dashboard/activity-ranking — 群活跃度排行榜 */
+export async function activityRanking(req: Request, res: Response): Promise<void> {
+  try {
+    const limit = typeof req.query.limit === 'string' ? Number(req.query.limit) : 20;
+    const data = getActivityRanking(limit);
+    sendSuccess(res, data);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/dashboard/reports/weekly — 周报(§11.9) */
+export async function weeklyReport(_req: Request, res: Response): Promise<void> {
+  try {
+    const data = generatePeriodicReport('weekly');
+    sendSuccess(res, data);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/dashboard/reports/monthly — 月报(§11.9) */
+export async function monthlyReport(_req: Request, res: Response): Promise<void> {
+  try {
+    const data = generatePeriodicReport('monthly');
+    sendSuccess(res, data);
+  } catch (error) {
+    throw error;
+  }
+}

+ 34 - 0
lami-base-v1/backend/src/apps/pc/dashboard/routes/dashboard.routes.ts

@@ -0,0 +1,34 @@
+/**
+ * 数据看板模块 — 路由层
+ *
+ * 接口一览:
+ *   GET /api/dashboard/overview                  总览看板
+ *   GET /api/dashboard/community/:communityId    小区看板
+ *   GET /api/dashboard/store/:storeId           门店看板
+ *   GET /api/dashboard/activity/:roomId         群活跃度
+ *   GET /api/dashboard/activity-ranking         活跃度排行榜
+ *   GET /api/dashboard/reports/weekly           周报
+ *   GET /api/dashboard/reports/monthly          月报
+ */
+
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import {
+  overview,
+  communityDashboard,
+  storeDashboard,
+  activityIndex,
+  activityRanking,
+  weeklyReport,
+  monthlyReport,
+} from '../controllers/dashboard.controller.js';
+
+export const pcDashboardApiRouter: ExpressRouter = Router();
+
+pcDashboardApiRouter.get('/dashboard/overview', overview);
+pcDashboardApiRouter.get('/dashboard/community/:communityId', communityDashboard);
+pcDashboardApiRouter.get('/dashboard/store/:storeId', storeDashboard);
+pcDashboardApiRouter.get('/dashboard/activity/:roomId', activityIndex);
+pcDashboardApiRouter.get('/dashboard/activity-ranking', activityRanking);
+pcDashboardApiRouter.get('/dashboard/reports/weekly', weeklyReport);
+pcDashboardApiRouter.get('/dashboard/reports/monthly', monthlyReport);

+ 229 - 0
lami-base-v1/backend/src/apps/pc/dashboard/services/dashboard.service.ts

@@ -0,0 +1,229 @@
+/**
+ * 数据看板与经营复盘模块 — 业务服务层
+ *
+ * 对应规范文档 §十一「模块 8:数据看板与经营复盘模块」
+ *
+ * 说明:无新增 QiWe 接口,所有数据来源于已同步的群、消息、合规、风控等模块聚合。
+ *
+ * 包含的看板:
+ *   §11.1 总览看板:群数、人数、消息活跃、合规/预警计数
+ *   §11.2 小区看板:按小区聚合各指标
+ *   §11.3 门店看板:按门店聚合多小区指标、排名
+ *   §11.4 群活跃度指数:消息量、UV 公式计算
+ *   §11.5 小区触达率:群人数 / 小区总户数
+ *   §11.6 图表下钻:支持 communityId → roomId → staff 参数
+ *   §11.7 添加微信与链接率:依赖 §12.1 填报数据
+ *   §11.8 签单与转化率:【占位】CRM 对接
+ *   §11.9 周月报自动汇总
+ */
+
+import type { PageResult } from '../../../../shared/types/page.js';
+import { makePageResult } from '../../../../shared/types/page.js';
+
+/** 总览看板数据 */
+export interface OverviewDashboard {
+  /** 总群数 */
+  totalRooms: number;
+  /** 总群成员数 */
+  totalMembers: number;
+  /** 近 7 天消息量 */
+  messageCount7d: number;
+  /** 活跃群数(7 天内有消息的群) */
+  activeRooms: number;
+  /** 合规达标率 */
+  complianceRate: number;
+  /** 未处理预警数 */
+  openAlerts: number;
+  /** 未处理工单数 */
+  openWorkOrders: number;
+  /** 生成时间 */
+  generatedAt: string;
+}
+
+/** 小区看板数据 */
+export interface CommunityDashboard {
+  communityId: number;
+  communityName: string;
+  totalHouseholds: number;
+  /** 群人数之和 */
+  groupMemberCount: number;
+  /** 触达率 = groupMemberCount / totalHouseholds */
+  reachRate: number;
+  /** 消息活跃度 */
+  messageCount7d: number;
+  /** 合规问题数 */
+  complianceIssues: number;
+}
+
+/** 门店看板数据 */
+export interface StoreDashboard {
+  storeId: number;
+  storeName: string;
+  /** 下属小区数 */
+  communityCount: number;
+  /** 下属群数 */
+  roomCount: number;
+  /** 总触达率 */
+  reachRate: number;
+  /** 合规排名 */
+  complianceRank: number;
+}
+
+/** 群活跃度指数 */
+export interface ActivityIndex {
+  roomId: string;
+  roomName: string;
+  /** 活跃度指数(0-100) */
+  score: number;
+  /** 消息量 */
+  messageCount: number;
+  /** 参与人数(UV) */
+  uniqueUsers: number;
+  period: string;
+}
+
+/** 周/月报 */
+export interface PeriodicReport {
+  title: string;
+  period: string;
+  /** 群与活跃概览 */
+  overview: OverviewDashboard;
+  /** 合规概况 */
+  complianceSummary: {
+    total: number;
+    resolved: number;
+    pending: number;
+  };
+  /** 风控概况 */
+  riskSummary: {
+    totalAlerts: number;
+    handledAlerts: number;
+  };
+  /** 转化数据(依赖填报) */
+  conversionSummary: {
+    newLeads: number;
+    orders: number;
+    note: string; // 缺失数据标注
+  };
+  generatedAt: string;
+}
+
+/**
+ * 生成总览看板(§11.1)
+ *
+ * 聚合群数、人数、消息活跃度、合规/预警计数。
+ */
+export function getOverviewDashboard(): OverviewDashboard {
+  // TODO: 从各模块聚合真实数据
+  return {
+    totalRooms: 156,
+    totalMembers: 2340,
+    messageCount7d: 8920,
+    activeRooms: 142,
+    complianceRate: 0.85,
+    openAlerts: 3,
+    openWorkOrders: 2,
+    generatedAt: new Date().toISOString(),
+  };
+}
+
+/**
+ * 查询小区看板(§11.2)
+ *
+ * 按 communityId 聚合群数据、触达率、合规情况。
+ */
+export function getCommunityDashboard(communityId: number): CommunityDashboard {
+  // TODO: 从 community + room + compliance 模块聚合
+  return {
+    communityId,
+    communityName: `小区-${communityId}`,
+    totalHouseholds: 1200,
+    groupMemberCount: 380,
+    reachRate: 0.32,
+    messageCount7d: 560,
+    complianceIssues: 1,
+  };
+}
+
+/**
+ * 查询门店看板(§11.3)
+ *
+ * 按门店聚合多小区指标、排名。
+ */
+export function getStoreDashboard(storeId: number): StoreDashboard {
+  // TODO: 按门店聚合社区看板数据
+  return {
+    storeId,
+    storeName: `门店-${storeId}`,
+    communityCount: 5,
+    roomCount: 12,
+    reachRate: 0.35,
+    complianceRank: 2,
+  };
+}
+
+/**
+ * 计算群活跃度指数(§11.4)
+ *
+ * 基于消息量 + UV 综合加权计算(公式可调)。
+ * score = min(100, messageCount/100 * 0.6 + uniqueUsers/5 * 0.4)
+ */
+export function getActivityIndex(roomId: string): ActivityIndex {
+  // TODO: 从 message 表统计实际数据
+  const messageCount = Math.floor(Math.random() * 200);
+  const uniqueUsers = Math.floor(Math.random() * 30);
+  const score = Math.min(100, (messageCount / 100) * 60 + (uniqueUsers / 5) * 40);
+
+  return {
+    roomId,
+    roomName: `群-${roomId}`,
+    score: Math.round(score),
+    messageCount,
+    uniqueUsers,
+    period: '7d',
+  };
+}
+
+/**
+ * 批量查询群活跃度排行榜
+ */
+export function getActivityRanking(limit = 20): ActivityIndex[] {
+  // TODO: 从 room 模块获取所有 roomId,逐个计算活跃度
+  // 这里返回示例数据
+  const mockRoomIds = ['room-001', 'room-002', 'room-003', 'room-004', 'room-005'];
+  return mockRoomIds
+    .map((roomId) => getActivityIndex(roomId))
+    .sort((a, b) => b.score - a.score)
+    .slice(0, limit);
+}
+
+/**
+ * 生成周报/月报(§11.9)
+ *
+ * 自动汇总群/活跃/合规/预警块。
+ * 转化数据依赖手工填报(§12.2),缺失则标注「未录入」。
+ */
+export function generatePeriodicReport(period: 'weekly' | 'monthly'): PeriodicReport {
+  const overview = getOverviewDashboard();
+
+  return {
+    title: period === 'weekly' ? '周报' : '月报',
+    period: new Date().toISOString().slice(0, 7),
+    overview,
+    complianceSummary: {
+      total: 12,
+      resolved: 10,
+      pending: 2,
+    },
+    riskSummary: {
+      totalAlerts: 5,
+      handledAlerts: 4,
+    },
+    conversionSummary: {
+      newLeads: 0,
+      orders: 0,
+      note: '转化数据待填报(§12.2),当前未录入',
+    },
+    generatedAt: new Date().toISOString(),
+  };
+}

+ 27 - 0
lami-base-v1/backend/src/apps/pc/health/controllers/health.controller.ts

@@ -0,0 +1,27 @@
+import type { Request, Response } from 'express';
+
+/**
+ * GET /api/health — 健康检查
+ *
+ * 返回服务运行状态和已加载的模块列表,供监控和前端探活使用。
+ */
+export function getHealth(_req: Request, res: Response): void {
+  res.json({
+    status: 'ok',
+    platform: 'pc',
+    timestamp: new Date().toISOString(),
+    modules: [
+      'qiwei',        // 企微 API 代理 [模块1]
+      'staff',        // 人员管理 [模块1]
+      'community',    // 小区管理 [模块2]
+      'room',         // 群管理 [模块2]
+      'compliance',   // 合规检查 [模块4]
+      'risk',         // 群风控 [模块5]
+      'content',      // 内容运营 [模块6]
+      'koc',          // KOC与意向 [模块7]
+      'dashboard',    // 数据看板 [模块8]
+      'sales',        // 经营数据 [模块9]
+      'workbench',    // 工作台 [模块10]
+    ],
+  });
+}

+ 8 - 0
lami-base-v1/backend/src/apps/pc/health/routes/health.routes.ts

@@ -0,0 +1,8 @@
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import { getHealth } from '../controllers/health.controller.js';
+
+export const pcHealthApiRouter: ExpressRouter = Router();
+
+pcHealthApiRouter.get('/health', getHealth);
+

+ 34 - 0
lami-base-v1/backend/src/apps/pc/health/server.ts

@@ -0,0 +1,34 @@
+/**
+ * PC 端统一入口 — 端口监听层
+ *
+ * 创建唯一的 PC Express App(包含所有模块路由),监听单端口。
+ * 当前阶段所有 PC 模块共用一个进程,独立部署时可将模块 server.ts 拆出去。
+ */
+
+import { createPcApp } from '../app.js';
+import { getNumberEnv } from '../../../shared/config/env.js';
+
+export function startPcServer(): void {
+  const app = createPcApp();
+  const port = getNumberEnv('PC_PORT', 3101);
+
+  app.listen(port, () => {
+    console.log('──────────────────────────────────────────');
+    console.log(`  PC 端服务已启动 → http://localhost:${port}`);
+    console.log(`  接口测试台 (Swagger UI) → http://localhost:${port}/api-docs`);
+    console.log('  已加载模块:');
+    console.log('    /api/health            健康检查');
+    console.log('    /api/qiwei             企微 API 代理 [模块1]');
+    console.log('    /api/staff             人员管理 [模块1]');
+    console.log('    /api/communities       小区管理 [模块2]');
+    console.log('    /api/rooms             群管理 [模块2]');
+    console.log('    /api/compliance        合规检查 [模块4]');
+    console.log('    /api/risk              群风控 [模块5]');
+    console.log('    /api/content           内容运营 [模块6]');
+    console.log('    /api/koc               KOC与意向 [模块7]');
+    console.log('    /api/dashboard         数据看板 [模块8]');
+    console.log('    /api/sales             经营数据 [模块9]');
+    console.log('    /api/workbench         工作台 [模块10]');
+    console.log('──────────────────────────────────────────');
+  });
+}

+ 132 - 0
lami-base-v1/backend/src/apps/pc/koc/controllers/koc.controller.ts

@@ -0,0 +1,132 @@
+/**
+ * 拉群、KOC 与意向客户模块 — 控制器层
+ *
+ * 对应规范文档 §十「模块 7:拉群、KOC 与意向客户模块」
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../../../../shared/http/response.js';
+import {
+  createChannel, listChannels, updateChannel, deleteChannel,
+  syncContacts, listContacts, getContact,
+  findKocCandidates, labelKoc,
+  detectIntent, listLeads, updateLead, listTasks,
+} from '../services/koc.service.js';
+
+// ============================================================
+// 渠道管理
+// ============================================================
+
+export async function createCh(req: Request, res: Response): Promise<void> {
+  try {
+    const { name, code, type } = req.body;
+    if (!name || !code || !type) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:name, code, type');
+      return;
+    }
+    const ch = createChannel({ name, code, type, remark: req.body.remark || '' });
+    sendSuccess(res, ch, 201);
+  } catch (error) { throw error; }
+}
+
+export async function listCh(_req: Request, res: Response): Promise<void> {
+  try { sendSuccess(res, listChannels()); } catch (error) { throw error; }
+}
+
+export async function updateCh(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    sendSuccess(res, updateChannel(id, req.body));
+  } catch (error) { throw error; }
+}
+
+export async function deleteCh(req: Request, res: Response): Promise<void> {
+  try {
+    deleteChannel(Number(req.params.id));
+    sendSuccess(res, { deleted: true });
+  } catch (error) { throw error; }
+}
+
+// ============================================================
+// 外部联系人
+// ============================================================
+
+export async function syncCt(req: Request, res: Response): Promise<void> {
+  try {
+    const { guid, currentSeq, limit } = req.body;
+    if (!guid) { sendError(res, 400, 'VALIDATION_ERROR', '缺少 guid 参数'); return; }
+    const result = await syncContacts(guid, currentSeq || 0, limit || 50);
+    sendSuccess(res, result);
+  } catch (error) { throw error; }
+}
+
+export async function listCt(_req: Request, res: Response): Promise<void> {
+  try { sendSuccess(res, listContacts()); } catch (error) { throw error; }
+}
+
+export async function getCt(req: Request, res: Response): Promise<void> {
+  try { sendSuccess(res, getContact(String(req.params.userId))); } catch (error) { throw error; }
+}
+
+// ============================================================
+// KOC
+// ============================================================
+
+export async function kocCandidates(_req: Request, res: Response): Promise<void> {
+  try { sendSuccess(res, findKocCandidates()); } catch (error) { throw error; }
+}
+
+export async function labelKocUser(req: Request, res: Response): Promise<void> {
+  try {
+    const { userId, guid, labelId } = req.body;
+    if (!userId || !guid || !labelId) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:userId, guid, labelId');
+      return;
+    }
+    const result = await labelKoc(userId, guid, labelId);
+    sendSuccess(res, result);
+  } catch (error) { throw error; }
+}
+
+// ============================================================
+// 意向客户
+// ============================================================
+
+export async function detectIntentMsg(req: Request, res: Response): Promise<void> {
+  try {
+    const { userId, roomId, content } = req.body;
+    if (!userId || !roomId || !content) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:userId, roomId, content');
+      return;
+    }
+    const lead = detectIntent(userId, roomId, content);
+    if (!lead) {
+      sendSuccess(res, { matched: false, lead: null });
+      return;
+    }
+    sendSuccess(res, { matched: true, lead });
+  } catch (error) { throw error; }
+}
+
+export async function listLd(req: Request, res: Response): Promise<void> {
+  try {
+    const { intentLevel, status } = req.query;
+    sendSuccess(res, listLeads(
+      typeof intentLevel === 'string' ? intentLevel : undefined,
+      typeof status === 'string' ? status : undefined,
+    ));
+  } catch (error) { throw error; }
+}
+
+export async function updateLd(req: Request, res: Response): Promise<void> {
+  try {
+    sendSuccess(res, updateLead(Number(req.params.id), req.body));
+  } catch (error) { throw error; }
+}
+
+export async function listTk(req: Request, res: Response): Promise<void> {
+  try {
+    const { assigneeId } = req.query;
+    sendSuccess(res, listTasks(typeof assigneeId === 'string' ? Number(assigneeId) : undefined));
+  } catch (error) { throw error; }
+}

+ 88 - 0
lami-base-v1/backend/src/apps/pc/koc/models/koc.model.ts

@@ -0,0 +1,88 @@
+/**
+ * 拉群、KOC 与意向客户模块 — 数据模型定义
+ *
+ * 对应规范文档 §十「模块 7:拉群、KOC 与意向客户模块」
+ *
+ * 核心表:
+ *   - channel         :拉群渠道
+ *   - external_contact:外部联系人
+ *   - koc_candidate   :KOC 候选人
+ *   - intent_lead     :意向客户
+ *   - intent_task     :跟进待办
+ */
+
+/** 拉群渠道 */
+export interface Channel {
+  id: number;
+  name: string;
+  code: string;
+  /** 渠道类型:online=线上, offline=线下, referral=转介绍 */
+  type: 'online' | 'offline' | 'referral';
+  remark: string;
+  createdAt: string;
+}
+
+/** 外部联系人 */
+export interface ExternalContact {
+  userId: string;
+  nickname: string;
+  remark: string;
+  mobile: string;
+  avatarUrl: string;
+  /** KOC 标签 ID 列表 */
+  labelIds: string[];
+  /** 所属群 ID */
+  roomId: string;
+  /** 添加时间 */
+  addTime: string;
+}
+
+/** KOC 候选人 */
+export interface KocCandidate {
+  userId: string;
+  nickname: string;
+  /** 发言次数 */
+  messageCount: number;
+  /** 互动指数 */
+  engagementScore: number;
+  /** 推荐理由 */
+  reason: string;
+  /** 状态:candidate=候选, confirmed=已确认, labelled=已打标 */
+  status: 'candidate' | 'confirmed' | 'labelled';
+  evaluatedAt: string;
+}
+
+/** 意向客户 */
+export interface IntentLead {
+  id: number;
+  /** 用户 ID */
+  userId: string;
+  /** 所属群 ID */
+  roomId: string;
+  /** 意向等级:high=高, medium=中, low=低 */
+  intentLevel: 'high' | 'medium' | 'low';
+  /** 匹配关键词 */
+  matchedKeywords: string[];
+  /** 匹配的消息内容摘要 */
+  messageSummary: string;
+  /** 状态 */
+  status: 'new' | 'assigned' | 'contacted' | 'converted' | 'closed';
+  /** 指定跟进人 ID */
+  assigneeId: number;
+  /** 跟进待办 ID */
+  taskId: number;
+  createdAt: string;
+  updatedAt: string;
+}
+
+/** 跟进待办 */
+export interface IntentTask {
+  id: number;
+  leadId: number;
+  title: string;
+  description: string;
+  assigneeId: number;
+  deadline: string;
+  status: 'pending' | 'completed';
+  createdAt: string;
+}

+ 54 - 0
lami-base-v1/backend/src/apps/pc/koc/routes/koc.routes.ts

@@ -0,0 +1,54 @@
+/**
+ * 拉群、KOC 与意向客户模块 — 路由层
+ *
+ * 接口一览:
+ *   渠道:
+ *     POST   /api/channels              创建渠道
+ *     GET    /api/channels              查询渠道列表
+ *     PUT    /api/channels/:id          更新渠道
+ *     DELETE /api/channels/:id          删除渠道
+ *   联系人:
+ *     POST   /api/contacts/sync         同步外部联系人
+ *     GET    /api/contacts              查询联系人列表
+ *     GET    /api/contacts/:userId      查询联系人详情
+ *   KOC:
+ *     GET    /api/koc/candidates        KOC 候选人列表
+ *     POST   /api/koc/label             打 KOC 标签
+ *   意向客户:
+ *     POST   /api/intent-leads/detect   识别意向话术
+ *     GET    /api/intent-leads          查询意向客户列表
+ *     PUT    /api/intent-leads/:id      更新意向客户
+ *     GET    /api/intent-tasks          查询跟进待办
+ */
+
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import {
+  createCh, listCh, updateCh, deleteCh,
+  syncCt, listCt, getCt,
+  kocCandidates, labelKocUser,
+  detectIntentMsg, listLd, updateLd, listTk,
+} from '../controllers/koc.controller.js';
+
+export const pcKocApiRouter: ExpressRouter = Router();
+
+// 渠道
+pcKocApiRouter.post('/channels', createCh);
+pcKocApiRouter.get('/channels', listCh);
+pcKocApiRouter.put('/channels/:id', updateCh);
+pcKocApiRouter.delete('/channels/:id', deleteCh);
+
+// 联系人
+pcKocApiRouter.post('/contacts/sync', syncCt);
+pcKocApiRouter.get('/contacts', listCt);
+pcKocApiRouter.get('/contacts/:userId', getCt);
+
+// KOC
+pcKocApiRouter.get('/koc/candidates', kocCandidates);
+pcKocApiRouter.post('/koc/label', labelKocUser);
+
+// 意向客户
+pcKocApiRouter.post('/intent-leads/detect', detectIntentMsg);
+pcKocApiRouter.get('/intent-leads', listLd);
+pcKocApiRouter.put('/intent-leads/:id', updateLd);
+pcKocApiRouter.get('/intent-tasks', listTk);

+ 306 - 0
lami-base-v1/backend/src/apps/pc/koc/services/koc.service.ts

@@ -0,0 +1,306 @@
+/**
+ * 拉群、KOC 与意向客户模块 — 业务服务层
+ *
+ * 对应规范文档 §十「模块 7:拉群、KOC 与意向客户模块」
+ *
+ * 业务流程:
+ *   §10.1 拉群渠道登记:channel 表 CRUD
+ *   §10.2 各渠道拉群效果统计:进群事件结合 channelId 聚合
+ *   §10.3 自动筛选 KOC 候选人:按发言次数/互动规则出候选名单
+ *   §10.4 在企微给客户打 KOC 标签:确认 KOC 后调 API-19 打标签
+ *   §10.5 查看外部联系人档案:API-17 分页 + API-18 批量详情
+ *   §10.7 识别群内咨询类话术:关键词匹配群消息
+ *   §10.8 意向高/中/低分级:规则匹配 + 人工确认高意向
+ *   §10.9 生成跟进待办并通知销售:高意向写 intent_task → API-14 通知
+ */
+
+import { AppError } from '../../../../shared/errors/app-error.js';
+import { callQiWeApi } from '../../../../shared/qiwei/client.js';
+import type {
+  Channel,
+  ExternalContact,
+  KocCandidate,
+  IntentLead,
+  IntentTask,
+} from '../models/koc.model.js';
+
+// ---------- 内存存储(TODO: 替换为数据库)----------
+const channelStore = new Map<number, Channel>();
+const contactStore = new Map<string, ExternalContact>();
+const kocCandidateStore = new Map<string, KocCandidate>();
+const intentLeadStore = new Map<number, IntentLead>();
+const intentTaskStore = new Map<number, IntentTask>();
+let nextChannelId = 1;
+let nextLeadId = 1;
+let nextTaskId = 1;
+
+// ============================================================
+// 渠道管理(§10.1)
+// ============================================================
+
+export function createChannel(data: Omit<Channel, 'id' | 'createdAt'>): Channel {
+  const ch: Channel = { id: nextChannelId++, ...data, createdAt: new Date().toISOString() };
+  channelStore.set(ch.id, ch);
+  return ch;
+}
+
+export function listChannels(): Channel[] {
+  return Array.from(channelStore.values());
+}
+
+export function updateChannel(id: number, data: Partial<Channel>): Channel {
+  const ch = channelStore.get(id);
+  if (!ch) throw new AppError(404, 'CHANNEL_NOT_FOUND', `渠道 ID=${id} 不存在`);
+  Object.assign(ch, data);
+  channelStore.set(id, ch);
+  return ch;
+}
+
+export function deleteChannel(id: number): void {
+  if (!channelStore.has(id)) throw new AppError(404, 'CHANNEL_NOT_FOUND', `渠道 ID=${id} 不存在`);
+  channelStore.delete(id);
+}
+
+// ============================================================
+// 外部联系人(§10.5)
+// ============================================================
+
+/**
+ * 分页获取外部联系人列表(API-17 + API-18)
+ *
+ * 步骤:
+ *  1. 调 API-17 /contact/getWxContactList 分页拉联系人 userId
+ *  2. 调 API-18 /contact/batchGetUserinfo 批量获取详情
+ */
+export async function syncContacts(guid: string, currentSeq: number, limit: number): Promise<{
+  contacts: ExternalContact[];
+  hasMore: number;
+  nextSeq: number;
+}> {
+  // 步骤 1:分页拉外部联系人(API-17)
+  const listData = await callQiWeApi<{
+    hasMore: number;
+    currentSeq: number;
+    contactList: Array<{
+      userId: string;
+      nickname: string;
+      remark: string;
+    }>;
+  }>('/contact/getWxContactList', { guid, currentSeq, limit, bizType: 1 });
+
+  if (!listData.contactList || listData.contactList.length === 0) {
+    return { contacts: [], hasMore: listData.hasMore, nextSeq: listData.currentSeq };
+  }
+
+  const userIds = listData.contactList.map((c) => c.userId);
+
+  // 步骤 2:批量查详情(API-18)
+  const detailData = await callQiWeApi<{
+    contactList: Array<{
+      userId: string;
+      nickname: string;
+      mobile: string;
+      avatarUrl: string;
+    }>;
+  }>('/contact/batchGetUserinfo', { guid, userIdList: userIds });
+
+  const contacts: ExternalContact[] = [];
+  for (const detail of detailData.contactList || []) {
+    const contact: ExternalContact = {
+      userId: detail.userId,
+      nickname: detail.nickname,
+      remark: '',
+      mobile: detail.mobile,
+      avatarUrl: detail.avatarUrl,
+      labelIds: [],
+      roomId: '',
+      addTime: new Date().toISOString(),
+    };
+    contactStore.set(contact.userId, contact);
+    contacts.push(contact);
+  }
+
+  return { contacts, hasMore: listData.hasMore, nextSeq: listData.currentSeq };
+}
+
+/** 查询已存储的外部联系人 */
+export function listContacts(): ExternalContact[] {
+  return Array.from(contactStore.values());
+}
+
+/** 查询单个联系人详情 */
+export function getContact(userId: string): ExternalContact {
+  const contact = contactStore.get(userId);
+  if (!contact) throw new AppError(404, 'CONTACT_NOT_FOUND', `联系人 ${userId} 不存在`);
+  return contact;
+}
+
+// ============================================================
+// KOC 筛选与打标签(§10.3 ~ §10.4)
+// ============================================================
+
+/**
+ * 自动筛选 KOC 候选人
+ *
+ * 规则:
+ *   - 近 30 天发言次数 > 10
+ *   - 互动指数(发言+被回复)前 20%
+ *
+ * 产出候选名单,供人工确认后打标签。
+ */
+export function findKocCandidates(): KocCandidate[] {
+  const candidates: KocCandidate[] = [];
+
+  for (const contact of contactStore.values()) {
+    // TODO: 实际应从 message 表统计发言次数和互动指数
+    const messageCount = Math.floor(Math.random() * 20); // 示例
+    const engagementScore = Math.floor(Math.random() * 100);
+
+    if (messageCount > 10) {
+      const candidate: KocCandidate = {
+        userId: contact.userId,
+        nickname: contact.nickname,
+        messageCount,
+        engagementScore,
+        reason: `近30天发言${messageCount}次,互动指数${engagementScore}`,
+        status: 'candidate',
+        evaluatedAt: new Date().toISOString(),
+      };
+      kocCandidateStore.set(contact.userId, candidate);
+      candidates.push(candidate);
+    }
+  }
+
+  return candidates.sort((a, b) => b.engagementScore - a.engagementScore);
+}
+
+/**
+ * 确认 KOC 并调用 API-19 打标签
+ *
+ * @param userId  - 目标用户 ID
+ * @param guid    - 操作人员 guid
+ * @param labelId - 标签 ID
+ */
+export async function labelKoc(userId: string, guid: string, labelId: string): Promise<KocCandidate> {
+  const candidate = kocCandidateStore.get(userId);
+  if (!candidate) throw new AppError(404, 'KOC_CANDIDATE_NOT_FOUND', `KOC 候选人 ${userId} 未找到`);
+
+  // 调用 API-19 打标签
+  await callQiWeApi('/label/contactEditLabel', {
+    guid,
+    opType: 1, // 1=增加标签
+    paramList: [{
+      userId,
+      labelIdList: [labelId],
+      labelSuperIdList: [labelId],
+      labelOwnerList: [guid],
+    }],
+  });
+
+  candidate.status = 'labelled';
+  kocCandidateStore.set(userId, candidate);
+  return candidate;
+}
+
+// ============================================================
+// 意向客户(§10.7 ~ §10.9)
+// ============================================================
+
+/**
+ * 识别群内咨询类话术,生成意向客户线索
+ *
+ * 匹配关键词:价格、量房、方案、预算、装修风格等
+ *
+ * @param userId  - 用户 ID
+ * @param roomId  - 群 ID
+ * @param content - 消息内容
+ * @returns 创建的意向线索(若无匹配则返回 null)
+ */
+export function detectIntent(userId: string, roomId: string, content: string): IntentLead | null {
+  const interestKeywords = [
+    '价格', '多少钱', '预算', '报价',
+    '量房', '尺寸', '面积',
+    '方案', '设计', '风格',
+    '优惠', '活动', '折扣',
+    '装修', '全屋', '定制',
+    '样板间', '预约', '看房',
+  ];
+
+  const matched: string[] = [];
+  for (const kw of interestKeywords) {
+    if (content.includes(kw)) matched.push(kw);
+  }
+
+  if (matched.length === 0) return null;
+
+  // 意向分级(§10.8)
+  let intentLevel: IntentLead['intentLevel'] = 'low';
+  if (matched.length >= 3) {
+    intentLevel = 'high';
+  } else if (matched.length >= 2) {
+    intentLevel = 'medium';
+  }
+
+  const now = new Date().toISOString();
+  const lead: IntentLead = {
+    id: nextLeadId++,
+    userId,
+    roomId,
+    intentLevel,
+    matchedKeywords: matched,
+    messageSummary: content.slice(0, 100),
+    status: 'new',
+    assigneeId: 0,
+    taskId: 0,
+    createdAt: now,
+    updatedAt: now,
+  };
+
+  intentLeadStore.set(lead.id, lead);
+
+  // 高意向自动生成跟进待办(§10.9)
+  if (intentLevel === 'high') {
+    const task: IntentTask = {
+      id: nextTaskId++,
+      leadId: lead.id,
+      title: `高意向客户跟进:${userId}`,
+      description: `用户在群 ${roomId} 中提及了 ${matched.join('、')},建议及时跟进`,
+      assigneeId: 0, // 待分配
+      deadline: new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString(), // 2h 内
+      status: 'pending',
+      createdAt: now,
+    };
+    intentTaskStore.set(task.id, task);
+    lead.taskId = task.id;
+    intentLeadStore.set(lead.id, lead);
+  }
+
+  return lead;
+}
+
+/** 查询意向客户列表 */
+export function listLeads(
+  intentLevel?: string,
+  status?: string,
+): IntentLead[] {
+  let result = Array.from(intentLeadStore.values());
+  if (intentLevel) result = result.filter((l) => l.intentLevel === intentLevel);
+  if (status) result = result.filter((l) => l.status === status);
+  return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+}
+
+/** 更新意向客户(分配跟进人等) */
+export function updateLead(id: number, data: Partial<IntentLead>): IntentLead {
+  const lead = intentLeadStore.get(id);
+  if (!lead) throw new AppError(404, 'LEAD_NOT_FOUND', `意向客户 ID=${id} 不存在`);
+  Object.assign(lead, data, { updatedAt: new Date().toISOString() });
+  intentLeadStore.set(id, lead);
+  return lead;
+}
+
+/** 查询跟进待办列表 */
+export function listTasks(assigneeId?: number): IntentTask[] {
+  let result = Array.from(intentTaskStore.values());
+  if (assigneeId !== undefined) result = result.filter((t) => t.assigneeId === assigneeId);
+  return result;
+}

+ 301 - 0
lami-base-v1/backend/src/apps/pc/qiwei/controllers/qiwei.controller.ts

@@ -0,0 +1,301 @@
+/**
+ * QiWe 模块 — 控制器层
+ *
+ * 职责:
+ *   - 解析 HTTP 请求参数(query / body / path)
+ *   - 调用 service 层执行业务逻辑
+ *   - 使用统一响应格式返回数据
+ *
+ * 不直接操作数据库、不直接调用第三方 API(这些在 service 层完成)。
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../../../../shared/http/response.js';
+import {
+  proxyQiWeCall,
+  checkStaffOnline,
+  batchCheckOnline,
+  syncMessages,
+  processWebhook,
+  getRoomDocs,
+  getRoomDocByRoomId,
+  getRoomDocAnomalies,
+  resolveRoomDocAnomaly,
+  getMessages,
+} from '../services/qiwei.service.js';
+
+// ============================================================
+// QiWe 通用代理
+// ============================================================
+
+/**
+ * POST /api/qiwei/proxy
+ *
+ * 通用 QiWe API 代理:前端传入 method + params,后端转发到 QiWe 开放平台。
+ * 适用于开发调试或临时调用官方接口。
+ *
+ * Body:
+ *   { "method": "/room/getRoomList", "params": { "guid": "xxx" } }
+ */
+export async function proxyApi(req: Request, res: Response): Promise<void> {
+  try {
+    const { method, params } = req.body as { method?: string; params?: Record<string, unknown> };
+
+    // 参数校验
+    if (!method) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 method 参数(QiWe 接口路径)');
+      return;
+    }
+
+    const data = await proxyQiWeCall(method, params || {});
+    sendSuccess(res, data);
+  } catch (error) {
+    // 错误由 error-handler 中间件统一处理
+    throw error;
+  }
+}
+
+// ============================================================
+// 人员在线状态
+// ============================================================
+
+/**
+ * GET /api/qiwei/staff/:guid/status
+ *
+ * 查询指定人员企微在线状态(API-05 /login/checkLogin)。
+ *
+ * Path params:
+ *   guid - 人员设备 GUID
+ *
+ * 响应示例:
+ *   { "success": true, "data": { "userOnlineStatus": 2, "userId": "...", "nickname": "店长A" } }
+ */
+export async function getStaffOnlineStatus(req: Request, res: Response): Promise<void> {
+  try {
+    const guid = String(req.params.guid);
+
+    if (!guid) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 guid 参数');
+      return;
+    }
+
+    const status = await checkStaffOnline(guid);
+    sendSuccess(res, status);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * POST /api/qiwei/staff/batch-status
+ *
+ * 批量查询人员在线状态。
+ *
+ * Body:
+ *   { "guids": ["guid1", "guid2"] }
+ */
+export async function batchGetStaffStatus(req: Request, res: Response): Promise<void> {
+  try {
+    const { guids } = req.body as { guids?: string[] };
+
+    if (!guids || !Array.isArray(guids) || guids.length === 0) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 guids 参数(须为非空数组)');
+      return;
+    }
+
+    const results = await batchCheckOnline(guids);
+    sendSuccess(res, results);
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// 消息同步
+// ============================================================
+
+/**
+ * POST /api/qiwei/sync
+ *
+ * 触发历史消息同步(API-06 /msg/syncMsg)。
+ * 适用于手动补拉历史消息或定时任务触发。
+ *
+ * Body:
+ *   { "guid": "...", "msgSeq": 0, "limit": 50 }
+ *
+ * 响应:
+ *   { "success": true, "data": { "messages": [...], "hasMore": 1, "nextSeq": 12345 } }
+ */
+export async function triggerSync(req: Request, res: Response): Promise<void> {
+  try {
+    const { guid, msgSeq, limit } = req.body as {
+      guid?: string;
+      msgSeq?: number;
+      limit?: number;
+    };
+
+    if (!guid) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 guid 参数');
+      return;
+    }
+
+    const result = await syncMessages({
+      guid,
+      msgSeq: msgSeq ?? 0,
+      limit: limit ?? 50,
+    });
+
+    sendSuccess(res, result);
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// Webhook 回调
+// ============================================================
+
+/**
+ * POST /api/qiwei/webhook
+ *
+ * 接收 QiWe 开放平台的 Webhook 推送(API-04 回调)。
+ * QiWe 在有新消息时会 POST 到本接口(需通过 API-03 提前注册 callbackUrl)。
+ *
+ * 处理流程:
+ *   1. 收到推送体 → 解析 data[] 中的消息
+ *   2. 过滤普通消息(cmd=15000)
+ *   3. 检测链接消息(msgType=13)→ 解析文档链接 → 登记台账
+ *   4. 返回 200(QiWe 要求快速响应,不可在此做耗时操作)
+ *
+ * 注意:此接口必须公网可达,且需在 QiWe 控制台通过 API-03 注册。
+ */
+export async function receiveWebhook(req: Request, res: Response): Promise<void> {
+  try {
+    const payload = req.body as {
+      code: number;
+      data: Array<{
+        cmd: number;
+        guid: string;
+        msgType: number;
+        fromRoomId: string;
+        msgData: Record<string, unknown>;
+        timestamp: number;
+      }>;
+      msg: string;
+    };
+
+    // 快速处理并在内存中完成(生产环境可改为异步队列)
+    const result = await processWebhook(payload);
+
+    // 返回成功(QiWe 要求在 5s 内响应 200)
+    sendSuccess(res, result);
+  } catch (error) {
+    // Webhook 处理失败也要返回 200(避免 QiWe 重试风暴)
+    console.error('[Webhook] 处理失败:', error);
+    sendSuccess(res, { processedCount: 0, docLinksFound: 0, error: '内部处理异常' });
+  }
+}
+
+// ============================================================
+// 群-文档台账
+// ============================================================
+
+/**
+ * GET /api/qiwei/room-docs
+ *
+ * 查询群-文档台账列表。
+ * 用于管理端查看哪些群已经关联了沟通记录文档。
+ *
+ * Query params:
+ *   roomId - 群 ID(可选,不传则返回全部)
+ */
+export async function listRoomDocs(req: Request, res: Response): Promise<void> {
+  try {
+    const { roomId } = req.query;
+
+    if (typeof roomId === 'string') {
+      const doc = getRoomDocByRoomId(roomId);
+      if (!doc) {
+        sendError(res, 404, 'NOT_FOUND', `群 ${roomId} 未找到绑定的沟通记录文档`);
+        return;
+      }
+      sendSuccess(res, doc);
+      return;
+    }
+
+    const docs = getRoomDocs();
+    sendSuccess(res, docs);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * GET /api/qiwei/room-doc-anomalies
+ *
+ * 查询群-文档异常记录(多表异常等)。
+ */
+export async function listRoomDocAnomalies(_req: Request, res: Response): Promise<void> {
+  try {
+    const anomalies = getRoomDocAnomalies();
+    sendSuccess(res, anomalies);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * PUT /api/qiwei/room-doc-anomalies/:roomId/resolve
+ *
+ * 解决群-文档异常(人工确认后)。
+ *
+ * Body:
+ *   { "newDocId": "...", "resolution": "keep_new" | "keep_old" }
+ */
+export async function resolveAnomaly(req: Request, res: Response): Promise<void> {
+  try {
+    const roomId = String(req.params.roomId);
+    const { newDocId, resolution } = req.body as {
+      newDocId?: string;
+      resolution?: string;
+    };
+
+    if (!newDocId || !resolution) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 newDocId 或 resolution 参数');
+      return;
+    }
+
+    resolveRoomDocAnomaly(roomId, newDocId, resolution);
+    sendSuccess(res, { roomId, status: 'resolved' });
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// 消息查询
+// ============================================================
+
+/**
+ * GET /api/qiwei/messages
+ *
+ * 查询已存储的群消息(来自 webhook 或 syncMsg)。
+ *
+ * Query params:
+ *   roomId - 群 ID(可选)
+ *   guid   - 人员 guid(可选)
+ *   limit  - 返回条数上限,默认 50
+ */
+export async function listMessages(req: Request, res: Response): Promise<void> {
+  try {
+    const { roomId, guid, limit } = req.query;
+    const messages = getMessages(
+      typeof roomId === 'string' ? roomId : undefined,
+      typeof guid === 'string' ? guid : undefined,
+      limit ? Number(limit) : 50,
+    );
+    sendSuccess(res, messages);
+  } catch (error) {
+    throw error;
+  }
+}

+ 147 - 0
lami-base-v1/backend/src/apps/pc/qiwei/models/qiwei.model.ts

@@ -0,0 +1,147 @@
+/**
+ * QiWe 模块 — 数据模型 / DTO 定义
+ *
+ * 定义了 QiWe API 代理、消息、群-文档台账等核心数据结构。
+ * 这些类型用于 controller 和 service 之间传递数据。
+ */
+
+// ============================================================
+// 人员账号相关
+// ============================================================
+
+/** 人员企微账号信息 */
+export interface StaffAccount {
+  /** 主键(本方系统自增) */
+  id: number;
+  /** 企微设备 GUID,用于标识一个登录实例 */
+  guid: string;
+  /** 人员姓名 */
+  name: string;
+  /** 角色:manager=店长, designer=设计师, operator=运营 */
+  role: 'manager' | 'designer' | 'operator';
+  /** 所属门店 ID */
+  storeId: number;
+  /** 账号状态:1=启用, 0=停用 */
+  status: number;
+  /** 创建时间 */
+  createdAt: string;
+  /** 更新时间 */
+  updatedAt: string;
+}
+
+/** 在线状态查询结果(来自 API-05 /login/checkLogin) */
+export interface StaffOnlineStatus {
+  guid: string;
+  /** 在线状态:1=在线, 2=离线 */
+  userOnlineStatus: number;
+  /** 企微用户 ID */
+  userId: string;
+  /** 昵称 */
+  nickname: string;
+  /** 企业名称 */
+  corpName: string;
+}
+
+// ============================================================
+// 消息相关
+// ============================================================
+
+/** 群消息记录 */
+export interface GroupMessage {
+  /** 主键 */
+  id: number;
+  /** 所属人员 guid */
+  guid: string;
+  /** 群 ID(fromRoomId) */
+  roomId: string;
+  /** 消息类型:13=链接消息, 0=文本消息 */
+  msgType: number;
+  /** 消息内容体(JSON) */
+  msgData: Record<string, unknown>;
+  /** 消息序号(用于去重和分页) */
+  seq: number;
+  /** 消息时间戳 */
+  timestamp: number;
+  /** 消息去重键 */
+  dedupKey: string;
+  /** 记录创建时间 */
+  createdAt: string;
+}
+
+/** 消息同步请求参数 */
+export interface SyncMsgParams {
+  guid: string;
+  /** 起始序号,0 表示从头开始 */
+  msgSeq: number;
+  /** 每次拉取条数上限 */
+  limit: number;
+}
+
+// ============================================================
+// 群-文档台账相关
+// ============================================================
+
+/** 群与沟通记录文档的绑定关系 */
+export interface RoomDoc {
+  /** 主键 */
+  id: number;
+  /** 群 ID */
+  roomId: string;
+  /** 群名称(冗余,方便展示) */
+  roomName: string;
+  /** 文档 ID(从链接中解析出的 docid) */
+  docId: string;
+  /** 文档原始链接 */
+  docUrl: string;
+  /** 发现该文档的人员 guid */
+  discoveredBy: string;
+  /** 首次发现时间 */
+  firstSeenAt: string;
+  /** 最近更新时间 */
+  updatedAt: string;
+}
+
+/** 一个群里出现多张表时的异常记录 */
+export interface RoomDocAnomaly {
+  /** 主键 */
+  id: number;
+  /** 群 ID */
+  roomId: string;
+  /** 异常类型:multi_doc=多张表 */
+  type: 'multi_doc';
+  /** 已有的 docId */
+  existingDocId: string;
+  /** 新发现的 docId */
+  newDocId: string;
+  /** 状态:pending=待处理, resolved=已解决 */
+  status: 'pending' | 'resolved';
+  /** 记录时间 */
+  createdAt: string;
+}
+
+// ============================================================
+// QiWe Webhook 回调相关
+// ============================================================
+
+/** Webhook 回调推送的消息体结构(API-04) */
+export interface WebhookPayload {
+  code: number;
+  data: WebhookDataItem[];
+  msg: string;
+}
+
+/** Webhook data 数组中的单条 */
+export interface WebhookDataItem {
+  /** 命令类型:15000=普通消息 */
+  cmd: number;
+  /** 设备 GUID */
+  guid: string;
+  /** 消息类型:0=文本, 13=链接 */
+  msgType: number;
+  /** 群 ID(私有聊天的 fromRoomId 为 0) */
+  fromRoomId: string;
+  /** 消息体数据 */
+  msgData: Record<string, unknown>;
+  /** 时间戳 */
+  timestamp: number;
+}

+ 55 - 0
lami-base-v1/backend/src/apps/pc/qiwei/routes/qiwei.routes.ts

@@ -0,0 +1,55 @@
+/**
+ * QiWe 模块 — 路由层
+ *
+ * 职责:定义 URL 与 controller 方法的映射关系,不包含业务逻辑。
+ *
+ * 路由前缀:/api(在 app.ts 中挂载)
+ *
+ * 接口一览:
+ *   POST   /api/qiwei/proxy                       通用 QiWe API 代理
+ *   GET    /api/qiwei/staff/:guid/status           查询人员在线状态
+ *   POST   /api/qiwei/staff/batch-status           批量查询在线状态
+ *   POST   /api/qiwei/sync                         触发消息同步
+ *   POST   /api/qiwei/webhook                      接收 QiWe Webhook 推送
+ *   GET    /api/qiwei/messages                     查询消息列表
+ *   GET    /api/qiwei/room-docs                    查询群-文档台账
+ *   GET    /api/qiwei/room-doc-anomalies           查询台账异常记录
+ *   PUT    /api/qiwei/room-doc-anomalies/:roomId/resolve  解决异常
+ */
+
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import {
+  proxyApi,
+  getStaffOnlineStatus,
+  batchGetStaffStatus,
+  triggerSync,
+  receiveWebhook,
+  listRoomDocs,
+  listRoomDocAnomalies,
+  resolveAnomaly,
+  listMessages,
+} from '../controllers/qiwei.controller.js';
+
+export const pcQiWeApiRouter: ExpressRouter = Router();
+
+// ---- QiWe 通用代理 ----
+pcQiWeApiRouter.post('/qiwei/proxy', proxyApi);
+
+// ---- 人员在线状态 ----
+pcQiWeApiRouter.get('/qiwei/staff/:guid/status', getStaffOnlineStatus);
+pcQiWeApiRouter.post('/qiwei/staff/batch-status', batchGetStaffStatus);
+
+// ---- 消息同步 ----
+pcQiWeApiRouter.post('/qiwei/sync', triggerSync);
+
+// ---- Webhook 回调 ----
+pcQiWeApiRouter.post('/qiwei/webhook', receiveWebhook);
+
+// ---- 消息查询 ----
+pcQiWeApiRouter.get('/qiwei/messages', listMessages);
+
+// ---- 群-文档台账 ----
+pcQiWeApiRouter.get('/qiwei/room-docs', listRoomDocs);
+pcQiWeApiRouter.get('/qiwei/room-doc-anomalies', listRoomDocAnomalies);
+pcQiWeApiRouter.put('/qiwei/room-doc-anomalies/:roomId/resolve', resolveAnomaly);

+ 381 - 0
lami-base-v1/backend/src/apps/pc/qiwei/services/qiwei.service.ts

@@ -0,0 +1,381 @@
+/**
+ * QiWe 模块 — 业务服务层
+ *
+ * 封装与 QiWe 开放平台交互的核心业务逻辑:
+ *   - 统一代理调用(将本方的 REST 请求转换为 QiWe doApi 调用)
+ *   - 消息同步(syncMsg)分页拉取
+ *   - Webhook 回调消息解析与处理
+ *   - 群-文档台账维护
+ *   - 链接消息中的 docid 识别与登记
+ *
+ * 依赖:
+ *   - shared/qiwei/client.ts(QiWe API 调用客户端)
+ *   - shared/utils/docid.ts(文档 ID 解析)
+ *
+ * 注意:
+ *   当前版本 DB 操作使用内存 Map 模拟(TODO: 替换为真实数据库)。
+ *   外部第三方接口调用在 service 层完成,controller 不直接访问外部系统。
+ */
+
+import { callQiWeApi } from '../../../../shared/qiwei/client.js';
+import { parseDocId, isWeixinDocUrl } from '../../../../shared/utils/docid.js';
+import type {
+  StaffOnlineStatus,
+  SyncMsgParams,
+  GroupMessage,
+  RoomDoc,
+  RoomDocAnomaly,
+  WebhookDataItem,
+} from '../models/qiwei.model.js';
+
+// ---------- 内存存储(TODO: 替换为 MySQL/PostgreSQL)----------
+const roomDocStore = new Map<string, RoomDoc>();
+const roomDocAnomalyStore = new Map<string, RoomDocAnomaly>();
+const messageStore = new Map<string, GroupMessage>();
+
+/** 消息序号持久化(用于 syncMsg 断点续传) */
+const msgSeqStore = new Map<string, number>();
+
+// ============================================================
+// 通用代理
+// ============================================================
+
+/**
+ * 通用 QiWe API 代理调用
+ *
+ * 将前端传来的 method + params 直接转发到 QiWe 开放平台。
+ * 适用场景:前端需要临时调用某个 QiWe 接口,后端不做额外处理。
+ *
+ * @param method - QiWe 接口路径,如 "/room/getRoomList"
+ * @param params - 接口参数
+ * @returns QiWe 返回的 data 字段
+ */
+export async function proxyQiWeCall(method: string, params: Record<string, unknown>): Promise<unknown> {
+  return callQiWeApi(method, params);
+}
+
+// ============================================================
+// 人员在线检测(API-05)
+// ============================================================
+
+/**
+ * 查询指定人员的企微在线状态
+ *
+ * 调用 QiWe API-05:/login/checkLogin
+ * 批量任务执行前应先调用此接口确认在线,避免空跑。
+ *
+ * @param guid - 人员设备 GUID
+ * @returns 在线状态信息
+ */
+export async function checkStaffOnline(guid: string): Promise<StaffOnlineStatus> {
+  // 调用 QiWe API-05 /login/checkLogin
+  const data = await callQiWeApi<Omit<StaffOnlineStatus, 'guid'>>('/login/checkLogin', { guid });
+  return { ...data, guid };
+}
+
+/**
+ * 批量检查人员在线状态
+ *
+ * @param guids - 人员 guid 列表
+ * @returns 每个 guid 对应的在线状态列表
+ */
+export async function batchCheckOnline(guids: string[]): Promise<StaffOnlineStatus[]> {
+  const results: StaffOnlineStatus[] = [];
+  for (const guid of guids) {
+    try {
+      const status = await checkStaffOnline(guid);
+      results.push({ ...status, guid });
+    } catch {
+      // 单个查询失败不影响其他查询,记录离线状态
+      results.push({
+        guid,
+        userOnlineStatus: 2,
+        userId: '',
+        nickname: '',
+        corpName: '',
+      });
+    }
+  }
+  return results;
+}
+
+// ============================================================
+// 消息同步(API-06)
+// ============================================================
+
+/**
+ * 同步历史消息(分页拉取)
+ *
+ * 调用 QiWe API-06:/msg/syncMsg
+ * msgSeq 从上次断点开始逐步递增,直至 hasMore=0。
+ * 拉回的消息与 webhook 实时消息共用同一消息表。
+ *
+ * @param params - { guid, msgSeq, limit }
+ * @returns 同步到的消息列表
+ */
+export async function syncMessages(params: SyncMsgParams): Promise<{
+  messages: GroupMessage[];
+  hasMore: number;
+  nextSeq: number;
+}> {
+  const data = await callQiWeApi<{
+    hasMore: number;
+    travelSyncKey: number;
+    syncMsgList: Array<{
+      fromRoomId: string;
+      msgType: number;
+      msgData: Record<string, unknown>;
+      seq: number;
+      timestamp: number;
+    }>;
+  }>('/msg/syncMsg', {
+    guid: params.guid,
+    msgSeq: params.msgSeq,
+    limit: params.limit,
+  });
+
+  const messages: GroupMessage[] = [];
+
+  for (const raw of data.syncMsgList || []) {
+    // 构造消息去重键:guid + roomId + seq 组合唯一
+    const dedupKey = `${params.guid}_${raw.fromRoomId}_${raw.seq}`;
+
+    // 去重检查
+    if (messageStore.has(dedupKey)) continue;
+
+    const msg: GroupMessage = {
+      id: messageStore.size + 1,
+      guid: params.guid,
+      roomId: raw.fromRoomId,
+      msgType: raw.msgType,
+      msgData: raw.msgData,
+      seq: raw.seq,
+      timestamp: raw.timestamp,
+      dedupKey,
+      createdAt: new Date().toISOString(),
+    };
+
+    messageStore.set(dedupKey, msg);
+    messages.push(msg);
+  }
+
+  // 更新断点序号
+  msgSeqStore.set(params.guid, data.travelSyncKey);
+
+  return {
+    messages,
+    hasMore: data.hasMore,
+    nextSeq: data.travelSyncKey,
+  };
+}
+
+// ============================================================
+// Webhook 回调消息处理(API-04)
+// ============================================================
+
+/**
+ * 处理 QiWe Webhook 推送的消息
+ *
+ * QiWe POST 消息到 callbackUrl 后,此方法负责:
+ *   1. 解析推送体中的 data[] 数组
+ *   2. 过滤 cmd=15000(普通消息)
+ *   3. 写入消息存储
+ *   4. 检测链接消息(msgType=13)并触发 docid 解析 → 台账登记
+ *
+ * @param payload - Webhook 推送的完整 JSON body
+ * @returns 处理的消息条数和发现的文档链接数
+ */
+export async function processWebhook(payload: { code: number; data: WebhookDataItem[]; msg: string }): Promise<{
+  processedCount: number;
+  docLinksFound: number;
+}> {
+  let processedCount = 0;
+  let docLinksFound = 0;
+
+  for (const item of payload.data || []) {
+    // 只处理普通消息(cmd=15000)
+    if (item.cmd !== 15000) continue;
+
+    // 构造去重键
+    const dedupKey = `${item.guid}_${item.fromRoomId}_${item.timestamp}`;
+    if (messageStore.has(dedupKey)) continue;
+
+    const msg: GroupMessage = {
+      id: messageStore.size + 1,
+      guid: item.guid,
+      roomId: item.fromRoomId || '',
+      msgType: item.msgType,
+      msgData: item.msgData,
+      seq: 0, // webhook 消息无 seq,用 0 占位
+      timestamp: item.timestamp,
+      dedupKey,
+      createdAt: new Date().toISOString(),
+    };
+
+    messageStore.set(dedupKey, msg);
+    processedCount++;
+
+    // ---- 检测链接消息(msgType=13)并尝试提取文档 ID ----
+    if (item.msgType === 13 && item.msgData.linkUrl) {
+      const linkUrl = String(item.msgData.linkUrl);
+      if (isWeixinDocUrl(linkUrl)) {
+        const docId = parseDocId(linkUrl);
+        if (docId) {
+          registerRoomDoc(item.fromRoomId, '', linkUrl, docId, item.guid);
+          docLinksFound++;
+        } else {
+          // 链接异常:疑似文档链接但无法解析 docid → 记录异常
+          recordLinkAnomaly(item.fromRoomId, linkUrl);
+        }
+      }
+    }
+  }
+
+  return { processedCount, docLinksFound };
+}
+
+// ============================================================
+// 群-文档台账管理
+// ============================================================
+
+/**
+ * 登记群与文档的绑定关系
+ *
+ * 一个群只允许对应一张表(唯一 docid)。若发现新 docid 与已有不同,
+ * 则触发"多表异常"记录,由人工确认。
+ *
+ * @param roomId      - 群 ID
+ * @param roomName    - 群名称(冗余)
+ * @param docUrl      - 文档完整 URL
+ * @param docId       - 解析出的文档 ID
+ * @param discoveredBy - 发现人 guid
+ */
+export function registerRoomDoc(
+  roomId: string,
+  roomName: string,
+  docUrl: string,
+  docId: string,
+  discoveredBy: string,
+): RoomDoc {
+  const existing = roomDocStore.get(roomId);
+
+  // 已有记录且 docId 相同 → 更新 URL 和时间
+  if (existing && existing.docId === docId) {
+    existing.docUrl = docUrl;
+    existing.updatedAt = new Date().toISOString();
+    roomDocStore.set(roomId, existing);
+    return existing;
+  }
+
+  // 已有记录但 docId 不同 → 多表异常
+  if (existing && existing.docId !== docId) {
+    const anomaly: RoomDocAnomaly = {
+      id: roomDocAnomalyStore.size + 1,
+      roomId,
+      type: 'multi_doc',
+      existingDocId: existing.docId,
+      newDocId: docId,
+      status: 'pending',
+      createdAt: new Date().toISOString(),
+    };
+    roomDocAnomalyStore.set(`${roomId}_${docId}`, anomaly);
+  }
+
+  // 新建台账记录
+  const now = new Date().toISOString();
+  const record: RoomDoc = {
+    id: roomDocStore.size + 1,
+    roomId,
+    roomName,
+    docId,
+    docUrl,
+    discoveredBy,
+    firstSeenAt: existing ? existing.firstSeenAt : now,
+    updatedAt: now,
+  };
+
+  roomDocStore.set(roomId, record);
+  return record;
+}
+
+/**
+ * 查询群-文档台账列表
+ */
+export function getRoomDocs(): RoomDoc[] {
+  return Array.from(roomDocStore.values());
+}
+
+/**
+ * 按 roomId 查询台账记录
+ */
+export function getRoomDocByRoomId(roomId: string): RoomDoc | undefined {
+  return roomDocStore.get(roomId);
+}
+
+/**
+ * 记录链接解析异常(疑似文档链接但无法提取 docid)
+ */
+function recordLinkAnomaly(roomId: string, linkUrl: string): void {
+  console.warn(`[QiWe] 链接异常:roomId=${roomId}, url=${linkUrl}`);
+  // TODO: 写入 link_anomaly 表,通知运营人工核对
+}
+
+/**
+ * 查询所有群-文档异常记录
+ */
+export function getRoomDocAnomalies(): RoomDocAnomaly[] {
+  return Array.from(roomDocAnomalyStore.values());
+}
+
+/**
+ * 解决异常(人工确认后)
+ */
+export function resolveRoomDocAnomaly(roomId: string, newDocId: string, resolution: string): void {
+  const key = `${roomId}_${newDocId}`;
+  const anomaly = roomDocAnomalyStore.get(key);
+  if (anomaly) {
+    anomaly.status = 'resolved';
+    roomDocAnomalyStore.set(key, anomaly);
+  }
+
+  // 根据人工决定更新台账
+  if (resolution === 'keep_new') {
+    const existing = roomDocStore.get(roomId);
+    if (existing) {
+      existing.docId = newDocId;
+      existing.updatedAt = new Date().toISOString();
+      roomDocStore.set(roomId, existing);
+    }
+  }
+}
+
+// ============================================================
+// 消息查询
+// ============================================================
+
+/**
+ * 查询消息列表(按群、时间范围过滤)
+ *
+ * @param roomId  - 群 ID(可选)
+ * @param guid    - 人员 guid(可选)
+ * @param limit   - 返回条数
+ * @returns 消息列表
+ */
+export function getMessages(
+  roomId?: string,
+  guid?: string,
+  limit = 50,
+): GroupMessage[] {
+  let all = Array.from(messageStore.values());
+
+  if (roomId) {
+    all = all.filter((m) => m.roomId === roomId);
+  }
+  if (guid) {
+    all = all.filter((m) => m.guid === guid);
+  }
+
+  // 按时间倒序
+  all.sort((a, b) => b.timestamp - a.timestamp);
+  return all.slice(0, limit);
+}

+ 170 - 0
lami-base-v1/backend/src/apps/pc/risk/controllers/risk.controller.ts

@@ -0,0 +1,170 @@
+/**
+ * 群风控与异常干预模块 — 控制器层
+ *
+ * 对应规范文档 §八「模块 5:群风控与异常干预模块」
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../../../../shared/http/response.js';
+import {
+  createKeyword,
+  listKeywords,
+  updateKeyword,
+  deleteKeyword,
+  scanMessageForKeywords,
+  listAlerts,
+  handleAlert,
+  listWorkOrders,
+  updateWorkOrder,
+} from '../services/risk.service.js';
+import type { AlertType, WorkOrderStatus } from '../models/risk.model.js';
+
+// ============================================================
+// 风险关键词
+// ============================================================
+
+/** POST /api/risk/keywords — 创建关键词 */
+export async function createKw(req: Request, res: Response): Promise<void> {
+  try {
+    const { keyword, category, severity } = req.body;
+    if (!keyword || !category || !severity) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:keyword, category, severity');
+      return;
+    }
+    const kw = createKeyword({ ...req.body, enabled: req.body.enabled ?? 1, remark: req.body.remark || '' });
+    sendSuccess(res, kw, 201);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** GET /api/risk/keywords — 查询关键词列表 */
+export async function listKw(req: Request, res: Response): Promise<void> {
+  try {
+    const { category, enabled } = req.query;
+    const keywords = listKeywords(
+      typeof category === 'string' ? category : undefined,
+      enabled !== undefined ? Number(enabled) : undefined,
+    );
+    sendSuccess(res, keywords);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** PUT /api/risk/keywords/:id — 更新关键词 */
+export async function updateKw(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const kw = updateKeyword(id, req.body);
+    sendSuccess(res, kw);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** DELETE /api/risk/keywords/:id — 删除关键词 */
+export async function deleteKw(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    deleteKeyword(id);
+    sendSuccess(res, { deleted: true });
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** POST /api/risk/keywords/scan — 扫描消息内容匹配关键词 */
+export async function scanContent(req: Request, res: Response): Promise<void> {
+  try {
+    const { content, roomId, messageId } = req.body as {
+      content?: string;
+      roomId?: string;
+      messageId?: number;
+    };
+    if (!content || !roomId) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:content, roomId');
+      return;
+    }
+    const alerts = scanMessageForKeywords(content, roomId, messageId || 0);
+    sendSuccess(res, { alerts, hitCount: alerts.length });
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// 预警管理
+// ============================================================
+
+/** GET /api/risk/alerts — 查询预警列表 */
+export async function listAlertList(req: Request, res: Response): Promise<void> {
+  try {
+    const { roomId, type, status } = req.query;
+    const alerts = listAlerts(
+      typeof roomId === 'string' ? roomId : undefined,
+      type as AlertType | undefined,
+      status as 'open' | 'handled' | 'ignored' | undefined,
+    );
+    sendSuccess(res, alerts);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** PUT /api/risk/alerts/:id — 处理预警 */
+export async function handleAlertById(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const { status, handlerId, note } = req.body as {
+      status?: string;
+      handlerId?: number;
+      note?: string;
+    };
+    if (!status || !handlerId) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:status, handlerId');
+      return;
+    }
+    const alert = handleAlert(id, status as 'handled' | 'ignored', handlerId, note || '');
+    sendSuccess(res, alert);
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// 工单管理
+// ============================================================
+
+/** GET /api/risk/work-orders — 查询工单列表 */
+export async function listOrderList(req: Request, res: Response): Promise<void> {
+  try {
+    const { status, assigneeId } = req.query;
+    const orders = listWorkOrders(
+      status as WorkOrderStatus | undefined,
+      assigneeId ? Number(assigneeId) : undefined,
+    );
+    sendSuccess(res, orders);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/** PUT /api/risk/work-orders/:id — 更新工单状态 */
+export async function updateOrder(req: Request, res: Response): Promise<void> {
+  try {
+    const id = Number(req.params.id);
+    const { status, handleRecord } = req.body as {
+      status?: WorkOrderStatus;
+      handleRecord?: string;
+    };
+    if (!status) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 status 参数');
+      return;
+    }
+    const order = updateWorkOrder(id, status, handleRecord);
+    sendSuccess(res, order);
+  } catch (error) {
+    throw error;
+  }
+}

+ 91 - 0
lami-base-v1/backend/src/apps/pc/risk/models/risk.model.ts

@@ -0,0 +1,91 @@
+/**
+ * 群风控与异常干预模块 — 数据模型定义
+ *
+ * 对应规范文档 §八「模块 5:群风控与异常干预模块」
+ *
+ * 核心表:
+ *   - risk_keyword    :风险关键词库
+ *   - alert           :预警记录(命中敏感词、阈值异常等)
+ *   - work_order      :干预工单(预警自动生成)
+ *   - kb_case         :异常处理案例知识库(二期)
+ */
+
+/** 风险关键词 */
+export interface RiskKeyword {
+  /** 主键 */
+  id: number;
+  /** 关键词 */
+  keyword: string;
+  /** 分类:price=价格敏感, competitor=竞品, complaint=投诉, custom=自定义 */
+  category: 'price' | 'competitor' | 'complaint' | 'custom';
+  /** 严重程度:1=低, 2=中, 3=高 */
+  severity: 1 | 2 | 3;
+  /** 状态:1=启用, 0=停用 */
+  enabled: number;
+  /** 备注 */
+  remark: string;
+  /** 创建时间 */
+  createdAt: string;
+}
+
+/** 预警类型 */
+export type AlertType = 'keyword' | 'threshold' | 'abnormal';
+
+/** 预警记录 */
+export interface Alert {
+  /** 主键 */
+  id: number;
+  /** 群 ID */
+  roomId: string;
+  /** 预警类型 */
+  type: AlertType;
+  /** 预警等级:1=信息, 2=警告, 3=严重 */
+  level: 1 | 2 | 3;
+  /** 预警标题 */
+  title: string;
+  /** 详细描述 */
+  description: string;
+  /** 匹配到的关键词(keyword 类型时) */
+  matchedKeyword: string;
+  /** 关联的消息 ID */
+  messageId: number;
+  /** 状态:open=待处理, handled=已处理, ignored=已忽略 */
+  status: 'open' | 'handled' | 'ignored';
+  /** 处理人 ID */
+  handlerId: number;
+  /** 处理备注 */
+  handleNote: string;
+  /** 关联的工单 ID */
+  workOrderId: number;
+  /** 创建时间 */
+  createdAt: string;
+  /** 处理时间 */
+  handledAt: string;
+}
+
+/** 工单状态 */
+export type WorkOrderStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
+
+/** 干预工单 */
+export interface WorkOrder {
+  /** 主键 */
+  id: number;
+  /** 关联预警 ID */
+  alertId: number;
+  /** 工单标题 */
+  title: string;
+  /** 工单描述 */
+  description: string;
+  /** 指派负责人 ID */
+  assigneeId: number;
+  /** 截止时间 */
+  deadline: string;
+  /** 状态 */
+  status: WorkOrderStatus;
+  /** 处理记录 */
+  handleRecord: string;
+  /** 创建时间 */
+  createdAt: string;
+  /** 完成时间 */
+  completedAt: string;
+}

+ 42 - 0
lami-base-v1/backend/src/apps/pc/risk/routes/risk.routes.ts

@@ -0,0 +1,42 @@
+/**
+ * 群风控模块 — 路由层
+ *
+ * 接口一览:
+ *   关键词:
+ *     POST   /api/risk/keywords          创建关键词
+ *     GET    /api/risk/keywords          查询关键词列表
+ *     POST   /api/risk/keywords/scan     扫描消息内容匹配关键词
+ *     PUT    /api/risk/keywords/:id      更新关键词
+ *     DELETE /api/risk/keywords/:id      删除关键词
+ *   预警:
+ *     GET    /api/risk/alerts            查询预警列表
+ *     PUT    /api/risk/alerts/:id        处理预警
+ *   工单:
+ *     GET    /api/risk/work-orders       查询工单列表
+ *     PUT    /api/risk/work-orders/:id   更新工单状态
+ */
+
+import { Router } from 'express';
+import type { Router as ExpressRouter } from 'express';
+import {
+  createKw, listKw, updateKw, deleteKw, scanContent,
+  listAlertList, handleAlertById,
+  listOrderList, updateOrder,
+} from '../controllers/risk.controller.js';
+
+export const pcRiskApiRouter: ExpressRouter = Router();
+
+// 关键词
+pcRiskApiRouter.post('/risk/keywords', createKw);
+pcRiskApiRouter.get('/risk/keywords', listKw);
+pcRiskApiRouter.post('/risk/keywords/scan', scanContent);
+pcRiskApiRouter.put('/risk/keywords/:id', updateKw);
+pcRiskApiRouter.delete('/risk/keywords/:id', deleteKw);
+
+// 预警
+pcRiskApiRouter.get('/risk/alerts', listAlertList);
+pcRiskApiRouter.put('/risk/alerts/:id', handleAlertById);
+
+// 工单
+pcRiskApiRouter.get('/risk/work-orders', listOrderList);
+pcRiskApiRouter.put('/risk/work-orders/:id', updateOrder);

+ 247 - 0
lami-base-v1/backend/src/apps/pc/risk/services/risk.service.ts

@@ -0,0 +1,247 @@
+/**
+ * 群风控与异常干预模块 — 业务服务层
+ *
+ * 对应规范文档 §八「模块 5:群风控与异常干预模块」
+ *
+ * 业务流程:
+ *   §8.1 实时监听群消息:由 qiwei 模块的 webhook 消费者完成
+ *   §8.2 风险关键词库配置:CRUD 管理风险词
+ *   §8.3 命中敏感词自动预警:消息内容与词库匹配 → 生成 alert
+ *   §8.4 人数骤降等阈值异常:统计退群事件、零互动天数 → 生成 alert
+ *   §8.5 异常提醒到企微:通过 API-14 发送
+ *   §8.6 预警自动生成干预工单:alert 创建时联动生成 work_order
+ *   §8.7 工单处理与关闭:更新处理记录 → 计时统计响应时长
+ *   §8.8 异常处理案例知识库:已关闭工单归档为 kb_case(二期)
+ */
+
+import { AppError } from '../../../../shared/errors/app-error.js';
+import type {
+  RiskKeyword,
+  Alert,
+  AlertType,
+  WorkOrder,
+  WorkOrderStatus,
+} from '../models/risk.model.js';
+
+// ---------- 内存存储(TODO: 替换为数据库)----------
+const keywordStore = new Map<number, RiskKeyword>();
+const alertStore = new Map<number, Alert>();
+const workOrderStore = new Map<number, WorkOrder>();
+let nextKeywordId = 1;
+let nextAlertId = 1;
+let nextWorkOrderId = 1;
+
+// ============================================================
+// 风险关键词库(§8.2)
+// ============================================================
+
+/** 创建关键词 */
+export function createKeyword(data: Omit<RiskKeyword, 'id' | 'createdAt'>): RiskKeyword {
+  const kw: RiskKeyword = {
+    id: nextKeywordId++,
+    ...data,
+    createdAt: new Date().toISOString(),
+  };
+  keywordStore.set(kw.id, kw);
+  return kw;
+}
+
+/** 查询关键词列表 */
+export function listKeywords(category?: string, enabled?: number): RiskKeyword[] {
+  let result = Array.from(keywordStore.values());
+  if (category) result = result.filter((k) => k.category === category);
+  if (enabled !== undefined) result = result.filter((k) => k.enabled === enabled);
+  return result;
+}
+
+/** 更新关键词 */
+export function updateKeyword(id: number, data: Partial<RiskKeyword>): RiskKeyword {
+  const kw = keywordStore.get(id);
+  if (!kw) throw new AppError(404, 'KEYWORD_NOT_FOUND', `关键词 ID=${id} 不存在`);
+  Object.assign(kw, data);
+  keywordStore.set(id, kw);
+  return kw;
+}
+
+/** 删除关键词 */
+export function deleteKeyword(id: number): void {
+  if (!keywordStore.has(id)) throw new AppError(404, 'KEYWORD_NOT_FOUND', `关键词 ID=${id} 不存在`);
+  keywordStore.delete(id);
+}
+
+/**
+ * 扫描消息内容,匹配风险关键词
+ *
+ * 实际生产环境在 webhook 消费者中对每条消息实时执行。
+ *
+ * @param content  - 消息文本内容
+ * @param roomId   - 群 ID
+ * @param messageId - 消息 ID
+ * @returns 命中的预警列表(如无命中则返回空数组)
+ */
+export function scanMessageForKeywords(
+  content: string,
+  roomId: string,
+  messageId: number,
+): Alert[] {
+  const alerts: Alert[] = [];
+  const activeKeywords = listKeywords(undefined, 1);
+
+  for (const kw of activeKeywords) {
+    if (content.includes(kw.keyword)) {
+      const alert = createAlert({
+        roomId,
+        type: 'keyword',
+        level: kw.severity,
+        title: `命中风险关键词"${kw.keyword}"`,
+        description: `群 ${roomId} 的消息命中风险关键词"${kw.keyword}"(分类:${kw.category})`,
+        matchedKeyword: kw.keyword,
+        messageId,
+      });
+      alerts.push(alert);
+    }
+  }
+
+  return alerts;
+}
+
+// ============================================================
+// 预警管理(§8.3 ~ §8.5)
+// ============================================================
+
+interface CreateAlertParams {
+  roomId: string;
+  type: AlertType;
+  level: 1 | 2 | 3;
+  title: string;
+  description: string;
+  matchedKeyword?: string;
+  messageId?: number;
+}
+
+/** 创建预警记录 */
+export function createAlert(params: CreateAlertParams): Alert {
+  const now = new Date().toISOString();
+  const alert: Alert = {
+    id: nextAlertId++,
+    roomId: params.roomId,
+    type: params.type,
+    level: params.level,
+    title: params.title,
+    description: params.description,
+    matchedKeyword: params.matchedKeyword || '',
+    messageId: params.messageId || 0,
+    status: 'open',
+    handlerId: 0,
+    handleNote: '',
+    workOrderId: 0,
+    createdAt: now,
+    handledAt: '',
+  };
+
+  alertStore.set(alert.id, alert);
+
+  // ---- §8.6:高危预警自动生成干预工单 ----
+  if (params.level >= 3) {
+    const order = createWorkOrder({
+      alertId: alert.id,
+      title: `【自动】${params.title}`,
+      description: params.description,
+      assigneeId: 0, // 待分配
+    });
+    alert.workOrderId = order.id;
+    alertStore.set(alert.id, alert);
+  }
+
+  return alert;
+}
+
+/** 查询预警列表 */
+export function listAlerts(
+  roomId?: string,
+  type?: AlertType,
+  status?: 'open' | 'handled' | 'ignored',
+): Alert[] {
+  let result = Array.from(alertStore.values());
+  if (roomId) result = result.filter((a) => a.roomId === roomId);
+  if (type) result = result.filter((a) => a.type === type);
+  if (status) result = result.filter((a) => a.status === status);
+  return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+}
+
+/** 处理预警 */
+export function handleAlert(
+  id: number,
+  status: 'handled' | 'ignored',
+  handlerId: number,
+  note: string,
+): Alert {
+  const alert = alertStore.get(id);
+  if (!alert) throw new AppError(404, 'ALERT_NOT_FOUND', `预警 ID=${id} 不存在`);
+
+  alert.status = status;
+  alert.handlerId = handlerId;
+  alert.handleNote = note;
+  alert.handledAt = new Date().toISOString();
+  alertStore.set(id, alert);
+  return alert;
+}
+
+// ============================================================
+// 干预工单(§8.6 ~ §8.7)
+// ============================================================
+
+interface CreateWorkOrderParams {
+  alertId: number;
+  title: string;
+  description: string;
+  assigneeId: number;
+  deadline?: string;
+}
+
+/** 创建工单 */
+export function createWorkOrder(params: CreateWorkOrderParams): WorkOrder {
+  const now = new Date().toISOString();
+  // 默认截止时间为 24h 后
+  const deadline = params.deadline || new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString();
+
+  const order: WorkOrder = {
+    id: nextWorkOrderId++,
+    alertId: params.alertId,
+    title: params.title,
+    description: params.description,
+    assigneeId: params.assigneeId,
+    deadline,
+    status: 'pending',
+    handleRecord: '',
+    createdAt: now,
+    completedAt: '',
+  };
+
+  workOrderStore.set(order.id, order);
+  return order;
+}
+
+/** 查询工单列表 */
+export function listWorkOrders(status?: WorkOrderStatus, assigneeId?: number): WorkOrder[] {
+  let result = Array.from(workOrderStore.values());
+  if (status) result = result.filter((o) => o.status === status);
+  if (assigneeId !== undefined) result = result.filter((o) => o.assigneeId === assigneeId);
+  return result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
+}
+
+/** 更新工单状态 */
+export function updateWorkOrder(
+  id: number,
+  status: WorkOrderStatus,
+  handleRecord?: string,
+): WorkOrder {
+  const order = workOrderStore.get(id);
+  if (!order) throw new AppError(404, 'WORK_ORDER_NOT_FOUND', `工单 ID=${id} 不存在`);
+
+  order.status = status;
+  if (handleRecord) order.handleRecord = handleRecord;
+  if (status === 'completed') order.completedAt = new Date().toISOString();
+  workOrderStore.set(id, order);
+  return order;
+}

+ 188 - 0
lami-base-v1/backend/src/apps/pc/room/controllers/room.controller.ts

@@ -0,0 +1,188 @@
+/**
+ * 群管理模块 — 控制器层
+ *
+ * 对应功能:
+ *   §5.1「新建或登记外部客户群」
+ *   §5.2「自动拉取企微客户群名单」
+ *   §5.3「自动查看群名与群人数」
+ *   §5.4「自动发现进群与退群」
+ *   §5.7「群健康度评估」
+ */
+
+import type { Request, Response } from 'express';
+import { sendSuccess, sendError } from '../../../../shared/http/response.js';
+import {
+  syncRooms,
+  listRooms,
+  getRoomByRoomId,
+  syncMemberEvents,
+  listMemberEvents,
+  evaluateRoomHealth,
+  evaluateAllRoomsHealth,
+  getRoomHealth,
+} from '../services/room.service.js';
+
+// ============================================================
+// 群列表与详情
+// ============================================================
+
+/**
+ * GET /api/rooms
+ *
+ * 查询本方群列表(从本地存储读取)。
+ *
+ * Query params:
+ *   roomType - 群类型筛选:external=外部客户群, internal=内部群(可选)
+ */
+export async function list(req: Request, res: Response): Promise<void> {
+  try {
+    const { roomType } = req.query;
+    const rooms = listRooms(typeof roomType === 'string' ? roomType as 'external' | 'internal' : undefined);
+    sendSuccess(res, rooms);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * GET /api/rooms/:roomId
+ *
+ * 查询单个群详情。
+ */
+export async function getById(req: Request, res: Response): Promise<void> {
+  try {
+    const roomId = String(req.params.roomId);
+    const room = getRoomByRoomId(roomId);
+    sendSuccess(res, room);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * POST /api/rooms/sync
+ *
+ * 触发群列表同步(调用 API-07 + API-08 从 QiWe 拉取最新数据)。
+ *
+ * Body:
+ *   { "guid": "...", "nextStartIndex": 0 }
+ *   { "guid": "...", "roomIdList": ["1086...", "1094..."] }  // 来自 getSessionList,getRoomList 为空时
+ */
+export async function sync(req: Request, res: Response): Promise<void> {
+  try {
+    const { guid, nextStartIndex, roomIdList } = req.body as {
+      guid?: string;
+      nextStartIndex?: number;
+      roomIdList?: string[];
+    };
+
+    if (!guid) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少 guid 参数');
+      return;
+    }
+
+    const result = await syncRooms({
+      guid,
+      nextStartIndex: nextStartIndex ?? 0,
+      roomIdList: Array.isArray(roomIdList) ? roomIdList.map(String) : undefined,
+    });
+    sendSuccess(res, result);
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// 进退群事件
+// ============================================================
+
+/**
+ * POST /api/rooms/member-events/sync
+ *
+ * 同步群成员变动(API-09 /room/getRoomIncrSync)。
+ *
+ * Body:
+ *   { "guid": "...", "roomId": "...", "ver": 0, "nowMemberCount": 52 }
+ * ver 首次传 0;下次传上次响应的 nextVer。nowMemberCount 可省略(从群详情推断)。
+ */
+export async function syncEvents(req: Request, res: Response): Promise<void> {
+  try {
+    const { guid, roomId, ver, nowMemberCount } = req.body as {
+      guid?: string;
+      roomId?: string;
+      ver?: number;
+      nowMemberCount?: number;
+    };
+
+    if (!guid || !roomId) {
+      sendError(res, 400, 'VALIDATION_ERROR', '缺少必填参数:guid, roomId');
+      return;
+    }
+
+    const result = await syncMemberEvents({
+      guid,
+      roomId,
+      ver: ver !== undefined ? Number(ver) : undefined,
+      nowMemberCount:
+        nowMemberCount !== undefined ? Number(nowMemberCount) : undefined,
+    });
+    sendSuccess(res, result);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * GET /api/rooms/member-events
+ *
+ * 查询进退群事件列表(本地存储)。
+ *
+ * Query params:
+ *   roomId    - 群 ID(可选)
+ *   eventType - 事件类型:join / leave(可选)
+ */
+export async function listEvents(req: Request, res: Response): Promise<void> {
+  try {
+    const { roomId, eventType } = req.query;
+    const events = listMemberEvents(
+      typeof roomId === 'string' ? roomId : undefined,
+      typeof eventType === 'string' ? eventType as 'join' | 'leave' : undefined,
+    );
+    sendSuccess(res, events);
+  } catch (error) {
+    throw error;
+  }
+}
+
+// ============================================================
+// 群健康度
+// ============================================================
+
+/**
+ * GET /api/rooms/health
+ *
+ * 查询所有群的健康度评分。
+ */
+export async function allHealth(_req: Request, res: Response): Promise<void> {
+  try {
+    const results = evaluateAllRoomsHealth();
+    sendSuccess(res, results);
+  } catch (error) {
+    throw error;
+  }
+}
+
+/**
+ * GET /api/rooms/:roomId/health
+ *
+ * 查询单个群的健康度评分。
+ */
+export async function roomHealth(req: Request, res: Response): Promise<void> {
+  try {
+    const roomId = String(req.params.roomId);
+    const health = getRoomHealth(roomId);
+    sendSuccess(res, health);
+  } catch (error) {
+    throw error;
+  }
+}

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