微信营销管理系统 - 益童宝销售管理平台

- 数据库: PostgreSQL schema + 3名销售/21客户/341消息/6成交
- 采集代理: mock_sync.py 模拟微信聊天同步
- AI分析: analyze.py 规则模式 + 千问LLM模式
- 后端: FastAPI 11个API接口
- 前端: 仪表盘/销售列表/客户列表/成交记录/交流分析/录入成交
- 交流分析: 全部客户对话概览 + LLM标准范式对话生成
- 部署: systemd + nginx, 已部署至 sale.all8ai.top
This commit is contained in:
freedakgmail
2026-07-14 22:31:29 +08:00
commit 5926c73ff1
20 changed files with 6343 additions and 0 deletions
+17
View File
@@ -0,0 +1,17 @@
# Python
__pycache__/
*.pyc
*.pyo
# PostgreSQL data
demo/db/data/
demo/db/socket/
demo/db/postgres.log
# Runtime
demo/.run.pid
demo/web.log
demo/web/__pycache__/
# OS
.DS_Store
+400
View File
@@ -0,0 +1,400 @@
# 微信营销管理系统 MVP 方案
> 文档日期:2026-07-14
> 定位:最小可用产品,验证"微信聊天数据 → 客户与沟通记录 → 成交关联"的核心闭环
---
## 一、MVP 目标
用最简路径完成一件事:**把每个销售的微信聊天采集到中央数据库,AI 分析出客户和沟通记录,成交数据手动录入,形成"沟通→成交"的证据链。**
不做的事:
- 不做培训对练
- 不做 AI 自动回复
- 不做多租户
- 不做实时同步
- 不做复杂权限体系
---
## 二、MVP 范围
### 2.1 核心流程
```text
销售设备本机采集微信数据
→ 传输到中央数据库
→ AI 识别客户、提取沟通记录
→ 销售手动录入成交数据
→ 关联聊天与成交
→ 基础报表
```
### 2.2 支持规模
- 35 名销售
- 每人 1 个微信号
- 每日离线同步(复用现有 launchd 机制)
- 单台中央 PostgreSQL
---
## 三、系统架构
```text
销售设备 A (macOS) 销售设备 B (macOS) 销售设备 C (macOS)
本机微信数据库采集 本机微信数据库采集 本机微信数据库采集
采集代理脚本 采集代理脚本 采集代理脚本
│ │ │
└──────────────┬───────────┘──────────────────────────┘
中央 PostgreSQL
(wechat_sales_db)
AI 分析服务
(客户识别 + 沟通摘要)
Web 管理后台
(客户列表 / 聊天记录 / 成交录入 / 报表)
```
---
## 四、数据模型
MVP 只建 6 张核心表,不搞复杂实体关系。
### 4.1 salesperson — 销售人员
```sql
CREATE TABLE salesperson (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
team TEXT,
wx_account TEXT NOT NULL, -- 绑定的微信号
device_id TEXT, -- 采集设备标识
created_at TIMESTAMPTZ DEFAULT now()
);
```
### 4.2 contact — 联系人(微信好友)
```sql
CREATE TABLE contact (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
wx_username TEXT NOT NULL, -- 微信内部 ID
nickname TEXT, -- 昵称
remark TEXT, -- 备注
display_name TEXT, -- 计算字段:备注优先,其次昵称
is_group BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(salesperson_id, wx_username)
);
```
### 4.3 conversation — 会话
```sql
CREATE TABLE conversation (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
contact_id INT REFERENCES contact(id),
wx_chatroom TEXT, -- 群聊标识(群聊时用)
conv_type TEXT NOT NULL, -- 'single' | 'group'
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now()
);
```
### 4.4 message — 消息
```sql
CREATE TABLE message (
id BIGSERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
conversation_id INT NOT NULL REFERENCES conversation(id),
sender_wx_username TEXT NOT NULL,
sender_display_name TEXT,
message_type TEXT NOT NULL, -- text/image/voice/video/file/emoji/link/quote
raw_content TEXT,
normalized_content TEXT, -- 清洗后可读内容
created_at TIMESTAMPTZ NOT NULL,
source_shard TEXT,
source_table TEXT,
source_local_id BIGINT,
UNIQUE(source_shard, source_table, source_local_id)
);
CREATE INDEX idx_message_conv_time ON message(conversation_id, created_at);
CREATE INDEX idx_message_salesperson ON message(salesperson_id);
```
### 4.5 customer — 客户(AI 从联系人中识别)
```sql
CREATE TABLE customer (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
contact_id INT NOT NULL REFERENCES contact(id),
customer_name TEXT, -- AI 识别的客户名称
industry TEXT, -- AI 推断行业
intent_level TEXT, -- high/medium/low/none
key_needs TEXT[], -- AI 提取的关键需求
last_analysis TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(salesperson_id, contact_id)
);
```
### 4.6 deal — 成交记录(手动录入)
```sql
CREATE TABLE deal (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
customer_id INT REFERENCES customer(id),
contact_id INT REFERENCES contact(id),
product_name TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
deal_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'closed', -- closed/pending/refunded
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
```
---
## 五、采集方案
### 5.1 复用现有能力
当前单机系统已具备:
- macOS 微信 4.x 数据库定位与解密
- 联系人、会话、消息解析与标准化
- 每日 launchd 定时同步
- 增量去重(`UNIQUE(source_shard, source_table, source_local_id)`
MVP 阶段直接复用,只需扩展为多设备。
### 5.2 多设备采集流程
每台销售设备部署相同的采集代理:
```text
1. launchd 每天 05:00 触发
2. 退出微信 → 复制数据库副本 → 重启微信
3. 解密数据库副本
4. 解析联系人、会话、消息
5. 通过 SSH/HTTP 将增量数据推送到中央服务器
6. 中央服务器写入 PostgreSQL
7. 清理临时文件
```
### 5.3 中央服务器接收
中央服务器提供一个简单的接收接口:
```text
POST /api/sync
{
"salesperson_id": 1,
"device_id": "macbook-abc123",
"contacts": [...],
"conversations": [...],
"messages": [...]
}
```
或者更简单:每台设备直接配置 PostgreSQL 远程连接,采集脚本直接写入中央库。MVP 阶段建议后者,省去中间服务。
### 5.4 数据隔离
每条数据都带 `salesperson_id`,确保销售之间数据隔离。查询时按销售过滤。
---
## 六、AI 分析
### 6.1 分析目标
MVP 只做两件事:
1. **客户识别**:从联系人中筛选出真实客户(排除微商、广告、纯社交联系人)
2. **沟通摘要**:对每个客户会话生成结构化摘要
### 6.2 客户识别
对每个联系人,取最近 N 条消息,调用 LLM 判断:
```json
{
"is_customer": true,
"customer_name": "张总",
"industry": "餐饮",
"intent_level": "medium",
"key_needs": ["收银系统", "会员管理"],
"reason": "多次询问产品价格和功能,提到门店运营需求"
}
```
筛选规则:
- 有超过 5 条对话的联系人
- 消息内容涉及产品咨询、价格、合作等关键词
- 排除纯群聊通知、广告推送类联系人
### 6.3 沟通摘要
对每个客户会话,按时间窗口(如最近 7 天或全部)生成:
```json
{
"summary": "客户最初咨询收银系统价格,对比了竞品后关注会员管理功能,销售安排了产品演示,客户表示需要和合伙人商量。",
"stage": "产品演示",
"objections": ["价格偏高", "需要合伙人确认"],
"next_action": "等待客户反馈,建议3天后跟进",
"last_contact_date": "2026-07-12"
}
```
### 6.4 模型选择
- 优先使用现有 Ollama 本地模型(如 qwen2.5)降低成本
- 消息量大时分批处理,每次传入不超过 50 条消息
- 分析结果存入 `customer` 表,不单独建表
---
## 七、成交数据录入
### 7.1 录入方式
Web 后台提供简单表单:
- 选择销售
- 选择客户(从 AI 识别的客户列表中选)
- 填写产品名称、金额、成交日期
- 可选填写备注
### 7.2 关联逻辑
成交记录通过 `contact_id` 自动关联到对应的微信会话。这样就能看到:
- 某个成交客户的所有聊天记录
- 从首次接触到成交的完整沟通时间线
- 成交前的关键沟通节点
---
## 八、Web 管理后台
### 8.1 技术选型
| 模块 | 选型 |
|---|---|
| 前端 | React + TailwindCSS |
| 后端 | Python FastAPI |
| 数据库 | PostgreSQL 17 |
| 部署 | 单机部署,Nginx 反向代理 |
### 8.2 页面清单
MVP 只做 5 个页面:
#### 首页 / 仪表盘
- 今日新增消息数
- 活跃客户数
- 本月成交金额
- 各销售消息量对比
#### 销售列表
- 每个销售的消息量、客户数、成交金额
- 最后同步时间
- 同步状态
#### 客户列表
- 按销售筛选
- 按意向等级筛选
- 显示客户名称、行业、意向、最后沟通时间
- 点击进入客户详情
#### 客户详情
- 客户基本信息(AI 识别结果)
- 完整聊天记录(时间线展示,复用现有搜索页面能力)
- AI 沟通摘要
- 关联的成交记录
#### 成交录入
- 简单表单
- 成交列表(可按销售、日期、产品筛选)
---
## 九、实施计划
### 第 1 周:数据采集多设备化
- [ ] 创建中央 PostgreSQL 数据库 `wechat_sales_db`
- [ ] 建表(6 张核心表)
- [ ] 改造现有采集脚本,支持写入中央库(增加 `salesperson_id` 字段)
- [ ] 在 2 台设备上部署采集代理并验证
### 第 2 周:AI 分析 + Web 后台
- [ ] 搭建 FastAPI 后端骨架
- [ ] 实现客户识别分析脚本
- [ ] 实现沟通摘要分析脚本
- [ ] 搭建 React 前端骨架
- [ ] 完成客户列表和客户详情页
### 第 3 周:成交录入 + 报表
- [ ] 成交录入表单
- [ ] 成交列表页
- [ ] 仪表盘基础数据展示
- [ ] 聊天记录页面集成
### 第 4 周:联调 + 试点
- [ ] 35 名销售实际接入
- [ ] 录入真实成交数据
- [ ] 验证"聊天→客户→成交"关联准确性
- [ ] 收集反馈,修复问题
---
## 十、成功标准
MVP 上线后需验证:
1. **采集完整性**:每个销售每天的消息采集率 > 95%
2. **客户识别准确率**AI 识别的客户中,> 80% 被销售确认是真实客户
3. **成交关联可用**:手动录入的成交记录能正确关联到对应聊天记录
4. **基础报表可用**:能看到每个销售的消息量、客户数、成交金额
5. **闭环验证**:至少能从 1 个成交客户回溯完整沟通链路
---
## 十一、与完整方案的关系
MVP 是总体方案"第一阶段"的精简版,主要区别:
| 维度 | MVP | 完整方案第一阶段 |
|---|---|---|
| 数据模型 | 6 张表 | 20+ 张表 |
| 成交数据 | 手动录入 | 接入 CRM/订单系统 |
| AI 分析 | 客户识别 + 沟通摘要 | 阶段识别 + 意向评分 + 异议分析 + 待办 |
| 权限 | 按 salesperson_id 简单隔离 | RBAC + 数据范围权限 |
| 同步 | 每日离线 | 每日 + 小时级 |
| 报表 | 基础统计 | 销售漏斗 + 能力雷达 + 话术分析 |
| 部署 | 单机 | 单机但面向多设备 |
MVP 验证通过后,按完整方案路线图逐步扩展。
+399
View File
@@ -0,0 +1,399 @@
# 微信营销管理系统 MVP 需求规格
> 文档日期:2026-07-14
> 版本:v0.1
> 状态:需求定义
---
## 一、项目背景
基于《微信营销管理系统总体设计与实施方案》和《MVP.md》,在本机构建一个可运行的演示系统,模拟"销售本地处理系统 + 中央库管理系统"的完整闭环。
MVP 核心命题:**把每个销售的微信聊天采集到中央数据库,AI 分析出客户和沟通记录,成交数据手动录入,形成"沟通→成交"的证据链。**
---
## 二、系统边界
### 2.1 本期范围(In Scope
| 能力 | 说明 |
|---|---|
| 销售本地采集代理 | 模拟 3 个销售设备的采集代理,生成模拟微信数据并同步到中央库 |
| 中央数据库 | PostgreSQL 存储销售、联系人、会话、消息、客户、成交 6 张核心表 |
| 客户识别分析 | 从联系人中筛选真实客户,推断行业、意向等级、关键需求 |
| 沟通摘要分析 | 对每个客户会话生成结构化摘要(阶段、异议、下一步) |
| 成交数据录入 | Web 表单手动录入成交记录,关联到客户和聊天 |
| Web 管理后台 | 仪表盘、销售列表、客户列表、客户详情(含聊天记录)、成交录入 |
| 一键启动 | 单脚本启动数据库、模拟采集、AI 分析、Web 服务 |
### 2.2 本期不做(Out of Scope
| 排除项 | 原因 |
|---|---|
| 真实微信数据库解密 | MVP 用模拟数据验证闭环,不依赖真实微信 |
| 培训对练系统 | 第二阶段 |
| AI 自动回复 | 第三阶段 |
| 多租户 | MVP 单租户 |
| 实时同步 | MVP 每日批量同步 |
| 复杂权限体系 | MVP 按 salesperson_id 简单隔离 |
| 向量化与语义检索 | MVP 不做向量搜索 |
| 对象存储 | MVP 不处理多媒体文件 |
| CRM/订单系统对接 | MVP 成交数据手动录入 |
---
## 三、用户角色
| 角色 | 描述 | MVP 中的体现 |
|---|---|---|
| 销售人员 | 业务微信的使用者 | 系统自动模拟 3 名销售的数据采集 |
| 销售主管 | 查看团队数据和客户 | Web 后台的主要使用者 |
| 管理员 | 管理销售账号和系统配置 | 通过脚本或后台管理销售账号 |
---
## 四、功能需求
### FR-1 销售账号管理
**描述**:系统维护销售人员列表,每个销售绑定一个微信号和设备标识。
**需求项**
- FR-1.1 支持创建销售人员记录(姓名、团队、微信号、设备 ID)
- FR-1.2 系统预置 3 名销售用于演示
- FR-1.3 每条数据都携带 `salesperson_id`,实现数据隔离
**验收标准**
- 可通过 SQL 或 API 创建销售记录
- 3 名预置销售数据正确写入数据库
---
### FR-2 本地采集代理(模拟)
**描述**:模拟每台销售设备上的采集代理,生成模拟微信数据并同步到中央库。
**需求项**
- FR-2.1 每个代理绑定一个 `salesperson_id`
- FR-2.2 生成模拟联系人(每名销售 15~30 个联系人,含 2~3 个群聊)
- FR-2.3 生成模拟会话(每名销售 10~20 个会话)
- FR-2.4 生成模拟消息(每名销售 200~500 条消息,覆盖文本/图片/语音/文件等类型)
- FR-2.5 消息内容包含预设的销售场景模板(询价、产品咨询、异议处理、成交等)
- FR-2.6 支持增量同步:多次运行只新增不重复的消息
- FR-2.7 同步时记录 `last_synced_at`
**验收标准**
- 运行 `mock_sync.py --salesperson-id 1` 后,中央库中出现该销售的联系人、会话、消息
- 重复运行不产生重复消息
- 消息内容可读、类型分布合理
---
### FR-3 客户识别分析
**描述**:从联系人中识别出真实客户,排除非客户联系人。
**需求项**
- FR-3.1 对每个有超过 5 条消息的联系人执行分析
- FR-3.2 输出字段:`is_customer``customer_name``industry``intent_level``key_needs``reason`
- FR-3.3 `intent_level` 取值:high / medium / low / none
- FR-3.4 分析结果写入 `customer`
- FR-3.5 支持两种分析模式:
- 规则模式(默认):基于关键词和消息频率的规则引擎
- LLM 模式(可选):调用本地 Ollama 模型分析
- FR-3.6 已分析的联系人不重复分析(除非强制 `--force`
**验收标准**
- 运行分析后,`customer` 表中出现被识别的客户
- 非客户联系人(如广告、纯社交)不被识别为客户
- 规则模式无需外部依赖即可运行
---
### FR-4 沟通摘要分析
**描述**:对每个识别出的客户会话生成结构化摘要。
**需求项**
- FR-4.1 对 `customer` 表中的每个客户,取其关联会话的全部消息
- FR-4.2 输出字段:`summary`(文本摘要)、`stage`(销售阶段)、`objections`(异议列表)、`next_action`(下一步建议)
- FR-4.3 `stage` 取值:新线索 / 建立联系 / 需求发现 / 方案匹配 / 产品演示 / 报价 / 异议处理 / 决策推进 / 成交 / 未成交
- FR-4.4 结果写入 `customer` 表的 `last_analysis` 时间戳和扩展字段
- FR-4.5 支持规则模式和 LLM 模式
**验收标准**
- 运行分析后,客户记录中出现摘要和阶段信息
- 摘要内容与模拟对话场景一致
- 阶段判断合理
---
### FR-5 成交数据录入
**描述**:通过 Web 表单手动录入成交记录。
**需求项**
- FR-5.1 提供成交录入表单:选择销售 → 选择客户 → 填写产品名称、金额、成交日期
- FR-5.2 可选填写备注
- FR-5.3 成交记录通过 `contact_id` 自动关联到对应的微信会话
- FR-5.4 支持查看成交列表,按销售、日期、产品筛选
- FR-5.5 成交状态默认 `closed`,支持 `pending``refunded`
**验收标准**
- 通过 Web 表单成功录入一条成交记录
- 成交列表正确展示已录入记录
- 在客户详情页能看到关联的成交记录
---
### FR-6 Web 管理后台
**描述**:提供 Web 界面查看数据和管理成交录入。
#### FR-6.1 仪表盘
- 显示总消息数、活跃客户数、本月成交金额
- 显示各销售的消息量对比
- 显示最近同步时间
#### FR-6.2 销售列表
- 每个销售的消息量、客户数、成交金额
- 最后同步时间
- 同步状态
#### FR-6.3 客户列表
- 按销售筛选
- 按意向等级筛选
- 显示客户名称、行业、意向等级、最后沟通时间
- 点击进入客户详情
#### FR-6.4 客户详情
- 客户基本信息(AI 识别结果:行业、意向、关键需求)
- AI 沟通摘要(阶段、异议、下一步建议)
- 完整聊天记录(时间线展示,支持分页)
- 关联的成交记录
#### FR-6.5 成交录入与列表
- 成交录入表单
- 成交列表(按销售、日期、产品筛选)
**验收标准**
- 5 个页面均可正常访问
- 数据正确展示
- 客户详情页能看到聊天记录和成交记录
- 成交录入后列表实时更新
---
### FR-7 一键启动与停止
**描述**:提供脚本一键启动和停止整个演示系统。
**需求项**
- FR-7.1 `run_demo.sh` 完成以下步骤:
1. 启动 PostgreSQL(端口 5434
2. 执行建库建表
3. 运行 3 个模拟采集代理
4. 运行 AI 分析(规则模式)
5. 启动 FastAPI + Web 前端(端口 8770
6. 输出访问地址
- FR-7.2 `stop_demo.sh` 停止所有服务
- FR-7.3 启动脚本可重复运行(幂等)
**验收标准**
- 执行 `run_demo.sh` 后,浏览器访问 `http://127.0.0.1:8770` 能看到仪表盘
- 执行 `stop_demo.sh` 后,所有进程停止
- 重复运行不报错
---
## 五、数据模型
### 5.1 核心表(6 张)
| 表名 | 说明 | 主键 |
|---|---|---|
| `salesperson` | 销售人员 | `id` (SERIAL) |
| `contact` | 联系人(微信好友) | `id` (SERIAL),唯一约束 `(salesperson_id, wx_username)` |
| `conversation` | 会话 | `id` (SERIAL) |
| `message` | 消息 | `id` (BIGSERIAL),唯一约束 `(source_shard, source_table, source_local_id)` |
| `customer` | 客户(AI 识别) | `id` (SERIAL),唯一约束 `(salesperson_id, contact_id)` |
| `deal` | 成交记录 | `id` (SERIAL) |
### 5.2 关键字段补充
`customer` 表在 MVP.md 基础上增加分析摘要字段:
```sql
-- 沟通摘要分析结果(FR-4
summary TEXT, -- 文本摘要
stage TEXT, -- 销售阶段
objections TEXT[], -- 异议列表
next_action TEXT, -- 下一步建议
```
### 5.3 数据隔离
所有业务表均包含 `salesperson_id` 外键,查询时按销售过滤。
---
## 六、模拟数据规格
### 6.0 业务背景
**产品**:儿童益生菌粉(主打小儿抗过敏、调节免疫、改善肠道)
**销售模式**:toC 微信私域销售,目标客户为宝妈/家长
**产品信息**
- 产品名:益童宝儿童益生菌粉
- 规格:30 袋/盒,每袋 2g
- 零售价:298 元/盒,3 盒套餐 798 元,6 盒套餐 1499 元
- 核心卖点:丹麦进口菌株、抗过敏临床验证、0 岁以上可用、无敏配方
- 适用症状:小儿湿疹、过敏性鼻炎、食物过敏、免疫力低下、腹泻/便秘
**目标客户画像**
- 宝妈,2540 岁
- 孩子有过敏症状(湿疹、鼻炎、食物过敏)
- 关注成分安全,对"进口""临床验证"敏感
- 价格敏感度中等,更关注效果和安全性
- 决策链短(宝妈自己决定),但需要信任建立
### 6.1 销售人员(3 名)
| ID | 姓名 | 团队 | 微信号 | 设备 ID | 人设 |
|---|---|---|---|---|---|
| 1 | 张伟 | 华东团队 | wxid_zhangwei | macbook-zw-001 | 资深销售,擅长建立信任,成交率高 |
| 2 | 李娜 | 华东团队 | wxid_lina | macbook-ln-002 | 新人销售,热情但经验不足,容易过早报价 |
| 3 | 王强 | 华南团队 | wxid_wangqiang | macbook-wq-003 | 中等水平,擅长跟进但异议处理较弱 |
### 6.2 联系人类型分布(每名销售)
| 类型 | 数量 | 说明 |
|---|---|---|
| 真实客户(宝妈) | 5~8 | 有过敏症状咨询、产品问价、成分讨论、成交等对话 |
| 潜在客户 | 3~5 | 群里加的好友,初步咨询过但未深入 |
| 非客户联系人 | 5~10 | 朋友圈点赞、代购广告、其他品牌推销、家人朋友 |
| 群聊 | 2~3 | 宝妈群、育儿交流群、过敏宝宝互助群 |
### 6.3 消息场景模板
每个真实客户对应一个预设场景:
| 场景 | 阶段 | 消息轮次 | 示例内容 |
|---|---|---|---|
| 湿疹宝宝咨询后成交 | 成交 | 15~25 | 宝妈说孩子湿疹反复 → 销售问年龄症状 → 介绍益生菌抗过敏原理 → 发临床报告截图 → 宝妈问价格 → 销售推荐3盒套餐 → 宝妈确认下单 |
| 过敏性鼻炎咨询后犹豫 | 异议处理 | 10~20 | 宝妈说孩子鼻炎 → 销售介绍产品 → 宝妈问有没有副作用 → 销售解释无敏配方 → 宝妈觉得价格贵 → 销售解释日均成本 → 宝妈说要和老公商量 |
| 朋友推荐来咨询 | 需求发现 | 8~15 | 宝妈说朋友推荐 → 问产品适不适合自己孩子 → 销售问症状 → 宝妈描述孩子情况 → 销售初步建议 |
| 长期跟进复购 | 建立联系 | 20~30 | 首次咨询未买 → 销售定期关心孩子情况 → 宝妈反馈吃了效果不错 → 销售推荐复购套餐 → 成交 |
| 对比竞品后选择 | 方案匹配 | 12~20 | 宝妈说在对比合生元 → 销售对比菌株和临床数据 → 宝妈问为什么贵 → 销售解释进口菌株差异 → 宝妈犹豫 |
### 6.4 非客户联系人场景
| 类型 | 示例消息内容 |
|---|---|
| 代购广告 | "【韩国直邮】儿童维生素团购开始啦..." |
| 其他品牌推销 | "姐,我们新款益生菌做活动,比你现在用的便宜..." |
| 朋友圈互动 | "在吗?""你朋友圈那个是什么产品?" |
| 家人朋友 | "周末有空吗?""孩子上学怎么样了?" |
| 群聊通知 | "[群公告] 本群禁发广告..." / "有没有宝妈推荐好的儿童霜?" |
### 6.5 消息类型分布
| 类型 | 占比 | 典型内容 |
|---|---|---|
| text | ~75% | 文字对话 |
| image | ~10% | 产品图、临床报告截图、宝宝湿疹照片、成分表 |
| voice | ~6% | 宝妈发语音描述孩子症状、销售语音解释 |
| file | ~2% | 产品手册 PDF、检测报告 |
| emoji | ~5% | 表情包互动 |
| link | ~2% | 产品详情页链接、科普文章 |
---
## 七、接口规格
### 7.1 采集代理同步接口
采集代理直接写 PostgreSQL,不走 HTTP。MVP 阶段简化架构。
### 7.2 Web APIFastAPI
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/api/dashboard` | 仪表盘统计数据 |
| GET | `/api/salespersons` | 销售列表 |
| GET | `/api/salespersons/{id}/stats` | 单个销售统计 |
| GET | `/api/customers` | 客户列表(支持 `salesperson_id``intent_level` 筛选) |
| GET | `/api/customers/{id}` | 客户详情(含摘要) |
| GET | `/api/customers/{id}/messages` | 客户聊天记录(分页) |
| GET | `/api/customers/{id}/deals` | 客户关联成交 |
| POST | `/api/deals` | 录入成交记录 |
| GET | `/api/deals` | 成交列表(支持筛选) |
| POST | `/api/analyze` | 触发 AI 分析(客户识别 + 沟通摘要) |
| GET | `/api/sync/status` | 同步状态 |
---
## 八、非功能需求
| 维度 | 要求 |
|---|---|
| 运行环境 | macOS(本机) |
| Python | 3.11+ |
| PostgreSQL | 17(复用本机已安装版本) |
| 端口 | PostgreSQL 5434Web 8770(避开现有 5433 和 8765 |
| 启动时间 | < 30 秒 |
| 模拟数据量 | 3 名销售,约 1000 条消息,可秒级生成 |
| 前端 | 单页 HTML + TailwindCSS CDN,无需构建 |
| 后端 | FastAPI,单文件即可 |
| 依赖 | psycopg2-binary, fastapi, uvicorn3 个包) |
---
## 九、技术约束
1. **不修改现有系统**:不改动 `/Users/freedak/Documents/Codex/2026-07-14/ni/` 下的任何文件
2. **独立数据库**:使用独立数据库 `wxchat_sales`,不复用现有 `wechat_knowledge`
3. **独立端口**PostgreSQL 5434Web 8770,不与现有服务冲突
4. **无外部依赖**:规则模式不依赖 Ollama 或任何外部 API
5. **可重复运行**:脚本幂等,重复运行不报错、不产生重复数据
---
## 十、验收标准汇总
| 编号 | 验收项 | 验证方式 |
|---|---|---|
| AC-1 | `run_demo.sh` 一键启动全部服务 | 执行脚本,观察输出 |
| AC-2 | 浏览器访问 `http://127.0.0.1:8770` 看到仪表盘 | 手动访问 |
| AC-3 | 仪表盘显示 3 名销售的数据 | 检查数据非零 |
| AC-4 | 客户列表显示 AI 识别的客户 | 检查列表非空 |
| AC-5 | 客户详情页显示聊天记录 | 检查消息时间线 |
| AC-6 | 客户详情页显示 AI 摘要 | 检查摘要、阶段、异议字段 |
| AC-7 | 成交录入表单可提交 | 填写并提交 |
| AC-8 | 成交列表显示已录入记录 | 检查列表 |
| AC-9 | 客户详情页显示关联成交 | 检查成交区域 |
| AC-10 | `stop_demo.sh` 停止所有服务 | 执行脚本,检查进程 |
| AC-11 | 重复运行 `run_demo.sh` 不报错 | 执行两次 |
| AC-12 | 模拟采集代理增量同步不产生重复 | 运行两次 mock_sync,检查消息数 |
---
## 十一、后续演进
MVP 演示验证通过后的演进路径(不在本期范围):
1. **接入真实微信数据**:将 mock_sync 替换为真实的数据库采集代理
2. **接入 LLM 分析**:将规则引擎替换为 Ollama 本地模型
3. **小时级同步**:从每日批量改为小时级增量
4. **权限体系**:增加登录认证和 RBAC
5. **向量检索**:接入 pgvector 做语义搜索
6. **培训对练**:按总体方案第二阶段实施
+476
View File
@@ -0,0 +1,476 @@
# 微信营销管理系统 MVP 技术设计
> 文档日期:2026-07-14
> 版本:v0.1
> 依赖:0-req.md
---
## 一、系统架构
### 1.1 部署拓扑
单机部署,所有组件运行在本机 macOS 上。
```
┌──────────────────────────────────────────────────────────┐
│ 本机 macOS │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ mock_sync │ │ mock_sync │ │ mock_sync │ │
│ │ 销售A (id=1) │ │ 销售B (id=2) │ │ 销售C (id=3) │ │
│ └──────┬──────┘ └──────┬──────┘ └──────┬──────┘ │
│ │ │ │ │
│ └────────────────┼────────────────┘ │
│ ▼ │
│ ┌──────────────────────────────────────┐ │
│ │ PostgreSQL 17 (端口 5434) │ │
│ │ 数据库: wxchat_sales │ │
│ │ socket: demo/db/socket │ │
│ └──────────────────┬───────────────────┘ │
│ │ │
│ ┌──────────┼──────────┐ │
│ ▼ ▼ ▼ │
│ ┌───────────┐ ┌─────────┐ ┌────────────┐ │
│ │ analyze.py│ │ server │ │ index.html │ │
│ │ AI分析 │ │ FastAPI │ │ 前端页面 │ │
│ └───────────┘ └────┬────┘ └────────────┘ │
│ │ │
│ ▼ │
│ http://127.0.0.1:8770 │
└──────────────────────────────────────────────────────────┘
```
### 1.2 端口分配
| 服务 | 端口 | 说明 |
|---|---|---|
| PostgreSQL | 5434 | 避开现有 5433 |
| FastAPI + 前端 | 8770 | 避开现有 8765 |
### 1.3 目录结构
```
wxsales/demo/
├── 0-req.md # 需求规格
├── 1-design.md # 本文档
├── 2-impl.md # 实施计划
├── 3-data-spec.md # 模拟数据规格
├── README.md # 项目说明
├── requirements.txt # Python 依赖
├── run_demo.sh # 一键启动
├── stop_demo.sh # 一键停止
├── db/
│ ├── schema.sql # 建表 SQL
│ ├── init_db.sh # 数据库初始化脚本
│ ├── socket/ # Unix socket 目录
│ └── data/ # PostgreSQL 数据目录(自动生成)
├── agent/
│ └── mock_sync.py # 模拟采集代理
├── ai/
│ └── analyze.py # AI 分析服务(规则引擎 + 可选 LLM)
└── web/
├── server.py # FastAPI 后端
└── static/
└── index.html # 单页前端
```
---
## 二、数据库设计
### 2.1 ER 关系
```
salesperson 1───* contact 1───* conversation 1───* message
│ │
│ 1───* customer *───1
│ │
│ 1───* deal *───1
```
### 2.2 完整建表 SQL
```sql
-- salesperson
CREATE TABLE salesperson (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
team TEXT,
wx_account TEXT NOT NULL,
device_id TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- contact
CREATE TABLE contact (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
wx_username TEXT NOT NULL,
nickname TEXT,
remark TEXT,
display_name TEXT NOT NULL,
is_group BOOLEAN DEFAULT FALSE,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(salesperson_id, wx_username)
);
-- conversation
CREATE TABLE conversation (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
contact_id INT REFERENCES contact(id),
wx_identifier TEXT NOT NULL,
conv_type TEXT NOT NULL DEFAULT 'single',
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(salesperson_id, wx_identifier)
);
-- message
CREATE TABLE message (
id BIGSERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
conversation_id INT NOT NULL REFERENCES conversation(id),
sender_wx_username TEXT NOT NULL,
sender_display_name TEXT,
message_type TEXT NOT NULL,
raw_content TEXT,
normalized_content TEXT,
created_at TIMESTAMPTZ NOT NULL,
source_shard TEXT NOT NULL,
source_table TEXT NOT NULL,
source_local_id BIGINT NOT NULL,
UNIQUE(source_shard, source_table, source_local_id)
);
-- customer
CREATE TABLE customer (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
contact_id INT NOT NULL REFERENCES contact(id),
customer_name TEXT,
industry TEXT,
intent_level TEXT,
key_needs TEXT[],
reason TEXT,
summary TEXT,
stage TEXT,
objections TEXT[],
next_action TEXT,
last_analysis TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(salesperson_id, contact_id)
);
-- deal
CREATE TABLE deal (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id),
customer_id INT REFERENCES customer(id),
contact_id INT REFERENCES contact(id),
product_name TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
deal_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'closed',
notes TEXT,
created_at TIMESTAMPTZ DEFAULT now()
);
-- 索引
CREATE INDEX idx_msg_conv_time ON message(conversation_id, created_at);
CREATE INDEX idx_msg_salesperson ON message(salesperson_id);
CREATE INDEX idx_customer_salesperson ON customer(salesperson_id);
CREATE INDEX idx_deal_salesperson ON deal(salesperson_id);
CREATE INDEX idx_deal_customer ON deal(customer_id);
```
### 2.3 与 MVP.md 的差异
- `conversation` 表增加 `wx_identifier` 字段作为唯一约束的一部分,因为同一销售可能有多个会话指向同一联系人
- `customer` 表增加了 `summary``stage``objections``next_action` 字段,用于存储沟通摘要分析结果(FR-4)
- `message` 表的 `source_shard` 在模拟环境中用 `mock_<salesperson_id>` 格式
---
## 三、模拟采集代理设计
### 3.1 模块结构
```
mock_sync.py
├── main() # 入口,解析参数
├── generate_contacts() # 生成模拟联系人
├── generate_conversations() # 生成模拟会话
├── generate_messages() # 生成模拟消息(基于场景模板)
├── sync_to_central() # 写入中央 PostgreSQL
└── SCENARIOS # 预设销售场景模板
```
### 3.2 场景模板结构
每个场景模板定义一段完整的客户对话:
```python
{
"name": "湿疹宝宝咨询后成交",
"stage": "成交",
"contact": {
"nickname": "辰辰妈妈",
"remark": "辰辰妈-湿疹-2岁",
"industry": "宝妈",
"intent_level": "high",
},
"messages": [
{"sender": "customer", "type": "text", "content": "你好,我家宝宝2岁,湿疹反复好几个月了,朋友推荐你这边益生菌"},
{"sender": "sales", "type": "text", "content": "辰辰妈您好!宝宝湿疹确实让人心疼,请问现在湿疹主要在哪些部位?有用过什么药吗?"},
{"sender": "customer", "type": "text", "content": "脸上和手臂都有,医生开了激素药膏,但停了就复发"},
{"sender": "sales", "type": "text", "content": "理解,激素药膏只能暂时压制。益生菌是从肠道调节免疫,从根本上降低过敏反应。我们用的是丹麦进口的鼠李糖乳杆菌,有专门针对儿童湿疹的临床验证"},
{"sender": "sales", "type": "image", "content": "[图片:临床验证报告截图]"},
{"sender": "customer", "type": "text", "content": "这个是进口的?安全吗?2岁能吃吗?"},
{"sender": "sales", "type": "text", "content": "是的,丹麦进口菌株,0岁以上就能用,无敏配方,不含牛奶蛋白和麸质。很多宝妈反馈坚持吃2-3个月湿疹明显好转"},
{"sender": "customer", "type": "text", "content": "多少钱?怎么卖的?"},
{"sender": "sales", "type": "text", "content": "单盒298元30袋,建议先吃3盒一个周期,3盒套餐798元算下来每天不到9块钱"},
{"sender": "sales", "type": "image", "content": "[图片:产品包装图]"},
{"sender": "customer", "type": "text", "content": "3盒798是吧,效果不好怎么办?"},
{"sender": "sales", "type": "text", "content": "我们有售后指导,期间有任何问题随时找我。另外我发您几个同情况宝妈的反馈看看"},
{"sender": "sales", "type": "link", "content": "[链接:宝妈真实反馈合集]"},
{"sender": "customer", "type": "text", "content": "好的,那先来3盒试试"},
{"sender": "sales", "type": "text", "content": "好的辰辰妈!3盒套餐798元,您方便现在付款吗?我这边给您安排发货"},
]
}
```
### 3.3 增量同步机制
- 每次运行使用递增的 `source_local_id`
- `UNIQUE(source_shard, source_table, source_local_id)` 保证不重复
- `source_shard` = `mock_{salesperson_id}`
- `source_table` = `Msg_{conversation_wx_identifier_hash}`
- 消息时间从基准时间开始递增,每次运行追加新消息
### 3.4 非客户联系人模板
生成广告、社交等非客户联系人,消息内容为:
- 广告推送:"【XX商城】年中大促..."
- 社交寒暄:"在吗?""最近怎么样"
- 群聊通知:系统消息、转发内容
---
## 四、AI 分析服务设计
### 4.1 双模式架构
```
analyze.py
├── analyze_customers() # 客户识别
│ ├── rule_mode() # 规则引擎(默认)
│ └── llm_mode() # LLM 模式(可选)
├── analyze_summaries() # 沟通摘要
│ ├── rule_mode() # 规则引擎(默认)
│ └── llm_mode() # LLM 模式(可选)
└── main() # 入口
```
### 4.2 规则引擎 — 客户识别
**输入**:联系人 + 该联系人的全部消息
**规则**
1. 消息数 ≤ 5 → `is_customer = false`
2. 关键词匹配(产品咨询类):
- 症状词:湿疹、过敏、鼻炎、腹泻、便秘、免疫力、体质、红疹
- 产品词:益生菌、菌株、进口、配方、成分、丹麦、鼠李糖、无敏
- 价格词:价格、多少钱、费用、报价、优惠、套餐、盒、周期
- 成交词:买、下单、付款、发货、试试、来几盒、定了
- 命中 ≥ 2 个关键词 → `is_customer = true`
3. 行业推断:toC 场景统一标记为"宝妈"
4. 意向等级:
- high:提到成交词 + 价格词 ≥ 1
- medium:提到症状词 + 产品词,有价格讨论
- low:只有一般咨询,无价格讨论
- none:无业务关键词
### 4.3 规则引擎 — 沟通摘要
**输入**:客户会话的全部消息
**规则**
1. 阶段判断:
- 成交:包含"定了""下单""付款""发货""来几盒""试试""买"
- 报价:包含"多少钱""价格""套餐""298""798""1499"
- 异议处理:包含"贵""考虑""商量""副作用""安全""效果""对比""合生元"
- 需求发现:包含"湿疹""过敏""鼻炎""症状""多大""几个月"
- 建立联系:消息少于 5 条且为初次沟通
2. 异议提取:匹配"贵""太贵""考虑""商量""老公""副作用""安全吗""有没有效""没用过""合生元""对比"
3. 摘要生成:按时间顺序提取关键消息,拼接为摘要文本
4. 下一步建议:根据阶段和最后一条消息内容推断
### 4.4 LLM 模式(可选)
`--mode llm` 时,调用本地 Ollama
- 模型:qwen2.5(或本机已有的模型)
- 输入:最近 50 条消息
- 输出:JSON 结构化结果
- 超时:30 秒/请求
- 失败回退到规则模式
---
## 五、Web 后端设计
### 5.1 FastAPI 路由
```python
# 仪表盘
GET /api/dashboard
{total_messages, active_customers, monthly_deal_amount, salespersons: [...]}
# 销售列表
GET /api/salespersons
[{id, name, team, wx_account, message_count, customer_count, deal_amount, last_synced_at}]
# 客户列表
GET /api/customers?salesperson_id=&intent_level=
[{id, customer_name, industry, intent_level, last_analysis, contact_display_name}]
# 客户详情
GET /api/customers/{id}
{id, customer_name, industry, intent_level, key_needs, summary, stage, objections, next_action, ...}
# 客户聊天记录
GET /api/customers/{id}/messages?page=1&page_size=50
[{id, sender_display_name, message_type, normalized_content, created_at}]
# 客户成交
GET /api/customers/{id}/deals
[{id, product_name, amount, deal_date, status}]
# 成交录入
POST /api/deals
body: {salesperson_id, customer_id, product_name, amount, deal_date, notes?}
# 成交列表
GET /api/deals?salesperson_id=&start_date=&end_date=
[{id, salesperson_name, customer_name, product_name, amount, deal_date, status}]
# 触发分析
POST /api/analyze
body: {mode: "rule"|"llm"}
{customers_analyzed, summaries_generated}
# 同步状态
GET /api/sync/status
[{salesperson_id, name, last_synced_at, message_count}]
```
### 5.2 静态文件托管
FastAPI 直接托管 `web/static/` 目录:
```python
app.mount("/", StaticFiles(directory="web/static", html=True))
```
API 路由挂在 `/api/*` 前缀下,前端页面通过 fetch 调用。
---
## 六、Web 前端设计
### 6.1 技术选型
- 单页 HTML + TailwindCSS CDN
- 原生 JavaScript,无框架
- Chart.js CDN(仪表盘图表)
### 6.2 页面结构
```
index.html
├── <nav> 顶部导航
│ ├── 仪表盘
│ ├── 销售列表
│ ├── 客户列表
│ └── 成交记录
├── <main> 内容区(SPA 切换)
│ ├── #dashboard-panel
│ ├── #salespersons-panel
│ ├── #customers-panel
│ ├── #customer-detail-panel(含聊天记录 + 成交)
│ ├── #deals-form-panel
│ └── #deals-list-panel
└── <script> 路由 + 数据加载
```
### 6.3 交互流程
1. 打开页面 → 加载仪表盘
2. 点击"客户列表" → 加载客户列表
3. 点击客户 → 切换到客户详情 → 加载基本信息 + 聊天记录 + 成交
4. 点击"成交录入" → 表单 → 提交 → 刷新列表
5. 点击"销售列表" → 加载销售统计
---
## 七、一键启动脚本设计
### 7.1 run_demo.sh 流程
```bash
1. 检查 PostgreSQL 17 是否安装
2. 启动 PostgreSQL(端口 5434,数据目录 demo/db/data
3. 等待数据库就绪
4. 执行 schema.sql 建表
5. 插入 3 名预置销售
6. 运行 mock_sync.py --salesperson-id 1
7. 运行 mock_sync.py --salesperson-id 2
8. 运行 mock_sync.py --salesperson-id 3
9. 运行 analyze.py --mode rule
10. 启动 FastAPI (uvicorn) 后台运行
11. 输出 http://127.0.0.1:8770
```
### 7.2 stop_demo.sh 流程
```bash
1. 停止 uvicorn 进程
2. 停止 PostgreSQL
3. 清理 PID 文件
```
### 7.3 幂等保证
- 数据库已存在则跳过 initdb
- 表已存在则跳过建表(`CREATE TABLE IF NOT EXISTS`
- 销售记录用 `INSERT ... ON CONFLICT DO NOTHING`
- mock_sync 天然增量(UNIQUE 约束)
- analyze 用 `last_analysis IS NULL` 过滤
---
## 八、错误处理
| 场景 | 处理方式 |
|---|---|
| PostgreSQL 未安装 | 脚本报错并提示安装命令 |
| 端口被占用 | 脚本检测并提示 |
| mock_sync 连接失败 | 打印错误并退出,不影响其他销售 |
| analyze 无客户可分析 | 打印提示,正常退出 |
| FastAPI 启动失败 | 打印端口冲突提示 |
| 前端 API 调用失败 | 显示错误提示,不白屏 |
---
## 九、与现有系统的隔离
| 维度 | 现有系统 | 本演示系统 |
|---|---|---|
| 数据目录 | `ni/work/postgres/data` | `demo/db/data` |
| 数据库名 | `wechat_knowledge` | `wxchat_sales` |
| 端口 | 5433 | 5434 |
| Socket | `ni/work/postgres/socket` | `demo/db/socket` |
| Web 端口 | 8765 | 8770 |
| 代码目录 | `ni/work/` | `demo/` |
完全独立,互不影响。
+320
View File
@@ -0,0 +1,320 @@
# 微信营销管理系统 MVP 实施计划
> 文档日期:2026-07-14
> 版本:v0.1
> 依赖:0-req.md, 1-design.md
---
## 一、实施阶段
### 阶段 1:数据库与基础设施
**目标**:启动独立 PostgreSQL,建表,预置销售数据。
**产出文件**
- `demo/db/schema.sql` — 完整建表 SQL
- `demo/db/init_db.sh` — 数据库启动与初始化脚本
- `demo/requirements.txt` — Python 依赖
**任务清单**
- [ ] 编写 `schema.sql`6 张表 + 索引)
- [ ] 编写 `init_db.sh`(启动 PG 5434 → 建库 → 建表 → 插入 3 名销售)
- [ ] 编写 `requirements.txt`
- [ ] 验证:执行 `init_db.sh` 后,`psql` 能查到 3 名销售
**验证命令**
```bash
/usr/local/opt/postgresql@17/bin/psql -h demo/db/socket -p 5434 -d wxchat_sales -c "SELECT * FROM salesperson;"
```
---
### 阶段 2:模拟采集代理
**目标**:3 个模拟代理生成微信数据并写入中央库。
**产出文件**
- `demo/agent/mock_sync.py` — 模拟采集代理
**任务清单**
- [ ] 定义 5 个销售场景模板(询价成交、犹豫、咨询、长期跟进、竞品对比)
- [ ] 定义非客户联系人模板(广告、社交、群聊)
- [ ] 实现 `generate_contacts()` — 每销售 1530 个联系人
- [ ] 实现 `generate_conversations()` — 每销售 1020 个会话
- [ ] 实现 `generate_messages()` — 基于场景模板生成消息
- [ ] 实现 `sync_to_central()` — 批量写入 PG
- [ ] 实现增量同步(递增 source_local_id
- [ ] 支持 `--salesperson-id` 参数
- [ ] 支持 `--batch` 参数控制生成数量
**验证命令**
```bash
python demo/agent/mock_sync.py --salesperson-id 1
python demo/agent/mock_sync.py --salesperson-id 2
python demo/agent/mock_sync.py --salesperson-id 3
# 检查数据
psql ... -c "SELECT salesperson_id, count(*) FROM message GROUP BY salesperson_id;"
# 再次运行验证增量
python demo/agent/mock_sync.py --salesperson-id 1
psql ... -c "SELECT count(*) FROM message WHERE salesperson_id=1;"
# 消息数应增加
```
---
### 阶段 3AI 分析服务
**目标**:规则引擎实现客户识别和沟通摘要。
**产出文件**
- `demo/ai/analyze.py` — AI 分析服务
**任务清单**
- [ ] 实现客户识别规则引擎
- 消息数量过滤
- 关键词匹配(价格词、产品词、合作词)
- 行业推断
- 意向等级判断
- [ ] 实现沟通摘要规则引擎
- 阶段判断
- 异议提取
- 摘要生成
- 下一步建议
- [ ] 支持 `--mode rule|llm` 参数
- [ ] 支持 `--force` 强制重新分析
- [ ] 已分析客户跳过(`last_analysis IS NULL` 过滤)
**验证命令**
```bash
python demo/ai/analyze.py --mode rule
# 检查客户表
psql ... -c "SELECT customer_name, industry, intent_level, stage FROM customer;"
# 应有客户记录,非客户不在其中
```
---
### 阶段 4Web 后端
**目标**FastAPI 提供全部 API 接口。
**产出文件**
- `demo/web/server.py` — FastAPI 后端
**任务清单**
- [ ] 实现 DB 连接池
- [ ] 实现 `/api/dashboard`
- [ ] 实现 `/api/salespersons`
- [ ] 实现 `/api/customers`(支持筛选)
- [ ] 实现 `/api/customers/{id}`
- [ ] 实现 `/api/customers/{id}/messages`(分页)
- [ ] 实现 `/api/customers/{id}/deals`
- [ ] 实现 `POST /api/deals`
- [ ] 实现 `/api/deals`(支持筛选)
- [ ] 实现 `POST /api/analyze`
- [ ] 实现 `/api/sync/status`
- [ ] 静态文件托管(`/``web/static/index.html`
**验证命令**
```bash
uvicorn demo.web.server:app --port 8770 &
curl http://127.0.0.1:8770/api/dashboard | python -m json.tool
curl http://127.0.0.1:8770/api/customers | python -m json.tool
curl -X POST http://127.0.0.1:8770/api/deals \
-H "Content-Type: application/json" \
-d '{"salesperson_id":1,"customer_id":1,"product_name":"收银系统","amount":12000,"deal_date":"2026-07-14"}'
```
---
### 阶段 5Web 前端
**目标**:单页 HTML 实现 5 个页面。
**产出文件**
- `demo/web/static/index.html` — 单页前端
**任务清单**
- [ ] 页面骨架 + TailwindCSS CDN + 导航栏
- [ ] 仪表盘面板(统计卡片 + 销售对比图)
- [ ] 销售列表面板
- [ ] 客户列表面板(筛选 + 列表)
- [ ] 客户详情面板(基本信息 + 摘要 + 聊天时间线 + 成交记录)
- [ ] 成交录入表单
- [ ] 成交列表面板
- [ ] SPA 路由切换(hash 或手动切换)
- [ ] API 调用封装
- [ ] 错误提示
**验证方式**
- 浏览器访问 `http://127.0.0.1:8770`
- 逐个页面检查数据展示
- 录入一条成交,检查列表和客户详情
---
### 阶段 6:一键启动与集成
**目标**`run_demo.sh` 一键启动全部服务。
**产出文件**
- `demo/run_demo.sh` — 一键启动
- `demo/stop_demo.sh` — 一键停止
- `demo/README.md` — 项目说明
**任务清单**
- [ ] 编写 `run_demo.sh`
- 检查 PG 17
- 启动 PG 5434
- 建库建表
- 运行 3 个 mock_sync
- 运行 analyze
- 启动 uvicorn 后台
- 输出访问地址
- [ ] 编写 `stop_demo.sh`
- 停止 uvicorn
- 停止 PG
- [ ] 编写 `README.md`
- [ ] 端到端验证
**验证命令**
```bash
cd demo && bash run_demo.sh
# 浏览器访问 http://127.0.0.1:8770
bash stop_demo.sh
# 再次运行验证幂等
bash run_demo.sh
```
---
## 二、依赖清单
### Python 包
```
psycopg2-binary>=2.9
fastapi>=0.111
uvicorn>=0.30
```
### 系统依赖
- PostgreSQL 17(本机已安装于 `/usr/local/opt/postgresql@17/`
- Python 3.11+
- macOSlaunchd、pg_ctl 等)
### 可选依赖
- Ollama + qwen2.5LLM 分析模式,非必需)
---
## 三、风险与对策
| 风险 | 影响 | 对策 |
|---|---|---|
| PG 5434 端口被占用 | 启动失败 | 脚本检测并提示 |
| psycopg2 安装失败 | 无法连接 DB | 使用 psycopg2-binary 避免编译 |
| 模拟数据不够真实 | 演示效果差 | 场景模板覆盖 5 种典型销售对话 |
| 规则引擎误判 | 客户识别不准 | MVP 可接受,后续替换为 LLM |
| 前端无框架 | 代码维护性差 | MVP 可接受,后续可迁移到 React |
---
## 三点五、测试计划
### 测试分层
| 层级 | 范围 | 方式 | 产出文件 |
|---|---|---|---|
| 数据库层 | 建表、约束、索引 | SQL 验证脚本 | `demo/tests/test_db.sql` |
| 采集代理层 | 联系人/消息生成、增量同步、去重 | Python 单元测试 | `demo/tests/test_mock_sync.py` |
| AI 分析层 | 客户识别准确率、阶段判断、异议提取 | Python 单元测试 | `demo/tests/test_analyze.py` |
| API 层 | 全部 11 个接口的输入输出 | Python HTTP 测试 | `demo/tests/test_api.py` |
| 端到端 | run_demo.sh → 浏览器 → 成交录入 | Shell 脚本 + curl | `demo/tests/test_e2e.sh` |
### 各阶段测试任务
#### 阶段 1 测试(数据库)
- [ ] 执行 `test_db.sql` 验证 6 张表存在
- [ ] 验证唯一约束生效(插入重复数据应失败)
- [ ] 验证外键约束生效
- [ ] 验证 3 名预置销售数据正确
#### 阶段 2 测试(采集代理)
- [ ] 运行 mock_sync 后联系人数量在预期范围
- [ ] 运行 mock_sync 后消息数量 > 0
- [ ] 重复运行 mock_sync 不产生重复消息(增量同步)
- [ ] 消息类型分布合理(text 占比最高)
- [ ] 非客户联系人的消息不含业务关键词
#### 阶段 3 测试(AI 分析)
- [ ] 真实客户被正确识别(is_customer=true
- [ ] 非客户不被识别为客户(广告/社交/家人)
- [ ] 意向等级判断合理(成交场景=high,犹豫场景=medium
- [ ] 销售阶段判断合理(成交场景=成交,犹豫场景=异议处理)
- [ ] 异议提取包含"贵""考虑""商量"等关键词
- [ ] 重复运行不重复分析(last_analysis 过滤)
#### 阶段 4 测试(API
- [ ] `GET /api/dashboard` 返回非空统计数据
- [ ] `GET /api/customers` 返回客户列表
- [ ] `GET /api/customers?intent_level=high` 筛选正确
- [ ] `GET /api/customers/{id}` 返回客户详情含摘要
- [ ] `GET /api/customers/{id}/messages` 返回聊天记录
- [ ] `POST /api/deals` 成功创建成交记录
- [ ] `GET /api/deals` 返回成交列表
- [ ] `POST /api/analyze` 触发分析并返回统计
#### 阶段 6 测试(端到端)
- [ ] `run_demo.sh` 执行无报错
- [ ] `curl http://127.0.0.1:8770/api/dashboard` 返回 200
- [ ] `stop_demo.sh` 停止所有进程
- [ ] 重复运行 `run_demo.sh` 幂等
### 测试执行命令
```bash
# 数据库测试
psql -h demo/db/socket -p 5434 -d wxchat_sales -f demo/tests/test_db.sql
# Python 单元测试
python -m pytest demo/tests/ -v
# 端到端测试
bash demo/tests/test_e2e.sh
```
---
## 四、工作量估算
| 阶段 | 预计工作量 | 产出文件数 |
|---|---|---|
| 阶段 1:数据库 | 1~2 小时 | 3 个文件 |
| 阶段 2:采集代理 | 3~4 小时 | 1 个文件(大) |
| 阶段 3AI 分析 | 2~3 小时 | 1 个文件 |
| 阶段 4Web 后端 | 23 小时 | 1 个文件 |
| 阶段 5Web 前端 | 3~4 小时 | 1 个文件(大) |
| 阶段 6:集成启动 | 1 小时 | 3 个文件 |
| **合计** | **1217 小时** | **10 个文件** |
---
## 五、里程碑
| 里程碑 | 标志 | 验收标准 |
|---|---|---|
| M1 数据库就绪 | `init_db.sh` 成功执行 | 3 名销售在库中 |
| M2 数据采集就绪 | 3 个 mock_sync 运行完成 | 消息数 > 600 |
| M3 AI 分析就绪 | `analyze.py` 运行完成 | 客户表非空 |
| M4 API 就绪 | FastAPI 启动 | 所有 API 返回正确数据 |
| M5 前端就绪 | 浏览器可访问 | 5 个页面数据正确 |
| M6 一键启动 | `run_demo.sh` 成功 | AC-1 AC-12 全部通过 |
+348
View File
@@ -0,0 +1,348 @@
# 微信营销管理系统 MVP 模拟数据规格
> 文档日期:2026-07-14
> 版本:v0.1
> 依赖:0-req.md, 1-design.md
---
## 一、产品信息
| 维度 | 内容 |
|---|---|
| 产品名 | 益童宝儿童益生菌粉 |
| 品牌 | 益童宝 |
| 规格 | 30 袋/盒,每袋 2g |
| 产地 | 丹麦进口菌株,国内分装 |
| 菌株 | 鼠李糖乳杆菌 GG(LGG)+ 动物双歧杆菌 Bb-12 |
| 零售价 | 单盒 298 元 |
| 套餐价 | 3 盒 798 元(一个周期,约 3 个月),6 盒 1499 元(两个周期) |
| 日均成本 | 3 盒套餐 ≈ 8.8 元/天 |
| 核心卖点 | 丹麦进口菌株、抗过敏临床验证、0 岁以上可用、无敏配方(不含牛奶蛋白/麸质/大豆) |
| 适用症状 | 小儿湿疹、过敏性鼻炎、食物过敏、免疫力低下、腹泻/便秘 |
| 禁忌 | 免疫缺陷患儿需遵医嘱 |
| 售后 | 服用期间全程指导,有问题随时联系销售 |
---
## 二、销售人员
| ID | 姓名 | 微信号 | 团队 | 人设 | 销售风格 |
|---|---|---|---|---|---|
| 1 | 张伟 | wxid_zhangwei | 华东团队 | 资深销售,3 年经验 | 先问症状再报价格,擅长发案例和临床报告建立信任,成交率高 |
| 2 | 李娜 | wxid_lina | 华东团队 | 新人销售,3 个月 | 热情但容易过早报价,异议处理经验不足,常被"太贵"卡住 |
| 3 | 王强 | wxid_wangqiang | 华南团队 | 中等水平,1 年 | 擅长跟进和关心客户,但竞品对比时容易被动 |
---
## 三、客户场景模板
### 场景 1:湿疹宝宝咨询后成交
| 维度 | 内容 |
|---|---|
| 客户昵称 | 辰辰妈妈 |
| 备注 | 辰辰妈-湿疹-2岁 |
| 客户画像 | 宝妈,28 岁,孩子 2 岁,湿疹反复 |
| 意向等级 | high |
| 销售阶段 | 成交 |
| 消息轮次 | 1525 |
| 关联销售 | 张伟(id=1) |
**对话脚本**
```
[客户] 你好,我家宝宝2岁,湿疹反复好几个月了,朋友推荐你这边益生菌
[销售] 辰辰妈您好!宝宝湿疹确实让人心疼,请问现在湿疹主要在哪些部位?有用过什么药吗?
[客户] 脸上和手臂都有,医生开了激素药膏,但停了就复发
[销售] 理解,激素药膏只能暂时压制。益生菌是从肠道调节免疫,从根本上降低过敏反应。我们用的是丹麦进口的鼠李糖乳杆菌,有专门针对儿童湿疹的临床验证
[销售] [图片:临床验证报告截图]
[客户] 这个是进口的?安全吗?2岁能吃吗?
[销售] 是的,丹麦进口菌株,0岁以上就能用,无敏配方,不含牛奶蛋白和麸质。很多宝妈反馈坚持吃2-3个月湿疹明显好转
[客户] 多少钱?怎么卖的?
[销售] 单盒298元30袋,建议先吃3盒一个周期,3盒套餐798元算下来每天不到9块钱
[销售] [图片:产品包装图]
[客户] 3盒798是吧,效果不好怎么办?
[销售] 我们有售后指导,期间有任何问题随时找我。另外我发您几个同情况宝妈的反馈看看
[销售] [链接:宝妈真实反馈合集]
[客户] 好的,那先来3盒试试
[销售] 好的辰辰妈!3盒套餐798元,您方便现在付款吗?我这边给您安排发货
```
---
### 场景 2:过敏性鼻炎咨询后犹豫
| 维度 | 内容 |
|---|---|
| 客户昵称 | 乐乐妈 |
| 备注 | 乐乐妈-鼻炎-5岁 |
| 客户画像 | 宝妈,32 岁,孩子 5 岁,过敏性鼻炎 |
| 意向等级 | medium |
| 销售阶段 | 异议处理 |
| 消息轮次 | 1020 |
| 关联销售 | 李娜(id=2) |
**对话脚本**
```
[客户] 你好,听说益生菌对过敏性鼻炎有帮助?
[销售] 乐乐妈您好!是的,益生菌可以调节肠道免疫,对过敏性鼻炎有改善作用。请问宝宝现在鼻炎什么症状?
[客户] 早上起来一直打喷嚏流鼻涕,医生说是过敏性鼻炎
[销售] 益生菌从肠道调节免疫系统,坚持吃可以逐渐降低过敏反应。我们的菌株是丹麦进口的,有临床验证
[客户] 有没有副作用?安全吗?
[销售] 很安全的,无敏配方,不含牛奶蛋白和麸质,5岁完全没问题
[客户] 多少钱?
[销售] 单盒2983盒套餐798
[客户] 有点贵啊...
[销售] 算下来每天不到9块钱,比吃药划算多了
[客户] 我再想想,和老公商量一下
[销售] 好的,不着急。您可以先看看这个科普文章
[销售] [链接:益生菌与儿童过敏科普]
[客户] 好的,我看看
```
---
### 场景 3:朋友推荐来咨询
| 维度 | 内容 |
|---|---|
| 客户昵称 | 果果妈妈 |
| 备注 | 果果妈-推荐来的 |
| 客户画像 | 宝妈,30 岁,孩子 1 岁半,朋友推荐 |
| 意向等级 | medium |
| 销售阶段 | 需求发现 |
| 消息轮次 | 815 |
| 关联销售 | 张伟(id=1) |
**对话脚本**
```
[客户] 你好,是小林推荐我加你的,她说你家益生菌不错
[销售] 果果妈您好!谢谢小林推荐,请问宝宝多大了?有什么情况吗?
[客户] 1岁半,最近总是拉肚子,小林说吃益生菌调理一下
[销售] 拉肚子多久了?有去看过医生吗?
[客户] 断断续续一个多月了,医生说没什么大问题,让注意饮食
[销售] 那益生菌确实可以帮到宝宝。我们的菌株是丹麦进口的,专门针对儿童肠道和免疫调节。1岁半完全可以吃
[客户] 成分安全吗?
[销售] 无敏配方,不含牛奶蛋白、麸质和大豆,很多敏宝妈妈都在用
[客户] 好的,我先了解一下
[销售] 没问题,我给您发个产品详情,您先看看
[销售] [链接:产品详情页]
```
---
### 场景 4:长期跟进后复购
| 维度 | 内容 |
|---|---|
| 客户昵称 | 糖糖妈妈 |
| 备注 | 糖糖妈-复购-湿疹已好转 |
| 客户画像 | 宝妈,35 岁,孩子 3 岁,已购买过 3 盒 |
| 意向等级 | high |
| 销售阶段 | 成交 |
| 消息轮次 | 2030 |
| 关联销售 | 王强(id=3) |
**对话脚本(分多日)**
```
--- 第1天 ---
[客户] 你好,我想了解一下益生菌
[销售] 糖糖妈您好!请问宝宝什么情况?
[客户] 3岁,湿疹,反反复复的
[销售] 湿疹确实需要从内调理。我们的益生菌丹麦进口菌株,专门针对儿童过敏
[客户] 多少钱?
[销售] 单盒2983盒套餐798
[客户] 先了解一下,回头再说
[销售] 好的,不着急
--- 第7天 ---
[销售] 糖糖妈,宝宝最近湿疹怎么样了?
[客户] 还是老样子,断不了根
[销售] 湿疹确实需要坚持调理,益生菌一般吃2-3个月能看到明显改善
[客户] 我再考虑考虑
--- 第15天 ---
[销售] 糖糖妈,今天有个宝妈反馈说吃了2个月湿疹好多了,发您看看
[销售] [图片:宝妈反馈截图]
[客户] 真的吗?那我也试试吧
[销售] 好的!建议先拿3盒一个周期,798元
[客户] 好的,付款吧
[销售] 好的!我马上安排发货
--- 第60天 ---
[销售] 糖糖妈,宝宝吃了这段时间效果怎么样?
[客户] 比之前好多了,湿疹没那么频繁了
[销售] 太好了!建议继续吃巩固一下,现在有6盒套餐1499更划算
[客户] 好,那来6盒
[销售] 好的!马上安排
```
---
### 场景 5:对比竞品后选择
| 维度 | 内容 |
|---|---|
| 客户昵称 | 壮壮妈妈 |
| 备注 | 壮壮妈-对比合生元 |
| 客户画像 | 宝妈,29 岁,孩子 4 岁,免疫力差 |
| 意向等级 | medium |
| 销售阶段 | 方案匹配 |
| 消息轮次 | 1220 |
| 关联销售 | 王强(id=3) |
**对话脚本**
```
[客户] 你好,我想给孩子买益生菌,但在对比你们和合生元
[销售] 壮壮妈您好!对比是正常的。请问宝宝主要什么情况?
[客户] 经常感冒,免疫力不太好
[销售] 我们的菌株是丹麦进口的鼠李糖乳杆菌GG,有专门针对儿童免疫力的临床验证。合生元用的是国产菌株
[客户] 但你们比合生元贵啊
[销售] 菌株不一样,进口菌株的临床数据更充分,效果更有保障。算下来每天不到9块钱
[客户] 有什么区别吗?
[销售] [图片:菌株对比表]
[销售] 主要是菌株来源和临床验证数量不同。我们的LGG是全球研究最多的益生菌菌株之一
[客户] 我再看看吧
[销售] 好的,有问题随时问我
```
---
## 四、非客户联系人模板
### 4.1 代购广告
| 维度 | 内容 |
|---|---|
| 昵称 | 韩国代购-小美 |
| 消息内容 | "【韩国直邮】儿童维生素团购开始啦,DHA+益生菌组合装只要199!" |
| 消息数 | 13 条 |
### 4.2 其他品牌推销
| 维度 | 内容 |
|---|---|
| 昵称 | 母婴用品批发 |
| 消息内容 | "姐,我们新款益生菌做活动,比你现在用的便宜一半,要不要看看?" |
| 消息数 | 24 条 |
### 4.3 朋友圈互动
| 维度 | 内容 |
|---|---|
| 昵称 | 朵朵阿姨 |
| 消息内容 | "在吗?""你朋友圈那个是什么产品?""好的知道了" |
| 消息数 | 25 条 |
### 4.4 家人朋友
| 维度 | 内容 |
|---|---|
| 昵称 | 老婆 ❤️ |
| 消息内容 | "今晚回来吃饭吗?""孩子放学了""周末去哪玩?" |
| 消息数 | 38 条 |
### 4.5 群聊 — 宝妈群
| 维度 | 内容 |
|---|---|
| 群名 | 宝妈育儿交流群 |
| 消息内容 | "[群公告] 本群禁发广告""有没有宝妈推荐好的儿童面霜?""我家宝宝最近不爱吃饭怎么办?""谢谢推荐" |
| 消息数 | 515 条 |
### 4.6 群聊 — 过敏宝宝互助群
| 维度 | 内容 |
|---|---|
| 群名 | 过敏宝宝互助群 |
| 消息内容 | "我家宝宝湿疹终于好了""你们都用的什么益生菌?""益生菌真的有用吗?""我也想试试" |
| 消息数 | 820 条 |
---
## 五、联系人分配方案
### 5.1 张伟(id=1)— 资深销售
| 序号 | 昵称 | 类型 | 场景 | 消息数 |
|---|---|---|---|---|
| 1 | 辰辰妈妈 | 真实客户 | 场景1:湿疹成交 | ~20 |
| 2 | 果果妈妈 | 真实客户 | 场景3:朋友推荐 | ~12 |
| 3 | 依依妈 | 真实客户 | 场景1变体:食物过敏成交 | ~18 |
| 4 | 淘淘妈妈 | 真实客户 | 场景4变体:长期跟进复购 | ~25 |
| 5 | 糖糖妈妈 | 真实客户 | 场景4:长期跟进复购 | ~25 |
| 6 | 小宇妈 | 潜在客户 | 初步咨询 | ~6 |
| 7 | 安安妈妈 | 潜在客户 | 初步咨询 | ~4 |
| 8 | 韩国代购-小美 | 非客户 | 广告 | ~2 |
| 9 | 母婴用品批发 | 非客户 | 推销 | ~3 |
| 10 | 朵朵阿姨 | 非客户 | 朋友圈互动 | ~3 |
| 11 | 老婆 ❤️ | 非客户 | 家人 | ~5 |
| 12 | 宝妈育儿交流群 | 群聊 | — | ~12 |
| 13 | 过敏宝宝互助群 | 群聊 | — | ~15 |
### 5.2 李娜(id=2)— 新人销售
| 序号 | 昵称 | 类型 | 场景 | 消息数 |
|---|---|---|---|---|
| 1 | 乐乐妈 | 真实客户 | 场景2:鼻炎犹豫 | ~15 |
| 2 | 可可妈妈 | 真实客户 | 场景2变体:价格异议 | ~12 |
| 3 | 豆豆妈 | 真实客户 | 场景5变体:竞品对比 | ~15 |
| 4 | 欢欢妈妈 | 真实客户 | 过早报价后被拒 | ~10 |
| 5 | 甜甜妈 | 真实客户 | 场景3变体:推荐咨询 | ~8 |
| 6 | 球球妈妈 | 潜在客户 | 初步咨询 | ~5 |
| 7 | 米米妈 | 潜在客户 | 初步咨询 | ~3 |
| 8 | 同行-小张 | 非客户 | 同行交流 | ~4 |
| 9 | 快递小哥 | 非客户 | 物流通知 | ~3 |
| 10 | 大学同学 | 非客户 | 社交 | ~5 |
| 11 | 宝妈群-华东 | 群聊 | — | ~10 |
| 12 | 育儿干货分享群 | 群聊 | — | ~8 |
### 5.3 王强(id=3)— 中等水平
| 序号 | 昵称 | 类型 | 场景 | 消息数 |
|---|---|---|---|---|
| 1 | 壮壮妈妈 | 真实客户 | 场景5:竞品对比 | ~15 |
| 2 | 糖糖妈妈 | 真实客户 | 场景4:长期跟进复购 | ~25 |
| 3 | 阳阳妈 | 真实客户 | 场景1变体:腹泻成交 | ~18 |
| 4 | 贝贝妈妈 | 真实客户 | 场景2变体:安全顾虑 | ~12 |
| 5 | 星星妈 | 真实客户 | 场景3变体:推荐咨询 | ~10 |
| 6 | 晨晨妈妈 | 潜在客户 | 初步咨询 | ~6 |
| 7 | 露露妈 | 潜在客户 | 初步咨询 | ~4 |
| 8 | 健身教练 | 非客户 | 社交 | ~3 |
| 9 | 房产中介小王 | 非客户 | 广告 | ~2 |
| 10 | 表妹 | 非客户 | 家人 | ~4 |
| 11 | 过敏宝宝互助群 | 群聊 | — | ~15 |
| 12 | 宝妈群-华南 | 群聊 | — | ~10 |
---
## 六、预置成交数据
启动时自动录入以下成交记录,用于演示:
| 销售 | 客户 | 产品 | 金额 | 日期 | 状态 |
|---|---|---|---|---|---|
| 张伟 | 辰辰妈妈 | 益童宝3盒套餐 | 798 | 2026-07-05 | closed |
| 张伟 | 依依妈 | 益童宝3盒套餐 | 798 | 2026-07-08 | closed |
| 张伟 | 糖糖妈妈 | 益童宝6盒套餐 | 1499 | 2026-07-12 | closed |
| 李娜 | 甜甜妈 | 益童宝单盒 | 298 | 2026-07-10 | closed |
| 王强 | 阳阳妈 | 益童宝3盒套餐 | 798 | 2026-07-06 | closed |
| 王强 | 糖糖妈妈 | 益童宝3盒套餐 | 798 | 2026-06-20 | closed |
---
## 七、数据量估算
| 维度 | 数量 |
|---|---|
| 销售人员 | 3 |
| 联系人总数 | ~39(每销售 13 个) |
| 会话总数 | ~39 |
| 消息总数 | ~450(真实客户 ~300 + 非客户 ~50 + 群聊 ~100 |
| AI 识别客户数 | ~15(每销售 5 个) |
| 预置成交记录 | 6 |
+71
View File
@@ -0,0 +1,71 @@
# 微信营销管理系统 MVP 演示
> 在本机模拟"销售本地处理系统 + 中央库管理系统"的完整闭环。
> 场景:toC 益生菌(小儿抗过敏)微信私域销售。
## 快速开始
```bash
# 一键启动
cd demo && bash run_demo.sh
# 浏览器访问
open http://127.0.0.1:8770
# 一键停止
bash stop_demo.sh
```
## 系统组成
| 组件 | 说明 |
|---|---|
| PostgreSQL 17 | 中央数据库,端口 5434,数据库名 `wxchat_sales` |
| mock_sync.py | 模拟 3 个销售设备的采集代理,生成微信数据并写入中央库 |
| analyze.py | AI 分析服务(规则引擎),客户识别 + 沟通摘要 |
| server.py | FastAPI 后端,11 个 API 接口 |
| index.html | Web 管理后台(仪表盘/销售/客户/聊天/成交) |
## 模拟场景
**产品**:益童宝儿童益生菌粉(298 元/盒,3 盒 7986 盒 1499
**3 名销售**
- 张伟(华东团队)— 资深销售,成交率高
- 李娜(华东团队)— 新人,容易过早报价
- 王强(华南团队)— 中等水平,擅长跟进
**5 种对话场景**
1. 湿疹宝宝咨询后成交
2. 过敏性鼻炎咨询后犹豫
3. 朋友推荐来咨询
4. 长期跟进后复购
5. 对比竞品后选择
## 文档
| 文档 | 说明 |
|---|---|
| `0-req.md` | 需求规格 |
| `1-design.md` | 技术设计 |
| `2-impl.md` | 实施计划(含测试计划) |
| `3-data-spec.md` | 模拟数据规格 |
## 测试
```bash
# 数据库测试
/usr/local/opt/postgresql@17/bin/psql -h db/socket -p 5434 -d wxchat_sales -f tests/test_db.sql
# 端到端测试
bash tests/test_e2e.sh
```
## 端口分配
| 服务 | 端口 |
|---|---|
| PostgreSQL | 5434 |
| Web 服务 | 8770 |
与现有系统(5433/8765)完全隔离。
+740
View File
@@ -0,0 +1,740 @@
#!/usr/bin/env python3
"""模拟销售设备采集代理 — 生成模拟微信数据并同步到中央 PostgreSQL。
用法:
python mock_sync.py --salesperson-id 1
python mock_sync.py --salesperson-id 1 --dsn "host=127.0.0.1 port=5434 dbname=wxchat_sales"
"""
from __future__ import annotations
import argparse
import datetime as dt
import hashlib
import json
import pathlib
import random
import sys
import psycopg2
from psycopg2.extras import execute_values
TZ = dt.timezone(dt.timedelta(hours=8))
# ---------------------------------------------------------------------------
# 数据库连接
# ---------------------------------------------------------------------------
DEFAULT_SOCKET = str(pathlib.Path(__file__).resolve().parent.parent / "db" / "socket")
DEFAULT_DSN = f"host={DEFAULT_SOCKET} port=5434 dbname=wxchat_sales"
# ---------------------------------------------------------------------------
# 销售人员信息
# ---------------------------------------------------------------------------
SALESPERSONS = {
1: {"name": "张伟", "wx_account": "wxid_zhangwei", "device_id": "macbook-zw-001"},
2: {"name": "李娜", "wx_account": "wxid_lina", "device_id": "macbook-ln-002"},
3: {"name": "王强", "wx_account": "wxid_wangqiang", "device_id": "macbook-wq-003"},
}
# ---------------------------------------------------------------------------
# 场景模板 — 真实客户对话
# ---------------------------------------------------------------------------
SCENARIO_TEMPLATES = [
{
"name": "湿疹宝宝咨询后成交",
"stage": "成交",
"contact": {
"nickname": "辰辰妈妈",
"remark": "辰辰妈-湿疹-2岁",
"industry": "宝妈",
"intent_level": "high",
},
"messages": [
("customer", "text", "你好,我家宝宝2岁,湿疹反复好几个月了,朋友推荐你这边益生菌"),
("sales", "text", "辰辰妈您好!宝宝湿疹确实让人心疼,请问现在湿疹主要在哪些部位?有用过什么药吗?"),
("customer", "text", "脸上和手臂都有,医生开了激素药膏,但停了就复发"),
("sales", "text", "理解,激素药膏只能暂时压制。益生菌是从肠道调节免疫,从根本上降低过敏反应。我们用的是丹麦进口的鼠李糖乳杆菌,有专门针对儿童湿疹的临床验证"),
("sales", "image", "[图片:临床验证报告截图]"),
("customer", "text", "这个是进口的?安全吗?2岁能吃吗?"),
("sales", "text", "是的,丹麦进口菌株,0岁以上就能用,无敏配方,不含牛奶蛋白和麸质。很多宝妈反馈坚持吃2-3个月湿疹明显好转"),
("customer", "text", "多少钱?怎么卖的?"),
("sales", "text", "单盒298元30袋,建议先吃3盒一个周期,3盒套餐798元算下来每天不到9块钱"),
("sales", "image", "[图片:产品包装图]"),
("customer", "text", "3盒798是吧,效果不好怎么办?"),
("sales", "text", "我们有售后指导,期间有任何问题随时找我。另外我发您几个同情况宝妈的反馈看看"),
("sales", "link", "[链接:宝妈真实反馈合集]"),
("customer", "text", "好的,那先来3盒试试"),
("sales", "text", "好的辰辰妈!3盒套餐798元,您方便现在付款吗?我这边给您安排发货"),
("customer", "text", "已经转过去了,麻烦尽快发货"),
("sales", "text", "收到!今天就给您发顺丰,预计后天到"),
("customer", "emoji", "[表情:谢谢]"),
],
},
{
"name": "过敏性鼻炎咨询后犹豫",
"stage": "异议处理",
"contact": {
"nickname": "乐乐妈",
"remark": "乐乐妈-鼻炎-5岁",
"industry": "宝妈",
"intent_level": "medium",
},
"messages": [
("customer", "text", "你好,听说益生菌对过敏性鼻炎有帮助?"),
("sales", "text", "乐乐妈您好!是的,益生菌可以调节肠道免疫,对过敏性鼻炎有改善作用。请问宝宝现在鼻炎什么症状?"),
("customer", "text", "早上起来一直打喷嚏流鼻涕,医生说是过敏性鼻炎"),
("sales", "text", "益生菌从肠道调节免疫系统,坚持吃可以逐渐降低过敏反应。我们的菌株是丹麦进口的,有临床验证"),
("customer", "text", "有没有副作用?安全吗?"),
("sales", "text", "很安全的,无敏配方,不含牛奶蛋白和麸质,5岁完全没问题"),
("customer", "text", "多少钱?"),
("sales", "text", "单盒2983盒套餐798"),
("customer", "text", "有点贵啊..."),
("sales", "text", "算下来每天不到9块钱,比吃药划算多了"),
("customer", "text", "我再想想,和老公商量一下"),
("sales", "text", "好的,不着急。您可以先看看这个科普文章"),
("sales", "link", "[链接:益生菌与儿童过敏科普]"),
("customer", "text", "好的,我看看"),
],
},
{
"name": "朋友推荐来咨询",
"stage": "需求发现",
"contact": {
"nickname": "果果妈妈",
"remark": "果果妈-推荐来的",
"industry": "宝妈",
"intent_level": "medium",
},
"messages": [
("customer", "text", "你好,是小林推荐我加你的,她说你家益生菌不错"),
("sales", "text", "果果妈您好!谢谢小林推荐,请问宝宝多大了?有什么情况吗?"),
("customer", "text", "1岁半,最近总是拉肚子,小林说吃益生菌调理一下"),
("sales", "text", "拉肚子多久了?有去看过医生吗?"),
("customer", "text", "断断续续一个多月了,医生说没什么大问题,让注意饮食"),
("sales", "text", "那益生菌确实可以帮到宝宝。我们的菌株是丹麦进口的,专门针对儿童肠道和免疫调节。1岁半完全可以吃"),
("customer", "text", "成分安全吗?"),
("sales", "text", "无敏配方,不含牛奶蛋白、麸质和大豆,很多敏宝妈妈都在用"),
("customer", "text", "好的,我先了解一下"),
("sales", "text", "没问题,我给您发个产品详情,您先看看"),
("sales", "link", "[链接:产品详情页]"),
],
},
{
"name": "长期跟进后复购",
"stage": "成交",
"contact": {
"nickname": "糖糖妈妈",
"remark": "糖糖妈-复购-湿疹已好转",
"industry": "宝妈",
"intent_level": "high",
},
"messages": [
("customer", "text", "你好,我想了解一下益生菌"),
("sales", "text", "糖糖妈您好!请问宝宝什么情况?"),
("customer", "text", "3岁,湿疹,反反复复的"),
("sales", "text", "湿疹确实需要从内调理。我们的益生菌丹麦进口菌株,专门针对儿童过敏"),
("customer", "text", "多少钱?"),
("sales", "text", "单盒2983盒套餐798"),
("customer", "text", "先了解一下,回头再说"),
("sales", "text", "好的,不着急"),
("sales", "text", "糖糖妈,宝宝最近湿疹怎么样了?"),
("customer", "text", "还是老样子,断不了根"),
("sales", "text", "湿疹确实需要坚持调理,益生菌一般吃2-3个月能看到明显改善"),
("customer", "text", "我再考虑考虑"),
("sales", "text", "糖糖妈,今天有个宝妈反馈说吃了2个月湿疹好多了,发您看看"),
("sales", "image", "[图片:宝妈反馈截图]"),
("customer", "text", "真的吗?那我也试试吧"),
("sales", "text", "好的!建议先拿3盒一个周期,798元"),
("customer", "text", "好的,付款吧"),
("sales", "text", "好的!我马上安排发货"),
("sales", "text", "糖糖妈,宝宝吃了这段时间效果怎么样?"),
("customer", "text", "比之前好多了,湿疹没那么频繁了"),
("sales", "text", "太好了!建议继续吃巩固一下,现在有6盒套餐1499更划算"),
("customer", "text", "好,那来6盒"),
("sales", "text", "好的!马上安排"),
],
},
{
"name": "对比竞品后选择",
"stage": "方案匹配",
"contact": {
"nickname": "壮壮妈妈",
"remark": "壮壮妈-对比合生元",
"industry": "宝妈",
"intent_level": "medium",
},
"messages": [
("customer", "text", "你好,我想给孩子买益生菌,但在对比你们和合生元"),
("sales", "text", "壮壮妈您好!对比是正常的。请问宝宝主要什么情况?"),
("customer", "text", "经常感冒,免疫力不太好"),
("sales", "text", "我们的菌株是丹麦进口的鼠李糖乳杆菌GG,有专门针对儿童免疫力的临床验证。合生元用的是国产菌株"),
("customer", "text", "但你们比合生元贵啊"),
("sales", "text", "菌株不一样,进口菌株的临床数据更充分,效果更有保障。算下来每天不到9块钱"),
("customer", "text", "有什么区别吗?"),
("sales", "image", "[图片:菌株对比表]"),
("sales", "text", "主要是菌株来源和临床验证数量不同。我们的LGG是全球研究最多的益生菌菌株之一"),
("customer", "text", "我再看看吧"),
("sales", "text", "好的,有问题随时问我"),
],
},
]
# ---------------------------------------------------------------------------
# 场景变体 — 给不同销售使用
# ---------------------------------------------------------------------------
SCENARIO_VARIANTS = [
{
"name": "食物过敏咨询后成交",
"stage": "成交",
"contact": {
"nickname": "依依妈",
"remark": "依依妈-食物过敏-3岁",
"industry": "宝妈",
"intent_level": "high",
},
"messages": [
("customer", "text", "你好,我家宝宝对牛奶蛋白过敏,听说益生菌能帮助脱敏?"),
("sales", "text", "依依妈您好!是的,益生菌可以帮助调节肠道免疫,对食物过敏有辅助改善作用。请问宝宝现在多大了?"),
("customer", "text", "3岁,查出来对牛奶蛋白和鸡蛋过敏"),
("sales", "text", "理解,食物过敏的宝宝确实需要从肠道调理。我们的菌株是丹麦进口LGG,有临床数据支持对食物过敏的改善"),
("customer", "text", "无敏配方吗?我家对牛奶蛋白过敏"),
("sales", "text", "是的,完全无敏配方,不含牛奶蛋白、麸质和大豆,过敏宝宝可以放心吃"),
("sales", "image", "[图片:成分表]"),
("customer", "text", "看着成分确实干净,多少钱?"),
("sales", "text", "单盒298,3盒套餐798,建议先吃一个周期"),
("customer", "text", "好,来3盒吧"),
("sales", "text", "好的依依妈!3盒798元,给您安排发货"),
("customer", "text", "付款了,麻烦尽快发"),
("sales", "text", "收到!今天就发顺丰"),
],
},
{
"name": "腹泻咨询后成交",
"stage": "成交",
"contact": {
"nickname": "阳阳妈",
"remark": "阳阳妈-腹泻-1岁",
"industry": "宝妈",
"intent_level": "high",
},
"messages": [
("customer", "text", "你好,宝宝1岁,最近老拉肚子,朋友说益生菌有用"),
("sales", "text", "阳阳妈您好!益生菌对宝宝腹泻确实有帮助。请问拉肚子多久了?"),
("customer", "text", "一个多星期了,吃了药好点,停了又拉"),
("sales", "text", "这种情况益生菌可以帮到宝宝调节肠道菌群。我们的丹麦进口菌株专门针对儿童肠道健康"),
("customer", "text", "1岁能吃吗?"),
("sales", "text", "可以的,0岁以上就能用,无敏配方很安全"),
("customer", "text", "多少钱?"),
("sales", "text", "单盒2983盒套餐798"),
("customer", "text", "先来3盒试试"),
("sales", "text", "好的!3盒798元,马上安排发货"),
("customer", "text", "好的,已付款"),
],
},
{
"name": "价格异议后放弃",
"stage": "异议处理",
"contact": {
"nickname": "可可妈妈",
"remark": "可可妈-价格异议",
"industry": "宝妈",
"intent_level": "low",
},
"messages": [
("customer", "text", "益生菌多少钱?"),
("sales", "text", "可可妈您好!单盒2983盒套餐798"),
("customer", "text", "这么贵?网上益生菌才几十块"),
("sales", "text", "我们的是丹麦进口菌株,有临床验证,和网上几十块的不一样"),
("customer", "text", "但是也太贵了"),
("sales", "text", "算下来每天不到9块钱,效果有保障"),
("customer", "text", "我再看看吧"),
("sales", "text", "好的,有需要随时找我"),
],
},
{
"name": "安全顾虑后犹豫",
"stage": "异议处理",
"contact": {
"nickname": "贝贝妈妈",
"remark": "贝贝妈-安全顾虑",
"industry": "宝妈",
"intent_level": "medium",
},
"messages": [
("customer", "text", "你好,宝宝6个月,能吃益生菌吗?"),
("sales", "text", "贝贝妈您好!6个月以上的宝宝可以吃的。请问宝宝什么情况?"),
("customer", "text", "老是胀气,睡眠也不好"),
("sales", "text", "益生菌可以帮宝宝调节肠道,改善胀气。我们的丹麦进口菌株0岁以上可用"),
("customer", "text", "这么小的宝宝吃安全吗?有没有副作用?"),
("sales", "text", "很安全的,无敏配方,不含牛奶蛋白和麸质"),
("customer", "text", "我还是有点担心,毕竟宝宝才6个月"),
("sales", "text", "理解您的顾虑,很多宝妈也有同样的担心。其实益生菌是很成熟的品类,我们的菌株有大量临床数据支持"),
("customer", "text", "我再想想吧"),
("sales", "text", "好的,不着急。有问题随时问我"),
],
},
{
"name": "过早报价后被拒",
"stage": "异议处理",
"contact": {
"nickname": "欢欢妈妈",
"remark": "欢欢妈-过早报价",
"industry": "宝妈",
"intent_level": "low",
},
"messages": [
("customer", "text", "益生菌多少钱?"),
("sales", "text", "您好!单盒2983盒7986盒1499"),
("customer", "text", "哦,这么贵"),
("sales", "text", "丹麦进口菌株,效果很好的"),
("customer", "text", "我先看看吧"),
("sales", "text", "好的,需要随时联系"),
("customer", "text", ""),
],
},
]
# ---------------------------------------------------------------------------
# 非客户联系人模板
# ---------------------------------------------------------------------------
NON_CUSTOMER_TEMPLATES = [
{
"nickname": "韩国代购-小美",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "【韩国直邮】儿童维生素团购开始啦,DHA+益生菌组合装只要199!"),
("other", "text", "需要的姐妹私我~"),
],
},
{
"nickname": "母婴用品批发",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "姐,我们新款益生菌做活动,比你现在用的便宜一半,要不要看看?"),
("other", "text", "保证正品,支持验货"),
("other", "emoji", "[表情:微笑]"),
],
},
{
"nickname": "朵朵阿姨",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "在吗?"),
("other", "text", "你朋友圈那个是什么产品?"),
("other", "text", "好的知道了"),
],
},
{
"nickname": "老婆 ❤️",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "今晚回来吃饭吗?"),
("other", "text", "孩子放学了"),
("other", "text", "周末去哪玩?"),
("other", "text", "好的"),
],
},
{
"nickname": "快递小哥",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "你的快递到了,放门口了"),
("other", "text", "签收一下"),
],
},
{
"nickname": "大学同学",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "好久不见啊"),
("other", "text", "最近怎么样"),
("other", "text", "改天聚聚"),
],
},
{
"nickname": "同行-小张",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "张哥,最近怎么样?"),
("other", "text", "你们那边转化率怎么样?"),
("other", "text", "交流一下经验"),
],
},
{
"nickname": "健身教练",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "明天还来训练吗?"),
("other", "text", "记得带毛巾"),
],
},
{
"nickname": "房产中介小王",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "哥,最近有考虑换房吗?"),
("other", "text", "新盘有优惠"),
],
},
{
"nickname": "表妹",
"remark": "",
"is_group": False,
"messages": [
("other", "text", "哥,你那边益生菌我朋友想了解一下"),
("other", "text", "我推她微信给你"),
("other", "text", "好的"),
],
},
]
# ---------------------------------------------------------------------------
# 群聊模板
# ---------------------------------------------------------------------------
GROUP_TEMPLATES = [
{
"nickname": "宝妈育儿交流群",
"is_group": True,
"messages": [
("other", "text", "[群公告] 本群禁发广告,违者移出"),
("other", "text", "有没有宝妈推荐好的儿童面霜?"),
("other", "text", "我家用的那个丝塔芙还不错"),
("other", "text", "谢谢推荐"),
("other", "text", "有没有宝妈推荐好的儿童霜?"),
("other", "text", "大家宝宝都几岁上幼儿园的?"),
("other", "text", "我家3岁送的"),
("other", "text", "这么早吗?会不会太小了"),
("other", "text", "还好,适应期过了就好了"),
],
},
{
"nickname": "过敏宝宝互助群",
"is_group": True,
"messages": [
("other", "text", "我家宝宝湿疹终于好了"),
("other", "text", "怎么好的?用的什么?"),
("other", "text", "吃了益生菌加上注意饮食"),
("other", "text", "你们都用的什么益生菌?"),
("other", "text", "益生菌真的有用吗?"),
("other", "text", "我觉得有用的,坚持吃了2个月"),
("other", "text", "我也想试试"),
("other", "text", "可以试试,从肠道调理确实有道理"),
("other", "text", "有没有副作用?"),
("other", "text", "我们吃的没什么副作用"),
("other", "text", "好的,谢谢分享"),
],
},
{
"nickname": "育儿干货分享群",
"is_group": True,
"messages": [
("other", "text", "今天分享一篇关于儿童免疫力的文章"),
("other", "link", "[链接:儿童免疫力科普]"),
("other", "text", "写得好,收藏了"),
("other", "text", "谢谢分享"),
("other", "text", "有没有关于过敏的科普?"),
("other", "text", "有的,我找找"),
],
},
]
# ---------------------------------------------------------------------------
# 每个销售的联系人分配方案
# ---------------------------------------------------------------------------
# 格式: (scenario_index_or_variant_name, contact_nickname, contact_remark)
# scenario_index 指向 SCENARIO_TEMPLATES, variant_name 指向 SCENARIO_VARIANTS
SALESPERSON_ASSIGNMENTS = {
1: [ # 张伟 — 资深
("scenario_0", None, None), # 湿疹成交
("scenario_2", None, None), # 朋友推荐
("variant_食物过敏咨询后成交", None, None),
("variant_腹泻咨询后成交", None, None),
("scenario_3", None, None), # 长期跟进复购
# 潜在客户
("scenario_2", "小宇妈", "小宇妈-咨询"),
("scenario_2", "安安妈妈", "安安妈-咨询"),
# 非客户
("non_customer", "韩国代购-小美", ""),
("non_customer", "母婴用品批发", ""),
("non_customer", "朵朵阿姨", ""),
("non_customer", "老婆 ❤️", ""),
# 群聊
("group", "宝妈育儿交流群", ""),
("group", "过敏宝宝互助群", ""),
],
2: [ # 李娜 — 新人
("scenario_1", None, None), # 鼻炎犹豫
("variant_价格异议后放弃", None, None),
("scenario_4", None, None), # 竞品对比
("variant_过早报价后被拒", None, None),
("scenario_2", "甜甜妈", "甜甜妈-推荐咨询"),
# 潜在客户
("scenario_2", "球球妈妈", "球球妈-咨询"),
("scenario_2", "米米妈", "米米妈-咨询"),
# 非客户
("non_customer", "同行-小张", ""),
("non_customer", "快递小哥", ""),
("non_customer", "大学同学", ""),
# 群聊
("group", "宝妈育儿交流群", ""),
("group", "育儿干货分享群", ""),
],
3: [ # 王强 — 中等
("scenario_4", None, None), # 竞品对比
("scenario_3", None, None), # 长期跟进复购
("variant_腹泻咨询后成交", None, None),
("variant_安全顾虑后犹豫", None, None),
("scenario_2", "星星妈", "星星妈-推荐咨询"),
# 潜在客户
("scenario_2", "晨晨妈妈", "晨晨妈-咨询"),
("scenario_2", "露露妈", "露露妈-咨询"),
# 非客户
("non_customer", "健身教练", ""),
("non_customer", "房产中介小王", ""),
("non_customer", "表妹", ""),
# 群聊
("group", "过敏宝宝互助群", ""),
("group", "宝妈育儿交流群", ""),
],
}
# ---------------------------------------------------------------------------
# 生成与同步逻辑
# ---------------------------------------------------------------------------
def get_variant(name: str):
for v in SCENARIO_VARIANTS:
if v["name"] == name:
return v
raise KeyError(f"variant not found: {name}")
def generate_contacts_for_salesperson(sp_id: int):
"""返回 [(nickname, remark, is_group, wx_username, scenario_or_template)]"""
assignments = SALESPERSON_ASSIGNMENTS.get(sp_id, [])
result = []
for i, (kind, nick_override, remark_override) in enumerate(assignments):
if kind.startswith("scenario_"):
idx = int(kind.split("_")[1])
tpl = SCENARIO_TEMPLATES[idx]
nick = nick_override or tpl["contact"]["nickname"]
remark = remark_override or tpl["contact"].get("remark", "")
is_group = False
wx_username = f"{SALESPERSONS[sp_id]['wx_account']}_contact_{i+1}"
result.append((nick, remark, is_group, wx_username, tpl))
elif kind.startswith("variant_"):
tpl = get_variant(kind[8:])
nick = nick_override or tpl["contact"]["nickname"]
remark = remark_override or tpl["contact"].get("remark", "")
is_group = False
wx_username = f"{SALESPERSONS[sp_id]['wx_account']}_contact_{i+1}"
result.append((nick, remark, is_group, wx_username, tpl))
elif kind == "non_customer":
nick = nick_override
remark = remark_override or ""
# 找到对应模板
tpl = None
for t in NON_CUSTOMER_TEMPLATES:
if t["nickname"] == nick:
tpl = t
break
if tpl is None:
tpl = NON_CUSTOMER_TEMPLATES[i % len(NON_CUSTOMER_TEMPLATES)]
nick = tpl["nickname"]
is_group = False
wx_username = f"{SALESPERSONS[sp_id]['wx_account']}_contact_{i+1}"
result.append((nick, remark, is_group, wx_username, tpl))
elif kind == "group":
nick = nick_override
remark = remark_override or ""
tpl = None
for t in GROUP_TEMPLATES:
if t["nickname"] == nick:
tpl = t
break
if tpl is None:
tpl = GROUP_TEMPLATES[i % len(GROUP_TEMPLATES)]
nick = tpl["nickname"]
is_group = True
wx_username = f"{SALESPERSONS[sp_id]['wx_account']}_group_{i+1}@chatroom"
result.append((nick, remark, is_group, wx_username, tpl))
return result
def sync_to_central(pg, sp_id: int, contacts_data, run_counter: int = 1):
"""将联系人、会话、消息写入中央数据库。"""
sp_info = SALESPERSONS[sp_id]
source_shard = f"mock_{sp_id}"
base_time = dt.datetime(2026, 6, 15, 10, 0, 0, tzinfo=TZ)
# 每次运行追加的时间偏移
time_offset = dt.timedelta(days=run_counter * 7)
contact_ids = {}
conversation_ids = {}
with pg.cursor() as cur:
# 写入联系人
for nick, remark, is_group, wx_username, tpl in contacts_data:
display_name = remark or nick
cur.execute("""
INSERT INTO contact (salesperson_id, wx_username, nickname, remark, display_name, is_group)
VALUES (%s, %s, %s, %s, %s, %s)
ON CONFLICT (salesperson_id, wx_username) DO UPDATE SET
nickname=excluded.nickname, remark=excluded.remark,
display_name=excluded.display_name, is_group=excluded.is_group
RETURNING id
""", (sp_id, wx_username, nick, remark, display_name, is_group))
contact_ids[wx_username] = cur.fetchone()[0]
# 写入会话
for nick, remark, is_group, wx_username, tpl in contacts_data:
conv_type = "group" if is_group else "single"
cur.execute("""
INSERT INTO conversation (salesperson_id, contact_id, wx_identifier, conv_type, last_synced_at)
VALUES (%s, %s, %s, %s, now())
ON CONFLICT (salesperson_id, wx_identifier) DO UPDATE SET
contact_id=excluded.contact_id, last_synced_at=now()
RETURNING id
""", (sp_id, contact_ids[wx_username], wx_username, conv_type))
conversation_ids[wx_username] = cur.fetchone()[0]
# 写入消息
local_id_counter = (run_counter - 1) * 10000 # 每次运行从不同区间开始
msg_count = 0
batch = []
for nick, remark, is_group, wx_username, tpl in contacts_data:
conv_id = conversation_ids[wx_username]
contact_id = contact_ids[wx_username]
messages = tpl["messages"]
# 确定发送者
sp_wx = sp_info["wx_account"]
sp_name = sp_info["name"]
for j, (sender_role, msg_type, content) in enumerate(messages):
local_id_counter += 1
# 时间递增:每条消息间隔 2~30 分钟
msg_time = base_time + time_offset + dt.timedelta(minutes=j * random.randint(2, 30))
if sender_role == "customer":
sender_wx = wx_username if not is_group else f"wxid_customer_{j}"
sender_name = nick
elif sender_role == "sales":
sender_wx = sp_wx
sender_name = sp_name
else: # other
sender_wx = f"wxid_other_{j}" if is_group else wx_username
sender_name = nick
source_table = "Msg_" + hashlib.md5(wx_username.encode()).hexdigest()[:16]
batch.append((
sp_id, conv_id, sender_wx, sender_name, msg_type,
content, content, msg_time,
source_shard, source_table, local_id_counter,
))
msg_count += 1
if len(batch) >= 2000:
execute_values(cur, """
INSERT INTO message (salesperson_id, conversation_id, sender_wx_username,
sender_display_name, message_type, raw_content, normalized_content,
created_at, source_shard, source_table, source_local_id)
VALUES %s ON CONFLICT (source_shard, source_table, source_local_id) DO NOTHING
""", batch, page_size=2000)
pg.commit()
batch = []
if batch:
execute_values(cur, """
INSERT INTO message (salesperson_id, conversation_id, sender_wx_username,
sender_display_name, message_type, raw_content, normalized_content,
created_at, source_shard, source_table, source_local_id)
VALUES %s ON CONFLICT (source_shard, source_table, source_local_id) DO NOTHING
""", batch, page_size=2000)
pg.commit()
return len(contact_ids), msg_count
def get_run_counter(pg, sp_id: int) -> int:
"""根据已有消息数推断运行次数。"""
with pg.cursor() as cur:
cur.execute("""
SELECT count(DISTINCT source_local_id / 10000) FROM message
WHERE salesperson_id = %s AND source_shard = %s
""", (sp_id, f"mock_{sp_id}"))
result = cur.fetchone()[0]
return int(result or 0) + 1
def main():
ap = argparse.ArgumentParser(description="模拟采集代理")
ap.add_argument("--salesperson-id", type=int, required=True)
ap.add_argument("--dsn", default=DEFAULT_DSN)
args = ap.parse_args()
sp_id = args.salesperson_id
if sp_id not in SALESPERSONS:
print(f"[ERROR] 未知销售 ID: {sp_id}", file=sys.stderr)
return 1
sp_info = SALESPERSONS[sp_id]
print(f"[INFO] 模拟采集代理启动: {sp_info['name']} (id={sp_id})")
pg = psycopg2.connect(args.dsn)
pg.autocommit = False
try:
run_counter = get_run_counter(pg, sp_id)
print(f"[INFO] 第 {run_counter} 次同步")
contacts_data = generate_contacts_for_salesperson(sp_id)
print(f"[INFO] 生成 {len(contacts_data)} 个联系人")
contact_count, msg_count = sync_to_central(pg, sp_id, contacts_data, run_counter)
with pg.cursor() as cur:
cur.execute("SELECT count(*) FROM message WHERE salesperson_id=%s", (sp_id,))
total_msgs = cur.fetchone()[0]
print(json.dumps({
"salesperson_id": sp_id,
"salesperson_name": sp_info["name"],
"run": run_counter,
"contacts": contact_count,
"new_messages": msg_count,
"total_messages": total_msgs,
}, ensure_ascii=False, indent=2))
return 0
except Exception as exc:
pg.rollback()
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
finally:
pg.close()
if __name__ == "__main__":
sys.exit(main())
+452
View File
@@ -0,0 +1,452 @@
#!/usr/bin/env python3
"""AI 分析服务 — 客户识别 + 沟通摘要(规则引擎 / LLM 模式)。
用法:
python analyze.py --mode rule
python analyze.py --mode llm
python analyze.py --mode llm --force
环境变量:
DASHSCOPE_API_KEY — 千问 API Key
"""
from __future__ import annotations
import argparse
import datetime as dt
import json
import os
import pathlib
import sys
import urllib.request
import urllib.error
import psycopg2
TZ = dt.timezone(dt.timedelta(hours=8))
DEFAULT_SOCKET = str(pathlib.Path(__file__).resolve().parent.parent / "db" / "socket")
DEFAULT_DSN = f"host={DEFAULT_SOCKET} port=5434 dbname=wxchat_sales"
DASHSCOPE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
DASHSCOPE_MODEL = "qwen-plus"
# ---------------------------------------------------------------------------
# 关键词定义
# ---------------------------------------------------------------------------
SYMPTOM_WORDS = [
"湿疹", "过敏", "鼻炎", "腹泻", "便秘", "免疫力", "体质", "红疹",
"胀气", "拉肚子", "打喷嚏", "流鼻涕", "感冒", "食物过敏", "牛奶蛋白过敏",
]
PRODUCT_WORDS = [
"益生菌", "菌株", "进口", "配方", "成分", "丹麦", "鼠李糖", "无敏",
"LGG", "临床", "调理", "肠道", "免疫",
]
PRICE_WORDS = [
"价格", "多少钱", "费用", "报价", "优惠", "套餐", "", "周期",
"298", "798", "1499", "", "便宜", "划算",
]
DEAL_WORDS = [
"下单", "付款", "发货", "来几盒", "定了", "来3盒",
"来6盒", "已付", "转过去", "签收", "已经转", "麻烦尽快发",
]
OBJECTION_WORDS = [
"", "太贵", "考虑", "商量", "老公", "副作用", "安全吗", "有没有效",
"没用过", "合生元", "对比", "担心", "再看看", "网上", "几十块",
]
# 阶段关键词(按优先级排序)
STAGE_KEYWORDS = [
("成交", DEAL_WORDS),
("报价", ["多少钱", "价格", "套餐", "298", "798", "1499", "报价"]),
("异议处理", OBJECTION_WORDS),
("需求发现", SYMPTOM_WORDS + ["什么情况", "多大了", "几个月", "症状"]),
("建立联系", ["你好", "在吗", "请问"]),
]
def contains_any(text: str, words: list[str]) -> bool:
return any(w in text for w in words)
def count_hits(text: str, words: list[str]) -> int:
return sum(1 for w in words if w in text)
# ---------------------------------------------------------------------------
# 客户识别
# ---------------------------------------------------------------------------
def identify_customer(messages: list[dict], salesperson_name: str | None = None) -> dict | None:
"""规则引擎判断联系人是否为客户。返回 None 表示不是客户。"""
if len(messages) <= 5:
return None
# 检查销售是否参与了对话(非客户联系人不会有销售回复)
if salesperson_name:
has_sales_reply = any(
m["sender_display_name"] == salesperson_name for m in messages
)
if not has_sales_reply:
return None
all_text = " ".join(m["normalized_content"] or "" for m in messages)
symptom_hits = count_hits(all_text, SYMPTOM_WORDS)
product_hits = count_hits(all_text, PRODUCT_WORDS)
price_hits = count_hits(all_text, PRICE_WORDS)
deal_hits = count_hits(all_text, DEAL_WORDS)
total_keyword_hits = symptom_hits + product_hits + price_hits + deal_hits
if total_keyword_hits < 2:
return None
# 意向等级
if deal_hits >= 1 and price_hits >= 1:
intent_level = "high"
elif (symptom_hits >= 1 and product_hits >= 1) or price_hits >= 1:
intent_level = "medium"
else:
intent_level = "low"
# 提取关键需求
key_needs = []
for symptom in SYMPTOM_WORDS:
if symptom in all_text and symptom not in key_needs:
key_needs.append(symptom)
if len(key_needs) >= 3:
break
# 行业
industry = "宝妈"
return {
"is_customer": True,
"customer_name": None, # 后续用 display_name 填充
"industry": industry,
"intent_level": intent_level,
"key_needs": key_needs,
"reason": f"消息数{len(messages)}条,命中关键词{total_keyword_hits}个(症状{symptom_hits}+产品{product_hits}+价格{price_hits}+成交{deal_hits}",
}
# ---------------------------------------------------------------------------
# 沟通摘要
# ---------------------------------------------------------------------------
def generate_summary(messages: list[dict], mode: str = "rule") -> dict:
"""生成沟通摘要。mode='rule' 用规则引擎,mode='llm' 用千问 LLM。"""
if mode == "llm":
llm_result = generate_summary_llm(messages)
if llm_result:
return llm_result
print("[WARN] LLM 调用失败,回退到规则模式", file=sys.stderr)
return generate_summary_rule(messages)
def generate_summary_llm(messages: list[dict]) -> dict | None:
"""调用千问 LLM 生成沟通摘要。"""
api_key = os.environ.get("DASHSCOPE_API_KEY")
if not api_key:
print("[WARN] 未设置 DASHSCOPE_API_KEY,回退到规则模式", file=sys.stderr)
return None
# 构建对话文本
dialog_lines = []
for m in messages:
sender = m["sender_display_name"] or "未知"
content = m["normalized_content"] or f"[{m['message_type']}]"
dialog_lines.append(f"{sender}: {content}")
dialog_text = "\n".join(dialog_lines)
# 截断过长的对话
if len(dialog_text) > 8000:
dialog_text = dialog_text[:4000] + "\n...(中间部分省略)...\n" + dialog_text[-4000:]
prompt = f"""你是一个微信销售对话分析助手。以下是销售与客户的微信对话记录,请分析并输出 JSON 格式的沟通摘要。
## 产品背景
益童宝儿童益生菌粉:丹麦进口菌株,主打小儿抗过敏(湿疹、鼻炎、食物过敏),298元/盒,3盒套餐798元,6盒套餐1499元。目标客户是宝妈。
## 对话记录
{dialog_text}
## 输出要求
请输出严格的 JSON,字段如下:
- summary: 沟通核心要点总结(2-4句话,概括客户需求、销售策略、关键转折和结果)
- stage: 销售阶段,取值之一:建立联系/需求发现/报价/异议处理/成交
- key_points: 如果已成交,列出关键成交点(促成成交的关键因素,如信任建立、案例背书、价格拆解等);如果未成交,列出需要突破的问题点(阻碍成交的核心原因,如价格顾虑、安全担忧、决策链阻碍等)。返回数组,每条一句话。
- objections: 客户提出的异议或顾虑列表(如价格贵、安全顾虑、效果质疑等,空数组表示无)
- next_action: 下一步行动建议(具体可执行)
只输出 JSON,不要其他文字。"""
body = json.dumps({
"model": DASHSCOPE_MODEL,
"messages": [
{"role": "system", "content": "你是微信销售对话分析助手,擅长从对话中提取核心信息。"},
{"role": "user", "content": prompt},
],
"response_format": {"type": "json_object"},
"temperature": 0.3,
}).encode("utf-8")
req = urllib.request.Request(
DASHSCOPE_URL,
data=body,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
print(f" [LLM] 调用千问 ({len(messages)} 条消息)...", file=sys.stderr, flush=True)
with urllib.request.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"]
parsed = json.loads(content)
return {
"summary": parsed.get("summary", ""),
"stage": parsed.get("stage", "建立联系"),
"key_points": parsed.get("key_points", []),
"objections": parsed.get("objections", []),
"next_action": parsed.get("next_action", "继续跟进"),
}
except Exception as exc:
print(f"[WARN] LLM 调用失败: {exc}", file=sys.stderr)
return None
def generate_summary_rule(messages: list[dict]) -> dict:
"""规则引擎生成沟通摘要。"""
all_text = " ".join(m["normalized_content"] or "" for m in messages)
# 阶段判断(按优先级)
stage = "建立联系"
for stage_name, keywords in STAGE_KEYWORDS:
if contains_any(all_text, keywords):
stage = stage_name
break
# 异议提取
objections = []
for word in OBJECTION_WORDS:
if word in all_text and word not in objections:
objections.append(word)
if not objections and stage == "异议处理":
objections = ["其他异议"]
# 摘要文本:取前 5 条和后 3 条消息拼接
summary_parts = []
for m in messages[:5]:
sender = m["sender_display_name"] or "未知"
content = m["normalized_content"] or f"[{m['message_type']}]"
summary_parts.append(f"{sender}: {content}")
if len(messages) > 8:
summary_parts.append("...")
for m in messages[-3:]:
sender = m["sender_display_name"] or "未知"
content = m["normalized_content"] or f"[{m['message_type']}]"
summary_parts.append(f"{sender}: {content}")
summary = " | ".join(summary_parts)
# 下一步建议
next_action = "继续跟进"
if stage == "成交":
next_action = "安排发货并跟进使用效果,适时推荐复购"
elif stage == "异议处理":
next_action = "发送案例和科普文章,3天后跟进"
elif stage == "报价":
next_action = "等待客户反馈,适时推荐套餐"
elif stage == "需求发现":
next_action = "深入了解客户需求,介绍产品优势"
elif stage == "建立联系":
next_action = "保持互动,寻找需求切入点"
# 关键点(成交点或突破点)
key_points = []
if stage == "成交":
if "临床" in all_text or "验证" in all_text:
key_points.append("临床验证数据增强信任")
if "反馈" in all_text or "案例" in all_text:
key_points.append("真实案例背书打消顾虑")
if "798" in all_text or "套餐" in all_text:
key_points.append("套餐价格拆解降低价格敏感度")
if "售后" in all_text or "指导" in all_text:
key_points.append("售后承诺降低试错风险")
if not key_points:
key_points.append("客户需求明确,销售及时跟进促成成交")
else:
if "" in all_text or "太贵" in all_text:
key_points.append("价格顾虑:需进一步拆解日均成本或强调产品差异")
if "安全" in all_text or "副作用" in all_text:
key_points.append("安全顾虑:需提供更多无敏配方和临床数据")
if "考虑" in all_text or "商量" in all_text or "老公" in all_text:
key_points.append("决策链阻碍:需提供资料支持客户与家人沟通")
if "合生元" in all_text or "对比" in all_text:
key_points.append("竞品对比:需强化菌株差异和临床优势")
if "效果" in all_text and ("不好" in all_text or "没用" in all_text):
key_points.append("效果质疑:需提供更多真实反馈和售后保障")
if not key_points:
key_points.append("客户意向尚不明确,需持续跟进挖掘需求")
return {
"summary": summary[:500],
"stage": stage,
"key_points": key_points,
"objections": objections,
"next_action": next_action,
}
# ---------------------------------------------------------------------------
# 主流程
# ---------------------------------------------------------------------------
def main():
ap = argparse.ArgumentParser(description="AI 分析服务")
ap.add_argument("--dsn", default=DEFAULT_DSN)
ap.add_argument("--mode", choices=["rule", "llm"], default="rule")
ap.add_argument("--force", action="store_true", help="强制重新分析")
args = ap.parse_args()
if args.mode == "llm" and not os.environ.get("DASHSCOPE_API_KEY"):
print("[WARN] 未设置 DASHSCOPE_API_KEY 环境变量,将使用规则模式", file=sys.stderr)
pg = psycopg2.connect(args.dsn)
pg.autocommit = False
try:
with pg.cursor() as cur:
# 获取需要分析的联系人
if args.force:
cur.execute("""
SELECT c.id, c.salesperson_id, c.display_name, c.is_group,
c.wx_username
FROM contact c
WHERE c.is_group = FALSE
ORDER BY c.id
""")
else:
cur.execute("""
SELECT c.id, c.salesperson_id, c.display_name, c.is_group,
c.wx_username
FROM contact c
LEFT JOIN customer cu ON cu.contact_id = c.id AND cu.salesperson_id = c.salesperson_id
WHERE c.is_group = FALSE
AND (cu.id IS NULL OR cu.last_analysis IS NULL)
ORDER BY c.id
""")
contacts = cur.fetchall()
customers_created = 0
summaries_generated = 0
for contact_id, sp_id, display_name, is_group, wx_username in contacts:
# 获取该联系人的所有消息
with pg.cursor() as cur:
cur.execute("""
SELECT m.normalized_content, m.sender_display_name, m.message_type,
m.created_at, conv.id
FROM message m
JOIN conversation conv ON conv.id = m.conversation_id
WHERE conv.contact_id = %s
ORDER BY m.created_at
""", (contact_id,))
rows = cur.fetchall()
if not rows:
continue
messages = [
{
"normalized_content": r[0],
"sender_display_name": r[1],
"message_type": r[2],
"created_at": r[3],
}
for r in rows
]
# 获取销售名用于过滤
with pg.cursor() as cur2:
cur2.execute("SELECT name FROM salesperson WHERE id=%s", (sp_id,))
sp_name = cur2.fetchone()[0]
# 客户识别
result = identify_customer(messages, salesperson_name=sp_name)
if result is None:
# 不是客户,跳过
continue
conv_id = rows[0][4]
# 沟通摘要
summary_result = generate_summary(messages, mode=args.mode)
# 写入 customer 表
with pg.cursor() as cur:
cur.execute("""
INSERT INTO customer (salesperson_id, contact_id, customer_name, industry,
intent_level, key_needs, reason, summary, stage, key_points, objections,
next_action, last_analysis)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())
ON CONFLICT (salesperson_id, contact_id) DO UPDATE SET
customer_name=excluded.customer_name, industry=excluded.industry,
intent_level=excluded.intent_level, key_needs=excluded.key_needs,
reason=excluded.reason, summary=excluded.summary, stage=excluded.stage,
key_points=excluded.key_points,
objections=excluded.objections, next_action=excluded.next_action,
last_analysis=now()
""", (
sp_id, contact_id, display_name, result["industry"],
result["intent_level"], result["key_needs"], result["reason"],
summary_result["summary"], summary_result["stage"],
summary_result["key_points"],
summary_result["objections"], summary_result["next_action"],
))
customers_created += 1
summaries_generated += 1
pg.commit()
# 统计
with pg.cursor() as cur:
cur.execute("SELECT count(*) FROM customer")
total_customers = cur.fetchone()[0]
cur.execute("SELECT intent_level, count(*) FROM customer GROUP BY intent_level ORDER BY intent_level")
intent_dist = {r[0]: r[1] for r in cur.fetchall()}
cur.execute("SELECT stage, count(*) FROM customer GROUP BY stage ORDER BY count(*) DESC")
stage_dist = {r[0]: r[1] for r in cur.fetchall()}
print(json.dumps({
"mode": args.mode,
"contacts_analyzed": len(contacts),
"customers_identified": customers_created,
"summaries_generated": summaries_generated,
"total_customers": total_customers,
"intent_distribution": intent_dist,
"stage_distribution": stage_dist,
}, ensure_ascii=False, indent=2))
return 0
except Exception as exc:
pg.rollback()
print(f"[ERROR] {exc}", file=sys.stderr)
return 1
finally:
pg.close()
if __name__ == "__main__":
sys.exit(main())
+59
View File
@@ -0,0 +1,59 @@
#!/bin/sh
set -eu
# ============================================
# 微信营销管理系统 MVP - 数据库初始化
# ============================================
ROOT="$(cd "$(dirname "$0")" && pwd)"
BIN="/usr/local/opt/postgresql@17/bin"
DATA="$ROOT/data"
SOCKET="$ROOT/socket"
LOG="$ROOT/postgres.log"
DBNAME="wxchat_sales"
mkdir -p "$SOCKET"
chmod 700 "$SOCKET"
# 检查 PostgreSQL 17 是否安装
if [ ! -x "$BIN/pg_ctl" ]; then
echo "[ERROR] 未找到 PostgreSQL 17: $BIN/pg_ctl"
echo "请安装: brew install postgresql@17"
exit 1
fi
# 初始化数据目录(仅首次)
if [ ! -f "$DATA/PG_VERSION" ]; then
echo "[INFO] 初始化 PostgreSQL 数据目录..."
"$BIN/initdb" -D "$DATA" --encoding=UTF8 --locale=en_US.UTF-8 \
--auth-local=trust --auth-host=trust
fi
# 启动 PostgreSQL(端口 5434
if ! "$BIN/pg_ctl" -D "$DATA" status >/dev/null 2>&1; then
echo "[INFO] 启动 PostgreSQL (端口 5434)..."
"$BIN/pg_ctl" -D "$DATA" -l "$LOG" \
-o "-p 5434 -h '' -k $SOCKET" start
# 等待就绪
for i in $(seq 1 10); do
if "$BIN/psql" -h "$SOCKET" -p 5434 -d postgres -c "SELECT 1" >/dev/null 2>&1; then
break
fi
sleep 0.5
done
fi
# 创建数据库(幂等)
if ! "$BIN/psql" -h "$SOCKET" -p 5434 -d postgres -c "SELECT 1 FROM pg_database WHERE datname='$DBNAME'" | grep -q 1; then
echo "[INFO] 创建数据库: $DBNAME"
"$BIN/createdb" -h "$SOCKET" -p 5434 "$DBNAME"
fi
# 执行建表 SQL
echo "[INFO] 执行 schema.sql..."
"$BIN/psql" -h "$SOCKET" -p 5434 -d "$DBNAME" -f "$ROOT/schema.sql"
# 验证
COUNT=$("$BIN/psql" -h "$SOCKET" -p 5434 -d "$DBNAME" -t -c "SELECT count(*) FROM salesperson")
echo "[OK] 数据库就绪,销售人员数量: $COUNT"
echo "[OK] 连接方式: $BIN/psql -h $SOCKET -p 5434 -d $DBNAME"
+106
View File
@@ -0,0 +1,106 @@
-- 微信营销管理系统 MVP 建表 SQL
-- 数据库: wxchat_sales
-- 销售人员
CREATE TABLE IF NOT EXISTS salesperson (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
team TEXT,
wx_account TEXT NOT NULL,
device_id TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 联系人(微信好友)
CREATE TABLE IF NOT EXISTS contact (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id) ON DELETE CASCADE,
wx_username TEXT NOT NULL,
nickname TEXT,
remark TEXT,
display_name TEXT NOT NULL,
is_group BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(salesperson_id, wx_username)
);
-- 会话
CREATE TABLE IF NOT EXISTS conversation (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id) ON DELETE CASCADE,
contact_id INT REFERENCES contact(id) ON DELETE CASCADE,
wx_identifier TEXT NOT NULL,
conv_type TEXT NOT NULL DEFAULT 'single',
last_synced_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(salesperson_id, wx_identifier)
);
-- 消息
CREATE TABLE IF NOT EXISTS message (
id BIGSERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id) ON DELETE CASCADE,
conversation_id INT NOT NULL REFERENCES conversation(id) ON DELETE CASCADE,
sender_wx_username TEXT NOT NULL,
sender_display_name TEXT,
message_type TEXT NOT NULL,
raw_content TEXT,
normalized_content TEXT,
created_at TIMESTAMPTZ NOT NULL,
source_shard TEXT NOT NULL,
source_table TEXT NOT NULL,
source_local_id BIGINT NOT NULL,
UNIQUE(source_shard, source_table, source_local_id)
);
CREATE INDEX IF NOT EXISTS idx_msg_conv_time ON message(conversation_id, created_at);
CREATE INDEX IF NOT EXISTS idx_msg_salesperson ON message(salesperson_id);
CREATE INDEX IF NOT EXISTS idx_msg_created_at ON message(created_at);
-- 客户(AI 识别)
CREATE TABLE IF NOT EXISTS customer (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id) ON DELETE CASCADE,
contact_id INT NOT NULL REFERENCES contact(id) ON DELETE CASCADE,
customer_name TEXT,
industry TEXT,
intent_level TEXT,
key_needs TEXT[],
reason TEXT,
summary TEXT,
stage TEXT,
key_points TEXT[],
objections TEXT[],
next_action TEXT,
last_analysis TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(salesperson_id, contact_id)
);
CREATE INDEX IF NOT EXISTS idx_customer_salesperson ON customer(salesperson_id);
CREATE INDEX IF NOT EXISTS idx_customer_intent ON customer(intent_level);
-- 成交记录
CREATE TABLE IF NOT EXISTS deal (
id SERIAL PRIMARY KEY,
salesperson_id INT NOT NULL REFERENCES salesperson(id) ON DELETE CASCADE,
customer_id INT REFERENCES customer(id) ON DELETE SET NULL,
contact_id INT REFERENCES contact(id) ON DELETE SET NULL,
product_name TEXT NOT NULL,
amount NUMERIC(12,2) NOT NULL,
deal_date DATE NOT NULL,
status TEXT NOT NULL DEFAULT 'closed',
notes TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_deal_salesperson ON deal(salesperson_id);
CREATE INDEX IF NOT EXISTS idx_deal_customer ON deal(customer_id);
CREATE INDEX IF NOT EXISTS idx_deal_date ON deal(deal_date);
-- 预置 3 名销售人员(幂等)
INSERT INTO salesperson (id, name, team, wx_account, device_id) VALUES
(1, '张伟', '华东团队', 'wxid_zhangwei', 'macbook-zw-001'),
(2, '李娜', '华东团队', 'wxid_lina', 'macbook-ln-002'),
(3, '王强', '华南团队', 'wxid_wangqiang', 'macbook-wq-003')
ON CONFLICT (id) DO NOTHING;
+3
View File
@@ -0,0 +1,3 @@
psycopg2-binary>=2.9
fastapi>=0.111
uvicorn>=0.30
+132
View File
@@ -0,0 +1,132 @@
#!/bin/bash
set -eu
# ============================================
# 微信营销管理系统 MVP - 一键启动
# ============================================
ROOT="$(cd "$(dirname "$0")" && pwd)"
BIN="/usr/local/opt/postgresql@17/bin"
PID_FILE="$ROOT/.run.pid"
cd "$ROOT"
echo "=========================================="
echo " 微信营销管理系统 MVP 启动"
echo "=========================================="
# 1. 检查 PostgreSQL 17
if [ ! -x "$BIN/pg_ctl" ]; then
echo "[ERROR] 未找到 PostgreSQL 17: $BIN/pg_ctl"
echo "请安装: brew install postgresql@17"
exit 1
fi
# 2. 初始化并启动数据库
echo "[1/5] 初始化数据库..."
bash "$ROOT/db/init_db.sh"
# 3. 运行模拟采集代理
echo ""
echo "[2/5] 运行模拟采集代理..."
for sp_id in 1 2 3; do
python "$ROOT/agent/mock_sync.py" --salesperson-id $sp_id
done
# 4. AI 分析
echo ""
echo "[3/5] AI 分析..."
if [ -n "$DASHSCOPE_API_KEY" ]; then
echo " 使用千问 LLM 模式"
python "$ROOT/ai/analyze.py" --mode llm
else
echo " 未设置 DASHSCOPE_API_KEY,使用规则模式"
python "$ROOT/ai/analyze.py" --mode rule
fi
# 5. 预置成交数据
echo ""
echo "[4/5] 预置成交数据..."
PSQL="$BIN/psql -h $ROOT/db/socket -p 5434 -d wxchat_sales"
$PSQL -c "
-- 清理测试数据和重复数据
DELETE FROM deal WHERE product_name = '测试产品';
DELETE FROM deal d1 USING deal d2
WHERE d1.id > d2.id
AND d1.customer_id = d2.customer_id
AND d1.product_name = d2.product_name
AND d1.deal_date = d2.deal_date;
-- 预置成交(用 NOT EXISTS 防重复)
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-05', 'closed'
FROM salesperson s, customer cu
WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '辰辰%'
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-05');
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-08', 'closed'
FROM salesperson s, customer cu
WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '依依%'
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-08');
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
SELECT s.id, cu.id, '益童宝6盒套餐', 1499, '2026-07-12', 'closed'
FROM salesperson s, customer cu
WHERE s.id = 1 AND cu.salesperson_id = 1 AND cu.customer_name LIKE '糖糖%'
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝6盒套餐' AND deal_date = '2026-07-12');
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
SELECT s.id, cu.id, '益童宝单盒', 298, '2026-07-10', 'closed'
FROM salesperson s, customer cu
WHERE s.id = 2 AND cu.salesperson_id = 2 AND cu.customer_name LIKE '甜甜%'
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝单盒' AND deal_date = '2026-07-10');
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-07-06', 'closed'
FROM salesperson s, customer cu
WHERE s.id = 3 AND cu.salesperson_id = 3 AND cu.customer_name LIKE '阳阳%'
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-07-06');
INSERT INTO deal (salesperson_id, customer_id, product_name, amount, deal_date, status)
SELECT s.id, cu.id, '益童宝3盒套餐', 798, '2026-06-20', 'closed'
FROM salesperson s, customer cu
WHERE s.id = 3 AND cu.salesperson_id = 3 AND cu.customer_name LIKE '糖糖%'
AND NOT EXISTS (SELECT 1 FROM deal WHERE customer_id = cu.id AND product_name = '益童宝3盒套餐' AND deal_date = '2026-06-20');
" 2>/dev/null || true
DEAL_COUNT=$($PSQL -t -c "SELECT count(*) FROM deal")
echo " 成交记录: $DEAL_COUNT"
# 6. 启动 Web 服务
echo ""
echo "[5/5] 启动 Web 服务..."
# 检查端口是否被占用
if lsof -ti:8770 >/dev/null 2>&1; then
echo " 端口 8770 已被占用,先停止..."
kill $(lsof -ti:8770) 2>/dev/null || true
sleep 1
fi
nohup python -m uvicorn web.server:app --port 8770 --host 127.0.0.1 \
> "$ROOT/web.log" 2>&1 &
echo $! > "$PID_FILE"
sleep 2
# 验证
if curl -s http://127.0.0.1:8770/api/dashboard >/dev/null 2>&1; then
echo ""
echo "=========================================="
echo " ✓ 系统启动成功!"
echo "=========================================="
echo ""
echo " Web 界面: http://127.0.0.1:8770"
echo " API 文档: http://127.0.0.1:8770/docs"
echo ""
echo " 停止系统: bash stop_demo.sh"
echo "=========================================="
else
echo "[ERROR] Web 服务启动失败,请查看 web.log"
cat "$ROOT/web.log"
exit 1
fi
+36
View File
@@ -0,0 +1,36 @@
#!/bin/bash
set -eu
# ============================================
# 微信营销管理系统 MVP - 一键停止
# ============================================
ROOT="$(cd "$(dirname "$0")" && pwd)"
BIN="/usr/local/opt/postgresql@17/bin"
PID_FILE="$ROOT/.run.pid"
echo "停止微信营销管理系统 MVP..."
# 1. 停止 Web 服务
if [ -f "$PID_FILE" ]; then
PID=$(cat "$PID_FILE")
if kill -0 "$PID" 2>/dev/null; then
echo " 停止 Web 服务 (PID: $PID)..."
kill "$PID" 2>/dev/null || true
fi
rm -f "$PID_FILE"
fi
# 也检查端口
if lsof -ti:8770 >/dev/null 2>&1; then
echo " 停止占用 8770 端口的进程..."
kill $(lsof -ti:8770) 2>/dev/null || true
fi
# 2. 停止 PostgreSQL
if "$BIN/pg_ctl" -D "$ROOT/db/data" status >/dev/null 2>&1; then
echo " 停止 PostgreSQL..."
"$BIN/pg_ctl" -D "$ROOT/db/data" stop -m fast 2>/dev/null || true
fi
echo "✓ 已停止全部服务"
+52
View File
@@ -0,0 +1,52 @@
-- 数据库层测试脚本
-- 用法: psql -h demo/db/socket -p 5434 -d wxchat_sales -f test_db.sql
-- 测试1: 验证 6 张表存在
\echo '=== TEST 1: 表存在性检查 ==='
SELECT tablename FROM pg_tables WHERE schemaname='public' ORDER BY tablename;
-- 预期: contact, conversation, customer, deal, message, salesperson
-- 测试2: 验证 3 名预置销售
\echo '=== TEST 2: 销售人员数据 ==='
SELECT id, name, team, wx_account FROM salesperson ORDER BY id;
-- 预期: 3 行
-- 测试3: 验证唯一约束(插入重复销售应跳过)
\echo '=== TEST 3: 幂等插入 ==='
INSERT INTO salesperson (id, name, team, wx_account, device_id) VALUES
(1, '张伟', '华东团队', 'wxid_zhangwei', 'macbook-zw-001')
ON CONFLICT (id) DO NOTHING;
SELECT count(*) AS salesperson_count FROM salesperson;
-- 预期: 3
-- 测试4: 验证 contact 唯一约束
\echo '=== TEST 4: contact 唯一约束 ==='
INSERT INTO contact (salesperson_id, wx_username, display_name) VALUES (1, 'test_dup', '测试');
-- 第二次插入应失败
DO $$
BEGIN
INSERT INTO contact (salesperson_id, wx_username, display_name) VALUES (1, 'test_dup', '测试2');
RAISE EXCEPTION '唯一约束未生效';
EXCEPTION
WHEN unique_violation THEN
RAISE NOTICE '唯一约束生效: OK';
END $$;
-- 清理
DELETE FROM contact WHERE wx_username = 'test_dup';
-- 测试5: 验证外键约束
\echo '=== TEST 5: 外键约束 ==='
DO $$
BEGIN
INSERT INTO contact (salesperson_id, wx_username, display_name) VALUES (999, 'test_fk', '测试');
RAISE EXCEPTION '外键约束未生效';
EXCEPTION
WHEN foreign_key_violation THEN
RAISE NOTICE '外键约束生效: OK';
END $$;
-- 测试6: 验证索引存在
\echo '=== TEST 6: 索引检查 ==='
SELECT indexname FROM pg_indexes WHERE schemaname='public' ORDER BY indexname;
\echo '=== ALL TESTS DONE ==='
+82
View File
@@ -0,0 +1,82 @@
#!/bin/bash
set -eu
# ============================================
# 端到端测试
# ============================================
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
API="http://127.0.0.1:8770/api"
PASS=0
FAIL=0
check() {
local name="$1"
local condition="$2"
if eval "$condition"; then
echo "$name"
PASS=$((PASS + 1))
else
echo "$name"
FAIL=$((FAIL + 1))
fi
}
echo "=== 端到端测试 ==="
echo ""
# 测试1: 仪表盘
echo "[仪表盘]"
DASHBOARD=$(curl -s "$API/dashboard")
check "仪表盘返回200" '[ -n "$DASHBOARD" ]'
check "总消息数 > 0" 'echo "$DASHBOARD" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d[\"total_messages\"]>0 else 1)"'
check "销售人数 = 3" 'echo "$DASHBOARD" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d[\"salespersons\"])==3 else 1)"'
# 测试2: 销售列表
echo "[销售列表]"
SP=$(curl -s "$API/salespersons")
check "销售列表返回3条" 'echo "$SP" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d)==3 else 1)"'
# 测试3: 客户列表
echo "[客户列表]"
CUSTOMERS=$(curl -s "$API/customers")
check "客户列表非空" 'echo "$CUSTOMERS" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d)>0 else 1)"'
HIGH=$(curl -s "$API/customers?intent_level=high")
check "高意向筛选有效" 'echo "$HIGH" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if all(c[\"intent_level\"]==\"high\" for c in d) else 1)"'
# 测试4: 客户详情
echo "[客户详情]"
FIRST_CUST_ID=$(echo "$CUSTOMERS" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d[0][\"id\"])")
DETAIL=$(curl -s "$API/customers/$FIRST_CUST_ID")
check "客户详情有摘要" 'echo "$DETAIL" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get(\"summary\") else 1)"'
check "客户详情有阶段" 'echo "$DETAIL" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get(\"stage\") else 1)"'
# 测试5: 聊天记录
echo "[聊天记录]"
MSGS=$(curl -s "$API/customers/$FIRST_CUST_ID/messages?page=1&page_size=10")
check "聊天记录非空" 'echo "$MSGS" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d[\"messages\"])>0 else 1)"'
check "聊天记录总数 > 0" 'echo "$MSGS" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d[\"total\"]>0 else 1)"'
# 测试6: 成交录入
echo "[成交录入]"
DEAL_RES=$(curl -s -X POST "$API/deals" \
-H "Content-Type: application/json" \
-d "{\"salesperson_id\":1,\"customer_id\":$FIRST_CUST_ID,\"product_name\":\"测试产品\",\"amount\":99,\"deal_date\":\"2026-07-14\"}")
check "成交录入成功" 'echo "$DEAL_RES" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if d.get(\"id\") else 1)"'
# 测试7: 成交列表
echo "[成交列表]"
DEALS=$(curl -s "$API/deals")
check "成交列表非空" 'echo "$DEALS" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d)>0 else 1)"'
# 测试8: 同步状态
echo "[同步状态]"
SYNC=$(curl -s "$API/sync/status")
check "同步状态返回3条" 'echo "$SYNC" | python3 -c "import sys,json; d=json.load(sys.stdin); exit(0 if len(d)==3 else 1)"'
echo ""
echo "=========================================="
echo " 通过: $PASS 失败: $FAIL"
echo "=========================================="
[ "$FAIL" -eq 0 ] && exit 0 || exit 1
+667
View File
@@ -0,0 +1,667 @@
#!/usr/bin/env python3
"""微信营销管理系统 MVP — FastAPI 后端。
提供 11 个 API 接口 + 静态前端托管。
"""
from __future__ import annotations
import datetime as dt
import json
import pathlib
import re
import subprocess
import sys
import psycopg2
import psycopg2.pool
from fastapi import FastAPI, HTTPException, Query
from fastapi.staticfiles import StaticFiles
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
# ---------------------------------------------------------------------------
# 配置
# ---------------------------------------------------------------------------
ROOT = pathlib.Path(__file__).resolve().parent
STATIC_DIR = ROOT / "static"
SOCKET_DIR = ROOT.parent / "db" / "socket"
DSN = f"host={SOCKET_DIR} port=5434 dbname=wxchat_sales"
app = FastAPI(title="微信营销管理系统 MVP")
def parse_pg_array(val):
"""将 PostgreSQL TEXT[] 的字符串表示解析为 Python list。"""
if val is None:
return []
if isinstance(val, list):
return val
if isinstance(val, str):
s = val.strip()
if s == "{}" or s == "":
return []
# 去掉首尾 { }
s = s[1:-1] if s.startswith("{") and s.endswith("}") else s
# 按逗号分割(简单处理,不考虑逗号在引号内的情况)
return [item.strip().strip('"') for item in s.split(",") if item.strip()]
return []
# 连接池
_pool: psycopg2.pool.SimpleConnectionPool | None = None
def get_pool():
global _pool
if _pool is None:
_pool = psycopg2.pool.SimpleConnectionPool(1, 10, DSN)
return _pool
def get_conn():
return get_pool().getconn()
def put_conn(conn):
get_pool().putconn(conn)
# ---------------------------------------------------------------------------
# Pydantic 模型
# ---------------------------------------------------------------------------
class DealCreate(BaseModel):
salesperson_id: int
customer_id: int | None = None
contact_id: int | None = None
product_name: str
amount: float
deal_date: str
notes: str | None = None
status: str = "closed"
class AnalyzeRequest(BaseModel):
mode: str = "rule"
force: bool = False
# ---------------------------------------------------------------------------
# API 路由
# ---------------------------------------------------------------------------
@app.get("/api/dashboard")
def dashboard():
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("SELECT count(*) FROM message")
total_messages = cur.fetchone()[0]
cur.execute("SELECT count(*) FROM customer")
active_customers = cur.fetchone()[0]
cur.execute("""
SELECT COALESCE(sum(amount), 0) FROM deal
WHERE status = 'closed'
AND deal_date >= date_trunc('month', now())
""")
monthly_deal_amount = float(cur.fetchone()[0])
cur.execute("""
SELECT s.id, s.name, s.team,
count(DISTINCT m.id) AS msg_count,
count(DISTINCT cu.id) AS customer_count,
COALESCE(sd.deal_amount, 0) AS deal_amount,
max(conv.last_synced_at) AS last_synced
FROM salesperson s
LEFT JOIN message m ON m.salesperson_id = s.id
LEFT JOIN customer cu ON cu.salesperson_id = s.id
LEFT JOIN conversation conv ON conv.salesperson_id = s.id
LEFT JOIN LATERAL (
SELECT COALESCE(sum(d.amount), 0) AS deal_amount
FROM deal d WHERE d.salesperson_id = s.id AND d.status = 'closed'
) sd ON true
GROUP BY s.id, s.name, s.team, sd.deal_amount
ORDER BY s.id
""")
salespersons = [
{
"id": r[0], "name": r[1], "team": r[2],
"message_count": r[3], "customer_count": r[4],
"deal_amount": float(r[5]),
"last_synced_at": r[6].isoformat() if r[6] else None,
}
for r in cur.fetchall()
]
return {
"total_messages": total_messages,
"active_customers": active_customers,
"monthly_deal_amount": monthly_deal_amount,
"salespersons": salespersons,
}
finally:
put_conn(conn)
@app.get("/api/salespersons")
def list_salespersons():
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT s.id, s.name, s.team, s.wx_account, s.device_id,
count(DISTINCT c.id) AS contacts,
count(DISTINCT cu.id) AS customers,
count(DISTINCT m.id) AS messages,
COALESCE(sd.deal_amount, 0) AS deal_amount,
max(conv.last_synced_at) AS last_synced
FROM salesperson s
LEFT JOIN contact c ON c.salesperson_id = s.id
LEFT JOIN customer cu ON cu.salesperson_id = s.id
LEFT JOIN message m ON m.salesperson_id = s.id
LEFT JOIN conversation conv ON conv.salesperson_id = s.id
LEFT JOIN LATERAL (
SELECT COALESCE(sum(d.amount), 0) AS deal_amount
FROM deal d WHERE d.salesperson_id = s.id AND d.status = 'closed'
) sd ON true
GROUP BY s.id, s.name, s.team, s.wx_account, s.device_id, sd.deal_amount
ORDER BY s.id
""")
return [
{
"id": r[0], "name": r[1], "team": r[2],
"wx_account": r[3], "device_id": r[4],
"contact_count": r[5], "customer_count": r[6],
"message_count": r[7], "deal_amount": float(r[8]),
"last_synced_at": r[9].isoformat() if r[9] else None,
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.get("/api/customers")
def list_customers(
salesperson_id: int | None = Query(None),
intent_level: str | None = Query(None),
):
conn = get_conn()
try:
with conn.cursor() as cur:
sql = """
SELECT cu.id, cu.salesperson_id, s.name AS salesperson_name,
cu.customer_name, cu.industry, cu.intent_level,
cu.key_needs, cu.stage, cu.last_analysis,
c.display_name AS contact_display_name,
max(m.created_at) AS last_message_at
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
JOIN contact c ON c.id = cu.contact_id
LEFT JOIN conversation conv ON conv.contact_id = c.id
LEFT JOIN message m ON m.conversation_id = conv.id
"""
conditions = []
params = []
if salesperson_id is not None:
conditions.append("cu.salesperson_id = %s")
params.append(salesperson_id)
if intent_level is not None:
conditions.append("cu.intent_level = %s")
params.append(intent_level)
if conditions:
sql += " WHERE " + " AND ".join(conditions)
sql += """
GROUP BY cu.id, cu.salesperson_id, s.name, cu.customer_name,
cu.industry, cu.intent_level, cu.key_needs, cu.stage,
cu.last_analysis, c.display_name
ORDER BY cu.salesperson_id, cu.id
"""
cur.execute(sql, params)
return [
{
"id": r[0], "salesperson_id": r[1],
"salesperson_name": r[2],
"customer_name": r[3], "industry": r[4],
"intent_level": r[5], "key_needs": parse_pg_array(r[6]),
"stage": r[7],
"last_analysis": r[8].isoformat() if r[8] else None,
"contact_display_name": r[9],
"last_message_at": r[10].isoformat() if r[10] else None,
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.get("/api/customers/{customer_id}")
def get_customer(customer_id: int):
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT cu.id, cu.salesperson_id, s.name AS salesperson_name,
cu.customer_name, cu.industry, cu.intent_level,
cu.key_needs, cu.reason, cu.summary, cu.stage,
cu.key_points, cu.objections, cu.next_action, cu.last_analysis,
c.display_name AS contact_display_name,
c.remark, c.nickname
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
JOIN contact c ON c.id = cu.contact_id
WHERE cu.id = %s
""", (customer_id,))
r = cur.fetchone()
if not r:
raise HTTPException(status_code=404, detail="客户不存在")
return {
"id": r[0], "salesperson_id": r[1],
"salesperson_name": r[2],
"customer_name": r[3], "industry": r[4],
"intent_level": r[5], "key_needs": parse_pg_array(r[6]),
"reason": r[7], "summary": r[8], "stage": r[9],
"key_points": parse_pg_array(r[10]),
"objections": parse_pg_array(r[11]), "next_action": r[12],
"last_analysis": r[13].isoformat() if r[13] else None,
"contact_display_name": r[14],
"contact_remark": r[15], "contact_nickname": r[16],
}
finally:
put_conn(conn)
@app.get("/api/customers/{customer_id}/messages")
def get_customer_messages(
customer_id: int,
page: int = Query(1, ge=1),
page_size: int = Query(50, ge=1, le=200),
):
conn = get_conn()
try:
with conn.cursor() as cur:
# 获取 contact_id
cur.execute("SELECT contact_id FROM customer WHERE id = %s", (customer_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="客户不存在")
contact_id = row[0]
# 获取会话
cur.execute("SELECT id FROM conversation WHERE contact_id = %s", (contact_id,))
conv_ids = [r[0] for r in cur.fetchall()]
if not conv_ids:
return {"messages": [], "total": 0, "page": page, "page_size": page_size}
# 总数
cur.execute(
"SELECT count(*) FROM message WHERE conversation_id = ANY(%s)",
(conv_ids,),
)
total = cur.fetchone()[0]
# 分页
offset = (page - 1) * page_size
cur.execute("""
SELECT id, sender_display_name, message_type, normalized_content, created_at
FROM message
WHERE conversation_id = ANY(%s)
ORDER BY created_at, id
LIMIT %s OFFSET %s
""", (conv_ids, page_size, offset))
messages = [
{
"id": r[0],
"sender_display_name": r[1],
"message_type": r[2],
"normalized_content": r[3],
"created_at": r[4].isoformat() if r[4] else None,
}
for r in cur.fetchall()
]
return {
"messages": messages,
"total": total,
"page": page,
"page_size": page_size,
}
finally:
put_conn(conn)
@app.get("/api/customers/{customer_id}/deals")
def get_customer_deals(customer_id: int):
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT d.id, d.product_name, d.amount, d.deal_date, d.status, d.notes,
d.created_at, s.name AS salesperson_name
FROM deal d
JOIN salesperson s ON s.id = d.salesperson_id
WHERE d.customer_id = %s
ORDER BY d.deal_date DESC
""", (customer_id,))
return [
{
"id": r[0], "product_name": r[1],
"amount": float(r[2]),
"deal_date": r[3].isoformat() if r[3] else None,
"status": r[4], "notes": r[5],
"created_at": r[6].isoformat() if r[6] else None,
"salesperson_name": r[7],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.post("/api/deals")
def create_deal(deal: DealCreate):
conn = get_conn()
try:
with conn.cursor() as cur:
# 如果没有 contact_id,从 customer 获取
contact_id = deal.contact_id
if contact_id is None and deal.customer_id:
cur.execute("SELECT contact_id FROM customer WHERE id = %s", (deal.customer_id,))
row = cur.fetchone()
if row:
contact_id = row[0]
cur.execute("""
INSERT INTO deal (salesperson_id, customer_id, contact_id, product_name,
amount, deal_date, status, notes)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""", (
deal.salesperson_id, deal.customer_id, contact_id,
deal.product_name, deal.amount, deal.deal_date,
deal.status, deal.notes,
))
deal_id = cur.fetchone()[0]
conn.commit()
return {"id": deal_id, "message": "成交记录已创建"}
except Exception:
conn.rollback()
raise
finally:
put_conn(conn)
@app.get("/api/deals")
def list_deals(
salesperson_id: int | None = Query(None),
start_date: str | None = Query(None),
end_date: str | None = Query(None),
):
conn = get_conn()
try:
with conn.cursor() as cur:
sql = """
SELECT d.id, d.salesperson_id, s.name AS salesperson_name,
d.customer_id, cu.customer_name,
d.product_name, d.amount, d.deal_date, d.status, d.notes
FROM deal d
JOIN salesperson s ON s.id = d.salesperson_id
LEFT JOIN customer cu ON cu.id = d.customer_id
"""
conditions = []
params = []
if salesperson_id is not None:
conditions.append("d.salesperson_id = %s")
params.append(salesperson_id)
if start_date:
conditions.append("d.deal_date >= %s")
params.append(start_date)
if end_date:
conditions.append("d.deal_date <= %s")
params.append(end_date)
if conditions:
sql += " WHERE " + " AND ".join(conditions)
sql += " ORDER BY d.deal_date DESC, d.id DESC"
cur.execute(sql, params)
return [
{
"id": r[0], "salesperson_id": r[1],
"salesperson_name": r[2],
"customer_id": r[3], "customer_name": r[4],
"product_name": r[5],
"amount": float(r[6]),
"deal_date": r[7].isoformat() if r[7] else None,
"status": r[8], "notes": r[9],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.post("/api/analyze")
def trigger_analyze(req: AnalyzeRequest):
"""触发 AI 分析(调用 analyze.py 脚本)。"""
analyze_script = ROOT.parent / "ai" / "analyze.py"
cmd = [sys.executable, str(analyze_script), "--mode", req.mode]
if req.force:
cmd.append("--force")
try:
result = subprocess.run(
cmd, capture_output=True, text=True, timeout=120,
)
if result.returncode != 0:
raise HTTPException(status_code=500, detail=result.stderr)
return json.loads(result.stdout)
except subprocess.TimeoutExpired:
raise HTTPException(status_code=504, detail="分析超时")
@app.get("/api/conversations")
def list_conversations(
salesperson_id: int | None = Query(None),
stage: str | None = Query(None),
):
"""全部客户的交流对话分析概览。"""
conn = get_conn()
try:
with conn.cursor() as cur:
sql = """
SELECT cu.id, cu.customer_name, s.name AS salesperson_name,
cu.industry, cu.intent_level, cu.stage,
cu.summary, cu.key_points, cu.objections, cu.next_action,
cu.key_needs, cu.last_analysis,
(SELECT count(*) FROM message m
JOIN conversation conv ON conv.id = m.conversation_id
WHERE conv.contact_id = cu.contact_id) AS msg_count
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
"""
conditions = []
params = []
if salesperson_id is not None:
conditions.append("cu.salesperson_id = %s")
params.append(salesperson_id)
if stage:
conditions.append("cu.stage = %s")
params.append(stage)
if conditions:
sql += " WHERE " + " AND ".join(conditions)
sql += " ORDER BY cu.salesperson_id, cu.id"
cur.execute(sql, params)
return [
{
"id": r[0], "customer_name": r[1],
"salesperson_name": r[2], "industry": r[3],
"intent_level": r[4], "stage": r[5],
"summary": r[6],
"key_points": parse_pg_array(r[7]),
"objections": parse_pg_array(r[8]),
"next_action": r[9],
"key_needs": parse_pg_array(r[10]),
"last_analysis": r[11].isoformat() if r[11] else None,
"msg_count": r[12],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
@app.post("/api/conversations/{customer_id}/paradigm")
def generate_paradigm(customer_id: int):
"""调用 LLM 生成标准范式对话。"""
import os as _os
import urllib.request as _urllib
import urllib.error as _urllib_err
api_key = _os.environ.get("DASHSCOPE_API_KEY")
if not api_key:
raise HTTPException(status_code=500, detail="未设置 DASHSCOPE_API_KEY 环境变量")
conn = get_conn()
try:
with conn.cursor() as cur:
# 获取客户信息
cur.execute("""
SELECT cu.customer_name, cu.industry, cu.stage, cu.summary,
cu.objections, cu.key_needs, s.name
FROM customer cu
JOIN salesperson s ON s.id = cu.salesperson_id
WHERE cu.id = %s
""", (customer_id,))
customer = cur.fetchone()
if not customer:
raise HTTPException(status_code=404, detail="客户不存在")
# 获取对话记录
cur.execute("SELECT contact_id FROM customer WHERE id = %s", (customer_id,))
contact_id = cur.fetchone()[0]
cur.execute("SELECT id FROM conversation WHERE contact_id = %s", (contact_id,))
conv_ids = [r[0] for r in cur.fetchall()]
if not conv_ids:
raise HTTPException(status_code=400, detail="该客户无对话记录")
cur.execute("""
SELECT sender_display_name, message_type, normalized_content, created_at
FROM message
WHERE conversation_id = ANY(%s)
ORDER BY created_at, id
LIMIT 100
""", (conv_ids,))
messages = cur.fetchall()
finally:
put_conn(conn)
# 构建对话文本
dialog_lines = []
for m in messages:
sender = m[0] or "未知"
content = m[2] or f"[{m[1]}]"
dialog_lines.append(f"{sender}: {content}")
dialog_text = "\n".join(dialog_lines)
if len(dialog_text) > 6000:
dialog_text = dialog_text[:3000] + "\n...(中间部分省略)...\n" + dialog_text[-3000:]
customer_name, industry, stage, summary, objections, key_needs, sp_name = customer
objections_str = "".join(parse_pg_array(objections)) if objections else ""
key_needs_str = "".join(parse_pg_array(key_needs)) if key_needs else "未明确"
prompt = f"""你是一个微信销售对话分析专家。请基于以下真实销售对话,生成一套标准范式对话模板。
## 产品背景
益童宝儿童益生菌粉:丹麦进口菌株,主打小儿抗过敏(湿疹、鼻炎、食物过敏),298元/盒,3盒套餐798元,6盒套餐1499元。目标客户是宝妈。
## 客户信息
- 客户: {customer_name}
- 行业: {industry}
- 销售人员: {sp_name}
- 当前阶段: {stage}
- 关键需求: {key_needs_str}
- 异议: {objections_str}
- 摘要: {summary or ''}
## 真实对话记录
{dialog_text}
## 输出要求
请基于上述真实对话,提炼并生成一套**标准范式对话模板**,即针对此类客户的最优销售话术流程。输出严格 JSON:
- customer_type: 客户类型描述(如"湿疹宝妈-价格敏感型"
- stages: 按销售阶段排列的对话步骤数组,每个元素包含:
- stage: 阶段名称(建立联系/需求发现/报价/异议处理/成交)
- goal: 该阶段目标
- sales_script: 销售话术(1-3句,具体可执行)
- expected_response: 预期客户回应
- tips: 该阶段技巧提示
- key_techniques: 核心销售技巧总结(数组,每条一句话)
- improvement_points: 真实对话中可改进的点(数组,每条一句话)
只输出 JSON,不要其他文字。"""
body = json.dumps({
"model": "qwen-plus",
"messages": [
{"role": "system", "content": "你是微信销售对话分析专家,擅长从真实对话中提炼标准范式。"},
{"role": "user", "content": prompt},
],
"response_format": {"type": "json_object"},
"temperature": 0.4,
}).encode("utf-8")
req = _urllib.Request(
"https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",
data=body,
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
method="POST",
)
try:
with _urllib.urlopen(req, timeout=60) as resp:
result = json.loads(resp.read().decode("utf-8"))
content = result["choices"][0]["message"]["content"]
parsed = json.loads(content)
return parsed
except Exception as exc:
raise HTTPException(status_code=502, detail=f"LLM 调用失败: {exc}")
@app.get("/api/sync/status")
def sync_status():
conn = get_conn()
try:
with conn.cursor() as cur:
cur.execute("""
SELECT s.id, s.name,
max(conv.last_synced_at) AS last_synced,
count(DISTINCT m.id) AS message_count
FROM salesperson s
LEFT JOIN conversation conv ON conv.salesperson_id = s.id
LEFT JOIN message m ON m.salesperson_id = s.id
GROUP BY s.id, s.name
ORDER BY s.id
""")
return [
{
"salesperson_id": r[0],
"name": r[1],
"last_synced_at": r[2].isoformat() if r[2] else None,
"message_count": r[3],
}
for r in cur.fetchall()
]
finally:
put_conn(conn)
# ---------------------------------------------------------------------------
# 静态文件托管(前端)
# ---------------------------------------------------------------------------
if STATIC_DIR.exists():
app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
+693
View File
@@ -0,0 +1,693 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微信营销管理系统</title>
<link rel="stylesheet" href="/tailwind.min.css">
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; }
.chat-bubble { max-width: 70%; padding: 8px 14px; border-radius: 12px; margin: 4px 0; }
.chat-sales { background: #95ec69; align-self: flex-start; }
.chat-customer { background: #fff; border: 1px solid #e5e7eb; align-self: flex-end; }
.chat-other { background: #f3f4f6; align-self: flex-end; }
</style>
</head>
<body class="bg-gray-50 text-gray-900">
<nav class="bg-white border-b shadow-sm sticky top-0 z-50">
<div class="max-w-7xl mx-auto px-4 flex items-center h-14 gap-6">
<span class="font-bold text-lg text-green-600">益童宝销售管理系统</span>
<button onclick="showPanel('dashboard')" class="nav-btn text-sm hover:text-green-600">仪表盘</button>
<button onclick="showPanel('salespersons')" class="nav-btn text-sm hover:text-green-600">销售列表</button>
<button onclick="showPanel('customers')" class="nav-btn text-sm hover:text-green-600">客户列表</button>
<button onclick="showPanel('deals')" class="nav-btn text-sm hover:text-green-600">成交记录</button>
<button onclick="showPanel('analysis')" class="nav-btn text-sm hover:text-green-600">交流分析</button>
<button onclick="showPanel('deal-form')" class="nav-btn text-sm hover:text-green-600">录入成交</button>
</div>
</nav>
<main class="max-w-7xl mx-auto px-4 py-6">
<!-- 仪表盘 -->
<div id="dashboard-panel" class="panel">
<h2 class="text-xl font-bold mb-4">仪表盘</h2>
<div id="dashboard-cards" class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6"></div>
<h3 class="text-lg font-semibold mb-3">各销售数据对比</h3>
<div id="dashboard-sales" class="bg-white rounded-lg shadow p-4 overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="border-b text-left text-gray-500">
<th class="py-2">销售</th><th>团队</th><th>消息数</th><th>客户数</th><th>成交金额</th><th>最后同步</th>
</tr></thead>
<tbody id="dashboard-sales-tbody"></tbody>
</table>
</div>
</div>
<!-- 销售列表 -->
<div id="salespersons-panel" class="panel hidden">
<h2 class="text-xl font-bold mb-4">销售列表</h2>
<div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="border-b text-left text-gray-500">
<th class="py-2">销售</th><th>团队</th><th>微信号</th><th>联系人</th><th>客户</th><th>消息</th><th>成交</th><th>最后同步</th>
</tr></thead>
<tbody id="salespersons-tbody"></tbody>
</table>
</div>
</div>
<!-- 客户列表 -->
<div id="customers-panel" class="panel hidden">
<h2 class="text-xl font-bold mb-4">客户列表</h2>
<div class="flex gap-4 mb-4">
<select id="filter-salesperson" onchange="loadCustomers()" class="border rounded px-3 py-1.5 text-sm">
<option value="">全部销售</option>
</select>
<select id="filter-intent" onchange="loadCustomers()" class="border rounded px-3 py-1.5 text-sm">
<option value="">全部意向</option>
<option value="high">高意向</option>
<option value="medium">中意向</option>
<option value="low">低意向</option>
</select>
</div>
<div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="border-b text-left text-gray-500">
<th class="py-2">客户</th><th>销售</th><th>行业</th><th>意向</th><th>阶段</th><th>关键需求</th><th>最后沟通</th>
</tr></thead>
<tbody id="customers-tbody"></tbody>
</table>
</div>
</div>
<!-- 客户详情 -->
<div id="customer-detail-panel" class="panel hidden">
<button onclick="showPanel('customers')" class="text-sm text-green-600 mb-3">&larr; 返回客户列表</button>
<div id="detail-info" class="bg-white rounded-lg shadow p-6 mb-4"></div>
<div id="detail-summary" class="bg-white rounded-lg shadow p-6 mb-4"></div>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div class="bg-white rounded-lg shadow p-6">
<h3 class="font-semibold mb-3">聊天记录</h3>
<div id="detail-messages" class="flex flex-col gap-1 max-h-96 overflow-y-auto"></div>
<div id="detail-pagination" class="mt-3 flex items-center gap-3"></div>
</div>
<div class="bg-white rounded-lg shadow p-6">
<h3 class="font-semibold mb-3">成交记录</h3>
<div id="detail-deals"></div>
</div>
</div>
</div>
<!-- 成交记录列表 -->
<div id="deals-panel" class="panel hidden">
<h2 class="text-xl font-bold mb-4">成交记录</h2>
<div class="bg-white rounded-lg shadow p-4 overflow-x-auto">
<table class="w-full text-sm">
<thead><tr class="border-b text-left text-gray-500">
<th class="py-2">销售</th><th>客户</th><th>产品</th><th>金额</th><th>日期</th><th>状态</th>
</tr></thead>
<tbody id="deals-tbody"></tbody>
</table>
</div>
</div>
<!-- 交流分析 -->
<div id="analysis-panel" class="panel hidden">
<h2 class="text-xl font-bold mb-4">交流分析</h2>
<div class="flex gap-1 mb-4">
<button id="analysis-tab-list" onclick="analysisView='list';renderAnalysisView()" class="px-4 py-1.5 rounded text-sm bg-green-600 text-white">对话列表</button>
<button id="analysis-tab-paradigm" onclick="analysisView='paradigm';renderAnalysisView()" class="px-4 py-1.5 rounded text-sm bg-white border hover:bg-gray-50">标准范式</button>
</div>
<!-- 对话列表视图 -->
<div id="analysis-list-view">
<div class="flex gap-3 mb-4">
<select id="analysis-filter-sp" onchange="loadConversations()" class="border rounded px-3 py-1.5 text-sm">
<option value="">全部销售</option>
</select>
<select id="analysis-filter-stage" onchange="loadConversations()" class="border rounded px-3 py-1.5 text-sm">
<option value="">全部阶段</option>
<option value="建立联系">建立联系</option>
<option value="需求发现">需求发现</option>
<option value="报价">报价</option>
<option value="异议处理">异议处理</option>
<option value="成交">成交</option>
</select>
</div>
<div id="analysis-list" class="space-y-3"></div>
</div>
<!-- 标准范式视图 -->
<div id="analysis-paradigm-view" class="hidden">
<div class="bg-white rounded-lg shadow p-4 mb-4">
<p class="text-sm text-gray-500 mb-2">选择一个客户,基于其真实对话生成标准范式对话模板</p>
<div class="flex gap-3 items-center">
<select id="paradigm-customer-select" class="border rounded px-3 py-2 text-sm flex-1">
<option value="">请选择客户</option>
</select>
<button onclick="generateParadigm()" id="paradigm-btn" class="bg-green-600 text-white px-6 py-2 rounded text-sm hover:bg-green-700">生成范式对话</button>
</div>
</div>
<div id="paradigm-result"></div>
</div>
</div>
<!-- 录入成交 -->
<div id="deal-form-panel" class="panel hidden">
<h2 class="text-xl font-bold mb-4">录入成交</h2>
<div class="bg-white rounded-lg shadow p-6 max-w-lg">
<form onsubmit="submitDeal(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium mb-1">销售 *</label>
<select id="deal-salesperson" required class="w-full border rounded px-3 py-2 text-sm" onchange="loadDealCustomers()">
<option value="">请选择</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">客户 *</label>
<select id="deal-customer" required class="w-full border rounded px-3 py-2 text-sm">
<option value="">请选择</option>
</select>
</div>
<div>
<label class="block text-sm font-medium mb-1">产品名称 *</label>
<input id="deal-product" type="text" required placeholder="如:益童宝3盒套餐" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium mb-1">金额 *</label>
<input id="deal-amount" type="number" step="0.01" required placeholder="798" class="w-full border rounded px-3 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium mb-1">成交日期 *</label>
<input id="deal-date" type="date" required class="w-full border rounded px-3 py-2 text-sm">
</div>
<div>
<label class="block text-sm font-medium mb-1">备注</label>
<textarea id="deal-notes" rows="2" class="w-full border rounded px-3 py-2 text-sm"></textarea>
</div>
<button type="submit" class="bg-green-600 text-white px-6 py-2 rounded text-sm hover:bg-green-700">提交</button>
<span id="deal-result" class="ml-4 text-sm text-green-600"></span>
</form>
</div>
</div>
</main>
<script>
const API = '/api';
let currentCustomerId = null;
let currentMsgPage = 1;
function formatYuan(val) {
return '¥' + (val || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
const intentLabels = {high: '高意向', medium: '中意向', low: '低意向'};
const dealStatusLabels = {closed: '已成交', pending: '待确认', cancelled: '已取消'};
// --- 路由 ---
function showPanel(name) {
document.querySelectorAll('.panel').forEach(p => p.classList.add('hidden'));
const panel = document.getElementById(name + '-panel');
if (panel) panel.classList.remove('hidden');
if (name === 'dashboard') loadDashboard();
if (name === 'salespersons') loadSalespersons();
if (name === 'customers') loadCustomers();
if (name === 'deals') loadDeals();
if (name === 'analysis') loadAnalysis();
if (name === 'deal-form') loadDealForm();
}
// --- API 封装 ---
async function api(path, options) {
const res = await fetch(API + path, options);
if (!res.ok) throw new Error(`API ${path}: ${res.status}`);
return res.json();
}
// --- 仪表盘 ---
async function loadDashboard() {
const data = await api('/dashboard');
const cards = document.getElementById('dashboard-cards');
cards.innerHTML = [
{label: '总消息数', value: data.total_messages, color: 'blue'},
{label: '活跃客户', value: data.active_customers, color: 'green'},
{label: '本月成交', value: formatYuan(data.monthly_deal_amount), color: 'orange'},
{label: '销售人数', value: data.salespersons.length, color: 'purple'},
].map(c => `
<div class="bg-white rounded-lg shadow p-4">
<div class="text-2xl font-bold text-${c.color}-600">${c.value}</div>
<div class="text-sm text-gray-500 mt-1">${c.label}</div>
</div>
`).join('');
const tbody = document.getElementById('dashboard-sales-tbody');
tbody.innerHTML = data.salespersons.map(s => `
<tr class="border-b hover:bg-gray-50">
<td class="py-2 font-medium">${s.name}</td>
<td>${s.team}</td>
<td>${s.message_count}</td>
<td>${s.customer_count}</td>
<td>${formatYuan(s.deal_amount)}</td>
<td class="text-gray-400 text-xs">${s.last_synced_at ? new Date(s.last_synced_at).toLocaleString('zh-CN') : '-'}</td>
</tr>
`).join('');
}
// --- 销售列表 ---
async function loadSalespersons() {
const data = await api('/salespersons');
const tbody = document.getElementById('salespersons-tbody');
tbody.innerHTML = data.map(s => `
<tr class="border-b hover:bg-gray-50">
<td class="py-2 font-medium">${s.name}</td>
<td>${s.team}</td>
<td class="text-gray-500">${s.wx_account}</td>
<td>${s.contact_count}</td>
<td>${s.customer_count}</td>
<td>${s.message_count}</td>
<td>${formatYuan(s.deal_amount)}</td>
<td class="text-gray-400 text-xs">${s.last_synced_at ? new Date(s.last_synced_at).toLocaleString('zh-CN') : '-'}</td>
</tr>
`).join('');
}
// --- 客户列表 ---
async function loadCustomers() {
const sp = document.getElementById('filter-salesperson').value;
const intent = document.getElementById('filter-intent').value;
let path = '/customers?';
if (sp) path += `salesperson_id=${sp}&`;
if (intent) path += `intent_level=${intent}&`;
const data = await api(path);
// 填充销售筛选
const spFilter = document.getElementById('filter-salesperson');
if (spFilter.options.length <= 1) {
const spData = await api('/salespersons');
spData.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id; opt.textContent = s.name;
spFilter.appendChild(opt);
});
}
const intentColors = {high: 'red', medium: 'yellow', low: 'gray'};
const tbody = document.getElementById('customers-tbody');
tbody.innerHTML = data.map(c => `
<tr class="border-b hover:bg-gray-50 cursor-pointer" onclick="showCustomerDetail(${c.id})">
<td class="py-2 font-medium text-green-700">${c.customer_name || c.contact_display_name}</td>
<td>${c.salesperson_name}</td>
<td>${c.industry || '-'}</td>
<td><span class="px-2 py-0.5 rounded text-xs bg-${intentColors[c.intent_level] || 'gray'}-100 text-${intentColors[c.intent_level] || 'gray'}-700">${intentLabels[c.intent_level] || c.intent_level || '-'}</span></td>
<td>${c.stage || '-'}</td>
<td class="text-xs text-gray-500">${(c.key_needs || []).join('、')}</td>
<td class="text-gray-400 text-xs">${c.last_message_at ? new Date(c.last_message_at).toLocaleDateString('zh-CN') : '-'}</td>
</tr>
`).join('');
}
// --- 客户详情 ---
async function showCustomerDetail(id) {
currentCustomerId = id;
currentMsgPage = 1;
showPanel('customer-detail');
const customer = await api(`/customers/${id}`);
const info = document.getElementById('detail-info');
info.innerHTML = `
<h3 class="text-lg font-bold mb-3">${customer.customer_name || customer.contact_display_name}</h3>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 text-sm">
<div><span class="text-gray-500">销售:</span> ${customer.salesperson_name}</div>
<div><span class="text-gray-500">行业:</span> ${customer.industry || '-'}</div>
<div><span class="text-gray-500">意向:</span> <span class="font-medium">${intentLabels[customer.intent_level] || customer.intent_level || '-'}</span></div>
<div><span class="text-gray-500">阶段:</span> <span class="font-medium">${customer.stage || '-'}</span></div>
</div>
<div class="mt-3 text-sm">
<span class="text-gray-500">关键需求:</span> ${(customer.key_needs || []).join('、') || '-'}
</div>
<div class="mt-2 text-sm text-gray-400">识别原因: ${customer.reason || '-'}</div>
`;
const summary = document.getElementById('detail-summary');
summary.innerHTML = `
<div class="flex items-center justify-between mb-3">
<h3 class="font-semibold">AI 沟通摘要</h3>
<span class="text-xs text-gray-400">分析时间: ${customer.last_analysis ? new Date(customer.last_analysis).toLocaleString('zh-CN') : '-'}</span>
</div>
<div class="bg-gray-50 border border-gray-100 rounded-lg p-4 mb-4">
<p class="text-sm text-gray-700 leading-relaxed">${customer.summary || '暂无摘要'}</p>
</div>
${(customer.key_points || []).length > 0 ? `
<div class="mb-4">
<h4 class="text-sm font-semibold mb-2 ${customer.stage === '成交' ? 'text-green-700' : 'text-orange-700'}">${customer.stage === '成交' ? '关键成交点' : '需要突破的问题点'}</h4>
<ul class="space-y-1.5">
${(customer.key_points || []).map(p => `
<li class="flex gap-2 items-start text-sm">
<span class="${customer.stage === '成交' ? 'text-green-500' : 'text-orange-500'} shrink-0">▸</span>
<span class="text-gray-700">${p}</span>
</li>
`).join('')}
</ul>
</div>
` : ''}
<div class="space-y-3 text-sm border-t pt-3">
<div class="flex gap-2 items-start">
<span class="text-gray-500 shrink-0 w-14">异议:</span>
<span class="text-gray-700">${(customer.objections || []).join('、') || '无'}</span>
</div>
<div class="flex gap-2 items-start">
<span class="text-gray-500 shrink-0 w-14">下一步:</span>
<span class="text-gray-700">${customer.next_action || '-'}</span>
</div>
</div>
`;
await loadCustomerMessages();
await loadCustomerDeals();
}
async function loadCustomerMessages() {
const data = await api(`/customers/${currentCustomerId}/messages?page=${currentMsgPage}&page_size=50`);
const container = document.getElementById('detail-messages');
container.innerHTML = data.messages.map(m => {
const isSales = m.sender_display_name && ['张伟','李娜','王强'].includes(m.sender_display_name);
const cls = isSales ? 'chat-sales' : 'chat-customer';
const content = m.message_type === 'text' ? m.normalized_content : m.normalized_content || `[${m.message_type}]`;
return `
<div class="flex flex-col">
<span class="text-xs text-gray-400 mb-0.5">${m.sender_display_name || '未知'} · ${m.created_at ? new Date(m.created_at).toLocaleString('zh-CN') : ''}</span>
<div class="chat-bubble ${cls} text-sm">${escapeHtml(content)}</div>
</div>
`;
}).join('');
const pagination = document.getElementById('detail-pagination');
const totalPages = Math.ceil(data.total / 50);
pagination.innerHTML = `
<span class="text-sm text-gray-500">${data.total} 条消息,第 ${currentMsgPage}/${totalPages} 页</span>
${currentMsgPage > 1 ? `<button onclick="prevPage()" class="text-sm text-green-600">上一页</button>` : ''}
${currentMsgPage < totalPages ? `<button onclick="nextPage()" class="text-sm text-green-600">下一页</button>` : ''}
`;
}
function prevPage() { currentMsgPage--; loadCustomerMessages(); }
function nextPage() { currentMsgPage++; loadCustomerMessages(); }
async function loadCustomerDeals() {
const data = await api(`/customers/${currentCustomerId}/deals`);
const container = document.getElementById('detail-deals');
if (data.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-400">暂无成交记录</p>';
return;
}
container.innerHTML = data.map(d => `
<div class="border-b py-2 text-sm">
<div class="flex justify-between">
<span class="font-medium">${d.product_name}</span>
<span class="font-bold text-green-600">${formatYuan(d.amount)}</span>
</div>
<div class="text-xs text-gray-400 mt-1">${d.deal_date} · ${dealStatusLabels[d.status] || d.status} · ${d.salesperson_name}</div>
${d.notes ? `<div class="text-xs text-gray-500 mt-1">${d.notes}</div>` : ''}
</div>
`).join('');
}
// --- 成交记录列表 ---
async function loadDeals() {
const data = await api('/deals');
const tbody = document.getElementById('deals-tbody');
if (data.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" class="py-4 text-center text-gray-400">暂无成交记录</td></tr>';
return;
}
tbody.innerHTML = data.map(d => `
<tr class="border-b hover:bg-gray-50">
<td class="py-2">${d.salesperson_name}</td>
<td>${d.customer_name || '-'}</td>
<td>${d.product_name}</td>
<td class="font-bold text-green-600">${formatYuan(d.amount)}</td>
<td>${d.deal_date}</td>
<td><span class="px-2 py-0.5 rounded text-xs ${d.status==='closed'?'bg-green-100 text-green-700':'bg-yellow-100 text-yellow-700'}">${dealStatusLabels[d.status] || d.status}</span></td>
</tr>
`).join('');
}
// --- 录入成交 ---
async function loadDealForm() {
const spSelect = document.getElementById('deal-salesperson');
spSelect.innerHTML = '<option value="">请选择</option>';
const spData = await api('/salespersons');
spData.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id; opt.textContent = s.name;
spSelect.appendChild(opt);
});
document.getElementById('deal-date').value = new Date().toISOString().slice(0, 10);
document.getElementById('deal-result').textContent = '';
}
async function loadDealCustomers() {
const spId = document.getElementById('deal-salesperson').value;
const custSelect = document.getElementById('deal-customer');
custSelect.innerHTML = '<option value="">请选择</option>';
if (!spId) return;
const data = await api(`/customers?salesperson_id=${spId}`);
data.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = c.customer_name || c.contact_display_name;
custSelect.appendChild(opt);
});
}
async function submitDeal(event) {
event.preventDefault();
const body = {
salesperson_id: parseInt(document.getElementById('deal-salesperson').value),
customer_id: parseInt(document.getElementById('deal-customer').value),
product_name: document.getElementById('deal-product').value,
amount: parseFloat(document.getElementById('deal-amount').value),
deal_date: document.getElementById('deal-date').value,
notes: document.getElementById('deal-notes').value || null,
};
try {
const result = await api('/deals', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body),
});
document.getElementById('deal-result').textContent = '✓ 已创建,ID: ' + result.id;
document.getElementById('deal-product').value = '';
document.getElementById('deal-amount').value = '';
document.getElementById('deal-notes').value = '';
} catch (e) {
document.getElementById('deal-result').textContent = '✗ 失败: ' + e.message;
}
}
// --- 交流分析 ---
let analysisView = 'list';
let conversationsData = [];
async function loadAnalysis() {
analysisView = 'list';
renderAnalysisView();
await loadConversations();
}
function renderAnalysisView() {
const listTab = document.getElementById('analysis-tab-list');
const paradigmTab = document.getElementById('analysis-tab-paradigm');
const listView = document.getElementById('analysis-list-view');
const paradigmView = document.getElementById('analysis-paradigm-view');
if (analysisView === 'list') {
listTab.className = 'px-4 py-1.5 rounded text-sm bg-green-600 text-white';
paradigmTab.className = 'px-4 py-1.5 rounded text-sm bg-white border hover:bg-gray-50';
listView.classList.remove('hidden');
paradigmView.classList.add('hidden');
} else {
listTab.className = 'px-4 py-1.5 rounded text-sm bg-white border hover:bg-gray-50';
paradigmTab.className = 'px-4 py-1.5 rounded text-sm bg-green-600 text-white';
listView.classList.add('hidden');
paradigmView.classList.remove('hidden');
populateParadigmSelect();
}
}
async function loadConversations() {
const sp = document.getElementById('analysis-filter-sp').value;
const stage = document.getElementById('analysis-filter-stage').value;
let path = '/conversations?';
if (sp) path += `salesperson_id=${sp}&`;
if (stage) path += `stage=${encodeURIComponent(stage)}&`;
try {
conversationsData = await api(path);
renderAnalysisList();
populateParadigmSelect();
} catch (e) {
document.getElementById('analysis-list').innerHTML = `<p class="text-sm text-red-500">加载失败: ${e.message}</p>`;
}
// 填充销售筛选
const spSelect = document.getElementById('analysis-filter-sp');
if (spSelect.options.length <= 1) {
const sps = await api('/salespersons');
sps.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
spSelect.appendChild(opt);
});
}
}
const STAGE_COLORS = {
'成交': 'bg-green-100 text-green-700',
'异议处理': 'bg-orange-100 text-orange-700',
'报价': 'bg-blue-100 text-blue-700',
'需求发现': 'bg-purple-100 text-purple-700',
'建立联系': 'bg-gray-100 text-gray-700',
};
function renderAnalysisList() {
const container = document.getElementById('analysis-list');
if (conversationsData.length === 0) {
container.innerHTML = '<p class="text-sm text-gray-400">暂无交流分析数据</p>';
return;
}
container.innerHTML = conversationsData.map(c => `
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-center justify-between mb-2">
<div class="flex items-center gap-3">
<span class="font-medium text-sm">${c.customer_name}</span>
<span class="text-xs text-gray-400">${c.salesperson_name}</span>
<span class="text-xs text-gray-400">${c.msg_count} 条消息</span>
</div>
<span class="px-2 py-0.5 rounded text-xs ${STAGE_COLORS[c.stage] || STAGE_COLORS['建立联系']}">${c.stage || '-'}</span>
</div>
<p class="text-sm text-gray-600 mb-2">${c.summary || '暂无摘要'}</p>
${(c.key_points || []).length > 0 ? `
<div class="mb-2">
<span class="text-xs font-semibold ${c.stage === '成交' ? 'text-green-700' : 'text-orange-700'}">${c.stage === '成交' ? '关键成交点' : '需要突破的问题点'}</span>
<ul class="mt-1 space-y-0.5">
${(c.key_points || []).map(p => `<li class="text-xs text-gray-600 flex gap-1"><span class="${c.stage === '成交' ? 'text-green-500' : 'text-orange-500'}">▸</span><span>${escapeHtml(p)}</span></li>`).join('')}
</ul>
</div>
` : ''}
<div class="flex gap-4 text-xs text-gray-500 border-t pt-2 mt-2">
<span>异议: ${(c.objections || []).join('、') || '无'}</span>
<span>下一步: ${c.next_action || '-'}</span>
</div>
</div>
`).join('');
}
function populateParadigmSelect() {
const sel = document.getElementById('paradigm-customer-select');
if (!sel) return;
const currentVal = sel.value;
sel.innerHTML = '<option value="">请选择客户</option>';
conversationsData.forEach(c => {
const opt = document.createElement('option');
opt.value = c.id;
opt.textContent = `${c.customer_name} (${c.salesperson_name}, ${c.stage})`;
sel.appendChild(opt);
});
if (currentVal) sel.value = currentVal;
}
async function generateParadigm() {
const customerId = document.getElementById('paradigm-customer-select').value;
if (!customerId) {
document.getElementById('paradigm-result').innerHTML = '<p class="text-sm text-orange-500">请先选择客户</p>';
return;
}
const btn = document.getElementById('paradigm-btn');
const result = document.getElementById('paradigm-result');
btn.disabled = true;
btn.textContent = '生成中...';
result.innerHTML = '<p class="text-sm text-gray-400">正在调用千问 LLM 生成标准范式对话,请稍候...</p>';
try {
const data = await api(`/conversations/${customerId}/paradigm`, { method: 'POST' });
renderParadigm(data);
} catch (e) {
result.innerHTML = `<p class="text-sm text-red-500">生成失败: ${e.message}</p>`;
} finally {
btn.disabled = false;
btn.textContent = '生成范式对话';
}
}
function renderParadigm(data) {
const result = document.getElementById('paradigm-result');
const stageColors = {
'成交': 'border-green-400 bg-green-50',
'异议处理': 'border-orange-400 bg-orange-50',
'报价': 'border-blue-400 bg-blue-50',
'需求发现': 'border-purple-400 bg-purple-50',
'建立联系': 'border-gray-300 bg-gray-50',
};
result.innerHTML = `
<div class="bg-white rounded-lg shadow p-6">
<h3 class="text-lg font-bold mb-1">标准范式对话模板</h3>
<p class="text-sm text-gray-500 mb-4">客户类型: ${data.customer_type || '-'}</p>
<div class="space-y-4 mb-6">
${(data.stages || []).map((s, i) => `
<div class="border-l-4 ${stageColors[s.stage] || stageColors['建立联系']} pl-4 py-3">
<div class="flex items-center gap-2 mb-2">
<span class="bg-gray-700 text-white rounded-full w-6 h-6 flex items-center justify-center text-xs font-bold">${i + 1}</span>
<span class="font-semibold text-sm">${s.stage}</span>
</div>
<p class="text-xs text-gray-500 mb-2">目标: ${s.goal || '-'}</p>
<div class="mb-2">
<span class="text-xs font-medium text-green-700">销售话术:</span>
<p class="text-sm text-gray-700 mt-1 bg-green-50 rounded p-2">${escapeHtml(s.sales_script || '-')}</p>
</div>
<div class="mb-2">
<span class="text-xs font-medium text-gray-600">预期回应:</span>
<p class="text-sm text-gray-600 mt-1">${escapeHtml(s.expected_response || '-')}</p>
</div>
<div>
<span class="text-xs font-medium text-blue-600">技巧提示:</span>
<p class="text-sm text-gray-600 mt-1">${escapeHtml(s.tips || '-')}</p>
</div>
</div>
`).join('')}
</div>
${(data.key_techniques || []).length > 0 ? `
<div class="mb-4">
<h4 class="text-sm font-semibold mb-2 text-green-700">核心销售技巧</h4>
<ul class="space-y-1">
${(data.key_techniques || []).map(t => `<li class="text-sm text-gray-700 flex gap-2"><span class="text-green-500">✓</span><span>${escapeHtml(t)}</span></li>`).join('')}
</ul>
</div>
` : ''}
${(data.improvement_points || []).length > 0 ? `
<div class="mb-4">
<h4 class="text-sm font-semibold mb-2 text-orange-700">可改进的点</h4>
<ul class="space-y-1">
${(data.improvement_points || []).map(t => `<li class="text-sm text-gray-700 flex gap-2"><span class="text-orange-500">!</span><span>${escapeHtml(t)}</span></li>`).join('')}
</ul>
</div>
` : ''}
</div>
`;
}
// --- 工具 ---
function escapeHtml(s) {
const div = document.createElement('div');
div.textContent = s || '';
return div.innerHTML;
}
// --- 初始化 ---
showPanel('dashboard');
</script>
</body>
</html>
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff