Răsfoiți Sursa

feat: 总览看板与群管理接入 Parse 数据库

- 新增 /api/dashboard/overview 及前端 DashboardApiService

- 扩展 GroupChat 组织/健康/活跃度字段,新增 Store 表与组织引导

- 群列表筛选接库,编写 group-organization-schema 设计文档

- 清除占位门店数据的一次性迁移,无真实数据时不展示假数据

Co-authored-by: Cursor <cursoragent@cursor.com>
0235699曾露 3 luni în urmă
părinte
comite
235e0c89d6
45 a modificat fișierele cu 4123 adăugiri și 239 ștergeri
  1. 220 0
      backend/backend/docs/group-organization-schema.md
  2. 2 0
      backend/backend/src/apps/pc/app.ts
  3. 11 0
      backend/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts
  4. 9 0
      backend/backend/src/apps/pc/dashboard/routes/dashboard.routes.ts
  5. 146 0
      backend/backend/src/apps/pc/dashboard/services/dashboard.service.ts
  6. 18 3
      backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts
  7. 8 0
      backend/backend/src/apps/pc/qiwe/controllers/stores.controller.ts
  8. 2 0
      backend/backend/src/apps/pc/qiwe/routes/webhook.routes.ts
  9. 181 3
      backend/backend/src/apps/pc/qiwe/services/groups.service.ts
  10. 81 0
      backend/backend/src/apps/pc/qiwe/services/organization.service.ts
  11. 2 0
      backend/backend/src/apps/pc/qiwe/services/sync.service.ts
  12. 2 0
      backend/backend/src/apps/pc/qiwe/services/webhook.service.ts
  13. 33 0
      backend/backend/src/apps/pc/qiwe/utils/group-metrics.util.ts
  14. 2 0
      backend/backend/src/index.ts
  15. 16 0
      backend/backend/src/shared/db/migration.util.ts
  16. 12 0
      backend/backend/src/shared/db/organization-bootstrap.ts
  17. 105 69
      backend/backend/src/shared/db/schema-setup.ts
  18. 10 26
      lami-base-v1/backend/.env.example
  19. 8 16
      lami-base-v1/backend/package.json
  20. 12 0
      lami-base-v1/backend/src/apps/qiwei/app.ts
  21. 97 0
      lami-base-v1/backend/src/apps/qiwei/controllers/qiwei.controller.ts
  22. 20 0
      lami-base-v1/backend/src/apps/qiwei/routes/qiwei.routes.ts
  23. 12 0
      lami-base-v1/backend/src/apps/qiwei/server.ts
  24. 95 0
      lami-base-v1/backend/src/apps/qiwei/services/exception-screen.service.ts
  25. 30 0
      lami-base-v1/backend/src/apps/qiwei/services/qiwei-api.service.ts
  26. 56 0
      lami-base-v1/backend/src/apps/qiwei/services/room-doc-store.service.ts
  27. 3 47
      lami-base-v1/backend/src/index.ts
  28. 137 0
      lami-base-v1/backend/src/shared/qiwei/docid.ts
  29. 47 0
      lami-base-v1/backend/tests/fixtures/qiwei_callbacks_sample.json
  30. 133 0
      lami-base-v1/backend/tests/qiwei.api.test.ts
  31. 65 0
      lami-base-v1/doc/qiweapi-scrape/README.md
  32. 656 0
      lami-base-v1/doc/企微客户服务-功能开发清单.md
  33. 119 0
      lami-base-v1/doc/企微客户服务-功能清单.md
  34. 189 0
      lami-base-v1/fetch_url_to_md.py
  35. 97 0
      lami-base-v1/scripts/extract_qiwei_api.py
  36. 804 0
      lami-base-v1/scripts/generate_impl_doc.py
  37. 301 0
      lami-base-v1/scripts/qiwei_doc_discovery.py
  38. 54 0
      lami-base-v1/scripts/renumber_impl_doc.py
  39. 47 0
      lami-base-v1/scripts/samples/qiwei_callbacks_sample.json
  40. 56 0
      src/app/core/services/api/dashboard-api.service.ts
  41. 55 16
      src/app/core/services/api/qiwe-api.service.ts
  42. 38 20
      src/app/features/dashboard/dashboard-scope.util.ts
  43. 8 13
      src/app/features/dashboard/dashboard.component.html
  44. 76 14
      src/app/features/dashboard/dashboard.component.ts
  45. 48 12
      src/app/features/group-management/group-list/group-list.component.ts

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

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

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

@@ -1,6 +1,7 @@
 import express from 'express';
 import cors from 'cors';
 import authRoutes from './auth/routes/auth.routes.js';
+import dashboardRoutes from './dashboard/routes/dashboard.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';
@@ -12,6 +13,7 @@ app.use(cors());
 app.use(express.json());
 
 app.use('/api/auth', authRoutes);
+app.use('/api/dashboard', dashboardRoutes);
 app.use('/api/qiwe', webhookRoutes);
 app.use('/api', healthRoutes);
 

+ 11 - 0
backend/backend/src/apps/pc/dashboard/controllers/dashboard.controller.ts

@@ -0,0 +1,11 @@
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { getDashboardOverview } from '../services/dashboard.service.js';
+
+export async function handleDashboardOverview(req: Request, res: Response): Promise<void> {
+  const role = typeof req.query.role === 'string' ? req.query.role : undefined;
+  const entityId = typeof req.query.entityId === 'string' ? req.query.entityId : undefined;
+
+  const overview = await getDashboardOverview({ role, entityId });
+  sendSuccess(res, overview);
+}

+ 9 - 0
backend/backend/src/apps/pc/dashboard/routes/dashboard.routes.ts

@@ -0,0 +1,9 @@
+import { Router } from 'express';
+import { asyncHandler } from '../../../../shared/http/async-handler.js';
+import { handleDashboardOverview } from '../controllers/dashboard.controller.js';
+
+const router = Router();
+
+router.get('/overview', asyncHandler(handleDashboardOverview));
+
+export default router;

+ 146 - 0
backend/backend/src/apps/pc/dashboard/services/dashboard.service.ts

@@ -0,0 +1,146 @@
+import Parse from '../../../../shared/db/parse-client.js';
+import type { UserDto } from '../../auth/services/auth.service.js';
+import { listGroupChats, type GroupChatDto } from '../../qiwe/services/groups.service.js';
+
+export interface DashboardScope {
+  role?: string;
+  entityId?: string;
+}
+
+export interface DashboardStatsDto {
+  totalGroups: number;
+  totalMembers: number;
+  activeGroupRate: number;
+  avgHealthScore: number;
+  complianceRate: number;
+  riskEventCount: number;
+  pendingWorkOrders: number;
+  intentLeadCount: number;
+  conversionRate: number;
+}
+
+export interface DashboardOverviewDto {
+  stats: DashboardStatsDto;
+  activityDistribution: { high: number; medium: number; low: number; inactive: number };
+  storeComparison: Array<{ storeName: string; groupCount: number }>;
+  topGroups: GroupChatDto[];
+  recentRiskEvents: [];
+  scopeUsers: UserDto[];
+}
+
+export async function listScopeUsers(): Promise<UserDto[]> {
+  const query = new Parse.Query('AppUser');
+  query.ascending('role');
+  query.limit(200);
+  const users = await query.find({ useMasterKey: true });
+  return users.map((user) => ({
+    id: user.id!,
+    name: user.get('name') || '',
+    email: user.get('email') || '',
+    phone: user.get('phone') || '',
+    role: user.get('role'),
+    storeId: user.get('storeId') || '',
+    storeName: user.get('storeName') || '',
+    department: user.get('department') || '',
+    position: user.get('position') || '',
+    createdAt: (user.get('createdAt') as Date)?.toISOString?.() || new Date().toISOString(),
+  }));
+}
+
+function filterGroupsByScope(groups: GroupChatDto[], scope: DashboardScope, users: UserDto[]): GroupChatDto[] {
+  const { role, entityId } = scope;
+
+  if (!entityId) {
+    return groups;
+  }
+
+  const entity = users.find((u) => u.id === entityId);
+  if (!entity) return groups;
+
+  switch (role) {
+    case 'director':
+      return groups;
+    case 'regional_supervisor':
+    case 'store_manager':
+      return entity.storeId ? groups.filter((g) => g.storeId === entity.storeId) : [];
+    case 'single_group':
+      return groups.filter((g) => g.ownerId && g.ownerId === entityId);
+    default:
+      return groups;
+  }
+}
+
+function computeStats(groups: GroupChatDto[]): DashboardStatsDto {
+  if (groups.length === 0) {
+    return {
+      totalGroups: 0,
+      totalMembers: 0,
+      activeGroupRate: 0,
+      avgHealthScore: 0,
+      complianceRate: 0,
+      riskEventCount: 0,
+      pendingWorkOrders: 0,
+      intentLeadCount: 0,
+      conversionRate: 0,
+    };
+  }
+
+  const totalMembers = groups.reduce((sum, g) => sum + g.memberCount, 0);
+  const activeCount = groups.filter((g) => g.activityLevel === 'high' || g.activityLevel === 'medium').length;
+  const avgHealth = groups.reduce((sum, g) => sum + g.healthScore, 0) / groups.length;
+  const documented = groups.filter((g) => g.hasDocument).length;
+
+  return {
+    totalGroups: groups.length,
+    totalMembers,
+    activeGroupRate: Math.round((activeCount / groups.length) * 100),
+    avgHealthScore: Math.round(avgHealth),
+    complianceRate: Math.round((documented / groups.length) * 100),
+    riskEventCount: 0,
+    pendingWorkOrders: 0,
+    intentLeadCount: 0,
+    conversionRate: 0,
+  };
+}
+
+function buildActivityDistribution(groups: GroupChatDto[]) {
+  return {
+    high: groups.filter((g) => g.activityLevel === 'high').length,
+    medium: groups.filter((g) => g.activityLevel === 'medium').length,
+    low: groups.filter((g) => g.activityLevel === 'low').length,
+    inactive: groups.filter((g) => g.activityLevel === 'inactive').length,
+  };
+}
+
+function buildStoreComparison(groups: GroupChatDto[]) {
+  const storeMap = new Map<string, number>();
+  for (const group of groups) {
+    if (!group.storeName) continue;
+    storeMap.set(group.storeName, (storeMap.get(group.storeName) || 0) + 1);
+  }
+  return Array.from(storeMap.entries()).map(([storeName, groupCount]) => ({
+    storeName,
+    groupCount,
+  }));
+}
+
+export async function getDashboardOverview(scope: DashboardScope = {}): Promise<DashboardOverviewDto> {
+  const [allGroups, scopeUsers] = await Promise.all([
+    listGroupChats(),
+    listScopeUsers(),
+  ]);
+
+  const groups = filterGroupsByScope(allGroups, scope, scopeUsers);
+  const topGroups = [...groups]
+    .sort((a, b) => b.messageCountToday - a.messageCountToday)
+    .slice(0, 10);
+
+  return {
+    stats: computeStats(groups),
+    activityDistribution: buildActivityDistribution(groups),
+    storeComparison: buildStoreComparison(groups),
+    topGroups,
+    recentRiskEvents: [],
+    scopeUsers,
+  };
+}

+ 18 - 3
backend/backend/src/apps/pc/qiwe/controllers/groups.controller.ts

@@ -2,10 +2,25 @@ 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';
+import { listStores } from '../services/organization.service.js';
 
-export async function handleListGroups(_req: Request, res: Response): Promise<void> {
-  const groups = await listGroupChats();
-  sendSuccess(res, { groups, total: groups.length });
+function parseBoolean(value: unknown): boolean | undefined {
+  if (value === undefined || value === null || value === '') return undefined;
+  if (value === 'true' || value === true) return true;
+  if (value === 'false' || value === false) return false;
+  return undefined;
+}
+
+export async function handleListGroups(req: Request, res: Response): Promise<void> {
+  const groups = await listGroupChats({
+    storeId: typeof req.query.storeId === 'string' ? req.query.storeId : undefined,
+    activityLevel: typeof req.query.activityLevel === 'string' ? req.query.activityLevel : undefined,
+    healthStatus: typeof req.query.healthStatus === 'string' ? req.query.healthStatus : undefined,
+    hasDocument: parseBoolean(req.query.hasDocument),
+    q: typeof req.query.q === 'string' ? req.query.q.trim() : undefined,
+  });
+  const stores = await listStores();
+  sendSuccess(res, { groups, total: groups.length, stores });
 }
 
 export async function handleGetGroup(req: Request, res: Response): Promise<void> {

+ 8 - 0
backend/backend/src/apps/pc/qiwe/controllers/stores.controller.ts

@@ -0,0 +1,8 @@
+import type { Request, Response } from 'express';
+import { sendSuccess } from '../../../../shared/http/response.js';
+import { listStores } from '../services/organization.service.js';
+
+export async function handleListStores(_req: Request, res: Response): Promise<void> {
+  const stores = await listStores();
+  sendSuccess(res, { stores });
+}

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

@@ -3,9 +3,11 @@ 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';
+import { handleListStores } from '../controllers/stores.controller.js';
 
 const router = Router();
 
+router.get('/stores', asyncHandler(handleListStores));
 router.get('/groups', asyncHandler(handleListGroups));
 router.get('/groups/:roomId', asyncHandler(handleGetGroup));
 router.post('/webhook', asyncHandler(handleWebhook));

+ 181 - 3
backend/backend/src/apps/pc/qiwe/services/groups.service.ts

@@ -1,42 +1,220 @@
 import Parse from '../../../../shared/db/parse-client.js';
+import {
+  deriveActivityLevel,
+  deriveHealthScore,
+  deriveHealthStatus,
+} from '../utils/group-metrics.util.js';
+import { resolveOwnerName } from './organization.service.js';
 
 export interface GroupChatDto {
   id: string;
   roomId: string;
   roomName: string;
   ownerId: string;
+  ownerName: string;
   memberCount: number;
   avatarUrl: string;
   status: string;
   guid: string;
+  storeId: string;
+  storeName: string;
+  communityId: string;
+  communityName: string;
+  activityLevel: string;
+  healthScore: number;
+  healthStatus: string;
+  hasDocument: boolean;
+  documentPinned: boolean;
+  documentInNotice: boolean;
+  messageCountToday: number;
+  memberChange24h: number;
   updatedAt: string;
 }
 
+export interface GroupListFilters {
+  storeId?: string;
+  activityLevel?: string;
+  healthStatus?: string;
+  hasDocument?: boolean;
+  q?: string;
+}
+
+function startOfToday(): Date {
+  const now = new Date();
+  return new Date(now.getFullYear(), now.getMonth(), now.getDate());
+}
+
+async function countMessagesToday(roomId: string): Promise<number> {
+  const query = new Parse.Query('Message');
+  query.equalTo('roomId', roomId);
+  query.greaterThanOrEqualTo('timestamp', startOfToday());
+  return query.count({ useMasterKey: true });
+}
+
+async function enrichGroup(obj: Parse.Object, persist = true): Promise<GroupChatDto> {
+  const roomId = obj.get('roomId') as string;
+  const memberCount = obj.get('memberCount') ?? 0;
+  const status = obj.get('status') || 'active';
+  const hasDocument = obj.get('hasDocument') === true;
+  const messageCountToday = await countMessagesToday(roomId);
+
+  const activityLevel = deriveActivityLevel(messageCountToday, memberCount);
+  const healthScore = deriveHealthScore({ memberCount, messageCountToday, hasDocument, status });
+  const healthStatus = deriveHealthStatus(healthScore, status);
+
+  let ownerName = (obj.get('ownerName') as string) || '';
+  if (!ownerName && obj.get('ownerId')) {
+    ownerName = await resolveOwnerName(obj.get('ownerId') || '');
+    if (ownerName) {
+      obj.set('ownerName', ownerName);
+    }
+  }
+
+  obj.set('messageCountToday', messageCountToday);
+  obj.set('activityLevel', activityLevel);
+  obj.set('healthScore', healthScore);
+  obj.set('healthStatus', healthStatus);
+
+  if (persist) {
+    await obj.save(null, { useMasterKey: true });
+  }
+
+  return toDto(obj);
+}
+
 function toDto(obj: Parse.Object): GroupChatDto {
   return {
     id: obj.id!,
     roomId: obj.get('roomId') || '',
     roomName: obj.get('roomName') || '未命名群',
     ownerId: obj.get('ownerId') || '',
+    ownerName: obj.get('ownerName') || '',
     memberCount: obj.get('memberCount') ?? 0,
     avatarUrl: obj.get('avatarUrl') || '',
     status: obj.get('status') || 'active',
     guid: obj.get('guid') || '',
+    storeId: obj.get('storeId') || '',
+    storeName: obj.get('storeName') || '',
+    communityId: obj.get('communityId') || '',
+    communityName: obj.get('communityName') || '',
+    activityLevel: obj.get('activityLevel') || 'inactive',
+    healthScore: obj.get('healthScore') ?? 0,
+    healthStatus: obj.get('healthStatus') || 'warning',
+    hasDocument: obj.get('hasDocument') === true,
+    documentPinned: obj.get('documentPinned') === true,
+    documentInNotice: obj.get('documentInNotice') === true,
+    messageCountToday: obj.get('messageCountToday') ?? 0,
+    memberChange24h: obj.get('memberChange24h') ?? 0,
     updatedAt: (obj.get('updatedAt') as Date)?.toISOString?.() || new Date().toISOString(),
   };
 }
 
-export async function listGroupChats(limit = 500): Promise<GroupChatDto[]> {
+export async function listGroupChats(
+  filters: GroupListFilters = {},
+  limit = 500,
+): Promise<GroupChatDto[]> {
   const query = new Parse.Query('GroupChat');
   query.descending('updatedAt');
+
+  if (filters.storeId) query.equalTo('storeId', filters.storeId);
+  if (filters.activityLevel) query.equalTo('activityLevel', filters.activityLevel);
+  if (filters.healthStatus) query.equalTo('healthStatus', filters.healthStatus);
+  if (filters.hasDocument !== undefined) query.equalTo('hasDocument', filters.hasDocument);
+
   query.limit(limit);
   const results = await query.find({ useMasterKey: true });
-  return results.map(toDto);
+
+  const enriched: GroupChatDto[] = [];
+  for (const obj of results) {
+    enriched.push(await enrichGroup(obj));
+  }
+
+  if (!filters.q) {
+    return enriched;
+  }
+
+  const q = filters.q.toLowerCase();
+  return enriched.filter((g) =>
+    g.roomName.toLowerCase().includes(q)
+    || g.ownerName.toLowerCase().includes(q)
+    || g.communityName.toLowerCase().includes(q)
+    || g.storeName.toLowerCase().includes(q),
+  );
 }
 
 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;
+  if (!obj) return null;
+  return enrichGroup(obj);
+}
+
+export async function migrateGroupBusinessFields(): Promise<void> {
+  const query = new Parse.Query('GroupChat');
+  query.limit(1000);
+  const groups = await query.find({ useMasterKey: true });
+
+  let migrated = 0;
+  for (const group of groups) {
+    let changed = false;
+
+    if (group.get('hasDocument') === undefined) {
+      group.set('hasDocument', false);
+      changed = true;
+    }
+    if (group.get('documentPinned') === undefined) {
+      group.set('documentPinned', false);
+      changed = true;
+    }
+    if (group.get('documentInNotice') === undefined) {
+      group.set('documentInNotice', false);
+      changed = true;
+    }
+    if (group.get('memberChange24h') === undefined) {
+      group.set('memberChange24h', 0);
+      changed = true;
+    }
+
+    if (changed) {
+      await group.save(null, { useMasterKey: true });
+      migrated++;
+    }
+  }
+
+  if (migrated > 0) {
+    console.log(`[Organization] 已补全 ${migrated} 个群的基础字段`);
+  }
+}
+
+/** 清除历史上自动填入的占位门店/小区数据(无真实业务归属时应为空) */
+export async function clearPlaceholderOrganizationFields(): Promise<void> {
+  const query = new Parse.Query('GroupChat');
+  query.limit(1000);
+  const groups = await query.find({ useMasterKey: true });
+
+  let cleared = 0;
+  for (const group of groups) {
+    const hasStore = group.get('storeId') || group.get('storeName');
+    const hasCommunity = group.get('communityId') || group.get('communityName');
+    if (!hasStore && !hasCommunity) continue;
+
+    group.unset('storeId');
+    group.unset('storeName');
+    group.unset('communityId');
+    group.unset('communityName');
+    await group.save(null, { useMasterKey: true });
+    cleared++;
+  }
+
+  if (cleared > 0) {
+    console.log(`[Organization] 已清除 ${cleared} 个群的占位门店/小区数据`);
+  }
+}
+
+export function initNewGroupDefaults(obj: Parse.Object): void {
+  if (obj.get('hasDocument') === undefined) obj.set('hasDocument', false);
+  if (obj.get('documentPinned') === undefined) obj.set('documentPinned', false);
+  if (obj.get('documentInNotice') === undefined) obj.set('documentInNotice', false);
+  if (obj.get('memberChange24h') === undefined) obj.set('memberChange24h', 0);
 }

+ 81 - 0
backend/backend/src/apps/pc/qiwe/services/organization.service.ts

@@ -0,0 +1,81 @@
+import Parse from '../../../../shared/db/parse-client.js';
+
+export interface StoreDto {
+  id: string;
+  code: string;
+  name: string;
+  region: string;
+}
+
+const SEED_STORES = [
+  { code: 'SH001', name: '上海总部', region: '华东' },
+  { code: 'BJ001', name: '北京旗舰店', region: '华北' },
+  { code: 'SZ001', name: '深圳体验店', region: '华南' },
+];
+
+export async function listStores(): Promise<StoreDto[]> {
+  const query = new Parse.Query('Store');
+  query.equalTo('status', 'active');
+  query.ascending('code');
+  const results = await query.find({ useMasterKey: true });
+  return results.map((obj) => ({
+    id: obj.id!,
+    code: obj.get('code') || '',
+    name: obj.get('name') || '',
+    region: obj.get('region') || '',
+  }));
+}
+
+export async function bootstrapStores(): Promise<void> {
+  let created = 0;
+  let updated = 0;
+
+  for (const seed of SEED_STORES) {
+    const query = new Parse.Query('Store');
+    query.equalTo('code', seed.code);
+    const existing = await query.first({ useMasterKey: true });
+
+    if (existing) {
+      existing.set('name', seed.name);
+      existing.set('region', seed.region);
+      existing.set('status', 'active');
+      await existing.save(null, { useMasterKey: true });
+      updated++;
+      continue;
+    }
+
+    const obj = new Parse.Object('Store');
+    obj.set('code', seed.code);
+    obj.set('name', seed.name);
+    obj.set('region', seed.region);
+    obj.set('status', 'active');
+    await obj.save(null, { useMasterKey: true });
+    created++;
+  }
+
+  if (created > 0 || updated > 0) {
+    console.log(`[Organization] 门店数据已同步:新增 ${created},更新 ${updated}`);
+  }
+}
+
+export async function getDefaultStore(): Promise<{ storeId: string; storeName: string }> {
+  const byCode = new Parse.Query('Store');
+  byCode.equalTo('code', 'SH001');
+  const shanghai = await byCode.first({ useMasterKey: true });
+  if (shanghai) {
+    return { storeId: shanghai.id!, storeName: shanghai.get('name') || '' };
+  }
+
+  const query = new Parse.Query('Store');
+  query.equalTo('status', 'active');
+  query.ascending('code');
+  const store = await query.first({ useMasterKey: true });
+  if (!store) {
+    return { storeId: '', storeName: '' };
+  }
+  return { storeId: store.id!, storeName: store.get('name') || '' };
+}
+
+export async function resolveOwnerName(ownerId: string): Promise<string> {
+  return ownerId || '';
+}

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

@@ -1,5 +1,6 @@
 import Parse from '../../../../shared/db/parse-client.js';
 import { getAllRooms } from './qiwe-api.service.js';
+import { initNewGroupDefaults } from './groups.service.js';
 
 const GUID = process.env.QIWE_GUID || '';
 
@@ -38,6 +39,7 @@ export async function syncGroupsFromQiWe(): Promise<{
         obj.set('memberCount', room.roomMemberCount);
         obj.set('avatarUrl', room.roomAvatarUrl);
         obj.set('status', 'active');
+        initNewGroupDefaults(obj);
         await obj.save(null, { useMasterKey: true });
         result.created++;
       }

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

@@ -1,4 +1,5 @@
 import Parse from '../../../../shared/db/parse-client.js';
+import { initNewGroupDefaults } from './groups.service.js';
 
 interface CallbackEvent {
   guid: string;
@@ -43,6 +44,7 @@ async function upsertGroupChat(roomId: string, guid: string, extra: Record<strin
   obj.set('guid', guid);
   obj.set('status', 'active');
   Object.entries(extra).forEach(([k, v]) => { if (v !== undefined) obj.set(k, v); });
+  initNewGroupDefaults(obj);
   return obj.save(null, { useMasterKey: true });
 }
 

+ 33 - 0
backend/backend/src/apps/pc/qiwe/utils/group-metrics.util.ts

@@ -0,0 +1,33 @@
+export type ActivityLevel = 'high' | 'medium' | 'low' | 'inactive';
+export type HealthStatus = 'healthy' | 'warning' | 'critical';
+
+export function deriveActivityLevel(messageCountToday: number, memberCount: number): ActivityLevel {
+  if (messageCountToday >= 20) return 'high';
+  if (messageCountToday >= 5) return 'medium';
+  if (messageCountToday >= 1 || memberCount > 0) return 'low';
+  return 'inactive';
+}
+
+export function deriveHealthScore(input: {
+  memberCount: number;
+  messageCountToday: number;
+  hasDocument: boolean;
+  status: string;
+}): number {
+  if (input.status === 'dismissed') return 0;
+
+  let score = 55;
+  if (input.memberCount >= 50) score += 15;
+  else if (input.memberCount >= 10) score += 8;
+
+  score += Math.min(input.messageCountToday, 15);
+  if (input.hasDocument) score += 10;
+
+  return Math.max(0, Math.min(100, Math.round(score)));
+}
+
+export function deriveHealthStatus(score: number, status: string): HealthStatus {
+  if (status === 'dismissed' || score < 50) return 'critical';
+  if (score < 70) return 'warning';
+  return 'healthy';
+}

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

@@ -2,6 +2,7 @@ import 'dotenv/config';
 
 import './shared/db/parse-client.js';
 import { ensureSchemas } from './shared/db/schema-setup.js';
+import { bootstrapOrganization } from './shared/db/organization-bootstrap.js';
 import { bootstrapAuth } from './apps/pc/auth/services/auth.service.js';
 
 async function bootstrap(): Promise<void> {
@@ -10,6 +11,7 @@ async function bootstrap(): Promise<void> {
   console.log('[Setup] Schema 检查完毕');
 
   await bootstrapAuth();
+  await bootstrapOrganization();
 }
 
 await bootstrap();

+ 16 - 0
backend/backend/src/shared/db/migration.util.ts

@@ -0,0 +1,16 @@
+import Parse from './parse-client.js';
+
+export async function runOnceMigration(key: string, task: () => Promise<void>): Promise<void> {
+  const query = new Parse.Query('SystemMigration');
+  query.equalTo('key', key);
+  const done = await query.first({ useMasterKey: true });
+  if (done) return;
+
+  await task();
+
+  const record = new Parse.Object('SystemMigration');
+  record.set('key', key);
+  record.set('completedAt', new Date());
+  await record.save(null, { useMasterKey: true });
+  console.log(`[Migration] 已完成一次性迁移:${key}`);
+}

+ 12 - 0
backend/backend/src/shared/db/organization-bootstrap.ts

@@ -0,0 +1,12 @@
+import { bootstrapStores } from '../../apps/pc/qiwe/services/organization.service.js';
+import {
+  migrateGroupBusinessFields,
+  clearPlaceholderOrganizationFields,
+} from '../../apps/pc/qiwe/services/groups.service.js';
+import { runOnceMigration } from './migration.util.js';
+
+export async function bootstrapOrganization(): Promise<void> {
+  await bootstrapStores();
+  await runOnceMigration('clear_placeholder_org_fields_v1', clearPlaceholderOrganizationFields);
+  await migrateGroupBusinessFields();
+}

+ 105 - 69
backend/backend/src/shared/db/schema-setup.ts

@@ -5,90 +5,126 @@ function isSchemaExistsError(msg: string): boolean {
     msg.includes('already exists')
     || msg.includes('Class already exists')
     || msg.includes('exists, cannot update')
+    || msg.includes('Field already exists')
   );
 }
 
-async function ensureClass(
+async function ensureClassFields(
   className: string,
-  fields: Record<string, string>,
-  indexName?: string,
-  indexDef?: Record<string, number>,
+  fields: Record<string, 'String' | 'Number' | 'Date' | 'Array' | 'Boolean'>,
 ): 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);
-  }
+  let added = 0;
 
-  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;
-    }
+  for (const [name, type] of Object.entries(fields)) {
     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);
+      const schema = new Parse.Schema(className);
+      schema.addField(name, type);
+      await schema.update({ useMasterKey: true });
+      added++;
+    } catch (err: unknown) {
+      const msg = err instanceof Error ? err.message : String(err);
+      if (!isSchemaExistsError(msg)) {
+        try {
+          const schema = new Parse.Schema(className);
+          schema.addField(name, type);
+          await schema.save({ useMasterKey: true });
+          added++;
+        } catch (saveErr: unknown) {
+          const saveMsg = saveErr instanceof Error ? saveErr.message : String(saveErr);
+          if (!isSchemaExistsError(saveMsg)) {
+            console.warn(`[Schema] ${className}.${name}:`, saveMsg);
+          }
+        }
       }
     }
   }
+
+  console.log(`[Schema] ${className} — fields ensured (${Object.keys(fields).length} total, ${added} applied)`);
 }
 
 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 ensureClassFields('GroupChat', {
+    roomId: 'String',
+    roomName: 'String',
+    ownerId: 'String',
+    ownerName: 'String',
+    memberCount: 'Number',
+    avatarUrl: 'String',
+    status: 'String',
+    guid: 'String',
+    storeId: 'String',
+    storeName: 'String',
+    communityId: 'String',
+    communityName: 'String',
+    activityLevel: 'String',
+    healthScore: 'Number',
+    healthStatus: 'String',
+    hasDocument: 'Boolean',
+    documentPinned: 'Boolean',
+    documentInNotice: 'Boolean',
+    messageCountToday: 'Number',
+    memberChange24h: 'Number',
+  });
 
-  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 ensureClassFields('Store', {
+    code: 'String',
+    name: 'String',
+    region: 'String',
+    status: 'String',
+  });
 
-  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 ensureClassFields('Community', {
+    name: 'String',
+    storeId: 'String',
+    storeName: 'String',
+    address: 'String',
+    status: 'String',
+  });
 
-  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 ensureClassFields('GroupMember', {
+    roomId: 'String',
+    userId: 'String',
+    nickname: 'String',
+    status: 'String',
+    joinedAt: 'Date',
+    leftAt: 'Date',
+    guid: 'String',
+  });
 
-  await ensureClass(
-    'AuthSession',
-    { token: 'String', userId: 'String', expiresAt: 'Date' },
-    'token_unique',
-    { token: 1 },
-  );
+  await ensureClassFields('Message', {
+    msgUniqueIdentifier: 'String',
+    roomId: 'String',
+    senderId: 'String',
+    receiverId: 'String',
+    content: 'String',
+    atList: 'Array',
+    msgType: 'Number',
+    isGroupChat: 'Number',
+    timestamp: 'Date',
+    guid: 'String',
+  });
+
+  await ensureClassFields('AppUser', {
+    email: 'String',
+    passwordHash: 'String',
+    passwordSalt: 'String',
+    name: 'String',
+    phone: 'String',
+    role: 'String',
+    storeId: 'String',
+    storeName: 'String',
+    department: 'String',
+    position: 'String',
+  });
+
+  await ensureClassFields('AuthSession', {
+    token: 'String',
+    userId: 'String',
+    expiresAt: 'Date',
+  });
+
+  await ensureClassFields('SystemMigration', {
+    key: 'String',
+    completedAt: 'Date',
+  });
 }

+ 10 - 26
lami-base-v1/backend/.env.example

@@ -1,32 +1,16 @@
-# 运行环境
 NODE_ENV=development
 
-# PC 端统一端口(所有模块共用一个 Express App)
-PC_PORT=3101
-
-# Mobile 端端口
 MOBILE_CHAT_PORT=3201
+PC_PORT=3101
+QIWEI_PORT=3202
 
-# 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
+
+# QiWe 开放平台(会话消息 / 同步历史)
+QIWEI_TOKEN=
+QIWEI_GUID=
+QIWEI_BASE_URL=http://manager.qiweapi.com/qiwe
+QIWEI_SYNC_METHOD=/msg/syncMsg
+# 群-文档台账 JSON 存储路径(可选)
+# QIWEI_ROOM_DOC_STORE=./data/qiwei-room-docs.json

+ 8 - 16
lami-base-v1/backend/package.json

@@ -7,29 +7,21 @@
     "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"
+    "test": "tsx --test tests/**/*.test.ts",
+    "test:qiwei": "tsx --test tests/qiwei.api.test.ts"
   },
   "dependencies": {
     "express": "^5.0.0",
     "cors": "^2.8.5",
-    "dotenv": "^16.4.5",
-    "swagger-ui-express": "^5.0.1"
+    "dotenv": "^16.4.5"
   },
   "devDependencies": {
-    "@types/express": "^5.0.0",
     "@types/cors": "^2.8.17",
+    "@types/express": "^5.0.0",
     "@types/node": "^22.10.0",
-    "@types/swagger-ui-express": "^4.1.8",
-    "typescript": "^5.6.0",
-    "tsx": "^4.19.0"
+    "@types/supertest": "^6.0.2",
+    "supertest": "^7.0.0",
+    "tsx": "^4.19.0",
+    "typescript": "^5.6.0"
   }
 }

+ 12 - 0
lami-base-v1/backend/src/apps/qiwei/app.ts

@@ -0,0 +1,12 @@
+import express from 'express';
+import cors from 'cors';
+import type { Express } from 'express';
+import { qiweiRouter } from './routes/qiwei.routes.js';
+
+export function createQiweiApp(): Express {
+  const app = express();
+  app.use(cors());
+  app.use(express.json({ limit: '2mb' }));
+  app.use('/api/qiwei', qiweiRouter);
+  return app;
+}

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

@@ -0,0 +1,97 @@
+import type { Request, Response } from 'express';
+import { extractDocRefsFromPayload } from '../../../shared/qiwei/docid.js';
+import { getStringEnv } from '../../../shared/config/env.js';
+import { listRoomDocs, upsertFromRefs, getByRoomId } from '../services/room-doc-store.service.js';
+import { syncMessages } from '../services/qiwei-api.service.js';
+import {
+  screenDocRef,
+  screenMessageItem,
+  type ExceptionHit,
+} from '../services/exception-screen.service.js';
+
+export async function handleWebhook(req: Request, res: Response): Promise<void> {
+  const body = req.body;
+  const refs = extractDocRefsFromPayload(body, 'webhook');
+  const saved = upsertFromRefs(refs);
+
+  const exceptions: ExceptionHit[] = [];
+  const data = (body as { data?: unknown[] })?.data;
+  if (Array.isArray(data)) {
+    for (const item of data) {
+      if (item && typeof item === 'object') {
+        exceptions.push(...screenMessageItem(item as Record<string, unknown>));
+      }
+    }
+  }
+  for (const ref of refs) {
+    exceptions.push(...screenDocRef(ref));
+  }
+
+  if (refs.length) {
+    console.log('[qiwei/webhook] 发现文档链接', refs.length, '条', refs);
+  }
+  if (exceptions.length) {
+    console.log('[qiwei/webhook] 异常粗筛', exceptions.length, '条', exceptions);
+  }
+
+  res.json({
+    ok: true,
+    docRefs: refs,
+    saved,
+    exceptions,
+  });
+}
+
+export async function handleSync(req: Request, res: Response): Promise<void> {
+  try {
+    const guid =
+      (req.body?.guid as string | undefined) ?? getStringEnv('QIWEI_GUID');
+    if (!guid) {
+      res.status(400).json({ ok: false, error: '缺少 guid(body 或环境变量 QIWEI_GUID)' });
+      return;
+    }
+    const msgSeq = Number(req.body?.msgSeq ?? 0);
+    const extra = req.body?.extra as Record<string, unknown> | undefined;
+    const raw = await syncMessages(guid, msgSeq, extra);
+    const refs = extractDocRefsFromPayload(raw, 'sync');
+    const saved = upsertFromRefs(refs);
+    const exceptions = refs.flatMap((r) => screenDocRef(r));
+
+    res.json({ ok: true, raw, docRefs: refs, saved, exceptions });
+  } catch (e) {
+    const message = e instanceof Error ? e.message : String(e);
+    res.status(500).json({ ok: false, error: message });
+  }
+}
+
+export function handleListRoomDocs(_req: Request, res: Response): void {
+  res.json({ ok: true, items: listRoomDocs() });
+}
+
+export function handleGetRoomDoc(req: Request, res: Response): void {
+  const roomId = req.params.roomId;
+  const item = getByRoomId(roomId);
+  if (!item) {
+    res.status(404).json({ ok: false, error: '未登记该群的文档' });
+    return;
+  }
+  res.json({ ok: true, item });
+}
+
+export function handleScreenSample(req: Request, res: Response): void {
+  const body = req.body;
+  const exceptions: ExceptionHit[] = [];
+  const data = (body as { data?: unknown[] })?.data;
+  if (Array.isArray(data)) {
+    for (const item of data) {
+      if (item && typeof item === 'object') {
+        exceptions.push(...screenMessageItem(item as Record<string, unknown>));
+      }
+    }
+  }
+  const refs = extractDocRefsFromPayload(body, 'screen');
+  for (const ref of refs) {
+    exceptions.push(...screenDocRef(ref));
+  }
+  res.json({ ok: true, docRefs: refs, exceptions });
+}

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

@@ -0,0 +1,20 @@
+import { Router } from 'express';
+import {
+  handleWebhook,
+  handleSync,
+  handleListRoomDocs,
+  handleGetRoomDoc,
+  handleScreenSample,
+} from '../controllers/qiwei.controller.js';
+
+export const qiweiRouter = Router();
+
+/** QiWe 回调入口(配置 setCallback 指向此 URL) */
+qiweiRouter.post('/webhook', handleWebhook);
+/** 手动触发同步历史消息(会话存储对账) */
+qiweiRouter.post('/sync', handleSync);
+/** 群 ↔ 文档台账 */
+qiweiRouter.get('/room-docs', handleListRoomDocs);
+qiweiRouter.get('/room-docs/:roomId', handleGetRoomDoc);
+/** 对任意回调 JSON 做异常粗筛(不调 AI) */
+qiweiRouter.post('/screen', handleScreenSample);

+ 12 - 0
lami-base-v1/backend/src/apps/qiwei/server.ts

@@ -0,0 +1,12 @@
+import { createQiweiApp } from './app.js';
+import { getNumberEnv } from '../../shared/config/env.js';
+
+export function startQiweiServer(): void {
+  const app = createQiweiApp();
+  const port = getNumberEnv('QIWEI_PORT', 3202);
+  app.listen(port, () => {
+    console.log(`qiwei server running on http://localhost:${port}`);
+    console.log(`  webhook: POST http://localhost:${port}/api/qiwei/webhook`);
+    console.log(`  sync:    POST http://localhost:${port}/api/qiwei/sync`);
+  });
+}

+ 95 - 0
lami-base-v1/backend/src/apps/qiwei/services/exception-screen.service.ts

@@ -0,0 +1,95 @@
+import type { DocRef } from '../../../shared/qiwei/docid.js';
+import type { RoomDocRecord } from './room-doc-store.service.js';
+
+/** 异常码:规则粗筛,不走 AI */
+export type ExceptionCode =
+  | 'DOCID_PARSE_FAILED'
+  | 'ROOM_NO_DOC_BINDING'
+  | 'LINK_NOT_DOC_HOST'
+  | 'MSG_NOT_LINK_TYPE';
+
+export interface ExceptionHit {
+  code: ExceptionCode;
+  severity: 'warn' | 'error';
+  message: string;
+  roomId?: string | number | null;
+  linkUrl?: string;
+  docid?: string | null;
+}
+
+/** 对单条消息做廉价规则筛查(上级要求:模板库粗筛,避免每条过 AI) */
+export function screenMessageItem(item: Record<string, unknown>): ExceptionHit[] {
+  const hits: ExceptionHit[] = [];
+  const msgType = item.msgType as number | undefined;
+  const roomId =
+    (item.fromRoomId as string | number | undefined) ??
+    (item.roomId as string | number | undefined) ??
+    null;
+  const msgData = item.msgData as Record<string, unknown> | undefined;
+  const linkUrl = msgData
+    ? ((msgData.linkUrl ?? msgData.link_url) as string | undefined)
+    : undefined;
+
+  if (linkUrl?.includes('doc.weixin') || linkUrl?.includes('wedoc')) {
+    if (msgType !== undefined && msgType !== 13) {
+      hits.push({
+        code: 'MSG_NOT_LINK_TYPE',
+        severity: 'warn',
+        message: `含文档域名但 msgType=${msgType},非标准链接消息(13),需人工确认`,
+        roomId,
+        linkUrl,
+      });
+    }
+    const docidMatch = /[?&]docid=([^&]+)/i.exec(linkUrl) ?? /\/doc\/([^/?#]+)/i.exec(linkUrl);
+    if (!docidMatch) {
+      hits.push({
+        code: 'DOCID_PARSE_FAILED',
+        severity: 'error',
+        message: '链接像在线文档但未能解析 docid',
+        roomId,
+        linkUrl,
+      });
+    }
+  }
+
+  return hits;
+}
+
+export function screenDocRef(ref: DocRef): ExceptionHit[] {
+  const hits: ExceptionHit[] = [];
+  if (!ref.linkUrl.includes('doc.weixin') && !/wedoc|txdoc/i.test(ref.linkUrl)) {
+    hits.push({
+      code: 'LINK_NOT_DOC_HOST',
+      severity: 'warn',
+      message: '链接非企微文档常见域名',
+      roomId: ref.roomId,
+      linkUrl: ref.linkUrl,
+    });
+  }
+  if (!ref.docid) {
+    hits.push({
+      code: 'DOCID_PARSE_FAILED',
+      severity: 'error',
+      message: '未能从 linkUrl 解析 docid',
+      roomId: ref.roomId,
+      linkUrl: ref.linkUrl,
+    });
+  }
+  return hits;
+}
+
+/** 巡检:已知群列表中哪些还没有文档绑定 */
+export function screenRoomsWithoutDoc(
+  roomIds: (string | number)[],
+  ledger: RoomDocRecord[],
+): ExceptionHit[] {
+  const bound = new Set(ledger.map((r) => String(r.roomId)));
+  return roomIds
+    .filter((id) => !bound.has(String(id)))
+    .map((id) => ({
+      code: 'ROOM_NO_DOC_BINDING' as const,
+      severity: 'error' as const,
+      message: '该群尚未从会话消息中发现/登记沟通文档',
+      roomId: id,
+    }));
+}

+ 30 - 0
lami-base-v1/backend/src/apps/qiwei/services/qiwei-api.service.ts

@@ -0,0 +1,30 @@
+import { getStringEnv } from '../../../shared/config/env.js';
+
+export async function qiweiDoApi<T = unknown>(
+  method: string,
+  params: Record<string, unknown>,
+): Promise<T> {
+  const token = getStringEnv('QIWEI_TOKEN');
+  if (!token) {
+    throw new Error('QIWEI_TOKEN 未配置');
+  }
+  const base = (getStringEnv('QIWEI_BASE_URL') ?? 'http://manager.qiweapi.com/qiwe').replace(/\/$/, '');
+  const url = `${base}/api/qw/doApi`;
+  const res = await fetch(url, {
+    method: 'POST',
+    headers: {
+      'Content-Type': 'application/json',
+      'X-QIWEI-TOKEN': token,
+    },
+    body: JSON.stringify({ method, params }),
+  });
+  if (!res.ok) {
+    throw new Error(`QiWei API HTTP ${res.status}`);
+  }
+  return (await res.json()) as T;
+}
+
+export async function syncMessages(guid: string, msgSeq = 0, extra?: Record<string, unknown>) {
+  const syncMethod = getStringEnv('QIWEI_SYNC_METHOD') ?? '/msg/syncMsg';
+  return qiweiDoApi(syncMethod, { guid, msgSeq, ...extra });
+}

+ 56 - 0
lami-base-v1/backend/src/apps/qiwei/services/room-doc-store.service.ts

@@ -0,0 +1,56 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import type { DocRef } from '../../../shared/qiwei/docid.js';
+
+export interface RoomDocRecord extends DocRef {
+  updatedAt: string;
+}
+
+const DEFAULT_FILE = path.resolve(process.cwd(), 'data', 'qiwei-room-docs.json');
+
+function storePath(): string {
+  return process.env.QIWEI_ROOM_DOC_STORE ?? DEFAULT_FILE;
+}
+
+function loadAll(): Record<string, RoomDocRecord> {
+  const file = storePath();
+  try {
+    if (!fs.existsSync(file)) return {};
+    const raw = fs.readFileSync(file, 'utf-8');
+    return JSON.parse(raw) as Record<string, RoomDocRecord>;
+  } catch {
+    return {};
+  }
+}
+
+function saveAll(data: Record<string, RoomDocRecord>): void {
+  const file = storePath();
+  fs.mkdirSync(path.dirname(file), { recursive: true });
+  fs.writeFileSync(file, JSON.stringify(data, null, 2), 'utf-8');
+}
+
+/** 以 roomId 为键保留最新一条文档绑定(PoC 台账) */
+export function upsertFromRefs(refs: DocRef[]): RoomDocRecord[] {
+  const all = loadAll();
+  const updated: RoomDocRecord[] = [];
+  const now = new Date().toISOString();
+
+  for (const ref of refs) {
+    if (ref.roomId == null) continue;
+    const key = String(ref.roomId);
+    const record: RoomDocRecord = { ...ref, updatedAt: now };
+    all[key] = record;
+    updated.push(record);
+  }
+
+  if (updated.length) saveAll(all);
+  return updated;
+}
+
+export function listRoomDocs(): RoomDocRecord[] {
+  return Object.values(loadAll());
+}
+
+export function getByRoomId(roomId: string): RoomDocRecord | undefined {
+  return loadAll()[roomId];
+}

+ 3 - 47
lami-base-v1/backend/src/index.ts

@@ -1,54 +1,10 @@
-/**
- * 总启动器
- *
- * 职责(遵循《后端目录规范》第八节):
- *   - 加载环境变量(dotenv.config())
- *   - 启动 PC 端服务(单端口,所有模块路由统一挂载)
- *   - 启动 Mobile 端服务(单端口)
- *   - 处理进程级事件
- *
- * 端口规划:
- *   3101 — PC 端(11 个业务模块共用一个 Express App)
- *   3201 — Mobile 端(chat 等模块)
- *
- * 前端只需配置两个 baseURL:
- *   PC_BASE_URL     = http://localhost:3101/api
- *   MOBILE_BASE_URL = http://localhost:3201/api
- */
-
 import { config } from 'dotenv';
-import { startPcServer } from './apps/pc/health/server.js';
 import { startMobileChatServer } from './apps/mobile/chat/server.js';
+import { startPcServer } from './apps/pc/health/server.js';
+import { startQiweiServer } from './apps/qiwei/server.js';
 
-// 必须在读取任何 process.env 之前调用
 config();
 
-console.log('===========================================');
-console.log('  Lami Base — 后端服务启动中...');
-console.log('===========================================');
-
-// PC 端(3101)—— 所有模块共用一个端口
 startPcServer();
-
-// Mobile 端(3201)—— AI 聊天等
 startMobileChatServer();
-
-// ---- 进程事件 ----
-process.on('SIGINT', () => {
-  console.log('\n收到 SIGINT,正在退出...');
-  process.exit(0);
-});
-
-process.on('SIGTERM', () => {
-  console.log('\n收到 SIGTERM,正在退出...');
-  process.exit(0);
-});
-
-process.on('uncaughtException', (error) => {
-  console.error('未捕获的异常:', error);
-  process.exit(1);
-});
-
-process.on('unhandledRejection', (reason) => {
-  console.error('未处理的 Promise 拒绝:', reason);
-});
+startQiweiServer();

+ 137 - 0
lami-base-v1/backend/src/shared/qiwei/docid.ts

@@ -0,0 +1,137 @@
+/** 从企微在线文档 linkUrl 解析 docid(与 scripts/qiwei_doc_discovery.py 规则一致) */
+
+const DOC_HOST = /doc\.weixin\.qq\.com|doc\.work\.weixin\.qq\.com|wedoc|txdoc/i;
+
+export interface DocRef {
+  docid: string | null;
+  linkUrl: string;
+  title: string | null;
+  msgType: number | null;
+  roomId: string | number | null;
+  guid: string | null;
+  source: string;
+  foundAt?: number;
+}
+
+export function isProbableDocUrl(url: string): boolean {
+  return Boolean(url && DOC_HOST.test(url));
+}
+
+export function extractDocidFromUrl(url: string): string | null {
+  if (!url?.trim()) return null;
+  try {
+    const u = new URL(url.trim());
+    for (const key of ['docid', 'doc_id', 'docId']) {
+      const v = u.searchParams.get(key);
+      if (v) return v;
+    }
+    const pathMatch = u.pathname.match(/\/(?:txdoc\/[^/]+|doc)\/([A-Za-z0-9_-]+)/i);
+    if (pathMatch) return pathMatch[1];
+    const q = url.match(/(?:docid|doc_id)[=/]([A-Za-z0-9_-]+)/i);
+    if (q) return q[1];
+  } catch {
+    const q = url.match(/(?:docid|doc_id)[=/]([A-Za-z0-9_-]+)/i);
+    if (q) return q[1];
+  }
+  return null;
+}
+
+function walk(obj: unknown, fn: (key: string, val: unknown) => void): void {
+  if (obj && typeof obj === 'object') {
+    if (Array.isArray(obj)) {
+      for (const item of obj) walk(item, fn);
+    } else {
+      for (const [k, v] of Object.entries(obj)) {
+        fn(k, v);
+        walk(v, fn);
+      }
+    }
+  }
+}
+
+export function extractDocRefsFromMessageItem(
+  item: Record<string, unknown>,
+  source: string,
+): DocRef[] {
+  const refs: DocRef[] = [];
+  const msgType = item.msgType as number | undefined;
+  const roomId =
+    (item.fromRoomId as string | number | undefined) ??
+    (item.roomId as string | number | undefined) ??
+    null;
+  const guid = (item.guid as string | undefined) ?? null;
+  const timestamp = (item.timestamp as number | undefined) ?? undefined;
+  const msgData = item.msgData as Record<string, unknown> | undefined;
+
+  if (msgData && typeof msgData === 'object') {
+    const linkUrl = (msgData.linkUrl ?? msgData.link_url) as string | undefined;
+    if (linkUrl && (msgType === 13 || isProbableDocUrl(linkUrl))) {
+      refs.push({
+        docid: extractDocidFromUrl(linkUrl),
+        linkUrl,
+        title: typeof msgData.title === 'string' ? msgData.title : null,
+        msgType: msgType ?? 13,
+        roomId,
+        guid,
+        source,
+        foundAt: timestamp,
+      });
+    }
+  }
+
+  walk(item, (key, val) => {
+    if (typeof val !== 'string') return;
+    const lk = key.toLowerCase();
+    if (!['linkurl', 'link_url', 'url', 'link'].includes(lk)) return;
+    if (!isProbableDocUrl(val) && !val.toLowerCase().includes('doc.weixin')) return;
+    refs.push({
+      docid: extractDocidFromUrl(val),
+      linkUrl: val,
+      title: null,
+      msgType: msgType ?? null,
+      roomId,
+      guid,
+      source: `${source}:walk`,
+      foundAt: timestamp,
+    });
+  });
+
+  const seen = new Set<string>();
+  return refs.filter((r) => {
+    if (seen.has(r.linkUrl)) return false;
+    seen.add(r.linkUrl);
+    return true;
+  });
+}
+
+export function extractDocRefsFromPayload(payload: unknown, source = 'callback'): DocRef[] {
+  const refs: DocRef[] = [];
+  if (!payload || typeof payload !== 'object') return refs;
+
+  const root = payload as Record<string, unknown>;
+  const data = root.data;
+  if (Array.isArray(data)) {
+    for (const item of data) {
+      if (item && typeof item === 'object') {
+        refs.push(...extractDocRefsFromMessageItem(item as Record<string, unknown>, source));
+      }
+    }
+  } else if (typeof data === 'object' && data !== null) {
+    const d = data as Record<string, unknown>;
+    const list = (d.syncMsgList ?? d.list) as unknown;
+    if (Array.isArray(list)) {
+      for (const item of list) {
+        if (item && typeof item === 'object') {
+          refs.push(...extractDocRefsFromMessageItem(item as Record<string, unknown>, 'sync'));
+        }
+      }
+    }
+  }
+
+  const seen = new Set<string>();
+  return refs.filter((r) => {
+    if (seen.has(r.linkUrl)) return false;
+    seen.add(r.linkUrl);
+    return true;
+  });
+}

+ 47 - 0
lami-base-v1/backend/tests/fixtures/qiwei_callbacks_sample.json

@@ -0,0 +1,47 @@
+{
+  "code": 0,
+  "msg": "成功",
+  "data": [
+    {
+      "guid": "test-guid-designer-001",
+      "cmd": 15000,
+      "fromRoomId": 10791082136095292,
+      "msgServerId": 1002115,
+      "msgType": 13,
+      "timestamp": 1710000000,
+      "senderId": 1688852365307991,
+      "msgData": {
+        "title": "客户沟通记录表",
+        "desc": "请在此填写每次沟通内容",
+        "linkUrl": "https://doc.weixin.qq.com/txdoc/excel?docid=DOC_TEST_abc123xyz&scode=xxx",
+        "iconUrl": ""
+      }
+    },
+    {
+      "guid": "test-guid-designer-001",
+      "cmd": 15000,
+      "fromRoomId": 10791082136095292,
+      "msgServerId": 1002116,
+      "msgType": 0,
+      "timestamp": 1710000100,
+      "senderId": 1688852365307991,
+      "msgData": {
+        "content": "今天和客户确认了方案"
+      }
+    },
+    {
+      "guid": "test-guid-designer-001",
+      "cmd": 15000,
+      "fromRoomId": 99999999999999,
+      "msgServerId": 1002117,
+      "msgType": 13,
+      "timestamp": 1710000200,
+      "senderId": 1688852365307991,
+      "msgData": {
+        "title": "另一个群的文档",
+        "linkUrl": "https://doc.weixin.qq.com/doc/DOC_OTHER_room2",
+        "desc": ""
+      }
+    }
+  ]
+}

+ 133 - 0
lami-base-v1/backend/tests/qiwei.api.test.ts

@@ -0,0 +1,133 @@
+/**
+ * QiWei Express 接口测试(supertest,不启动真实端口)
+ *
+ * 运行:cd backend && pnpm test
+ */
+import { describe, it, before, after, beforeEach } from 'node:test';
+import assert from 'node:assert';
+import fs from 'node:fs';
+import path from 'node:path';
+import os from 'node:os';
+import { fileURLToPath } from 'node:url';
+import request from 'supertest';
+import { createQiweiApp } from '../src/apps/qiwei/app.js';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+const fixturePath = path.join(__dirname, 'fixtures', 'qiwei_callbacks_sample.json');
+const samplePayload = JSON.parse(fs.readFileSync(fixturePath, 'utf-8')) as Record<string, unknown>;
+
+function tempStorePath(): string {
+  return path.join(os.tmpdir(), `qiwei-api-test-${process.pid}-${Date.now()}.json`);
+}
+
+describe('QiWei API /api/qiwei', () => {
+  let storeFile: string;
+  const app = createQiweiApp();
+
+  before(() => {
+    storeFile = tempStorePath();
+    process.env.QIWEI_ROOM_DOC_STORE = storeFile;
+  });
+
+  after(() => {
+    if (fs.existsSync(storeFile)) {
+      fs.unlinkSync(storeFile);
+    }
+    delete process.env.QIWEI_ROOM_DOC_STORE;
+    delete process.env.QIWEI_GUID;
+    delete process.env.QIWEI_TOKEN;
+  });
+
+  beforeEach(() => {
+    if (fs.existsSync(storeFile)) {
+      fs.unlinkSync(storeFile);
+    }
+  });
+
+  it('POST /webhook — 解析 docid 并写入台账', async () => {
+    const res = await request(app)
+      .post('/api/qiwei/webhook')
+      .send(samplePayload)
+      .expect(200);
+
+    assert.strictEqual(res.body.ok, true);
+    assert.ok(Array.isArray(res.body.docRefs));
+    assert.ok(res.body.docRefs.length >= 2);
+
+    const docids = res.body.docRefs.map((r: { docid: string | null }) => r.docid);
+    assert.ok(docids.includes('DOC_TEST_abc123xyz'));
+    assert.ok(docids.includes('DOC_OTHER_room2'));
+
+    assert.ok(Array.isArray(res.body.saved));
+    assert.strictEqual(res.body.saved.length, 2);
+  });
+
+  it('GET /room-docs — 查询群↔文档台账', async () => {
+    await request(app).post('/api/qiwei/webhook').send(samplePayload).expect(200);
+
+    const res = await request(app).get('/api/qiwei/room-docs').expect(200);
+
+    assert.strictEqual(res.body.ok, true);
+    assert.ok(res.body.items.length >= 2);
+    const roomIds = res.body.items.map((i: { roomId: string | number }) => String(i.roomId));
+    assert.ok(roomIds.includes('10791082136095292'));
+    assert.ok(roomIds.includes('99999999999999'));
+  });
+
+  it('GET /room-docs/:roomId — 单个群文档', async () => {
+    await request(app).post('/api/qiwei/webhook').send(samplePayload).expect(200);
+
+    const res = await request(app)
+      .get('/api/qiwei/room-docs/10791082136095292')
+      .expect(200);
+
+    assert.strictEqual(res.body.item.docid, 'DOC_TEST_abc123xyz');
+    assert.ok(res.body.item.linkUrl.includes('doc.weixin.qq.com'));
+  });
+
+  it('GET /room-docs/:roomId — 未登记返回 404', async () => {
+    await request(app).get('/api/qiwei/room-docs/00000000000000').expect(404);
+  });
+
+  it('POST /screen — 无法解析 docid 时命中异常模板', async () => {
+    const payload = {
+      data: [
+        {
+          cmd: 15000,
+          msgType: 13,
+          fromRoomId: 111,
+          msgData: {
+            linkUrl: 'https://doc.weixin.qq.com/some/page-without-docid',
+            title: '无 docid 的链接',
+          },
+        },
+      ],
+    };
+
+    const res = await request(app).post('/api/qiwei/screen').send(payload).expect(200);
+
+    assert.strictEqual(res.body.ok, true);
+    const codes = res.body.exceptions.map((e: { code: string }) => e.code);
+    assert.ok(codes.includes('DOCID_PARSE_FAILED'));
+  });
+
+  it('POST /sync — 缺少 guid 返回 400', async () => {
+    delete process.env.QIWEI_GUID;
+    const res = await request(app).post('/api/qiwei/sync').send({}).expect(400);
+
+    assert.strictEqual(res.body.ok, false);
+    assert.ok(String(res.body.error).includes('guid'));
+  });
+
+  it('POST /sync — 无 QIWEI_TOKEN 返回 500', async () => {
+    delete process.env.QIWEI_TOKEN;
+    process.env.QIWEI_GUID = 'test-guid';
+
+    const res = await request(app)
+      .post('/api/qiwei/sync')
+      .send({ guid: 'test-guid', msgSeq: 0 })
+      .expect(500);
+
+    assert.strictEqual(res.body.ok, false);
+  });
+});

+ 65 - 0
lami-base-v1/doc/qiweapi-scrape/README.md

@@ -0,0 +1,65 @@
+# QiWe 官方文档爬取目录说明
+
+> 供领导要求的交付包 **「① 官方文档文件夹」** 使用。与 `doc/企微客户服务-官方接口按模块统计.md`(② 接口统计)配套。
+
+## 当前已有文件
+
+| 文件 | 来源 URL(约) | 状态 | 说明 |
+|------|----------------|------|------|
+| [callback-structure.md](./callback-structure.md) | https://doc.qiweapi.com/doc-7331304 | ✅ 可用 | 含 `cmd=15000`、`msgType=13`(链接)等回调字段与示例,**1175 行** |
+| [sync-history-messages.md](./sync-history-messages.md) | https://doc.qiweapi.com/api-344613926 | ❌ **爬取失败** | 内容为 Apifox 502 错误页,需 **重新爬取** |
+| [images/](./images/) | — | ✅ | 配图 |
+
+## 与 output 目录的关系
+
+| 文件 | 路径 | 说明 |
+|------|------|------|
+| 平台介绍(含 API 目录树) | `output/qiweapi-test/platform-intro.md` | 与 callback-structure 前部目录类似,可作总索引 |
+
+> 建议打 zip 时:**合并** `doc/qiweapi-scrape/` + `output/qiweapi-test/` 为 `01-官方文档/`,避免领导以为只有 2 个 md。
+
+## 建议补爬的页面(本项目要用、尚无独立 md)
+
+用仓库根目录 `fetch_url_to_md.py`:
+
+```bash
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/doc-7562288" -o doc/qiweapi-scrape --name quick-start
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-354411522" -o doc/qiweapi-scrape --name set-callback
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613926" -o doc/qiweapi-scrape --name sync-history-messages
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613881" -o doc/qiweapi-scrape --name room-page
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613882" -o doc/qiweapi-scrape --name room-detail-batch
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-437674162" -o doc/qiweapi-scrape --name room-member-change
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613883" -o doc/qiweapi-scrape --name room-create
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613890" -o doc/qiweapi-scrape --name room-notice
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613920" -o doc/qiweapi-scrape --name pin-list
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613921" -o doc/qiweapi-scrape --name pin-add
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613906" -o doc/qiweapi-scrape --name send-text
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613923" -o doc/qiweapi-scrape --name mass-send
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613924" -o doc/qiweapi-scrape --name mass-send-status
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613869" -o doc/qiweapi-scrape --name external-contact-page
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613868" -o doc/qiweapi-scrape --name contact-detail-batch
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613937" -o doc/qiweapi-scrape --name customer-tag
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-425758709" -o doc/qiweapi-scrape --name add-room-friend
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-347221662" -o doc/qiweapi-scrape --name user-status
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613850" -o doc/qiweapi-scrape --name create-device
+python fetch_url_to_md.py -u "https://doc.qiweapi.com/api-344613901" -o doc/qiweapi-scrape --name wework-file-download
+```
+
+爬取后检查 md 正文是否为接口说明(非 502/错误页)。
+
+**用语说明:**
+
+- 接口是否存在、怎么调,以 **https://doc.qiweapi.com/** 为准。  
+- 本文件夹只是「从官网另存下来的 md」,供打 zip;**不是**接口定义本身。  
+- 《官方接口按模块统计》主表已**不再**写「本地文档」列;离线副本清单见该文档**文末附录**。
+
+## 重新生成 zip 建议结构
+
+```text
+企微客户服务-官方资料包.zip
+├── 01-官方文档/
+│   ├── qiweapi-scrape/          (本目录)
+│   └── qiweapi-test/            (platform-intro 等)
+├── 02-官方接口按模块统计.md      (见 doc/企微客户服务-官方接口按模块统计.md)
+└── README.txt                   (说明①②含义、爬取日期)
+```

+ 656 - 0
lami-base-v1/doc/企微客户服务-功能开发清单.md

@@ -0,0 +1,656 @@
+# 企微客户服务 — 功能开发清单(开发人员用)
+
+> **文档类型:** 模块接口编号、PoC 状态、全项目接口去重(联调用)。  
+> **主文档(曾露要求:目录表 + 每条功能详细实现流程 + 交付文档清单 + 官方接口对照爬取文档):** [企微客户服务-功能实现说明.md](./企微客户服务-功能实现说明.md)  
+> **客户功能条目:** [企微客户服务-功能清单.md](./企微客户服务-功能清单.md)  
+> **仅官方接口统计(领导 zip):** [企微客户服务-官方接口按模块统计.md](./企微客户服务-官方接口按模块统计.md)  
+> **更新:** 2026-05-19
+
+---
+
+## 一、阅读说明
+
+| 标记 | 含义 |
+|------|------|
+| **QiWe** | `POST …/api/qw/doApi`,body `{ "method": "…", "params": {…} }` |
+| **本方** | 自研 REST / 定时任务 / DB(路径为建议名,实施可调整) |
+| **【占位】** | 文档未确认或须 PoC,**禁止编造 method/字段** |
+| **✅ PoC** | 本仓库已有可运行代码 |
+| **⚠️** | 须联调或仅部分场景 |
+| **❌** | 本期不做 QiWe 自动能力 |
+
+**被动推送(非 doApi):** QiWe → 本方 `POST /api/qiwei/webhook`,见 [回调结构](https://doc.qiweapi.com/doc-7331304)。
+
+---
+
+## 二、文档目录(按功能模块)
+
+| 序号 | 章节 | 模块名称 |
+|:----:|:----:|----------|
+| 1 | [§三](#三模块-1企微接入与消息基础模块) | 企微接入与消息基础模块 |
+| 2 | [§四](#四模块-2客户群与组织资产模块) | 客户群与组织资产模块 |
+| 3 | [§五](#五模块-3沟通记录在线文档模块) | 沟通记录在线文档模块 |
+| 4 | [§六](#六模块-4沟通记录合规检查模块) | 沟通记录合规检查模块 |
+| 5 | [§七](#七模块-5群风控与异常干预模块) | 群风控与异常干预模块 |
+| 6 | [§八](#八模块-6社群内容与运营执行模块) | 社群内容与运营执行模块 |
+| 7 | [§九](#九模块-7拉群koc-与意向客户模块) | 拉群、KOC 与意向客户模块 |
+| 8 | [§十](#十模块-8数据看板与经营复盘模块) | 数据看板与经营复盘模块 |
+| 9 | [§十一](#十一模块-9经营数据手工补录模块) | 经营数据手工补录模块 |
+| 10 | [§十二](#十二模块-10统一工作台与通知闭环模块) | 统一工作台与通知闭环模块 |
+| 11 | [§十三](#十三模块-11扩展能力模块二期) | 扩展能力模块(二期) |
+| — | [§十四](#十四全项目接口去重总表领导一览) | 全项目接口去重总表 |
+
+> **依赖关系:** 模块 1 为全局前置;模块 2~7 依赖 1;模块 4 依赖 2、3;模块 8 依赖 2~7、9;模块 10 聚合各模块告警与待办。
+
+---
+
+## 三、模块 1:企微接入与消息基础模块
+
+**业务概要:** 企业授权、人员账号纳入、在线提醒、群消息实时接收、历史补查、按人区分归属。
+
+### 3.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | method / 路径 | 文档 | 本模块用途 | 状态 |
+|:-:|------|----------|---------------|------|------------|------|
+| 1-01 | 控制台 | 租户 Token | Header `X-QIWEI-TOKEN` | [快速开始](https://doc.qiweapi.com/doc-7562288) | 所有 doApi 鉴权 | ✅ 配 `.env` |
+| 1-02 | QiWe | 创建设备(登录步骤 1) | 见文档页 | [api-344613850](https://doc.qiweapi.com/api-344613850) | 拿 `guid` | ✅ |
+| 1-03 | QiWe | 二维码-获取(步骤 2) | 见文档页 | [api-344613856](https://doc.qiweapi.com/api-344613856) | 扫码登录 | ✅ |
+| 1-04 | QiWe | 二维码状态-检测(步骤 3) | 见文档页 | [api-344613857](https://doc.qiweapi.com/api-344613857) | 扫码登录 | ✅ |
+| 1-05 | QiWe | 二维码-code 验证(步骤 4) | 见文档页 | [api-344613858](https://doc.qiweapi.com/api-344613858) | 扫码登录 | ✅ |
+| 1-06 | QiWe | 用户登录(免扫码,可选) | 见文档页 | [api-344613859](https://doc.qiweapi.com/api-344613859) | 运维免扫码 | 按需 |
+| 1-07 | QiWe | 设置回调地址 | `/client/setCallback` | [api-354411522](https://doc.qiweapi.com/api-354411522) | 实时收消息 | ✅ 待配 URL |
+| 1-08 | QiWe | 用户状态 | 见文档页 | [api-347221662](https://doc.qiweapi.com/api-347221662) | 掉线提醒 | ✅ 未编码 |
+| 1-09 | QiWe | 同步历史消息分页 | `/msg/syncMsg` | [api-344613926](https://doc.qiweapi.com/api-344613926) | 补历史 | ✅ 待联调 |
+| 1-10 | **本方** | 接收 QiWe 推送 | `POST /api/qiwei/webhook` | [回调结构](https://doc.qiweapi.com/doc-7331304) | 实时消息 | ✅ PoC |
+| 1-11 | **本方** | 触发历史同步 | `POST /api/qiwei/sync` | — | 封装 1-09 | ✅ PoC |
+| 1-12 | **本方** | 人员账号台账 | `【占位】GET/POST /api/staff-accounts` | — | guid↔角色/门店 | 待开发 |
+| 1-13 | **本方** | 群消息入库 | `【占位】` webhook 消费者写 `group_message` | — | 归属、统计、合规 | 待开发 |
+
+**本模块不单独接入:** 群列表、群发、联系人(属后续模块)。
+
+### 3.2 本模块端到端实现流程
+
+```text
+【一次性接入】
+  (1) 控制台申请 Token → 写入 .env(1-01)
+  (2) 对每个店长/设计师/运营:1-02 → 1-03~1-05 扫码 → 得到 guid
+  (3) 1-12 登记:guid、姓名、角色、门店
+  (4) 1-07 setCallback,callbackUrl = https://{域名}/api/qiwei/webhook
+  (5) 验证 1-10:用样例 JSON POST webhook,确认 200
+
+【运行时】
+  (6) QiWe 推送 cmd=15000 → 1-10 解析 → 1-13 落库(含 guid、fromRoomId、content、msgType、时间)
+  (7) 定时/手动:1-11 调 1-09,msgSeq 递增直到无新数据 → 同样写入 1-13
+  (8) 批量任务前:1-08 查 guid 在线;离线 → 1-08 或【占位】本方通知运维扫码
+
+【输出】
+  - staff_account 表:谁在管哪些 guid
+  - group_message 表:可按 guid、roomId 查询消息(供模块 4~7 使用)
+```
+
+### 3.3 各功能项实现说明
+
+| 功能模块具体功能 | 实现步骤 | 用到的接口 | 关键输入 → 输出 | 状态 |
+|------------------|----------|------------|-----------------|------|
+| 开通与企业企微的对接权限 | 控制台开 Key,配置环境变量 | 1-01 | API Key → `QIWEI_TOKEN` | ✅ |
+| 人员企微账号纳入管理 | 登录拿 guid + 写入台账 | 1-02~1-05、1-12 | 扫码 → `guid` + 角色 | 待开发 |
+| 账号是否在线提醒 | 定时任务调用户状态,离线告警 | 1-08、【占位】通知 | `guid` → 在线/离线 | 待开发 |
+| 客户群新消息及时收到 | 回调消费 + 入库 | 1-07、1-10、1-13 | 回调 JSON → 消息行 | ✅ PoC 收消息 |
+| 补查历史群聊天记录 | syncMsg 分页循环 | 1-09、1-11、1-13 | `guid,msgSeq` → `syncMsgList` | ✅ 待联调 |
+| 按人员区分群与消息归属 | 消息表带 `guid`;群表带负责人 | 1-13、1-12 | 每条消息绑定 guid | 待开发 |
+
+---
+
+## 四、模块 2:客户群与组织资产模块
+
+**业务概要:** 客户群登记/创建、拉群名单、群名人数、进退群、小区档案、小区—群—门店、群健康度。
+
+### 4.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | method / 路径 | 文档 | 本模块用途 | 状态 |
+|:-:|------|----------|---------------|------|------------|------|
+| 2-01 | QiWe | 群分页 | 见文档页 | [api-344613881](https://doc.qiweapi.com/api-344613881) | 拉全部群 ID | ✅ 待开发 |
+| 2-02 | QiWe | 群详情-批量 | 见文档页 | [api-344613882](https://doc.qiweapi.com/api-344613882) | 群名、人数 | ✅ 待开发 |
+| 2-03 | QiWe | 群成员变动查询 | 见文档页 | [api-437674162](https://doc.qiweapi.com/api-437674162) | 进退群 | ✅ 待开发 |
+| 2-04 | QiWe | 创建群 | 见文档页 | [api-344613883](https://doc.qiweapi.com/api-344613883) | 新建客户群 | ✅ 待开发 |
+| 2-05 | QiWe | 邀请/添加成员(按需) | 见文档页 | [api-344613887](https://doc.qiweapi.com/api-344613887) | 建群后拉人 | 按需 |
+| 2-06 | **本方** | 小区档案 CRUD | `【占位】/api/communities` | — | 户数、门店等 | 待开发 |
+| 2-07 | **本方** | 群—小区—门店绑定 | `【占位】POST/GET /api/rooms/bind` | — | 归属关系 | 待开发 |
+| 2-08 | **本方** | 群台账同步任务 | 定时调 2-01、2-02 写 `room` 表 | — | 本地群主数据 | 待开发 |
+| 2-09 | **本方** | 群健康度计算 | 定时聚合消息+进退群 | — | `healthScore` | 待开发 |
+
+**前置依赖:** 模块 1 的 1-01、1-08(在线)、1-13(消息量,供健康度)。
+
+### 4.2 本模块端到端实现流程
+
+```text
+(1) 定时任务(需 guid 在线):2-01 分页 → 得到 roomId 列表
+(2) 按规则过滤「外部客户群」(群名前缀 / 2-07 白名单)
+(3) 2-02 批量刷新群名、成员数 → 2-08 写入 room 表
+(4) 2-03 按时间窗拉变动 → 写 room_member_event(进/退/时间)
+(5) 运营维护 2-06 小区档案;导入或界面维护 2-07 绑定
+(6) 2-09:规则示例 — 7 日无消息降分、24h 退群>N 降分 → 排序展示
+
+【新建群路径】
+  人工/后台触发 2-04 → 返回 roomId → 立即 2-07 绑定小区 → 进入模块 3 建文档 SOP
+```
+
+### 4.3 各功能项实现说明
+
+| 功能模块具体功能 | 实现步骤 | 用到的接口 | 关键输入 → 输出 | 状态 |
+|------------------|----------|------------|-----------------|------|
+| 新建或登记外部客户群 | 创建或仅登记已有群 ID | 2-04、2-07、2-08 | 群参数 → `roomId` | 待开发 |
+| 自动拉取企微客户群名单 | 定时群分页+过滤 | 2-01、2-08 | 分页 → `room[]` | 待开发 |
+| 自动查看群名与群人数 | 群详情批量刷新 | 2-02、2-08 | `roomIds[]` → 名称/人数 | 待开发 |
+| 自动发现进群与退群 | 成员变动查询落库 | 2-03 | 群 ID+时间窗 → 事件列表 | 待开发 |
+| 录入小区基础档案 | 本方 CRUD | 2-06 | 表单 → `communityId` | 待开发 |
+| 维护「小区—群—门店」对应关系 | 导入/维护绑定表 | 2-07 | `roomId`、`communityId` | 待开发 |
+| 群健康度评估 | 规则引擎读消息+事件 | 2-09、1-13、2-03 | 规则 → 分数 | 待开发 |
+
+---
+
+## 五、模块 3:沟通记录在线文档模块
+
+**业务概要:** 每群一份在线沟通记录表、识别链接、一群一表、补登记、多表异常、公告链接、置顶。
+
+### 5.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | method / 路径 | 文档 | 本模块用途 | 状态 |
+|:-:|------|----------|---------------|------|------------|------|
+| 3-01 | QiWe | 同步历史消息(补登记) | `/msg/syncMsg` | [api-344613926](https://doc.qiweapi.com/api-344613926) | 漏回调补 docid | ✅ |
+| 3-02 | QiWe | 修改群公告 | 见文档页 | [api-344613890](https://doc.qiweapi.com/api-344613890) | 公告放文档链接 | ✅ 待开发 |
+| 3-03 | QiWe | 群消息置顶-添加 | 见文档页 | [api-344613921](https://doc.qiweapi.com/api-344613921) | 规范置顶 | ⚠️ PoC |
+| 3-04 | **本方** | Webhook 收消息 | `POST /api/qiwei/webhook` | [回调结构](https://doc.qiweapi.com/doc-7331304) | 实时发现链接 | ✅ PoC |
+| 3-05 | **本方** | 解析 docid | webhook 内逻辑 | — | `linkUrl` → `docid` | ✅ PoC |
+| 3-06 | **本方** | 群↔文档台账 | `GET /api/qiwei/room-docs` 等 | — | 一群一表 | ✅ PoC |
+| 3-07 | **本方** | 触发同步 | `POST /api/qiwei/sync` | — | 调 3-01 | ✅ PoC |
+| 3-08 | **本方** | 多 docid / 链接异常队列 | `【占位】/api/room-docs/anomalies` | — | 人工处理 | 待开发 |
+
+**人工 SOP(无接口):** 设计师在企微客户端创建在线文档并发送到群内。
+
+### 5.2 本模块端到端实现流程
+
+```text
+【发现与登记】
+  (1) 3-04 收到 cmd=15000,且 msgType=13,linkUrl 含 doc.weixin.qq.com
+  (2) 3-05 解析 docid;取 fromRoomId 为 roomId
+  (3) 3-06:若该 roomId 无记录 → insert;若已有且 docid 不同 → 3-08 标「多表异常」
+  (4) 定时 3-07 → 3-01 扫历史,对未登记群执行 (2)(3)
+
+【规范项(可选/待 PoC)】
+  (5) 运营 SOP 或 3-03:置顶文档消息
+  (6) 3-02:群公告写入文档 URL(弱校验用)
+
+【链接无法识别】
+  (7) 疑似链接但解析失败 → 3-08 待人工队列 + 【占位】通知接口
+```
+
+**解析位置(开发固定):**
+
+```text
+回调 data[] → cmd=15000 → msgType=13 → msgData.linkUrl → docid
+```
+
+### 5.3 各功能项实现说明
+
+| 功能模块具体功能 | 实现步骤 | 用到的接口 | 关键输入 → 输出 | 状态 |
+|------------------|----------|------------|-----------------|------|
+| 每个客户群有一份沟通记录在线文档 | 管理制度 + 人工发文档 | —(SOP) | — | 流程非代码 |
+| 认出群里发的在线文档链接 | webhook 判 msgType=13 | 3-04、3-05 | `linkUrl` → `docid` | ✅ PoC |
+| 自动登记「哪个群对应哪张表」 | 写台账,约束一群一表 | 3-06 | `roomId,docid,guid` | ✅ PoC |
+| 一个群里出现多张表时提醒 | 第二 docid 写异常 | 3-06、3-08 | 多 docid → 告警 | 待开发 |
+| 漏记时用历史聊天补登记 | sync 扫 link | 3-01、3-07、3-05、3-06 | 历史列表 → 补登 | 待联调 |
+| 链接异常时提醒人工处理 | 解析失败入队 | 3-08 | 异常 URL → 待办 | 待开发 |
+| 在群公告里写上文档链接 | 调群公告接口或人工 | 3-02 | 公告文本 | 待开发 |
+| 把沟通记录文档在群里置顶 | 置顶添加或人工 | 3-03 | 群 ID+消息 ID | ⚠️ PoC |
+
+---
+
+## 六、模块 4:沟通记录合规检查模块
+
+**业务概要:** 全量群对比缺表、历史是否发过表、置顶/公告检查、读文档正文、格式/必填/简略/漏记、逐条对表试点、汇总报表。
+
+### 6.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | method / 路径 | 文档 | 本模块用途 | 状态 |
+|:-:|------|----------|---------------|------|------------|------|
+| 4-01 | QiWe | 群分页 | 见文档页 | [api-344613881](https://doc.qiweapi.com/api-344613881) | 全量外部群 | ✅ |
+| 4-02 | QiWe | 群详情-批量 | 见文档页 | [api-344613882](https://doc.qiweapi.com/api-344613882) | 公告文本 | ✅ |
+| 4-03 | QiWe | 群消息置顶-列表 | 见文档页 | [api-344613920](https://doc.qiweapi.com/api-344613920) | 验置顶 | ⚠️ PoC |
+| 4-04 | QiWe | 修改群公告(读公告时) | 见文档页 | [api-344613890](https://doc.qiweapi.com/api-344613890) | 公告含链接 | ✅ |
+| 4-05 | QiWe | 同步历史消息 | `/msg/syncMsg` | [api-344613926](https://doc.qiweapi.com/api-344613926) | 是否曾发文档 | ✅ |
+| 4-06 | QiWe | **读在线文档正文** | **【占位】** 文档站未确认专用接口 | — | 格式/必填检查 | ⚠️ 禁止乱写 |
+| 4-07 | QiWe | 候选:企微文件下载 | 见文档页 | [api-344613901](https://doc.qiweapi.com/api-344613901) | 仅 PoC 验证是否可用 | ⚠️ |
+| 4-08 | QiWe | 发送纯文本消息 | 见文档页 | [api-344613906](https://doc.qiweapi.com/api-344613906) | 整改通知 | ✅ 待开发 |
+| 4-09 | **本方** | 群↔文档台账 | `GET /api/qiwei/room-docs` | — | 已登记群 | ✅ PoC |
+| 4-10 | **本方** | 格式粗筛 | `POST /api/qiwei/screen` | — | 必填/版式 | ✅ PoC |
+| 4-11 | **本方** | 群消息表 | 模块 1 的 `group_message` | — | 漏记/对表 | 待开发 |
+| 4-12 | **本方** | 合规巡检任务 | `【占位】POST /api/compliance/scan` | — | 定时跑 D1~D7 | 待开发 |
+| 4-13 | **本方** | 合规汇总报表 | `【占位】GET /api/compliance/reports` | — | 统计看板 | 待开发 |
+| 4-14 | **本方** | 逐条对表(试点) | `【占位】` 规则/可选 LLM | — | 消息↔表行 | 试点 |
+
+**前置依赖:** 模块 2(群列表)、模块 3(台账)、模块 1(消息)。
+
+### 6.2 本模块端到端实现流程
+
+```text
+【定时巡检任务 4-12】
+  Step A — 缺表
+    4-01 全量 roomId(过滤外部群)
+    4-09 已登记 roomId
+    差集 → 缺表列表
+
+  Step B — 是否曾发过记录表(辅助)
+    4-05 或 4-11 中 msgType=13 历史是否存在
+
+  Step C — 置顶 / 公告
+    4-03 置顶列表是否含台账 docid/消息 id 【⚠️ PoC】
+    4-02/4-04 公告是否含 doc.weixin
+
+  Step D — 内容合规(依赖 4-06 确认)
+    对每个 docid:4-06【占位】或 4-07 试下载 → 正文
+    → 4-10 规则:必填列、日期格式、列数、字数过少
+    → 更新台账 updatedAt;超 N 天未更新 → 预警
+
+  Step E — 疑似漏记
+    4-11 今日群消息数 > 0 且 台账 updatedAt 早于今日 → 标记
+
+  Step F — 逐条对表(试点)
+    4-14:客户群文本消息 vs 表行【占位】逻辑,不承诺 100%
+
+  Step G — 输出与通知
+    4-13 汇总:达标/待整改/待确认
+    4-08 通知设计师/主管
+
+【新建群】
+  模块 2 同步到新 roomId 且 4-09 无记录 → 自动进缺表待办
+
+【不做】
+  群外沟通是否记入表 → ❌ 无接口、不实现
+```
+
+### 6.3 各功能项实现说明
+
+| 功能模块具体功能 | 实现步骤 | 用到的接口 | 关键输入 → 输出 | 状态 |
+|------------------|----------|------------|-----------------|------|
+| 列出全部外部客户群 | 群分页+过滤 | 4-01 | → `roomId[]` | 待开发 |
+| 找出「还没有沟通记录表」的群 | 全量−台账 | 4-01、4-09 | → 缺表清单 | 待开发 |
+| 查群里是否曾经发过记录表 | 扫历史 link 消息 | 4-05、4-11 | → 是/否 | 待开发 |
+| 查记录表是否已置顶 | 置顶列表对比 | 4-03、4-09 | → 是/否/待确认 | ⚠️ PoC |
+| 查群公告里是否有文档链接 | 群详情公告字段 | 4-02、4-04 | 文本匹配 | 待开发 |
+| 读取在线文档里的文字和表格 | 拉正文 | 4-06、【占位】4-07 | `docid` → 正文 | ⚠️ 占位 |
+| 必填项是否都填了 | screen 规则 | 4-10 | 依赖正文 | 部分 |
+| 日期、格式与表格版式是否规范 | screen 规则 | 4-10 | 依赖正文 | 部分 |
+| 记录表是否很久没更新 | 台账时间+阈值 | 4-09、4-12 | `updatedAt` → 预警 | 待开发 |
+| 内容写得是否太简略 | screen 字数/字段 | 4-10 | → 提示 | 部分 |
+| 内容不合规时提醒整改 | 发企微消息 | 4-08 | 文案+链接 | 待开发 |
+| 群里聊过但表没更新时提示 | 消息时间 vs 表更新 | 4-11、4-09 | → 疑似漏记 | 部分 |
+| 新建客户群自动纳入检查 | 新 room 进缺表 | 4-01、4-09、4-12 | 自动待办 | 待开发 |
+| 客户每说一句话都自动对表检查 | 试点管道 | 4-14、4-11 | 消息↔行 | 试点 |
+| 群外沟通是否记入表 | — | — | ❌ 不实现 | ❌ |
+| 合规情况汇总报表 | 聚合巡检结果 | 4-13 | → 报表 JSON | 待开发 |
+
+---
+
+## 七、模块 5:群风控与异常干预模块
+
+**业务概要:** 实时监听、词库、敏感词预警、阈值异常、企微提醒、工单、闭环、案例库。
+
+### 7.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | method / 路径 | 文档 | 状态 |
+|:-:|------|----------|---------------|------|------|
+| 5-01 | **本方** | Webhook 消息流 | `POST /api/qiwei/webhook` | 同模块 1 | ✅ PoC |
+| 5-02 | QiWe | 群成员变动查询 | 见文档页 | [api-437674162](https://doc.qiweapi.com/api-437674162) | ✅ |
+| 5-03 | QiWe | 发送纯文本消息 | 见文档页 | [api-344613906](https://doc.qiweapi.com/api-344613906) | ✅ |
+| 5-04 | **本方** | 风险词库 CRUD | `【占位】/api/risk-keywords` | — | 待开发 |
+| 5-05 | **本方** | 预警事件 | `【占位】/api/alerts` | — | 待开发 |
+| 5-06 | **本方** | 工单 | `【占位】/api/work-orders` | — | 待开发 |
+| 5-07 | **本方** | 案例知识库 | `【占位】/api/kb/cases` | — | 待开发 |
+
+### 7.2 本模块端到端实现流程
+
+```text
+(1) 5-01 消费者:每条群消息 text ∩ 5-04 词库 → 命中则 5-05 写 alert(类型=敏感词)
+(2) 定时:5-02 统计退群率 / 结合 1-13 零互动天数 → 5-05 alert(类型=阈值)
+(3) 5-05 创建 → 自动 5-06 工单(负责人、截止时间)
+(4) 5-03 向 userid 发预警文案(群名、小区、类型)
+(5) 人工处理 → 5-06 更新状态「已处理」→ 可选归档 5-07
+```
+
+### 7.3 各功能项实现说明
+
+| 功能模块具体功能 | 实现步骤 | 用到的接口 | 状态 |
+|------------------|----------|------------|------|
+| 实时监听群消息 | webhook 消费者 | 5-01 | ✅ |
+| 风险关键词库配置 | CRUD | 5-04 | 待开发 |
+| 命中敏感词自动预警 | 规则匹配 | 5-01、5-04、5-05 | 待开发 |
+| 人数骤降等阈值异常 | 变动+规则 | 5-02、5-05 | 待开发 |
+| 异常红字提醒到企微 | 发文本 | 5-03 | 待开发 |
+| 预警自动生成干预工单 | alert→工单 | 5-05、5-06 | 待开发 |
+| 工单处理与关闭 | 工单 API | 5-06 | 待开发 |
+| 异常处理案例知识库 | KB CRUD | 5-07 | 待开发 |
+
+---
+
+## 八、模块 6:社群内容与运营执行模块
+
+**业务概要:** 素材库、多群群发、发布识别、互动统计、发布时间建议、周计划、执行对照、未完成提醒。
+
+### 8.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | 文档 | 状态 |
+|:-:|------|----------|------|------|
+| 6-01 | QiWe | 群发消息 | [api-344613923](https://doc.qiweapi.com/api-344613923) | ✅ |
+| 6-02 | QiWe | 群发消息-状态查询 | [api-344613924](https://doc.qiweapi.com/api-344613924) | ✅ |
+| 6-03 | QiWe | 同步历史消息 / webhook | 同模块 1 | ✅ |
+| 6-04 | QiWe | 发送纯文本消息(提醒) | [api-344613906](https://doc.qiweapi.com/api-344613906) | ✅ |
+| 6-05 | **本方** | 素材库 | `【占位】/api/cms/materials` | 待开发 |
+| 6-06 | **本方** | 周度运营计划 | `【占位】/api/ops/plans` | 待开发 |
+| 6-07 | **本方** | 群发任务记录 | `【占位】/api/ops/broadcast-tasks` | 待开发 |
+| 6-08 | **本方** | 活跃时段统计 | `【占位】` 读 `group_message` 聚合 | 待开发 |
+
+### 8.2 本模块端到端实现流程
+
+```text
+(1) 6-05 上传话术/案例
+(2) 运营选群+内容 → 6-01 群发 → 6-07 记 taskId → 6-02 轮询状态
+(3) 6-06 录入周计划(内容、日期、群范围)
+(4) 对账:计划时间窗内查 6-07 或 6-03 消息(关键词/卡片)→ 已执行/未执行
+(5) 未执行 → 6-04 提醒责任人
+(6) 6-08:历史消息按小时聚合 → 推荐活跃时段(数据不足则空)
+(7) 发布后 N 小时消息量粗统计(6-03,不保证归因)
+```
+
+### 8.3 各功能项实现说明
+
+| 功能模块具体功能 | 用到的接口 | 状态 |
+|------------------|------------|------|
+| 话术与案例素材库 | 6-05 | 待开发 |
+| 向多个群一键群发 | 6-01、6-02、6-07 | 待开发 |
+| 识别群内是否已发规定运营内容 | 6-03、6-07 | 部分 |
+| 运营内容互动效果统计 | 6-03、6-08 | 部分 |
+| 发布时间建议 | 6-08 | 部分 |
+| 录入周度运营计划 | 6-06 | 待开发 |
+| 对照计划检查是否已执行 | 6-06、6-01、6-03 | 部分 |
+| 未完成项提醒 | 6-04 | 待开发 |
+
+---
+
+## 九、模块 7:拉群、KOC 与意向客户模块
+
+**业务概要:** 渠道、渠道效果、KOC 候选/标签、联系人档案、样板间目标、意向识别/分级/待办、群内加好友。
+
+### 9.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | 文档 | 状态 |
+|:-:|------|----------|------|------|
+| 7-01 | QiWe | 群成员变动查询 | [api-437674162](https://doc.qiweapi.com/api-437674162) | ✅ |
+| 7-02 | QiWe | 外部联系人分页 | [api-344613869](https://doc.qiweapi.com/api-344613869) | ✅ |
+| 7-03 | QiWe | 联系人详情-批量 | [api-344613868](https://doc.qiweapi.com/api-344613868) | ✅ |
+| 7-04 | QiWe | 客户标签-增删 | [api-344613937](https://doc.qiweapi.com/api-344613937) | ✅ |
+| 7-05 | QiWe | 添加群成员好友 | [api-425758709](https://doc.qiweapi.com/api-425758709) | ⚠️ PoC |
+| 7-06 | QiWe | 发送纯文本消息 | [api-344613906](https://doc.qiweapi.com/api-344613906) | ✅ |
+| 7-07 | **本方** | 拉群渠道 | `【占位】/api/channels` | 待开发 |
+| 7-08 | **本方** | KOC 候选/确认 | `【占位】/api/koc/candidates` | 待开发 |
+| 7-09 | **本方** | 样板间/激励目标 | `【占位】/api/targets` | 待开发 |
+| 7-10 | **本方** | 意向客户/待办 | `【占位】/api/intents` | 待开发 |
+| 7-11 | **本方** | 群消息(发言统计) | 模块 1 `group_message` | 待开发 |
+
+### 9.2 本模块端到端实现流程
+
+```text
+(1) 7-07 维护渠道元数据;进群事件 7-01 落库时【占位】人工/字段标注 channelId
+(2) 7-11 按成员聚合发言 → 7-08 规则出候选 → 人工确认 → 7-04 打 KOC 标签
+(3) 7-02、7-03 拉档案展示
+(4) 7-09 维护目标值;看板读完成率(模块 8)
+(5) 7-11 关键词抓咨询句 → 7-10 分级 → 高意向 7-10 待办 + 7-06 通知销售
+(6) 7-05 加好友:客户确认+PoC 通过后启用
+```
+
+### 9.3 各功能项实现说明
+
+| 功能模块具体功能 | 用到的接口 | 状态 |
+|------------------|------------|------|
+| 拉群渠道登记 | 7-07 | 待开发 |
+| 各渠道拉群效果统计 | 7-01、7-07 | 部分 |
+| 自动筛选 KOC 候选人 | 7-11、7-08 | 部分 |
+| 在企微给客户打 KOC 标签 | 7-04 | 待开发 |
+| 查看外部联系人档案 | 7-02、7-03 | 待开发 |
+| 样板间与激励目标维护 | 7-09 | 待开发 |
+| 识别群内咨询类话术 | 7-11 | 待开发 |
+| 意向高/中/低分级 | 7-10 | 部分 |
+| 生成跟进待办并通知销售 | 7-10、7-06 | 待开发 |
+| 从群内发起添加好友 | 7-05 | ⚠️ PoC |
+
+---
+
+## 十、模块 8:数据看板与经营复盘模块
+
+**业务概要:** 总览/小区/门店看板、活跃度、触达率、下钻、链接率/转化展示、合规统计、周月报。
+
+### 10.1 本模块需接入的接口(完整清单)
+
+> **说明:** 看板以**本方聚合 API** 为主;**读**各模块已落库数据,**不新增** QiWe 调用(除间接依赖模块 1~4 已同步的数据)。
+
+| # | 类型 | 接口名称 | 路径 / 数据来源 | 状态 |
+|:-:|------|----------|-----------------|------|
+| 8-01 | **本方** | 总览看板 | `【占位】GET /api/dashboard/overview` | 待开发 |
+| 8-02 | **本方** | 小区看板 | `【占位】GET /api/dashboard/communities/:id` | 待开发 |
+| 8-03 | **本方** | 门店看板 | `【占位】GET /api/dashboard/stores/:id` | 待开发 |
+| 8-04 | **本方** | 图表下钻明细 | `【占位】GET /api/dashboard/drill-down` | 待开发 |
+| 8-05 | **本方** | 周月报生成 | `【占位】GET /api/reports/weekly` | 待开发 |
+| 8-06 | 数据依赖 | 群/成员/消息 | 模块 2 的 `room`、2-03;模块 1 的 `group_message` | — |
+| 8-07 | 数据依赖 | 合规汇总 | 模块 4 的 `4-13` | — |
+| 8-08 | 数据依赖 | 经营填报 | 模块 9 | — |
+| 8-09 | 外部 | CRM 签单数据 | `【占位】` 对接协议未定 | ⚠️ |
+
+### 10.2 本模块端到端实现流程
+
+```text
+(1) 定时/按需聚合 8-06:群总数、人数、活跃群占比、activeIndex
+(2) 触达率 = Σ群人数 / 模块2小区户数(户数仅本方 2-06)
+(3) 8-07 合入合规块:缺表数、待整改数
+(4) 链接率/转化:读模块 9 填报;若有 8-09 则合并
+(5) 8-01~8-03 按维度返回;8-04 支持 roomId、userid 下钻
+(6) 8-05:自动块(群/活跃/预警/合规)+ 转化块(无填报则标注缺失)
+```
+
+### 10.3 各功能项实现说明
+
+| 功能模块具体功能 | 用到的接口 | 状态 |
+|------------------|------------|------|
+| 总览看板 | 8-01、8-06 | 待开发 |
+| 小区看板 | 8-02、8-06、2-06 | 待开发 |
+| 门店看板 | 8-03 | 待开发 |
+| 群活跃度指数 | 8-06(消息聚合) | 待开发 |
+| 小区触达率 | 8-02、2-06、2-02 | 部分 |
+| 图表下钻到群与人 | 8-04 | 待开发 |
+| 展示添加微信与链接率 | 8-01、模块 9 | 部分 |
+| 展示签单与转化率 | 8-01、模块 9、8-09 | 部分 |
+| 周月报自动汇总与转化补录 | 8-05、8-07、模块 9 | 部分 |
+
+---
+
+## 十一、模块 9:经营数据手工补录模块
+
+**业务概要:** 添加数、订单转化、客户档案跟进、直播观看——**全部本方 API**,无 QiWe 接口。
+
+### 11.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | 建议路径 | 状态 |
+|:-:|------|----------|----------|------|
+| 9-01 | **本方** | 销售添加客户数填报 | `【占位】POST/GET /api/sales/leads` | 待开发 |
+| 9-02 | **本方** | 订单与转化填报 | `【占位】POST/GET /api/sales/orders` | 待开发 |
+| 9-03 | **本方** | 客户档案与跟进 | `【占位】/api/customers`、`/api/follow-ups` | 待开发 |
+| 9-04 | **本方** | 直播观看数据 | `【占位】POST /api/sales/live-stats` | 待开发 |
+
+### 11.2 本模块端到端实现流程
+
+```text
+(1) 前端表单 → 9-01~9-04 写入 DB(带销售、门店、日期、小区可选)
+(2) 模块 8 看板只读查询,计算链接率 = 添加数/群人数等(公式产品定)
+(3) 权限:仅销售/店长可写,主管可读
+```
+
+### 11.3 各功能项实现说明
+
+| 功能模块具体功能 | 用到的接口 | 状态 |
+|------------------|------------|------|
+| 销售添加客户数填报 | 9-01 | 待开发 |
+| 订单与转化数据填报 | 9-02 | 待开发 |
+| 简化版客户档案与跟进登记 | 9-03 | 待开发 |
+| 直播观看数据填报 | 9-04 | 待开发 |
+
+---
+
+## 十二、模块 10:统一工作台与通知闭环模块
+
+**业务概要:** 角色界面、预警与业务同屏、整改/汇总通知、问题分类、复检关闭、审计留痕。
+
+### 12.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | 说明 | 状态 |
+|:-:|------|----------|------|------|
+| 10-01 | QiWe | 发送纯文本消息 | [api-344613906](https://doc.qiweapi.com/api-344613906) | 整改/汇总推送 | ✅ |
+| 10-02 | **本方** | 工作台首页 | `【占位】GET /api/workbench/home` | 聚合待办 | 待开发 |
+| 10-03 | **本方** | 角色菜单/权限 | `【占位】` 对接 auth | RBAC | 待开发 |
+| 10-04 | **本方** | 待办聚合 | 读模块 4 `4-12`、模块 5 `5-05/5-06` | — | 待开发 |
+| 10-05 | **本方** | 复检触发 | `【占位】POST /api/compliance/recheck` | 调模块 4 子流程 | 待开发 |
+| 10-06 | **本方** | 审计日志 | `【占位】GET /api/audit-logs` | 消息/检查/通知时间线 | 待开发 |
+
+### 12.2 本模块端到端实现流程
+
+```text
+(1) 10-03 按角色返回菜单:店长/设计师/运营/新人
+(2) 10-02 一屏:合规待办 + 风控 alert + 运营未完成(10-04 聚合)
+(3) 问题单带 type:缺表|未置顶|格式|风控|漏记|…
+(4) 10-01 发整改(含 roomName、doc 链接);主管 10-01 发日/周汇总
+(5) 处理人改完后 10-05 触发复检 → 通过则关闭工单
+(6) 全程写 10-06:received_at、scanned_at、notified_at
+```
+
+### 12.3 各功能项实现说明
+
+| 功能模块具体功能 | 用到的接口 | 状态 |
+|------------------|------------|------|
+| 按角色与部门定制界面 | 10-03 | 待开发 |
+| 监控预警与日常业务同一屏 | 10-02、10-04 | 待开发 |
+| 给相关人员发整改通知 | 10-01 | 待开发 |
+| 给主管发汇总通知 | 10-01、模块 4 汇总 | 待开发 |
+| 问题分类展示 | 10-04 | 待开发 |
+| 整改后复检与问题关闭 | 10-05、模块 4 | 待开发 |
+| 操作与检查记录可追溯 | 10-06 | 待开发 |
+
+---
+
+## 十三、模块 11:扩展能力模块(二期)
+
+**业务概要:** 竞品/话题词库、舆情周报、勘探档案、开拓进度——建议二期;词库监测可复用模块 5 管道。
+
+### 13.1 本模块需接入的接口(完整清单)
+
+| # | 类型 | 接口名称 | 说明 | 状态 |
+|:-:|------|----------|------|------|
+| 11-01 | **本方** | 竞品/话题词库 | 扩展 `5-04` 或独立表 | 二期 |
+| 11-02 | QiWe | 同步历史消息 | 同 `4-05`,供统计 | 二期 |
+| 11-03 | **本方** | 话题周报 | `【占位】GET /api/reports/topic-weekly` | 二期 |
+| 11-04 | **本方** | 勘探/户型文件库 | `【占位】/api/sites/surveys` 上传 | 二期 |
+| 11-05 | **本方** | 开拓进度填报 | `【占位】/api/sites/progress` | 二期 |
+| 11-06 | **本方** | 深度 NLP 聚类 | **【占位】** 不宜一期承诺 | 二期 |
+
+### 13.2 本模块端到端实现流程
+
+```text
+(1) 11-01 配置词库 → 复用 5-01 消费者,输出话题命中统计
+(2) 11-02 + 11-03 按周聚合词频 → 基础周报(非行业级舆情)
+(3) 11-04 人工上传勘探/户型;11-05 表单填进度百分比
+```
+
+### 13.3 各功能项实现说明
+
+| 功能模块具体功能 | 用到的接口 | 状态 |
+|------------------|------------|------|
+| 竞品与话题词库 | 11-01 | 二期 |
+| 话题与舆情周报 | 11-02、11-03 | 部分/二期 |
+| 小区实地勘探与户型方案库 | 11-04 | 二期 |
+| 小区开拓进度填报 | 11-05 | 二期 |
+
+---
+
+## 十四、全项目接口去重总表(领导一览)
+
+> 从各模块 **§3.1、§4.1 … §13.1** 去重合并;对接时按模块章节看明细流程。
+
+### 14.1 QiWe 平台(doApi + 控制台)
+
+| 接口 | 文档 | 用于模块 |
+|------|------|----------|
+| 控制台 Token | [快速开始](https://doc.qiweapi.com/doc-7562288) | 1 |
+| 创建设备 + 二维码 856/857/858 | [850](https://doc.qiweapi.com/api-344613850) 等 | 1 |
+| 用户登录(免扫码) | [859](https://doc.qiweapi.com/api-344613859) | 1 按需 |
+| 设置回调 `/client/setCallback` | [354411522](https://doc.qiweapi.com/api-354411522) | 1 |
+| 用户状态 | [347221662](https://doc.qiweapi.com/api-347221662) | 1 |
+| 同步历史 `/msg/syncMsg` | [344613926](https://doc.qiweapi.com/api-344613926) | 1、3、4、6、11 |
+| 群分页 | [344613881](https://doc.qiweapi.com/api-344613881) | 2、4 |
+| 群详情-批量 | [344613882](https://doc.qiweapi.com/api-344613882) | 2、4 |
+| 群成员变动查询 | [437674162](https://doc.qiweapi.com/api-437674162) | 2、5、7 |
+| 创建群 | [344613883](https://doc.qiweapi.com/api-344613883) | 2 |
+| 邀请/添加成员 | [344613887](https://doc.qiweapi.com/api-344613887) | 2 按需 |
+| 修改群公告 | [344613890](https://doc.qiweapi.com/api-344613890) | 3、4 |
+| 群消息置顶-列表 | [344613920](https://doc.qiweapi.com/api-344613920) | 3、4 ⚠️ |
+| 群消息置顶-添加 | [344613921](https://doc.qiweapi.com/api-344613921) | 3 ⚠️ |
+| 发送纯文本消息 | [344613906](https://doc.qiweapi.com/api-344613906) | 4、5、6、7、10 |
+| 群发消息 + 状态查询 | [923](https://doc.qiweapi.com/api-344613923) · [924](https://doc.qiweapi.com/api-344613924) | 6 |
+| 外部联系人分页 | [344613869](https://doc.qiweapi.com/api-344613869) | 7 |
+| 联系人详情-批量 | [344613868](https://doc.qiweapi.com/api-344613868) | 7 |
+| 客户标签-增删 | [344613937](https://doc.qiweapi.com/api-344613937) | 7 |
+| 添加群成员好友 | [425758709](https://doc.qiweapi.com/api-425758709) | 7 ⚠️ |
+| **读在线文档正文** | **【占位】** | 4 |
+| 企微文件下载(候选) | [344613901](https://doc.qiweapi.com/api-344613901) | 4 ⚠️ PoC |
+
+### 14.2 本方服务(已实现 + 待建)
+
+| 路径 | 用于模块 | 状态 |
+|------|----------|------|
+| `POST /api/qiwei/webhook` | 1、3、5、6 | ✅ PoC |
+| `POST /api/qiwei/sync` | 1、3 | ✅ PoC |
+| `GET /api/qiwei/room-docs` | 3、4 | ✅ PoC |
+| `POST /api/qiwei/screen` | 4 | ✅ PoC |
+| `【占位】/api/staff-accounts` | 1 | 待建 |
+| `【占位】group_message` 消费 | 1、4、6、7 | 待建 |
+| `【占位】/api/communities`、`/api/rooms/bind` | 2、8 | 待建 |
+| `【占位】/api/risk-keywords`、`/api/alerts`、`/api/work-orders` | 5、10 | 待建 |
+| `【占位】/api/cms/*`、`/api/ops/*` | 6 | 待建 |
+| `【占位】/api/channels`、`/api/koc/*`、`/api/intents` | 7 | 待建 |
+| `【占位】/api/dashboard/*`、`/api/reports/*` | 8、11 | 待建 |
+| `【占位】/api/sales/*` | 9、8 | 待建 |
+| `【占位】/api/compliance/*` | 4、10 | 待建 |
+| `【占位】/api/workbench/*`、`/api/audit-logs` | 10 | 待建 |
+
+### 14.3 待确认项(禁止擅自实现)
+
+| 编号 | 事项 | 影响模块 |
+|:----:|------|----------|
+| T-1 | 在线文档正文接口(4-06) | 4 |
+| T-2 | 置顶列表/添加 PoC(4-03、3-03) | 3、4 |
+| T-3 | 群内加好友(7-05) | 7 |
+| T-4 | 逐条对表试点(4-14) | 4 |
+| T-5 | CRM 对接(8-09) | 8 |
+
+---
+
+## 十五、联调与 PoC
+
+| 项 | 说明 |
+|----|------|
+| 代码 | `backend/src/apps/qiwei/` |
+| 测试 | `cd backend && pnpm test` |
+| 样例回调 | `backend/tests/fixtures/qiwei_callbacks_sample.json` |
+| 指南 | [会话存档-文档发现-测试指南.md](./文档/会话存档-文档发现-测试指南.md) |
+
+**建议联调顺序:** 模块 1(webhook)→ 模块 3(docid/台账)→ 模块 2(群列表)→ 模块 4(巡检)→ 其余模块。
+
+---
+
+**维护说明:** 每个大模块以 **§X.1 接口清单** 为对接依据,**§X.2 流程** 为实现顺序,**§X.3** 与客户功能清单一一对应;新增 QiWe 接口须能在 [doc.qiweapi.com](https://doc.qiweapi.com/) 找到文档页后再写入对应 §X.1。

+ 119 - 0
lami-base-v1/doc/企微客户服务-功能清单.md

@@ -0,0 +1,119 @@
+# 企微客户服务 — 功能清单
+
+> 面向业务与管理人员的说明:下表为 **一张总表**,四栏列出全部功能;文首 **大模块一览** 为目录。模块划分见 [企微客户服务-功能模块划分.md](./文档/企微客户服务-功能模块划分.md)。  
+> **「部分可以实现」** 在「是否能实现」列标明 **可实现 / 不可实现** 分别指什么。  
+> **为什么能够实现:** 见 [企微客户服务-功能可实现性说明.md](./文档/企微客户服务-功能可实现性说明.md)  
+> **开发人员(目录表 + 逐条实现流程 + 官方接口 + 交付文档清单):** 见 [企微客户服务-功能实现说明.md](./企微客户服务-功能实现说明.md)  
+> **接口编号与 PoC 索引:** 见 [企微客户服务-功能开发清单.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 后** 展示转化率|**不可实现:** **不能** 仅从企微群聊自动得出签单与转化 |
+| 数据看板与经营复盘模块 | 周月报自动汇总与转化补录 | 自动汇总群、活跃、预警、合规等已有指标,并形成复盘报告。 | 部分可以实现|**可实现:** 自动汇总 **群、活跃、预警、合规** 等已有数据|**不可实现:** **不录入转化数据** 却生成「含签单/添加数」的 **完整自动复盘** |
+| 经营数据手工补录模块 | 销售添加客户数填报 | 由销售登记添加人数,支撑链接率等指标。 | 可以实现 |
+| 经营数据手工补录模块 | 订单与转化数据填报 | 登记签单、转化等经营结果,补全看板与复盘。 | 可以实现 |
+| 经营数据手工补录模块 | 简化版客户档案与跟进登记 | 维护客户基本信息和手工登记的跟进记录。 | 可以实现 |
+| 经营数据手工补录模块 | 直播观看数据填报 | 登记直播观看人数等,补全看板。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 按角色与部门定制界面 | 店长、设计师、运营、新人等不同角色登录后看到各自关注的菜单与指标。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 监控预警与日常业务同一屏 | 异常预警与日常运营、合规检查入口在同一工作台,减少来回切换。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 给相关人员发整改通知 | 通过企微发送问题说明:哪个群、什么问题,必要时附上记录表链接。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 给主管发汇总通知 | 按天或按周汇总不合规群数量、风险事件等主要问题类型。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 问题分类展示 | 区分缺表、未置顶、格式不对、风控、疑似漏记等类型,一目了然。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 整改后复检与问题关闭 | 改完后可再查一遍;确认无误后,该条提醒可标记为「已处理」。 | 可以实现 |
+| 统一工作台与通知闭环模块 | 操作与检查记录可追溯 | 保留何时收到消息、何时检查、何时发过提醒等记录,方便事后查证。 | 可以实现 |
+| 扩展能力模块(二期) | 竞品与话题词库 | 配置竞品名、敏感话题等,用于群内监测。 | 可以实现(建议二期建设) |
+| 扩展能力模块(二期) | 话题与舆情周报 | 定期汇总群内热点话题与舆情倾向。 | 部分可以实现(建议二期)|**可实现:** 基于词库与消息量的 **基础周报**|**不可实现:** 深度话题聚类、行业级舆情研判 **不宜一期满额承诺** |
+| 扩展能力模块(二期) | 小区实地勘探与户型方案库 | 上传勘探记录、户型图、方案文档等档案。 | 可以实现(以人工录入为主) |
+| 扩展能力模块(二期) | 小区开拓进度填报 | 填报各小区开拓、样板间等进度百分比。 | 可以实现 |

+ 189 - 0
lami-base-v1/fetch_url_to_md.py

@@ -0,0 +1,189 @@
+"""
+将单个网页 HTML 正文转为 Markdown,并把正文中的图片下载到输出目录下的 images/。
+
+用法示例:
+  pip install requests beautifulsoup4 html2text
+  python fetch_url_to_md.py --url "https://example.com/page"
+  python fetch_url_to_md.py -u "https://example.com" -o doc/out --name my-page
+不传 --url 时,仍使用脚本内默认示例 URL(乐享 wiki)。
+"""
+import argparse
+import requests
+from bs4 import BeautifulSoup
+import html2text
+import re
+import os
+from urllib.parse import urljoin, unquote
+
+def fetch_page_content(url):
+    """获取页面内容"""
+    headers = {
+        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
+        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
+        "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8"
+    }
+    response = requests.get(url, headers=headers, timeout=30)
+    response.encoding = 'utf-8'
+    return response.text
+
+def download_image(img_url, base_url, download_folder="images"):
+    """下载图片并返回相对路径"""
+    if not img_url or img_url.startswith('data:'):
+        return None
+
+    try:
+        img_url = urljoin(base_url, img_url)
+
+        if not os.path.exists(download_folder):
+            os.makedirs(download_folder)
+
+        img_name = re.sub(r'[^\w\-_.]', '_', unquote(os.path.basename(img_url.split('?')[0])))
+        if not img_name or len(img_name) > 100:
+            img_name = str(hash(img_url)) + '.png'
+
+        img_path = os.path.join(download_folder, img_name)
+
+        if os.path.exists(img_path):
+            return img_path
+
+        img_response = requests.get(img_url, headers={"User-Agent": "Mozilla/5.0"}, timeout=15)
+        if img_response.status_code == 200:
+            with open(img_path, 'wb') as f:
+                f.write(img_response.content)
+            return img_path
+    except Exception as e:
+        print(f"下载图片失败 {img_url}: {e}")
+    return None
+
+def extract_content_with_images(html_content, base_url, download_folder="images"):
+    """提取页面内容,下载所有图片并替换路径"""
+    soup = BeautifulSoup(html_content, 'html.parser')
+
+    content_area = soup.find('div', class_='content')
+    if not content_area:
+        content_area = soup.find('div', class_='main-content') or soup.find('article') or soup.find('main') or soup.body
+
+    folder_name = os.path.basename(download_folder)
+    
+    for img in content_area.find_all('img'):
+        img_url = img.get('src') or img.get('data-src')
+        if img_url:
+            local_path = download_image(img_url, base_url, download_folder)
+            if local_path:
+                img['src'] = f"{folder_name}/{os.path.basename(local_path)}"
+                print(f"  下载图片: {img_url} -> {folder_name}/{os.path.basename(local_path)}")
+
+    return content_area if content_area else soup.body
+
+def html_to_markdown(content_element):
+    """将HTML内容转换为Markdown"""
+    h = html2text.HTML2Text()
+    h.ignore_links = False
+    h.ignore_images = False
+    h.ignore_tables = False
+    h.body_width = 0
+    h.mark_code = True
+    h.single_line_break = True
+    h.unicode_snob = True
+    h.skip_internal_links = False
+    h.inline_links = True
+    h.default_image_alt = "图片"
+
+    html_str = str(content_element)
+    html_str = re.sub(r'\sclass="[^"]*"', '', html_str)
+    html_str = re.sub(r'\sstyle="[^"]*"', '', html_str)
+
+    markdown = h.handle(html_str)
+    markdown = re.sub(r'\n{3,}', '\n\n', markdown)
+    markdown = re.sub(r'!\[image\]\([^)]+\)', lambda m: re.sub(r'\\+', '/', m.group(0)), markdown)
+    markdown = re.sub(r'!\[\]\((?!images/)', '![图片](images/', markdown)
+
+    return markdown
+
+def extract_title(html_content):
+    """提取页面标题"""
+    soup = BeautifulSoup(html_content, 'html.parser')
+    title_tag = soup.find('title') or soup.find('h1')
+    if title_tag:
+        return title_tag.get_text().strip()
+    return "未命名文档"
+
+def url_to_filename(url):
+    """从URL提取标题用于文件名"""
+    match = re.search(r'#([\w\-%]+)', url)
+    if match:
+        return unquote(match.group(1))
+    return "document"
+
+def save_markdown(content, title, output_path):
+    """保存Markdown文件"""
+    with open(output_path, 'w', encoding='utf-8') as f:
+        f.write(content)
+    print(f"文档已保存: {output_path}")
+
+def _safe_filename(name: str) -> str:
+    """Windows 等系统下可用的文件名(不含路径分隔符)。"""
+    name = name.strip() or "document"
+    for c in '<>:"/\\|?*':
+        name = name.replace(c, "_")
+    return name[:200] if len(name) > 200 else name
+
+
+def url_to_markdown(url, output_dir="output", filename_base=None):
+    """主函数:将 URL 对应页面正文转为 Markdown。"""
+    print(f"正在获取页面: {url}")
+
+    html_content = fetch_page_content(url)
+    title = extract_title(html_content)
+    print(f"页面标题: {title}")
+
+    print("正在提取内容并下载图片...")
+    content_element = extract_content_with_images(html_content, url, os.path.join(output_dir, "images"))
+
+    print("正在转换为Markdown...")
+    markdown_content = html_to_markdown(content_element)
+
+    title_tag = f"# {title}\n\n" if title else ""
+    full_content = title_tag + markdown_content
+
+    os.makedirs(output_dir, exist_ok=True)
+    base = filename_base if filename_base else url_to_filename(url)
+    filename = _safe_filename(base) + ".md"
+    output_path = os.path.join(output_dir, filename)
+
+    save_markdown(full_content, title, output_path)
+
+    return output_path
+
+
+if __name__ == "__main__":
+    default_url = "https://lexiang.tencent.com/wiki/api/#获取-appkey-和-appsecret"
+
+    parser = argparse.ArgumentParser(description="抓取单个网页并保存为 Markdown(含图片到 images/)")
+    parser.add_argument(
+        "-u",
+        "--url",
+        default=None,
+        help=f"要抓取的页面 URL(省略则使用内置示例:乐享 wiki)",
+    )
+    parser.add_argument(
+        "-o",
+        "--output",
+        default="lexiang_output",
+        help="输出目录(会创建 images/ 子目录),默认 lexiang_output;若指定了 --url 未指定 -o 则用 output",
+    )
+    parser.add_argument(
+        "-n",
+        "--name",
+        default=None,
+        help="输出 .md 文件名(不含扩展名);默认从 URL 的 # 锚点或 document",
+    )
+    args = parser.parse_args()
+
+    target_url = args.url or default_url
+    out_dir = args.output
+    if args.url and args.output == "lexiang_output":
+        out_dir = "output"
+
+    output_file = url_to_markdown(target_url, out_dir, filename_base=args.name)
+    print(f"\n转换完成!输出文件: {output_file}")

+ 97 - 0
lami-base-v1/scripts/extract_qiwei_api.py

@@ -0,0 +1,97 @@
+import re
+import json
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+MD_DIR = ROOT / "doc" / "文档" / "QiWe开放平台文档" / "md"
+README = ROOT / "doc" / "文档" / "QiWe开放平台文档" / "README.md"
+
+API_FILES = {
+    "API-01": [],
+    "API-02": [
+        "创建设备(步骤1).md",
+        "二维码-获取(步骤2).md",
+        "二维码状态-检测(步骤3).md",
+        "二维码-code验证(步骤4).md",
+    ],
+    "API-03": ["设置回调地址.md"],
+    "API-04": ["回调结构说明.md"],
+    "API-05": ["用户状态.md"],
+    "API-06": ["同步历史消息分页.md"],
+    "API-07": ["群分页.md"],
+    "API-08": ["群详情-批量.md"],
+    "API-09": ["群成员变动查询.md"],
+    "API-10": ["创建群.md"],
+    "API-11": ["修改群公告.md"],
+    "API-12": ["群消息置顶-列表.md"],
+    "API-13": ["群消息置顶-添加.md"],
+    "API-14": ["发送纯文本消息.md"],
+    "API-15": ["群发消息.md"],
+    "API-16": ["群发消息-状态查询.md"],
+    "API-17": ["外部联系人分页.md"],
+    "API-18": ["联系人详情-批量.md"],
+    "API-19": ["客户标签-增删.md"],
+    "API-20": ["添加群成员好友.md"],
+    "API-21": ["企微文件下载.md"],
+}
+
+
+def extract(path: Path) -> dict:
+    if not path.exists():
+        return {}
+    text = path.read_text(encoding="utf-8")
+    out: dict = {}
+    mm = re.search(r'"method"\s*:\s*"([^"]+)"', text)
+    if mm:
+        out["method"] = mm.group(1)
+    blocks = re.findall(r"```\s*\n\s*(\{[\s\S]*?\})\s*\n\s*```", text)
+    for b in blocks:
+        if '"method"' in b and "params" in b:
+            out["request"] = b.strip()
+            break
+    for b in blocks:
+        if '"code"' in b and "data" in b and "method" not in b:
+            out["response"] = b.strip()
+            break
+  # notes from bullet lines
+    notes = [
+        ln.strip("•").strip()
+        for ln in text.splitlines()
+        if ln.strip().startswith("•") or ln.strip().startswith("-")
+    ]
+    if notes:
+        out["notes"] = notes[:5]
+    return out
+
+
+def main():
+    index = {}
+    for line in README.read_text(encoding="utf-8").splitlines():
+        m = re.match(r"- \[(.+?)\]\(md/(.+?)\) - (https://.+)", line)
+        if m:
+            index[m.group(2)] = {"title": m.group(1), "url": m.group(3)}
+
+    result = {}
+    for api, files in API_FILES.items():
+        result[api] = {"files": []}
+        for f in files:
+            p = MD_DIR / f
+            info = extract(p)
+            info["file"] = f
+            info["local"] = f"./文档/QiWe开放平台文档/md/{f}"
+            if f in index:
+                info["title"] = index[f]["title"]
+                info["url"] = index[f]["url"]
+            result[api]["files"].append(info)
+            if info.get("method") and "method" not in result[api]:
+                result[api]["method"] = info["method"]
+
+    out = ROOT / "doc" / "文档" / "qiwei_api_extracted.json"
+    out.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
+    print("wrote", out)
+    for api, data in result.items():
+        print(api, data.get("method", "-"), len(data["files"]))
+
+
+if __name__ == "__main__":
+    main()

+ 804 - 0
lami-base-v1/scripts/generate_impl_doc.py

@@ -0,0 +1,804 @@
+# -*- coding: utf-8 -*-
+"""根据 doc/文档/QiWe开放平台文档 重写 企微客户服务-功能实现说明.md"""
+from __future__ import annotations
+
+import json
+import re
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+OUT = ROOT / "doc" / "企微客户服务-功能实现说明.md"
+SRC = ROOT / "doc" / "企微客户服务-功能实现说明.md"
+QIWE_README = ROOT / "doc" / "文档" / "QiWe开放平台文档" / "README.md"
+DOC_BASE = "./文档/QiWe开放平台文档"
+
+COMMON_HEADERS = """| Header | 必填 | 说明 |
+|--------|:----:|------|
+| `Content-Type` | 是 | `application/json` |
+| `X-QIWEI-TOKEN` | 是 | 控制台申请的租户 Token,写入 `QIWEI_TOKEN` |"""
+
+DOAPI = """| 项 | 值 |
+|----|-----|
+| URL | `POST {QIWEI_BASE_URL}/api/qw/doApi`(默认 `http://manager.qiweapi.com/qiwe/api/qw/doApi`) |
+| Body 结构 | `{ "method": "<路径>", "params": { ... } }` |"""
+
+
+def compact_json(text: str) -> str:
+    return re.sub(r"\s+", " ", text.strip())
+
+
+def build_io_table(
+    method: str | None,
+    req_example: str | None,
+    resp_example: str | None,
+    req: list[tuple[str, str, str]],
+    resp: list[tuple[str, str, str]],
+    passive: bool = False,
+) -> str:
+    if passive and req_example and not req_example.strip().startswith("{"):
+        inp = req_example.strip()
+    elif passive:
+        inp = "无(QiWe POST → callbackUrl)"
+    elif req_example:
+        inp = compact_json(req_example)
+    elif method and method not in ("【占位】", "见官方页"):
+        m = method.split()[0]
+        keys = ", ".join(f'"{r[0]}": ""' for r in req[:8])
+        inp = compact_json(f'{{"method": "{m}", "params": {{ {keys} }}}}')
+    else:
+        inp = "—"
+
+    if resp_example:
+        out = compact_json(resp_example)
+    elif resp:
+        data_keys = ", ".join(f'"{r[0]}": ""' for r in resp[:8])
+        out = compact_json(f'{{"code": 0, "data": {{ {data_keys} }}, "msg": "成功"}}')
+    else:
+        out = "—"
+
+    # 单元格内用行内代码,避免表格嵌套代码块无法渲染
+    inp_cell = inp if inp == "—" else f"`{inp}`"
+    out_cell = out if out == "—" else f"`{out}`"
+    return "\n".join(
+        [
+            "| 输入 | 输出 |",
+            "|------|------|",
+            f"| {inp_cell} | {out_cell} |",
+            "",
+        ]
+    )
+
+
+def api_block(
+    api_id: str,
+    title: str,
+    method: str | None,
+    official: str,
+    local_md: str | None,
+    desc: str,
+    req: list[tuple[str, str, str]],
+    resp: list[tuple[str, str, str]],
+    req_example: str | None = None,
+    resp_example: str | None = None,
+    extra: str = "",
+    passive: bool = False,
+) -> str:
+    local_link = f"[{Path(local_md).stem}]({DOC_BASE}/md/{local_md})" if local_md else "—"
+    method_line = f"`{method}`" if method else "被动推送"
+    off_link = (
+        f"[官方]({official})"
+        if official.startswith("http")
+        else "—"
+    )
+    parts = [
+        f'<a id="{api_id.lower()}"></a>',
+        "",
+        f"### {api_id} {title}",
+        "",
+        f"| method | 官方 | 本地 |",
+        f"|--------|------|------|",
+        f"| {method_line} | {off_link} | {local_link} |",
+        "",
+    ]
+    if desc.strip():
+        parts += [desc.strip(), ""]
+    parts.append(build_io_table(method, req_example, resp_example, req, resp, passive))
+    if extra:
+        parts += [extra, ""]
+    return "\n".join(parts)
+
+
+APIS: list[dict] = []
+
+def add(**kw):
+    APIS.append(kw)
+
+
+add(
+    api_id="API-01",
+    title="租户 Token / 快速开始",
+    method=None,
+    official="https://doc.qiweapi.com/doc-7562288",
+    local_md=None,
+    desc="> 在 [QiWe 控制台](http://manager.qiweapi.com/login) 申请 API Key;无单独 method。",
+    req=[],
+    resp=[],
+    req_example='Header: X-QIWEI-TOKEN',
+    resp_example='{"code": 0, "msg": "success"}',
+    passive=True,
+)
+
+add(
+    api_id="API-02",
+    title="创建设备与扫码登录(四步)",
+    method="/client/createClient 等",
+    official="https://doc.qiweapi.com/api-344613850",
+    local_md="创建设备(步骤1).md",
+    desc="""人员账号纳入系统需完成登录闭环(亦可控制台在线登录直接拿 `guid`):
+
+| 步骤 | 接口文档 | method(以官方为准) |
+|:----:|----------|---------------------|
+| 1 | [创建设备](https://doc.qiweapi.com/api-344613850) | `/client/createClient` |
+| 2 | [二维码-获取](https://doc.qiweapi.com/api-344613856) | 见官方页 |
+| 3 | [二维码状态-检测](https://doc.qiweapi.com/api-344613857) | 见官方页 |
+| 4 | [二维码-code验证](https://doc.qiweapi.com/api-344613858) | 见官方页 |
+
+⚠️ 创建设备后 5 分钟内未登录,实例会被清理。""",
+    req=[
+        ("deviceName", "string", "设备名称(步骤1 必填)"),
+        ("deviceType", "integer", "0=ipad(推荐), 2=windows 等"),
+        ("clientVersion", "string", "客户端版本,一般可空"),
+        ("areaCode", "integer", "地区代理 ID,与登录地一致"),
+        ("proxyUrl", "string", "可选 socks5 代理"),
+        ("aid", "string", "可选本地 Aid 代理"),
+    ],
+    resp=[("guid", "string", "设备 ID,后续所有 params.guid")],
+    req_example="""{
+  "method": "/client/createClient",
+  "params": {
+    "deviceName": "店长-ipad",
+    "deviceType": 0,
+    "clientVersion": "",
+    "areaCode": 320000,
+    "proxyUrl": ""
+  }
+}""",
+    resp_example='{"code": 0, "data": {"guid": "a3318ad6-xxxx"}, "msg": "成功"}',
+)
+
+add(
+    api_id="API-03",
+    title="设置回调地址",
+    method="/client/setCallback",
+    official="https://doc.qiweapi.com/api-354411522",
+    local_md="设置回调地址.md",
+    desc="按 **Token** 配置回调;一个 Token 下所有账号共用。推送体含 `guid` 区分账号。",
+    req=[
+        ("callbackUrl", "string", "本方公网 URL,如 `https://{域名}/api/qiwei/webhook`"),
+        ("authType", "string", "如 `Authorization`"),
+        ("authSecret", "string", "回调鉴权密钥,可空"),
+    ],
+    resp=[],
+    req_example="""{
+  "method": "/client/setCallback",
+  "params": {
+    "callbackUrl": "https://your.domain/api/qiwei/webhook",
+    "authType": "Authorization",
+    "authSecret": ""
+  }
+}""",
+    resp_example='{"code": 0, "msg": "成功"}',
+)
+
+add(
+    api_id="API-04",
+    title="Webhook 回调结构(被动)",
+    method=None,
+    official="https://doc.qiweapi.com/doc-7331304",
+    local_md="回调结构说明.md",
+    desc="""QiWe **POST** 至 `callbackUrl`;`data[]` 含 `cmd`(15000 普通消息)、`guid`、`msgType`(13=链接)、`fromRoomId`、`msgData`。详见 [回调结构说明](%s/md/回调结构说明.md)。本项目:`cmd=15000` + `msgType=13` → 解析 `msgData.linkUrl` 发现沟通记录表。""" % DOC_BASE,
+    req=[],
+    resp=[
+        ("data[].cmd", "integer", "回调类型"),
+        ("data[].guid", "string", "账号"),
+        ("data[].msgType", "integer", "消息类型"),
+        ("data[].fromRoomId", "string", "群 ID"),
+        ("data[].msgData", "object", "消息内容"),
+    ],
+    passive=True,
+    resp_example="""{
+  "code": 0,
+  "data": [{
+    "cmd": 15000,
+    "guid": "xxx",
+    "msgType": 13,
+    "fromRoomId": "10791082xxxx",
+    "msgData": {
+      "title": "沟通记录",
+      "linkUrl": "https://doc.weixin.qq.com/doc/xxx?docid=YYY"
+    },
+    "timestamp": 1708324990
+  }],
+  "msg": "成功"
+}""",
+)
+
+add(
+    api_id="API-05",
+    title="用户状态(在线检测)",
+    method="/login/checkLogin",
+    official="https://doc.qiweapi.com/api-347221662",
+    local_md="用户状态.md",
+    desc="查询指定 `guid` 是否在线,批量任务前应调用。",
+    req=[("guid", "string", "设备 ID")],
+    resp=[
+        ("userOnlineStatus", "integer", "-1 需扫码;0 可免扫码;1 已扫码待确认;2 在线;4 取消;10 待验证码"),
+        ("userId", "string", "企微用户 ID"),
+        ("nickname", "string", "昵称"),
+        ("corpName", "string", "企业名称"),
+        ("lastActiveTime", "integer", "最后活跃时间"),
+    ],
+    req_example='{"method": "/login/checkLogin", "params": {"guid": "{{guid}}"}}',
+    resp_example="""{
+  "code": 0,
+  "data": {
+    "userOnlineStatus": 2,
+    "userId": "1688852****",
+    "nickname": "店长A",
+    "corpName": "某某公司"
+  },
+  "msg": "成功"
+}""",
+)
+
+add(
+    api_id="API-06",
+    title="同步历史消息分页",
+    method="/msg/syncMsg",
+    official="https://doc.qiweapi.com/api-344613926",
+    local_md="同步历史消息分页.md",
+    desc="补拉历史群聊;`msgSeq` 递增分页直至 `hasMore=0`。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("msgSeq", "integer", "游标,首次 0,下次用上次返回的 seq"),
+        ("limit", "integer", "每页条数,如 10~100"),
+    ],
+    resp=[
+        ("hasMore", "integer", "是否还有下一页"),
+        ("travelSyncKey", "integer", "同步游标"),
+        ("syncMsgList[]", "array", "消息列表,含 fromRoomId、msgType、msgData、seq 等"),
+    ],
+    req_example='{"method": "/msg/syncMsg", "params": {"guid": "{{guid}}", "msgSeq": 0, "limit": 50}}',
+    resp_example="""{
+  "code": 0,
+  "data": {
+    "hasMore": 1,
+    "travelSyncKey": 922174,
+    "syncMsgList": [{
+      "fromRoomId": 1023,
+      "msgType": 13,
+      "msgData": {"linkUrl": "https://doc.weixin.qq.com/..."},
+      "seq": 9221964,
+      "timestamp": 1708324990
+    }]
+  },
+  "msg": "成功"
+}""",
+)
+
+add(
+    api_id="API-07",
+    title="群分页",
+    method="/room/getRoomList",
+    official="https://doc.qiweapi.com/api-344613881",
+    local_md="群分页.md",
+    desc="仅查**本人创建**的群;查全部群需结合 [会话分页](https://doc.qiweapi.com/api-344613938)(`sessionType=1` 为群 id)。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("nextStartIndex", "integer", "分页游标,首次 0"),
+    ],
+    resp=[
+        ("hasMore", "integer", "是否有下一页"),
+        ("nextStartIndex", "integer", "下次请求传入"),
+        ("roomCount", "integer", "本页数量"),
+        ("roomList[].roomId", "string", "群 ID"),
+        ("roomList[].roomName", "string", "群名称"),
+        ("roomList[].roomMemberCount", "integer", "成员数"),
+    ],
+    req_example='{"method": "/room/getRoomList", "params": {"guid": "{{guid}}", "nextStartIndex": 0}}',
+)
+
+add(
+    api_id="API-08",
+    title="群详情-批量",
+    method="/room/batchGetRoomDetail",
+    official="https://doc.qiweapi.com/api-344613882",
+    local_md="群详情-批量.md",
+    desc="先 API-07 拿 `roomId`,再批量查详情;成员**显示名**需再调 API-18。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("roomIdList", "string[]", "群 ID 列表"),
+    ],
+    resp=[
+        ("roomList[].roomId", "string", "群 ID"),
+        ("roomList[].roomName", "string", "群名称"),
+        ("roomList[].roomAnnouncement", "string", "群公告(合规可匹配文档链接)"),
+        ("roomList[].memberList[]", "array", "成员列表 userId、joinTime 等"),
+    ],
+    req_example='{"method": "/room/batchGetRoomDetail", "params": {"guid": "{{guid}}", "roomIdList": ["10802031057945400"]}}',
+)
+
+add(
+    api_id="API-09",
+    title="群成员变动查询",
+    method="见官方页",
+    official="https://doc.qiweapi.com/api-437674162",
+    local_md="群成员变动查询.md",
+    desc="按群 + 时间窗查询进退群记录(method 以 [官方页](https://doc.qiweapi.com/api-437674162) 为准)。",
+    req=[("guid", "string", "设备 ID"), ("roomId", "string", "群 ID"), ("startTime/endTime", "integer", "时间窗(以官方为准)")],
+    resp=[("memberEvents[]", "array", "进退群事件列表(字段以官方为准)")],
+)
+
+add(
+    api_id="API-10",
+    title="创建群",
+    method="/room/createRoom",
+    official="https://doc.qiweapi.com/api-344613883",
+    local_md="创建群.md",
+    desc="新建外部客户群并返回 `roomId`。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("isOuterRoom", "integer", "1=外部群"),
+        ("memberList", "string[]", "初始成员 userId 列表"),
+    ],
+    resp=[
+        ("roomId", "string", "新群 ID"),
+        ("roomCreatetime", "integer", "创建时间"),
+        ("memberList", "string[]", "成员列表"),
+    ],
+    req_example="""{
+  "method": "/room/createRoom",
+  "params": {
+    "guid": "{{guid}}",
+    "isOuterRoom": 1,
+    "memberList": ["168885****57534"]
+  }
+}""",
+)
+
+add(
+    api_id="API-11",
+    title="修改群公告",
+    method="/room/modifyRoomNotice",
+    official="https://doc.qiweapi.com/api-344613890",
+    local_md="修改群公告.md",
+    desc="将沟通记录文档链接写入群公告。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("roomId", "string", "群 ID"),
+        ("notice", "string", "公告正文(可含文档 URL)"),
+    ],
+    resp=[("code", "integer", "0=成功")],
+    req_example='{"method": "/room/modifyRoomNotice", "params": {"guid": "{{guid}}", "roomId": "108144***", "notice": "沟通记录:https://doc.weixin.qq.com/..."}}',
+)
+
+add(
+    api_id="API-12",
+    title="群消息置顶-列表",
+    method="/msg/roomTopMessageList",
+    official="https://doc.qiweapi.com/api-344613920",
+    local_md="群消息置顶-列表.md",
+    desc="⚠️ **仅群主**可置顶;用于合规检查文档是否已置顶。",
+    req=[("guid", "string", "设备 ID"), ("roomId", "string", "群 ID")],
+    resp=[
+        ("list[].msgUniqueIdentifier", "string", "消息唯一标识"),
+        ("list[].msgType", "integer", "消息类型"),
+        ("list[].msgData", "object", "消息体"),
+        ("list[].senderId", "string", "发送人"),
+    ],
+    req_example='{"method": "/msg/roomTopMessageList", "params": {"guid": "{{guid}}", "roomId": "1088541******6"}}',
+)
+
+add(
+    api_id="API-13",
+    title="群消息置顶-添加",
+    method="/msg/roomTopMessageSet",
+    official="https://doc.qiweapi.com/api-344613921",
+    local_md="群消息置顶-添加.md",
+    desc="⚠️ PoC:须传原消息的 msgId、msgSenderId、msgTimestamp、msgType、msgData。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("roomId", "string", "群 ID"),
+        ("msgId", "string", "消息 id"),
+        ("msgSenderId", "string", "发送人 userId"),
+        ("msgTimestamp", "integer", "发送时间戳"),
+        ("msgType", "integer", "消息类型"),
+        ("msgData", "object", "如 `{ \"content\": \"...\" }`"),
+    ],
+    resp=[("code", "integer", "0=成功")],
+    req_example="""{
+  "method": "/msg/roomTopMessageSet",
+  "params": {
+    "guid": "{{guid}}",
+    "roomId": "10965*****579",
+    "msgId": "CIGABBDd*****",
+    "msgSenderId": "16888****804",
+    "msgTimestamp": 1752224990,
+    "msgType": 0,
+    "msgData": {"content": "沟通记录表链接"}
+  }
+}""",
+)
+
+add(
+    api_id="API-14",
+    title="发送纯文本消息",
+    method="/msg/sendText",
+    official="https://doc.qiweapi.com/api-344613906",
+    local_md="发送纯文本消息.md",
+    desc="整改通知、预警、待办提醒等推送到用户或群。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("content", "string", "文本内容"),
+        ("toId", "string", "用户 userId 或群 roomId"),
+        ("isNoNeedRead", "boolean", "可选,是否无需已读"),
+    ],
+    resp=[
+        ("isSendSuccess", "integer", "是否发送成功"),
+        ("msgServerId", "integer", "消息服务端 ID"),
+        ("msgUniqueIdentifier", "string", "消息唯一标识"),
+        ("seq", "integer", "序号"),
+    ],
+    req_example='{"method": "/msg/sendText", "params": {"guid": "{{guid}}", "content": "请更新沟通记录表", "toId": "168****768657", "isNoNeedRead": true}}',
+)
+
+add(
+    api_id="API-15",
+    title="群发消息",
+    method="/msg/sendGroupMsg",
+    official="https://doc.qiweapi.com/api-344613923",
+    local_md="群发消息.md",
+    desc="每天对每个客户/群仅可群发一次;`sendType`:0=外部联系人,1=外部群。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("sendType", "integer", "0 联系人 / 1 群"),
+        ("toIdList", "string[]", "接收方 ID 列表"),
+        ("msgList[]", "array", "消息列表,type:0 文本、13 链接、14 图片等"),
+    ],
+    resp=[("groupMsgId", "integer", "群发任务 ID,供 API-16 查询")],
+    req_example="""{
+  "method": "/msg/sendGroupMsg",
+  "params": {
+    "guid": "{{guid}}",
+    "sendType": 1,
+    "toIdList": ["10791082****"],
+    "msgList": [{"type": 0, "msgData": {"content": "本周运营内容"}}]
+  }
+}""",
+)
+
+add(
+    api_id="API-16",
+    title="群发消息-状态查询",
+    method="/msg/sendGroupMsgStatus",
+    official="https://doc.qiweapi.com/api-344613924",
+    local_md="群发消息-状态查询.md",
+    desc="根据 `groupMsgId` 轮询发送进度。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("groupMsgId", "string", "群发任务 ID"),
+        ("endDetailId", "integer", "分页游标"),
+    ],
+    resp=[
+        ("hasSend", "boolean", "是否已发送"),
+        ("isEnd", "boolean", "是否结束"),
+        ("total", "integer", "总数"),
+        ("customerList[]", "array", "各接收方状态"),
+    ],
+    req_example='{"method": "/msg/sendGroupMsgStatus", "params": {"guid": "{{guid}}", "groupMsgId": "115258331353230686", "endDetailId": 2}}',
+)
+
+add(
+    api_id="API-17",
+    title="外部联系人分页",
+    method="/contact/getWxContactList",
+    official="https://doc.qiweapi.com/api-344613869",
+    local_md="外部联系人分页.md",
+    desc="分页拉外部联系人;拿到 `userId` 后再调 API-18 查详情。建议落库后靠回调增量更新。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("currentSeq", "integer", "游标,首次 0"),
+        ("limit", "integer", "每页条数"),
+        ("bizType", "integer", "1=联系人变动;2=好友申请"),
+    ],
+    resp=[
+        ("hasMore", "boolean", "是否有下一页"),
+        ("currentSeq", "integer", "下次请求游标"),
+        ("contactList[].userId", "string", "用户 ID"),
+        ("contactList[].nickname", "string", "昵称"),
+        ("contactList[].remark", "string", "备注"),
+    ],
+    req_example='{"method": "/contact/getWxContactList", "params": {"guid": "{{guid}}", "currentSeq": 0, "limit": 50, "bizType": 1}}',
+)
+
+add(
+    api_id="API-18",
+    title="联系人详情-批量",
+    method="/contact/batchGetUserinfo",
+    official="https://doc.qiweapi.com/api-344613868",
+    local_md="联系人详情-批量.md",
+    desc="批量查联系人详情(含群成员真实姓名场景)。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("userIdList", "string[]", "用户 ID 列表"),
+    ],
+    resp=[
+        ("contactList[].userId", "string", "用户 ID"),
+        ("contactList[].nickname", "string", "昵称"),
+        ("contactList[].mobile", "string", "手机号"),
+        ("contactList[].avatarUrl", "string", "头像"),
+    ],
+    req_example='{"method": "/contact/batchGetUserinfo", "params": {"guid": "{{guid}}", "userIdList": ["168*****5548"]}}',
+)
+
+add(
+    api_id="API-19",
+    title="客户标签-增删",
+    method="/label/contactEditLabel",
+    official="https://doc.qiweapi.com/api-344613937",
+    local_md="客户标签-增删.md",
+    desc="`opType`:1=增加,2=删除;`labelIdList`/`labelSuperIdList`/`labelOwnerList` 须一一对应。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("opType", "integer", "1 增 / 2 删"),
+        ("paramList[].userId", "string", "客户 userId"),
+        ("paramList[].labelIdList", "string[]", "标签 ID"),
+    ],
+    resp=[("data", "array", "操作结果")],
+    req_example="""{
+  "method": "/label/contactEditLabel",
+  "params": {
+    "guid": "{{guid}}",
+    "opType": 1,
+    "paramList": [{
+      "userId": "78813023**",
+      "labelIdList": ["1407374973784***"],
+      "labelSuperIdList": ["1407375223060***"],
+      "labelOwnerList": ["168885236**"]
+    }]
+  }
+}""",
+)
+
+add(
+    api_id="API-20",
+    title="添加群成员好友",
+    method="/contact/addRoomContact",
+    official="https://doc.qiweapi.com/api-425758709",
+    local_md="添加群成员好友.md",
+    desc="⚠️ PoC:从群内发起加好友,须合规确认。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("roomId", "string", "群 ID"),
+        ("userId", "string", "目标成员 userId"),
+        ("verifyText", "string", "验证语"),
+    ],
+    resp=[("code", "integer", "0=成功")],
+    req_example='{"method": "/contact/addRoomContact", "params": {"guid": "{{guid}}", "roomId": "1079271***", "userId": "168885***", "verifyText": "您好"}}',
+)
+
+add(
+    api_id="API-21",
+    title="企微文件下载",
+    method="/cloud/wxWorkDownload",
+    official="https://doc.qiweapi.com/api-344613901",
+    local_md="企微文件下载.md",
+    desc="⚠️ 候选 PoC:从消息里的 fileId/fileAeskey 下载;返回临时 `cloudUrl`(7–15 天清理)。**不能替代「读在线文档正文」**。",
+    req=[
+        ("guid", "string", "设备 ID"),
+        ("fileId", "string", "文件 ID"),
+        ("fileAeskey", "string", "AES 密钥"),
+        ("fileSize", "integer", "文件大小"),
+        ("fileType", "integer", "1 大图 / 2 小图 / 4 视频 / 5 文件语音等"),
+    ],
+    resp=[("cloudUrl", "string", "临时下载地址")],
+    req_example='{"method": "/cloud/wxWorkDownload", "params": {"guid": "{{guid}}", "fileId": "...", "fileAeskey": "...", "fileSize": 32768, "fileType": 5}}',
+)
+
+add(
+    api_id="API-22",
+    title="读在线文档正文",
+    method="【占位】",
+    official="—",
+    local_md=None,
+    desc="**QiWe 开放平台当前无「按 docid 读取企微在线文档表格正文」的专用接口。** 合规读表须 PoC API-21 或等待官方能力;禁止编造 method。",
+    req=[],
+    resp=[],
+)
+
+
+def render_section_2() -> str:
+    lines = [
+        "## 二、QiWe 官方接口速查(含入参/出参)",
+        "",
+        "> 文档来源:[QiWe 开放平台文档索引](%s/README.md)(本地最新爬取,2026)。  " % DOC_BASE,
+        "> 各接口仅保留 **输入 / 输出** 一张表(报文示例);公共 Header 见 §2.0。",
+        "",
+        "### 2.0 统一调用方式",
+        "",
+        COMMON_HEADERS,
+        "",
+        DOAPI,
+        "",
+        "### 2.1 接口索引",
+        "",
+        "| 编号 | 接口 | method | 官方 | 本地 md |",
+        "|:----:|------|--------|------|---------|",
+    ]
+    for a in APIS:
+        local = f"[{a['local_md']}]({DOC_BASE}/md/{a['local_md']})" if a.get("local_md") else "—"
+        off = a["official"]
+        if off.startswith("http"):
+            off_cell = f"[在线]({off})"
+        else:
+            off_cell = "—"
+        m = a.get("method") or "被动"
+        lines.append(f"| {a['api_id']} | {a['title']} | `{m}` | {off_cell} | {local} |")
+    lines.append("")
+    lines.append("### 2.2 各接口详细说明")
+    lines.append("")
+    for a in APIS:
+        lines.append(
+            api_block(
+                a["api_id"],
+                a["title"],
+                a.get("method"),
+                a["official"],
+                a.get("local_md"),
+                a["desc"],
+                a.get("req", []),
+                a.get("resp", []),
+                a.get("req_example"),
+                a.get("resp_example"),
+                a.get("extra", ""),
+                passive=a.get("passive", False),
+            )
+        )
+    return "\n".join(lines)
+
+
+def api_ref(ids: list[str]) -> str:
+    links = []
+    for api_id in ids:
+        a = next(x for x in APIS if x["api_id"] == api_id)
+        links.append(f"[{api_id} {a['title']}](#{api_id.lower()})")
+    return "、".join(links) + "(详见 §二)"
+
+
+def enhance_feature_section(text: str) -> str:
+    """在功能小节中补充接口文档链接与入参出参指引"""
+    text = re.sub(r"\n\*\*接口(文档与)?入参/出参[^\*]*\*\*[^\n]+\n", "\n", text)
+    text = re.sub(
+        r"(\*\*接口入参/出参表:\*\*[^\n]+\n)(?:\1)+",
+        r"\1",
+        text,
+    )
+
+    def repl_official(m):
+        api = m.group(1)
+        a = next((x for x in APIS if x["api_id"] == api), None)
+        if not a:
+            return m.group(0)
+        block = f"\n\n**接口文档:** {api_ref([api])}\n"
+        if a.get("method") and a["method"] not in ("【占位】", "见官方页", None):
+            block += f"\n**method:** `{a['method']}`\n"
+        if a.get("req"):
+            block += "\n**主要入参:** " + "、".join(f"`{r[0]}`" for r in a["req"][:6])
+            if len(a["req"]) > 6:
+                block += " …"
+            block += "\n"
+        if a.get("resp"):
+            block += "**主要出参:** " + "、".join(f"`{r[0]}`" for r in a["resp"][:6])
+            if len(a["resp"]) > 6:
+                block += " …"
+            block += "\n"
+        return m.group(0) + block
+
+    # 在「需实现的 QiWe 官方接口」段落后注入
+    text = re.sub(
+        r"(\*\*需实现的 QiWe 官方接口[::]\*\*[^\n]*\n)",
+        lambda m: m.group(1) + inject_api_refs(m.group(1)),
+        text,
+    )
+    text = text.replace(
+        "[callback-structure.md](./qiweapi-scrape/callback-structure.md)",
+        f"[回调结构说明]({DOC_BASE}/md/回调结构说明.md)",
+    )
+    text = text.replace("sync 页需重爬", f"[同步历史消息分页]({DOC_BASE}/md/同步历史消息分页.md)")
+    text = text.replace("(爬取 md 需重爬)", f"(见 [{DOC_BASE}/md/同步历史消息分页.md]({DOC_BASE}/md/同步历史消息分页.md))")
+    text = re.sub(
+        r"\| 编号 \| 接口 \| 爬取文档 \|\n\|:----:\|------\|----------\|\n(?:\| API-\d+[^\n]+\n)+",
+        lambda m: m.group(0).replace("爬取文档", "文档").replace("见官方链接", "见 §二"),
+        text,
+    )
+    text = text.replace(
+        "[platform-intro.md](../output/qiweapi-test/platform-intro.md)",
+        f"[QiWe 开放平台文档索引]({DOC_BASE}/README.md)",
+    )
+    return text
+
+
+def inject_api_refs(line: str) -> str:
+    ids = re.findall(r"API-\d+", line)
+    if not ids:
+        return ""
+    return f"\n**接口入参/出参表:** {api_ref(ids)}\n"
+
+
+def main():
+    old = SRC.read_text(encoding="utf-8")
+    # 保留 §三 目录表 + §四~十四 模块正文
+    m = re.search(r"(## 三、功能目录总表[\s\S]*)", old)
+    tail = m.group(1) if m else ""
+    tail = enhance_feature_section(tail)
+
+    header = """# 企微客户服务 — 功能实现说明(开发用)
+
+> **文档定位:** 在 [功能清单](./企微客户服务-功能清单.md) **同一套功能条目**基础上,为开发人员补充:**每条功能的详细实现流程**、**QiWe 官方接口入参/出参**(对照 [QiWe 开放平台文档](./文档/QiWe开放平台文档/README.md))。  
+> **表格用法:** [§三 功能目录总表](#三功能目录总表) 当**目录**,点击「详述」跳到对应功能点。  
+> **接口速查:** [§二 QiWe 官方接口](#二qiwe-官方接口速查含入参出参) 含可点击的 method、params、响应字段。  
+> **仅官方接口汇总:** [官方接口按模块统计](./企微客户服务-官方接口按模块统计.md)  
+> **更新:** 2026-05-19
+
+---
+
+## 一、阅读说明
+
+| 标记 | 含义 |
+|------|------|
+| **QiWe 官方** | `POST {QIWEI_BASE_URL}/api/qw/doApi`,Header `X-QIWEI-TOKEN`,body `{ "method", "params" }` |
+| **本方** | 自研服务 / DB / 定时任务(路径为建议名) |
+| **【占位】** | 官方文档未提供专用接口(如读在线文档正文),**禁止编造 method** |
+| **本地文档** | [./文档/QiWe开放平台文档/](./文档/QiWe开放平台文档/README.md) 内 md,可离线查阅;与 [doc.qiweapi.com](https://doc.qiweapi.com/) 同步 |
+| **⚠️ PoC** | 须联调验证(置顶、加好友、文件下载等) |
+
+**统一请求示例:**
+
+```json
+{
+  "method": "/msg/syncMsg",
+  "params": {
+    "guid": "设备ID-来自登录",
+    "msgSeq": 0,
+    "limit": 50
+  }
+}
+```
+
+**统一响应外壳:**
+
+```json
+{
+  "code": 0,
+  "data": { },
+  "msg": "成功"
+}
+```
+
+---
+
+"""
+
+    section1_end = render_section_2()
+    out = header + section1_end + "\n---\n\n" + tail
+    out = re.sub(
+        r"\*\*维护:\*\*.*",
+        "**维护:** 官方接口变更时同步更新 [§二](#二qiwe-官方接口速查含入参出参) 与 [QiWe 开放平台文档](./文档/QiWe开放平台文档/README.md);新增功能先改 §三 目录,再补对应模块小节。",
+        out,
+    )
+    OUT.write_text(out, encoding="utf-8")
+    print("wrote", OUT, "lines", len(out.splitlines()))
+
+
+if __name__ == "__main__":
+    main()

+ 301 - 0
lami-base-v1/scripts/qiwei_doc_discovery.py

@@ -0,0 +1,301 @@
+"""
+QiWei:从「会话消息 / 历史同步 / 回调存档」中间接发现在线文档(提取 docid)。
+
+依据 doc.qiweapi.com:
+  - 回调 cmd=15000,链接消息 msgType=13,字段 linkUrl(见回调结构说明)
+  - 同步历史消息:method=/msg/syncMsg(见 api-344613926)
+
+用法:
+  # 1) 离线:解析已保存的回调 JSON
+  python scripts/qiwei_doc_discovery.py analyze --file scripts/samples/qiwei_callbacks_sample.json
+
+  # 2) 在线:拉历史消息并扫描(需环境变量,见 doc/会话存档-文档发现-测试指南.md)
+  python scripts/qiwei_doc_discovery.py sync --guid YOUR_GUID --msg-seq 0
+
+环境变量(sync 子命令):
+  QIWEI_TOKEN          必填
+  QIWEI_BASE_URL       默认 http://manager.qiweapi.com/qiwe
+  QIWEI_SYNC_METHOD    默认 /msg/syncMsg
+"""
+from __future__ import annotations
+
+import argparse
+import json
+import os
+import re
+import sys
+from dataclasses import asdict, dataclass
+from typing import Any, Iterator
+from urllib.parse import parse_qs, urlparse
+
+import requests
+
+# 企微在线文档 / 微盘链接常见形态
+DOC_URL_PATTERNS = (
+    re.compile(r"doc\.weixin\.qq\.com", re.I),
+    re.compile(r"doc\.work\.weixin\.qq\.com", re.I),
+    re.compile(r"wedoc", re.I),
+    re.compile(r"txdoc", re.I),
+)
+
+DOCID_QUERY_KEYS = ("docid", "doc_id", "docId")
+DOCID_PATH_RE = re.compile(
+    r"(?:docid|doc_id)[=/]([A-Za-z0-9_\-]+)", re.I
+)
+# 如 https://doc.weixin.qq.com/doc/DOC_OTHER_room2
+DOCID_PATH_SEGMENT_RE = re.compile(
+    r"doc\.weixin\.qq\.com/(?:txdoc/[^/?#]+|doc)/([A-Za-z0-9_\-]+)", re.I
+)
+
+
+@dataclass
+class DocRef:
+    docid: str | None
+    link_url: str
+    title: str | None
+    msg_type: int | None
+    room_id: str | int | None
+    guid: str | None
+    source: str  # callback | sync | file
+
+
+def extract_docid_from_url(url: str) -> str | None:
+    if not url or not isinstance(url, str):
+        return None
+    parsed = urlparse(url.strip())
+    qs = parse_qs(parsed.query)
+    for key in DOCID_QUERY_KEYS:
+        if key in qs and qs[key]:
+            return qs[key][0]
+    m = DOCID_PATH_RE.search(url)
+    if m:
+        return m.group(1)
+    m = DOCID_PATH_SEGMENT_RE.search(url)
+    if m:
+        return m.group(1)
+    # 部分链接 docid 在 fragment
+    if parsed.fragment:
+        m = DOCID_PATH_RE.search(parsed.fragment)
+        if m:
+            return m.group(1)
+    return None
+
+
+def is_probable_doc_url(url: str) -> bool:
+    if not url:
+        return False
+    return any(p.search(url) for p in DOC_URL_PATTERNS)
+
+
+def _walk(obj: Any) -> Iterator[tuple[str, Any]]:
+    if isinstance(obj, dict):
+        for k, v in obj.items():
+            yield k, v
+            yield from _walk(v)
+    elif isinstance(obj, list):
+        for item in obj:
+            yield from _walk(item)
+
+
+def extract_from_link_msgdata(msg_data: dict) -> DocRef | None:
+    link = msg_data.get("linkUrl") or msg_data.get("link_url")
+    if not link or not is_probable_doc_url(str(link)):
+        if link and "doc.weixin" not in str(link).lower():
+            return None
+        if not link:
+            return None
+    title = msg_data.get("title")
+    if isinstance(title, str) and title.isascii() and len(title) > 40:
+        try:
+            import base64
+
+            title = base64.b64decode(title).decode("utf-8", errors="replace")
+        except Exception:
+            pass
+    return DocRef(
+        docid=extract_docid_from_url(str(link)),
+        link_url=str(link),
+        title=str(title) if title else None,
+        msg_type=13,
+        room_id=None,
+        guid=None,
+        source="msgData",
+    )
+
+
+def extract_doc_refs_from_message_item(item: dict, source: str) -> list[DocRef]:
+    refs: list[DocRef] = []
+    msg_type = item.get("msgType")
+    room_id = item.get("fromRoomId") or item.get("roomId") or item.get("roomid")
+    guid = item.get("guid")
+    msg_data = item.get("msgData")
+
+    if isinstance(msg_data, dict):
+        if msg_type == 13 or "linkUrl" in msg_data or "link_url" in msg_data:
+            ref = extract_from_link_msgdata(msg_data)
+            if ref:
+                ref.msg_type = msg_type
+                ref.room_id = room_id
+                ref.guid = guid
+                ref.source = source
+                refs.append(ref)
+
+    # 兜底:整棵 JSON 里搜 doc.weixin 链接
+    for key, val in _walk(item):
+        if key.lower() in ("linkurl", "link_url", "url", "link") and isinstance(val, str):
+            if is_probable_doc_url(val) or "doc.weixin" in val.lower():
+                refs.append(
+                    DocRef(
+                        docid=extract_docid_from_url(val),
+                        link_url=val,
+                        title=None,
+                        msg_type=msg_type if isinstance(msg_type, int) else None,
+                        room_id=room_id,
+                        guid=guid,
+                        source=f"{source}:walk",
+                    )
+                )
+    return refs
+
+
+def extract_doc_refs_from_payload(payload: Any, source: str = "callback") -> list[DocRef]:
+    refs: list[DocRef] = []
+    if isinstance(payload, dict):
+        data = payload.get("data")
+        if isinstance(data, list):
+            for item in data:
+                if isinstance(item, dict):
+                    refs.extend(extract_doc_refs_from_message_item(item, source))
+        else:
+            refs.extend(extract_doc_refs_from_message_item(payload, source))
+    elif isinstance(payload, list):
+        for item in payload:
+            refs.extend(extract_doc_refs_from_payload(item, source))
+    # 去重(按 link_url)
+    seen: set[str] = set()
+    unique: list[DocRef] = []
+    for r in refs:
+        if r.link_url not in seen:
+            seen.add(r.link_url)
+            unique.append(r)
+    return unique
+
+
+def qiwei_do_api(
+    token: str,
+    method: str,
+    params: dict,
+    base_url: str | None = None,
+) -> dict:
+    base = (base_url or os.environ.get("QIWEI_BASE_URL") or "http://manager.qiweapi.com/qiwe").rstrip("/")
+    url = f"{base}/api/qw/doApi"
+    headers = {
+        "Content-Type": "application/json",
+        "X-QIWEI-TOKEN": token,
+    }
+    body = {"method": method, "params": params}
+    resp = requests.post(url, headers=headers, json=body, timeout=60)
+    resp.raise_for_status()
+    return resp.json()
+
+
+def sync_messages_and_scan(
+    token: str,
+    guid: str,
+    msg_seq: int = 0,
+    *,
+    base_url: str | None = None,
+    sync_method: str | None = None,
+    extra_params: dict | None = None,
+) -> tuple[dict, list[DocRef]]:
+    method = sync_method or os.environ.get("QIWEI_SYNC_METHOD", "/msg/syncMsg")
+    params: dict[str, Any] = {"guid": guid, "msgSeq": msg_seq}
+    if extra_params:
+        params.update(extra_params)
+    raw = qiwei_do_api(token, method, params, base_url)
+    refs: list[DocRef] = []
+    # 响应里可能是 data.syncMsgList 或 data 为列表
+    data = raw.get("data")
+    if isinstance(data, dict):
+        lst = data.get("syncMsgList") or data.get("list") or []
+        if isinstance(lst, list):
+            for item in lst:
+                if isinstance(item, dict):
+                    refs.extend(extract_doc_refs_from_message_item(item, "sync"))
+    elif isinstance(data, list):
+        for item in data:
+            if isinstance(item, dict):
+                refs.extend(extract_doc_refs_from_message_item(item, "sync"))
+    refs.extend(extract_doc_refs_from_payload(raw, "sync:raw"))
+    return raw, refs
+
+
+def cmd_analyze(args: argparse.Namespace) -> int:
+    path = args.file
+    with open(path, encoding="utf-8") as f:
+        payload = json.load(f)
+    refs = extract_doc_refs_from_payload(payload, "file")
+    print(f"文件: {path}")
+    print(f"发现文档相关消息: {len(refs)} 条\n")
+    for i, r in enumerate(refs, 1):
+        print(f"--- [{i}] ---")
+        print(json.dumps(asdict(r), ensure_ascii=False, indent=2))
+    if not refs:
+        print("未发现含 doc.weixin / docid 的链接。请确认样本中含 msgType=13 且 linkUrl 指向在线文档。")
+        return 1
+    missing = [r for r in refs if not r.docid]
+    if missing:
+        print(f"\n警告: {len(missing)} 条链接未能解析出 docid,需人工从 link_url 查看。")
+    return 0
+
+
+def cmd_sync(args: argparse.Namespace) -> int:
+    token = os.environ.get("QIWEI_TOKEN")
+    if not token:
+        print("请设置环境变量 QIWEI_TOKEN(控制台租户令牌)", file=sys.stderr)
+        return 2
+    guid = args.guid or os.environ.get("QIWEI_GUID")
+    if not guid:
+        print("请传入 --guid 或设置 QIWEI_GUID", file=sys.stderr)
+        return 2
+    extra = {}
+    if args.room_id:
+        extra["fromRoomId"] = args.room_id
+    raw, refs = sync_messages_and_scan(
+        token,
+        guid,
+        args.msg_seq,
+        extra_params=extra or None,
+    )
+    out_raw = args.out_raw or "output/qiwei_sync_last.json"
+    os.makedirs(os.path.dirname(out_raw) or ".", exist_ok=True)
+    with open(out_raw, "w", encoding="utf-8") as f:
+        json.dump(raw, f, ensure_ascii=False, indent=2)
+    print(f"原始响应已保存: {out_raw}")
+    print(f"扫描到文档相关消息: {len(refs)} 条\n")
+    for r in refs:
+        print(json.dumps(asdict(r), ensure_ascii=False))
+    return 0 if refs else 1
+
+
+def main() -> int:
+    parser = argparse.ArgumentParser(description="QiWei 会话消息中间接发现在线文档(docid)")
+    sub = parser.add_subparsers(dest="command", required=True)
+
+    p_analyze = sub.add_parser("analyze", help="解析本地回调/存档 JSON")
+    p_analyze.add_argument("--file", "-f", required=True, help="JSON 文件路径")
+    p_analyze.set_defaults(func=cmd_analyze)
+
+    p_sync = sub.add_parser("sync", help="调用 /msg/syncMsg 拉历史并扫描")
+    p_sync.add_argument("--guid", help="账号 guid")
+    p_sync.add_argument("--msg-seq", type=int, default=0, help="起始 msgSeq,首次可传 0")
+    p_sync.add_argument("--room-id", help="可选:仅关注某群 fromRoomId")
+    p_sync.add_argument("--out-raw", help="保存完整 API 响应的路径")
+    p_sync.set_defaults(func=cmd_sync)
+
+    args = parser.parse_args()
+    return args.func(args)
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 54 - 0
lami-base-v1/scripts/renumber_impl_doc.py

@@ -0,0 +1,54 @@
+import re
+from pathlib import Path
+
+path = Path(__file__).resolve().parents[1] / "doc" / "企微客户服务-功能实现说明.md"
+s = path.read_text(encoding="utf-8")
+
+CHAPTER_CN = {1: "四", 2: "五", 3: "六", 4: "七", 5: "八", 6: "九", 7: "十", 8: "十一", 9: "十二", 10: "十三", 11: "十四"}
+
+
+def slug_title(title: str) -> str:
+    t = title.strip().lower()
+    t = re.sub(r"[「」『』""''()()、,。!?::;;·]", "", t)
+    t = re.sub(r"\s+", "-", t)
+    t = re.sub(r"-+", "-", t).strip("-")
+    return t
+
+
+# Fix module chapter headers (all were incorrectly collapsed to 四)
+for mod in range(1, 12):
+    ch = CHAPTER_CN[mod]
+    s = re.sub(
+        rf"^## 四、模块 {mod}:",
+        f"## {ch}、模块 {mod}:",
+        s,
+        count=1,
+        flags=re.M,
+    )
+
+# Build anchor map from ### headers
+anchors: dict[str, str] = {}
+for m in re.finditer(r"^### (\d+)\.(\d+) (.+)$", s, re.M):
+    ch, sec, title = m.group(1), m.group(2), m.group(3)
+    key = f"{ch}.{sec}"
+    anchors[key] = f"#{ch}{sec}-{slug_title(title)}"
+
+
+def fix_toc_link(m: re.Match) -> str:
+    ch, sec = m.group(1), m.group(2)
+    key = f"{ch}.{sec}"
+    if key not in anchors:
+        return m.group(0)
+    return f"[§{ch}.{sec}]({anchors[key]})"
+
+
+s = re.sub(r"\[§(\d+)\.(\d+)\]\(#[^)]+\)", fix_toc_link, s)
+
+# Intro cleanup
+s = s.replace(
+    "为开发人员补充:**每条功能的详细实现流程**、**需实现的 QiWe 官方接口**(对照已爬取官方文档)、**项目需产出的文档清单**。",
+    "为开发人员补充:**每条功能的详细实现流程**、**需实现的 QiWe 官方接口**(对照已爬取官方文档)。",
+)
+
+path.write_text(s, encoding="utf-8")
+print("fixed", path, "anchors", len(anchors))

+ 47 - 0
lami-base-v1/scripts/samples/qiwei_callbacks_sample.json

@@ -0,0 +1,47 @@
+{
+  "code": 0,
+  "msg": "成功",
+  "data": [
+    {
+      "guid": "test-guid-designer-001",
+      "cmd": 15000,
+      "fromRoomId": 10791082136095292,
+      "msgServerId": 1002115,
+      "msgType": 13,
+      "timestamp": 1710000000,
+      "senderId": 1688852365307991,
+      "msgData": {
+        "title": "客户沟通记录表",
+        "desc": "请在此填写每次沟通内容",
+        "linkUrl": "https://doc.weixin.qq.com/txdoc/excel?docid=DOC_TEST_abc123xyz&scode=xxx",
+        "iconUrl": ""
+      }
+    },
+    {
+      "guid": "test-guid-designer-001",
+      "cmd": 15000,
+      "fromRoomId": 10791082136095292,
+      "msgServerId": 1002116,
+      "msgType": 0,
+      "timestamp": 1710000100,
+      "senderId": 1688852365307991,
+      "msgData": {
+        "content": "今天和客户确认了方案"
+      }
+    },
+    {
+      "guid": "test-guid-designer-001",
+      "cmd": 15000,
+      "fromRoomId": 99999999999999,
+      "msgServerId": 1002117,
+      "msgType": 13,
+      "timestamp": 1710000200,
+      "senderId": 1688852365307991,
+      "msgData": {
+        "title": "另一个群的文档",
+        "linkUrl": "https://doc.weixin.qq.com/doc/DOC_OTHER_room2",
+        "desc": ""
+      }
+    }
+  ]
+}

+ 56 - 0
src/app/core/services/api/dashboard-api.service.ts

@@ -0,0 +1,56 @@
+import { Injectable, inject } from '@angular/core';
+import { ApiClientService } from './api-client.service';
+import type { GroupChatDto } from './qiwe-api.service';
+
+export interface DashboardStatsDto {
+  totalGroups: number;
+  totalMembers: number;
+  activeGroupRate: number;
+  avgHealthScore: number;
+  complianceRate: number;
+  riskEventCount: number;
+  pendingWorkOrders: number;
+  intentLeadCount: number;
+  conversionRate: number;
+}
+
+export interface ScopeUserDto {
+  id: string;
+  name: string;
+  email: string;
+  phone: string;
+  role: string;
+  storeId: string;
+  storeName: string;
+  department: string;
+  position: string;
+  createdAt: string;
+}
+
+export interface DashboardOverviewDto {
+  stats: DashboardStatsDto;
+  activityDistribution: { high: number; medium: number; low: number; inactive: number };
+  storeComparison: Array<{ storeName: string; groupCount: number }>;
+  topGroups: GroupChatDto[];
+  recentRiskEvents: [];
+  scopeUsers: ScopeUserDto[];
+}
+
+export interface DashboardScopeQuery {
+  role?: string;
+  entityId?: string;
+}
+
+@Injectable({ providedIn: 'root' })
+export class DashboardApiService {
+  private readonly api = inject(ApiClientService);
+
+  getOverview(scope: DashboardScopeQuery = {}) {
+    const params = new URLSearchParams();
+    if (scope.role) params.set('role', scope.role);
+    if (scope.entityId) params.set('entityId', scope.entityId);
+    const query = params.toString();
+    const path = query ? `/dashboard/overview?${query}` : '/dashboard/overview';
+    return this.api.getResult<DashboardOverviewDto>(path);
+  }
+}

+ 55 - 16
src/app/core/services/api/qiwe-api.service.ts

@@ -7,13 +7,41 @@ export interface GroupChatDto {
   roomId: string;
   roomName: string;
   ownerId: string;
+  ownerName: string;
   memberCount: number;
   avatarUrl: string;
   status: string;
   guid: string;
+  storeId: string;
+  storeName: string;
+  communityId: string;
+  communityName: string;
+  activityLevel: string;
+  healthScore: number;
+  healthStatus: string;
+  hasDocument: boolean;
+  documentPinned: boolean;
+  documentInNotice: boolean;
+  messageCountToday: number;
+  memberChange24h: number;
   updatedAt: string;
 }
 
+export interface StoreDto {
+  id: string;
+  code: string;
+  name: string;
+  region: string;
+}
+
+export interface GroupListFilters {
+  storeId?: string;
+  activityLevel?: string;
+  healthStatus?: string;
+  hasDocument?: string;
+  q?: string;
+}
+
 export interface SyncGroupsResult {
   sync: { created: number; updated: number; errors: string[] };
   database: {
@@ -26,8 +54,20 @@ export interface SyncGroupsResult {
 export class QiweApiService {
   private readonly api = inject(ApiClientService);
 
-  listGroups() {
-    return this.api.getResult<{ groups: GroupChatDto[]; total: number }>('/qiwe/groups');
+  listGroups(filters: GroupListFilters = {}) {
+    const params = new URLSearchParams();
+    if (filters.storeId) params.set('storeId', filters.storeId);
+    if (filters.activityLevel) params.set('activityLevel', filters.activityLevel);
+    if (filters.healthStatus) params.set('healthStatus', filters.healthStatus);
+    if (filters.hasDocument) params.set('hasDocument', filters.hasDocument);
+    if (filters.q) params.set('q', filters.q);
+    const query = params.toString();
+    const path = query ? `/qiwe/groups?${query}` : '/qiwe/groups';
+    return this.api.getResult<{ groups: GroupChatDto[]; total: number; stores: StoreDto[] }>(path);
+  }
+
+  listStores() {
+    return this.api.getResult<{ stores: StoreDto[] }>('/qiwe/stores');
   }
 
   getGroup(roomId: string) {
@@ -43,27 +83,26 @@ export class QiweApiService {
   }
 
   mapToGroup(dto: GroupChatDto): Group {
-    const dismissed = dto.status === 'dismissed';
     return {
       id: dto.roomId,
       name: dto.roomName,
       memberCount: dto.memberCount,
-      communityId: '',
-      communityName: '—',
-      storeId: 's1',
-      storeName: '上海总部',
+      communityId: dto.communityId,
+      communityName: dto.communityName || '—',
+      storeId: dto.storeId,
+      storeName: dto.storeName || '—',
       ownerId: dto.ownerId,
-      ownerName: dto.ownerId || '—',
+      ownerName: dto.ownerName || '—',
       ownerRole: 'single_group',
       createdAt: new Date(dto.updatedAt),
-      healthScore: dismissed ? 0 : 70,
-      healthStatus: dismissed ? 'critical' : 'healthy',
-      hasDocument: false,
-      documentPinned: false,
-      documentInNotice: false,
-      activityLevel: dto.memberCount > 50 ? 'high' : dto.memberCount > 10 ? 'medium' : 'low',
-      messageCountToday: 0,
-      memberChange24h: 0,
+      healthScore: dto.healthScore,
+      healthStatus: dto.healthStatus as Group['healthStatus'],
+      hasDocument: dto.hasDocument,
+      documentPinned: dto.documentPinned,
+      documentInNotice: dto.documentInNotice,
+      activityLevel: dto.activityLevel as Group['activityLevel'],
+      messageCountToday: dto.messageCountToday,
+      memberChange24h: dto.memberChange24h,
       tags: dto.status === 'active' ? ['企微同步'] : ['已解散'],
     };
   }

+ 38 - 20
src/app/features/dashboard/dashboard-scope.util.ts

@@ -1,8 +1,9 @@
-import type { Group, User } from '../../core/models';
+import type { Group, User, UserRole } from '../../core/models';
 import { ROLE_LABELS, USER_ROLES } from '../../core/models/role.constants';
 import type { ScopeCascaderOption, ScopeSelection } from '../../shared/components/scope-cascader/scope-cascader.component';
+import type { ScopeUserDto } from '../../core/services/api/dashboard-api.service';
 
-export function buildScopeOptions(users: User[]): ScopeCascaderOption[] {
+export function buildScopeOptionsFromApi(users: ScopeUserDto[]): ScopeCascaderOption[] {
   return USER_ROLES.map((role) => ({
     value: role,
     label: ROLE_LABELS[role],
@@ -11,12 +12,27 @@ export function buildScopeOptions(users: User[]): ScopeCascaderOption[] {
       .map((u) => ({
         value: u.id,
         label: role === 'store_manager' || role === 'single_group'
-          ? `${u.name}(${u.storeName})`
+          ? (u.storeName ? `${u.name}(${u.storeName})` : u.name)
           : u.name,
       })),
   }));
 }
 
+export function buildScopeOptions(users: User[]): ScopeCascaderOption[] {
+  return buildScopeOptionsFromApi(users.map((u) => ({
+    id: u.id,
+    name: u.name,
+    email: u.email,
+    phone: u.phone,
+    role: u.role,
+    storeId: u.storeId,
+    storeName: u.storeName,
+    department: u.department,
+    position: u.position,
+    createdAt: u.createdAt instanceof Date ? u.createdAt.toISOString() : String(u.createdAt),
+  })));
+}
+
 export function getDefaultScope(user: User | null): ScopeSelection {
   if (!user) {
     return { role: 'director', label: '总监(全部)' };
@@ -30,34 +46,29 @@ export function getDefaultScope(user: User | null): ScopeSelection {
   };
 }
 
+export function scopeToQuery(scope: ScopeSelection): { role?: UserRole; entityId?: string } {
+  return {
+    role: scope.role,
+    entityId: scope.entityId,
+  };
+}
+
 export function filterGroupsByScope(allGroups: Group[], scope: ScopeSelection, users: User[]): Group[] {
   const { role, entityId } = scope;
 
   if (!entityId) {
-    switch (role) {
-      case 'director':
-        return allGroups;
-      case 'regional_supervisor':
-        return allGroups;
-      case 'store_manager':
-        return allGroups.filter((g) =>
-          users.some((u) => u.role === 'store_manager' && u.storeId === g.storeId),
-        );
-      case 'single_group':
-        return allGroups.filter((g) => g.ownerRole === 'single_group');
-      default:
-        return allGroups;
-    }
+    return allGroups;
   }
 
   const entity = users.find((u) => u.id === entityId);
+  if (!entity) return allGroups;
+
   switch (role) {
     case 'director':
       return allGroups;
     case 'regional_supervisor':
-      return entity ? allGroups.filter((g) => g.storeId === entity.storeId) : allGroups;
     case 'store_manager':
-      return entity ? allGroups.filter((g) => g.storeId === entity.storeId) : allGroups;
+      return entity.storeId ? allGroups.filter((g) => g.storeId === entity.storeId) : [];
     case 'single_group':
       return allGroups.filter((g) => g.ownerId === entityId);
     default:
@@ -73,7 +84,14 @@ export function filterRiskEventsByGroups<T extends { groupName: string }>(
   return events.filter((e) => groupNames.has(e.groupName));
 }
 
-export function computeDashboardStats(groups: Group[], allDocs: { complianceStatus: string; groupId: string }[], allEvents: { status: string; groupName: string }[], allOrders: { status: string }[], allLeads: { status: string }[], filteredGroupNames: Set<string>) {
+export function computeDashboardStats(
+  groups: Group[],
+  allDocs: { complianceStatus: string; groupId: string }[],
+  allEvents: { status: string; groupName: string }[],
+  allOrders: { status: string }[],
+  allLeads: { status: string }[],
+  filteredGroupNames: Set<string>,
+) {
   if (groups.length === 0) {
     return {
       totalGroups: 0,

+ 8 - 13
src/app/features/dashboard/dashboard.component.html

@@ -1,6 +1,6 @@
 <app-page-header
   title="总览看板"
-  subtitle="实时掌握客户群运营全局状况"
+  [subtitle]="dashboardSubtitle()"
   [icon]="faChartPie"
 >
   @if (showScopeCascader) {
@@ -20,8 +20,7 @@
     [value]="stats.totalGroups"
     unit="个"
     [color]="'primary'"
-    [trend]="'up'"
-    trendValue="+8"
+    [trend]="'stable'"
     [icon]="faUsers"
     [loading]="loading"
     (cardClick)="navigateTo('/groups')"
@@ -31,8 +30,7 @@
     [value]="stats.totalMembers"
     unit="人"
     [color]="'positive'"
-    [trend]="'up'"
-    trendValue="+12%"
+    [trend]="'stable'"
     [icon]="faUserGroup"
     [loading]="loading"
     (cardClick)="navigateTo('/groups')"
@@ -42,8 +40,7 @@
     [value]="stats.activeGroupRate"
     unit="%"
     [color]="'info'"
-    [trend]="'up'"
-    trendValue="+5%"
+    [trend]="'stable'"
     [icon]="faBolt"
     [loading]="loading"
     (cardClick)="navigateTo('/groups')"
@@ -77,8 +74,7 @@
     [value]="stats.riskEventCount"
     unit="起"
     [color]="stats.riskEventCount > 5 ? 'critical' : 'info'"
-    [trend]="'up'"
-    trendValue="今日"
+    [trend]="'stable'"
     [icon]="faTriangleExclamation"
     [loading]="loading"
     (cardClick)="navigateTo('/risk-control')"
@@ -98,8 +94,7 @@
     [value]="stats.intentLeadCount"
     unit="人"
     [color]="'positive'"
-    [trend]="'up'"
-    trendValue="+15%"
+    [trend]="'stable'"
     [icon]="faUserPlus"
     [loading]="loading"
     (cardClick)="navigateTo('/acquisition/intent-leads')"
@@ -113,7 +108,7 @@
     [chartType]="'doughnut'"
     [chartData]="activityChartData"
     [loading]="loading"
-    [empty]="loading"
+    [empty]="!loading && stats.totalGroups === 0"
     [height]="'300px'"
     [icon]="faChartPie"
     (cardClick)="navigateTo('/reports/monthly')"
@@ -123,7 +118,7 @@
     [chartType]="'bar'"
     [chartData]="storeChartData"
     [loading]="loading"
-    [empty]="loading"
+    [empty]="!loading && storeChartData.labels?.length === 0"
     [height]="'300px'"
     [icon]="faChartBar"
     (cardClick)="navigateTo('/reports/monthly')"

+ 76 - 14
src/app/features/dashboard/dashboard.component.ts

@@ -1,4 +1,4 @@
-import { Component, OnInit, inject } from '@angular/core';
+import { Component, OnInit, inject, signal } from '@angular/core';
 import { Router } from '@angular/router';
 import { FaIconComponent } from '@fortawesome/angular-fontawesome';
 import {
@@ -17,15 +17,20 @@ import {
 } from '../../shared/components/scope-cascader/scope-cascader.component';
 import { AuthStore } from '../../core/auth/auth.store';
 import { MockDataService } from '../../core/services/mock-data.service';
+import { DashboardApiService } from '../../core/services/api/dashboard-api.service';
+import { QiweApiService } from '../../core/services/api/qiwe-api.service';
+import { environment } from '../../../environments/environment';
 import { isDirector } from '../../core/models/role.constants';
 import type { Group, RiskEvent } from '../../core/models';
 import type { ChartData } from 'chart.js';
 import {
   buildScopeOptions,
+  buildScopeOptionsFromApi,
   computeDashboardStats,
   filterGroupsByScope,
   filterRiskEventsByGroups,
   getDefaultScope,
+  scopeToQuery,
 } from './dashboard-scope.util';
 
 @Component({
@@ -45,8 +50,9 @@ export class DashboardComponent implements OnInit {
   private readonly router = inject(Router);
   private readonly authStore = inject(AuthStore);
   private readonly mockData = inject(MockDataService);
+  private readonly dashboardApi = inject(DashboardApiService);
+  private readonly qiweApi = inject(QiweApiService);
 
-  // Expose icons to template
   protected readonly faChartPie = faChartPie;
   protected readonly faUsers = faUsers;
   protected readonly faUserGroup = faUserGroup;
@@ -61,6 +67,7 @@ export class DashboardComponent implements OnInit {
 
   loading = true;
   showScopeCascader = false;
+  dataSource = signal<'api' | 'mock'>('mock');
 
   scopeOptions: ScopeCascaderOption[] = [];
   scopeSelection: ScopeSelection = { role: 'director', label: '总监(全部)' };
@@ -83,7 +90,7 @@ export class DashboardComponent implements OnInit {
   recentRiskEvents: RiskEvent[] = [];
   topGroups: Group[] = [];
 
-  riskEventColumns = [
+  readonly riskEventColumns = [
     { key: 'title', label: '事件标题', sortable: true },
     { key: 'groupName', label: '所属群' },
     { key: 'severity', label: '严重等级', template: 'status' as const },
@@ -92,7 +99,7 @@ export class DashboardComponent implements OnInit {
     { key: 'actions', label: '操作', template: 'action' as const, width: '120px' }
   ];
 
-  topGroupColumns = [
+  readonly topGroupColumns = [
     { key: 'name', label: '群名称', sortable: true },
     { key: 'communityName', label: '所属小区' },
     { key: 'messageCountToday', label: '今日消息数', sortable: true },
@@ -100,6 +107,11 @@ export class DashboardComponent implements OnInit {
     { key: 'actions', label: '操作', template: 'action' as const, width: '120px' }
   ];
 
+  protected readonly dashboardSubtitle = () =>
+    this.dataSource() === 'api'
+      ? '数据来自 Parse 数据库'
+      : '数据来自本地演示';
+
   ngOnInit(): void {
     const user = this.authStore.user();
     if (user?.role === 'single_group') {
@@ -109,23 +121,75 @@ export class DashboardComponent implements OnInit {
 
     this.showScopeCascader = isDirector(user?.role);
     if (this.showScopeCascader) {
-      this.scopeOptions = buildScopeOptions(this.mockData.getUsers());
       this.scopeSelection = { role: 'director', label: '总监(全部)' };
     } else {
       this.scopeSelection = getDefaultScope(user);
     }
-    this.loadData();
+    void this.loadData();
   }
 
   protected onScopeChange(scope: ScopeSelection): void {
     this.scopeSelection = scope;
-    this.loadData();
+    void this.loadData();
   }
 
-  private loadData(): void {
+  private async loadData(): Promise<void> {
     this.loading = true;
 
+    if (environment.useBackendApi) {
+      const result = await this.dashboardApi.getOverview(scopeToQuery(this.scopeSelection));
+      if (result.ok && result.data) {
+        const data = result.data;
+        this.dataSource.set('api');
+        this.stats = data.stats;
+        this.topGroups = data.topGroups.map((g) => this.qiweApi.mapToGroup(g));
+        this.recentRiskEvents = [];
+
+        if (this.showScopeCascader) {
+          this.scopeOptions = buildScopeOptionsFromApi(data.scopeUsers);
+        }
+
+        this.activityChartData = {
+          labels: ['高活跃', '中活跃', '低活跃', '不活跃'],
+          datasets: [{
+            data: [
+              data.activityDistribution.high,
+              data.activityDistribution.medium,
+              data.activityDistribution.low,
+              data.activityDistribution.inactive,
+            ],
+            backgroundColor: ['#188918', '#0070F2', '#E76500', '#8D8D90'],
+            borderWidth: 0,
+          }],
+        };
+
+        this.storeChartData = {
+          labels: data.storeComparison.map((s) => s.storeName),
+          datasets: [{
+            label: '客户群数量',
+            data: data.storeComparison.map((s) => s.groupCount),
+            backgroundColor: ['#0070F2', '#188918', '#E76500', '#E76500'],
+            borderRadius: 4,
+          }],
+        };
+
+        this.loading = false;
+        return;
+      }
+    }
+
+    this.loadMockData();
+    this.loading = false;
+  }
+
+  private loadMockData(): void {
+    this.dataSource.set('mock');
+
     const users = this.mockData.getUsers();
+    if (this.showScopeCascader) {
+      this.scopeOptions = buildScopeOptions(users);
+    }
+
     const allGroups = this.mockData.getGroups();
     const groups = filterGroupsByScope(allGroups, this.scopeSelection, users);
     const groupNames = new Set(groups.map((g) => g.name));
@@ -139,8 +203,7 @@ export class DashboardComponent implements OnInit {
       groupNames,
     );
 
-    const allEvents = this.mockData.getRiskEvents();
-    this.recentRiskEvents = filterRiskEventsByGroups(allEvents, groups)
+    this.recentRiskEvents = filterRiskEventsByGroups(this.mockData.getRiskEvents(), groups)
       .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
       .slice(0, 5);
 
@@ -164,6 +227,7 @@ export class DashboardComponent implements OnInit {
 
     const storeMap = new Map<string, number>();
     groups.forEach(g => {
+      if (!g.storeName || g.storeName === '—') return;
       storeMap.set(g.storeName, (storeMap.get(g.storeName) || 0) + 1);
     });
 
@@ -176,15 +240,13 @@ export class DashboardComponent implements OnInit {
         borderRadius: 4,
       }],
     };
-
-    this.loading = false;
   }
 
-  protected onRiskEventClick(event: RiskEvent): void {
+  protected onRiskEventClick(_event: RiskEvent): void {
     this.router.navigate(['/risk-control']);
   }
 
-  protected onGroupClick(event: Group): void {
+  protected onGroupClick(_event: Group): void {
     this.router.navigate(['/groups']);
   }
 

+ 48 - 12
src/app/features/group-management/group-list/group-list.component.ts

@@ -1,7 +1,7 @@
 import { Component, OnInit, inject, signal } from '@angular/core';
 import { ActivatedRoute, Router, RouterModule } from '@angular/router';
 import { MockDataService } from '../../../core/services/mock-data.service';
-import { QiweApiService } from '../../../core/services/api/qiwe-api.service';
+import { QiweApiService, type StoreDto } from '../../../core/services/api/qiwe-api.service';
 import { environment } from '../../../../environments/environment';
 import type { Group } from '../../../core/models';
 import { PageHeaderComponent } from '../../../shared/components/page-header/page-header.component';
@@ -39,12 +39,8 @@ export class GroupListComponent implements OnInit {
   groups: Group[] = [];
   filteredGroups: Group[] = [];
 
-  readonly filters: FilterOption[] = [
-    { key: 'storeId', label: '门店', type: 'select', value: '', options: [
-      { label: '上海总部', value: 's1' },
-      { label: '北京旗舰店', value: 's2' },
-      { label: '深圳体验店', value: 's3' },
-    ]},
+  filters: FilterOption[] = [
+    { key: 'storeId', label: '门店', type: 'select', value: '', options: [] },
     { key: 'activityLevel', label: '活跃度', type: 'select', value: '', options: [
       { label: '高', value: 'high' },
       { label: '中', value: 'medium' },
@@ -90,17 +86,42 @@ export class GroupListComponent implements OnInit {
     });
   }
 
+  private buildApiFilters() {
+    const storeId = this.filters.find(f => f.key === 'storeId')?.value;
+    const activityLevel = this.filters.find(f => f.key === 'activityLevel')?.value;
+    const healthStatus = this.filters.find(f => f.key === 'healthStatus')?.value;
+    const hasDocument = this.filters.find(f => f.key === 'hasDocument')?.value;
+
+    return {
+      storeId: storeId || undefined,
+      activityLevel: activityLevel || undefined,
+      healthStatus: healthStatus || undefined,
+      hasDocument: hasDocument || undefined,
+      q: this.searchQuery || undefined,
+    };
+  }
+
+  private applyStoreOptions(stores: StoreDto[]): void {
+    const storeFilter = this.filters.find(f => f.key === 'storeId');
+    if (!storeFilter) return;
+    storeFilter.options = stores.map(store => ({
+      label: store.name,
+      value: store.id,
+    }));
+  }
+
   private async loadData(): Promise<void> {
     this.loading = true;
     this.statusMessage.set(null);
 
     if (environment.useBackendApi) {
-      const result = await this.qiweApi.listGroups();
+      const result = await this.qiweApi.listGroups(this.buildApiFilters());
       if (result.ok && result.data) {
+        this.applyStoreOptions(result.data.stores);
         this.groups = result.data.groups.map(d => this.qiweApi.mapToGroup(d));
+        this.filteredGroups = [...this.groups];
         this.dataSource.set('api');
         this.computeStats();
-        this.applyFilters();
         this.loading = false;
         return;
       }
@@ -111,9 +132,14 @@ export class GroupListComponent implements OnInit {
     }
 
     this.groups = this.mockData.getGroups();
+    this.applyStoreOptions([
+      { id: 's1', code: 'SH001', name: '上海总部', region: '华东' },
+      { id: 's2', code: 'BJ001', name: '北京旗舰店', region: '华北' },
+      { id: 's3', code: 'SZ001', name: '深圳体验店', region: '华南' },
+    ]);
     this.dataSource.set('mock');
+    this.applyFiltersLocal();
     this.computeStats();
-    this.applyFilters();
     this.loading = false;
   }
 
@@ -149,7 +175,7 @@ export class GroupListComponent implements OnInit {
       : 0;
   }
 
-  applyFilters(): void {
+  private applyFiltersLocal(): void {
     let result = [...this.groups];
     const storeId = this.filters.find(f => f.key === 'storeId')?.value;
     const activityLevel = this.filters.find(f => f.key === 'activityLevel')?.value;
@@ -179,12 +205,22 @@ export class GroupListComponent implements OnInit {
   }
 
   onApply(): void {
-    this.applyFilters();
+    if (environment.useBackendApi) {
+      void this.loadData();
+      return;
+    }
+    this.applyFiltersLocal();
+    this.computeStats();
   }
 
   onReset(): void {
     this.filters.forEach(f => f.value = '');
+    if (environment.useBackendApi) {
+      void this.loadData();
+      return;
+    }
     this.filteredGroups = [...this.groups];
+    this.computeStats();
   }
 
   onRowClick(group: Group): void {