微信营销管理系统 - 益童宝销售管理平台
- 数据库: 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:
+17
@@ -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
|
||||
@@ -0,0 +1,400 @@
|
||||
# 微信营销管理系统 MVP 方案
|
||||
|
||||
> 文档日期:2026-07-14
|
||||
> 定位:最小可用产品,验证"微信聊天数据 → 客户与沟通记录 → 成交关联"的核心闭环
|
||||
|
||||
---
|
||||
|
||||
## 一、MVP 目标
|
||||
|
||||
用最简路径完成一件事:**把每个销售的微信聊天采集到中央数据库,AI 分析出客户和沟通记录,成交数据手动录入,形成"沟通→成交"的证据链。**
|
||||
|
||||
不做的事:
|
||||
- 不做培训对练
|
||||
- 不做 AI 自动回复
|
||||
- 不做多租户
|
||||
- 不做实时同步
|
||||
- 不做复杂权限体系
|
||||
|
||||
---
|
||||
|
||||
## 二、MVP 范围
|
||||
|
||||
### 2.1 核心流程
|
||||
|
||||
```text
|
||||
销售设备本机采集微信数据
|
||||
→ 传输到中央数据库
|
||||
→ AI 识别客户、提取沟通记录
|
||||
→ 销售手动录入成交数据
|
||||
→ 关联聊天与成交
|
||||
→ 基础报表
|
||||
```
|
||||
|
||||
### 2.2 支持规模
|
||||
|
||||
- 3~5 名销售
|
||||
- 每人 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 周:联调 + 试点
|
||||
|
||||
- [ ] 3~5 名销售实际接入
|
||||
- [ ] 录入真实成交数据
|
||||
- [ ] 验证"聊天→客户→成交"关联准确性
|
||||
- [ ] 收集反馈,修复问题
|
||||
|
||||
---
|
||||
|
||||
## 十、成功标准
|
||||
|
||||
MVP 上线后需验证:
|
||||
|
||||
1. **采集完整性**:每个销售每天的消息采集率 > 95%
|
||||
2. **客户识别准确率**:AI 识别的客户中,> 80% 被销售确认是真实客户
|
||||
3. **成交关联可用**:手动录入的成交记录能正确关联到对应聊天记录
|
||||
4. **基础报表可用**:能看到每个销售的消息量、客户数、成交金额
|
||||
5. **闭环验证**:至少能从 1 个成交客户回溯完整沟通链路
|
||||
|
||||
---
|
||||
|
||||
## 十一、与完整方案的关系
|
||||
|
||||
MVP 是总体方案"第一阶段"的精简版,主要区别:
|
||||
|
||||
| 维度 | MVP | 完整方案第一阶段 |
|
||||
|---|---|---|
|
||||
| 数据模型 | 6 张表 | 20+ 张表 |
|
||||
| 成交数据 | 手动录入 | 接入 CRM/订单系统 |
|
||||
| AI 分析 | 客户识别 + 沟通摘要 | 阶段识别 + 意向评分 + 异议分析 + 待办 |
|
||||
| 权限 | 按 salesperson_id 简单隔离 | RBAC + 数据范围权限 |
|
||||
| 同步 | 每日离线 | 每日 + 小时级 |
|
||||
| 报表 | 基础统计 | 销售漏斗 + 能力雷达 + 话术分析 |
|
||||
| 部署 | 单机 | 单机但面向多设备 |
|
||||
|
||||
MVP 验证通过后,按完整方案路线图逐步扩展。
|
||||
+399
@@ -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 岁以上可用、无敏配方
|
||||
- 适用症状:小儿湿疹、过敏性鼻炎、食物过敏、免疫力低下、腹泻/便秘
|
||||
|
||||
**目标客户画像**:
|
||||
- 宝妈,25~40 岁
|
||||
- 孩子有过敏症状(湿疹、鼻炎、食物过敏)
|
||||
- 关注成分安全,对"进口""临床验证"敏感
|
||||
- 价格敏感度中等,更关注效果和安全性
|
||||
- 决策链短(宝妈自己决定),但需要信任建立
|
||||
|
||||
### 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 API(FastAPI)
|
||||
|
||||
| 方法 | 路径 | 说明 |
|
||||
|---|---|---|
|
||||
| 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 5434,Web 8770(避开现有 5433 和 8765) |
|
||||
| 启动时间 | < 30 秒 |
|
||||
| 模拟数据量 | 3 名销售,约 1000 条消息,可秒级生成 |
|
||||
| 前端 | 单页 HTML + TailwindCSS CDN,无需构建 |
|
||||
| 后端 | FastAPI,单文件即可 |
|
||||
| 依赖 | psycopg2-binary, fastapi, uvicorn(3 个包) |
|
||||
|
||||
---
|
||||
|
||||
## 九、技术约束
|
||||
|
||||
1. **不修改现有系统**:不改动 `/Users/freedak/Documents/Codex/2026-07-14/ni/` 下的任何文件
|
||||
2. **独立数据库**:使用独立数据库 `wxchat_sales`,不复用现有 `wechat_knowledge`
|
||||
3. **独立端口**:PostgreSQL 5434,Web 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. **培训对练**:按总体方案第二阶段实施
|
||||
@@ -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
@@ -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()` — 每销售 15~30 个联系人
|
||||
- [ ] 实现 `generate_conversations()` — 每销售 10~20 个会话
|
||||
- [ ] 实现 `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;"
|
||||
# 消息数应增加
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 3:AI 分析服务
|
||||
|
||||
**目标**:规则引擎实现客户识别和沟通摘要。
|
||||
|
||||
**产出文件**:
|
||||
- `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;"
|
||||
# 应有客户记录,非客户不在其中
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 4:Web 后端
|
||||
|
||||
**目标**: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"}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 阶段 5:Web 前端
|
||||
|
||||
**目标**:单页 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+
|
||||
- macOS(launchd、pg_ctl 等)
|
||||
|
||||
### 可选依赖
|
||||
|
||||
- Ollama + qwen2.5(LLM 分析模式,非必需)
|
||||
|
||||
---
|
||||
|
||||
## 三、风险与对策
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
|---|---|---|
|
||||
| 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 个文件(大) |
|
||||
| 阶段 3:AI 分析 | 2~3 小时 | 1 个文件 |
|
||||
| 阶段 4:Web 后端 | 2~3 小时 | 1 个文件 |
|
||||
| 阶段 5:Web 前端 | 3~4 小时 | 1 个文件(大) |
|
||||
| 阶段 6:集成启动 | 1 小时 | 3 个文件 |
|
||||
| **合计** | **12~17 小时** | **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 全部通过 |
|
||||
@@ -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 |
|
||||
| 销售阶段 | 成交 |
|
||||
| 消息轮次 | 15~25 |
|
||||
| 关联销售 | 张伟(id=1) |
|
||||
|
||||
**对话脚本**:
|
||||
|
||||
```
|
||||
[客户] 你好,我家宝宝2岁,湿疹反复好几个月了,朋友推荐你这边益生菌
|
||||
[销售] 辰辰妈您好!宝宝湿疹确实让人心疼,请问现在湿疹主要在哪些部位?有用过什么药吗?
|
||||
[客户] 脸上和手臂都有,医生开了激素药膏,但停了就复发
|
||||
[销售] 理解,激素药膏只能暂时压制。益生菌是从肠道调节免疫,从根本上降低过敏反应。我们用的是丹麦进口的鼠李糖乳杆菌,有专门针对儿童湿疹的临床验证
|
||||
[销售] [图片:临床验证报告截图]
|
||||
[客户] 这个是进口的?安全吗?2岁能吃吗?
|
||||
[销售] 是的,丹麦进口菌株,0岁以上就能用,无敏配方,不含牛奶蛋白和麸质。很多宝妈反馈坚持吃2-3个月湿疹明显好转
|
||||
[客户] 多少钱?怎么卖的?
|
||||
[销售] 单盒298元30袋,建议先吃3盒一个周期,3盒套餐798元算下来每天不到9块钱
|
||||
[销售] [图片:产品包装图]
|
||||
[客户] 3盒798是吧,效果不好怎么办?
|
||||
[销售] 我们有售后指导,期间有任何问题随时找我。另外我发您几个同情况宝妈的反馈看看
|
||||
[销售] [链接:宝妈真实反馈合集]
|
||||
[客户] 好的,那先来3盒试试
|
||||
[销售] 好的辰辰妈!3盒套餐798元,您方便现在付款吗?我这边给您安排发货
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 2:过敏性鼻炎咨询后犹豫
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 客户昵称 | 乐乐妈 |
|
||||
| 备注 | 乐乐妈-鼻炎-5岁 |
|
||||
| 客户画像 | 宝妈,32 岁,孩子 5 岁,过敏性鼻炎 |
|
||||
| 意向等级 | medium |
|
||||
| 销售阶段 | 异议处理 |
|
||||
| 消息轮次 | 10~20 |
|
||||
| 关联销售 | 李娜(id=2) |
|
||||
|
||||
**对话脚本**:
|
||||
|
||||
```
|
||||
[客户] 你好,听说益生菌对过敏性鼻炎有帮助?
|
||||
[销售] 乐乐妈您好!是的,益生菌可以调节肠道免疫,对过敏性鼻炎有改善作用。请问宝宝现在鼻炎什么症状?
|
||||
[客户] 早上起来一直打喷嚏流鼻涕,医生说是过敏性鼻炎
|
||||
[销售] 益生菌从肠道调节免疫系统,坚持吃可以逐渐降低过敏反应。我们的菌株是丹麦进口的,有临床验证
|
||||
[客户] 有没有副作用?安全吗?
|
||||
[销售] 很安全的,无敏配方,不含牛奶蛋白和麸质,5岁完全没问题
|
||||
[客户] 多少钱?
|
||||
[销售] 单盒298,3盒套餐798
|
||||
[客户] 有点贵啊...
|
||||
[销售] 算下来每天不到9块钱,比吃药划算多了
|
||||
[客户] 我再想想,和老公商量一下
|
||||
[销售] 好的,不着急。您可以先看看这个科普文章
|
||||
[销售] [链接:益生菌与儿童过敏科普]
|
||||
[客户] 好的,我看看
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 3:朋友推荐来咨询
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 客户昵称 | 果果妈妈 |
|
||||
| 备注 | 果果妈-推荐来的 |
|
||||
| 客户画像 | 宝妈,30 岁,孩子 1 岁半,朋友推荐 |
|
||||
| 意向等级 | medium |
|
||||
| 销售阶段 | 需求发现 |
|
||||
| 消息轮次 | 8~15 |
|
||||
| 关联销售 | 张伟(id=1) |
|
||||
|
||||
**对话脚本**:
|
||||
|
||||
```
|
||||
[客户] 你好,是小林推荐我加你的,她说你家益生菌不错
|
||||
[销售] 果果妈您好!谢谢小林推荐,请问宝宝多大了?有什么情况吗?
|
||||
[客户] 1岁半,最近总是拉肚子,小林说吃益生菌调理一下
|
||||
[销售] 拉肚子多久了?有去看过医生吗?
|
||||
[客户] 断断续续一个多月了,医生说没什么大问题,让注意饮食
|
||||
[销售] 那益生菌确实可以帮到宝宝。我们的菌株是丹麦进口的,专门针对儿童肠道和免疫调节。1岁半完全可以吃
|
||||
[客户] 成分安全吗?
|
||||
[销售] 无敏配方,不含牛奶蛋白、麸质和大豆,很多敏宝妈妈都在用
|
||||
[客户] 好的,我先了解一下
|
||||
[销售] 没问题,我给您发个产品详情,您先看看
|
||||
[销售] [链接:产品详情页]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 4:长期跟进后复购
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 客户昵称 | 糖糖妈妈 |
|
||||
| 备注 | 糖糖妈-复购-湿疹已好转 |
|
||||
| 客户画像 | 宝妈,35 岁,孩子 3 岁,已购买过 3 盒 |
|
||||
| 意向等级 | high |
|
||||
| 销售阶段 | 成交 |
|
||||
| 消息轮次 | 20~30 |
|
||||
| 关联销售 | 王强(id=3) |
|
||||
|
||||
**对话脚本(分多日)**:
|
||||
|
||||
```
|
||||
--- 第1天 ---
|
||||
[客户] 你好,我想了解一下益生菌
|
||||
[销售] 糖糖妈您好!请问宝宝什么情况?
|
||||
[客户] 3岁,湿疹,反反复复的
|
||||
[销售] 湿疹确实需要从内调理。我们的益生菌丹麦进口菌株,专门针对儿童过敏
|
||||
[客户] 多少钱?
|
||||
[销售] 单盒298,3盒套餐798
|
||||
[客户] 先了解一下,回头再说
|
||||
[销售] 好的,不着急
|
||||
|
||||
--- 第7天 ---
|
||||
[销售] 糖糖妈,宝宝最近湿疹怎么样了?
|
||||
[客户] 还是老样子,断不了根
|
||||
[销售] 湿疹确实需要坚持调理,益生菌一般吃2-3个月能看到明显改善
|
||||
[客户] 我再考虑考虑
|
||||
|
||||
--- 第15天 ---
|
||||
[销售] 糖糖妈,今天有个宝妈反馈说吃了2个月湿疹好多了,发您看看
|
||||
[销售] [图片:宝妈反馈截图]
|
||||
[客户] 真的吗?那我也试试吧
|
||||
[销售] 好的!建议先拿3盒一个周期,798元
|
||||
[客户] 好的,付款吧
|
||||
[销售] 好的!我马上安排发货
|
||||
|
||||
--- 第60天 ---
|
||||
[销售] 糖糖妈,宝宝吃了这段时间效果怎么样?
|
||||
[客户] 比之前好多了,湿疹没那么频繁了
|
||||
[销售] 太好了!建议继续吃巩固一下,现在有6盒套餐1499更划算
|
||||
[客户] 好,那来6盒
|
||||
[销售] 好的!马上安排
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 场景 5:对比竞品后选择
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 客户昵称 | 壮壮妈妈 |
|
||||
| 备注 | 壮壮妈-对比合生元 |
|
||||
| 客户画像 | 宝妈,29 岁,孩子 4 岁,免疫力差 |
|
||||
| 意向等级 | medium |
|
||||
| 销售阶段 | 方案匹配 |
|
||||
| 消息轮次 | 12~20 |
|
||||
| 关联销售 | 王强(id=3) |
|
||||
|
||||
**对话脚本**:
|
||||
|
||||
```
|
||||
[客户] 你好,我想给孩子买益生菌,但在对比你们和合生元
|
||||
[销售] 壮壮妈您好!对比是正常的。请问宝宝主要什么情况?
|
||||
[客户] 经常感冒,免疫力不太好
|
||||
[销售] 我们的菌株是丹麦进口的鼠李糖乳杆菌GG,有专门针对儿童免疫力的临床验证。合生元用的是国产菌株
|
||||
[客户] 但你们比合生元贵啊
|
||||
[销售] 菌株不一样,进口菌株的临床数据更充分,效果更有保障。算下来每天不到9块钱
|
||||
[客户] 有什么区别吗?
|
||||
[销售] [图片:菌株对比表]
|
||||
[销售] 主要是菌株来源和临床验证数量不同。我们的LGG是全球研究最多的益生菌菌株之一
|
||||
[客户] 我再看看吧
|
||||
[销售] 好的,有问题随时问我
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、非客户联系人模板
|
||||
|
||||
### 4.1 代购广告
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 昵称 | 韩国代购-小美 |
|
||||
| 消息内容 | "【韩国直邮】儿童维生素团购开始啦,DHA+益生菌组合装只要199!" |
|
||||
| 消息数 | 1~3 条 |
|
||||
|
||||
### 4.2 其他品牌推销
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 昵称 | 母婴用品批发 |
|
||||
| 消息内容 | "姐,我们新款益生菌做活动,比你现在用的便宜一半,要不要看看?" |
|
||||
| 消息数 | 2~4 条 |
|
||||
|
||||
### 4.3 朋友圈互动
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 昵称 | 朵朵阿姨 |
|
||||
| 消息内容 | "在吗?""你朋友圈那个是什么产品?""好的知道了" |
|
||||
| 消息数 | 2~5 条 |
|
||||
|
||||
### 4.4 家人朋友
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 昵称 | 老婆 ❤️ |
|
||||
| 消息内容 | "今晚回来吃饭吗?""孩子放学了""周末去哪玩?" |
|
||||
| 消息数 | 3~8 条 |
|
||||
|
||||
### 4.5 群聊 — 宝妈群
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 群名 | 宝妈育儿交流群 |
|
||||
| 消息内容 | "[群公告] 本群禁发广告""有没有宝妈推荐好的儿童面霜?""我家宝宝最近不爱吃饭怎么办?""谢谢推荐" |
|
||||
| 消息数 | 5~15 条 |
|
||||
|
||||
### 4.6 群聊 — 过敏宝宝互助群
|
||||
|
||||
| 维度 | 内容 |
|
||||
|---|---|
|
||||
| 群名 | 过敏宝宝互助群 |
|
||||
| 消息内容 | "我家宝宝湿疹终于好了""你们都用的什么益生菌?""益生菌真的有用吗?""我也想试试" |
|
||||
| 消息数 | 8~20 条 |
|
||||
|
||||
---
|
||||
|
||||
## 五、联系人分配方案
|
||||
|
||||
### 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 |
|
||||
@@ -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 盒 798,6 盒 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)完全隔离。
|
||||
@@ -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", "单盒298,3盒套餐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", "单盒298,3盒套餐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", "单盒298,3盒套餐798"),
|
||||
("customer", "text", "先来3盒试试"),
|
||||
("sales", "text", "好的!3盒798元,马上安排发货"),
|
||||
("customer", "text", "好的,已付款"),
|
||||
],
|
||||
},
|
||||
{
|
||||
"name": "价格异议后放弃",
|
||||
"stage": "异议处理",
|
||||
"contact": {
|
||||
"nickname": "可可妈妈",
|
||||
"remark": "可可妈-价格异议",
|
||||
"industry": "宝妈",
|
||||
"intent_level": "low",
|
||||
},
|
||||
"messages": [
|
||||
("customer", "text", "益生菌多少钱?"),
|
||||
("sales", "text", "可可妈您好!单盒298,3盒套餐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", "您好!单盒298,3盒798,6盒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())
|
||||
@@ -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())
|
||||
Executable
+59
@@ -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"
|
||||
@@ -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;
|
||||
@@ -0,0 +1,3 @@
|
||||
psycopg2-binary>=2.9
|
||||
fastapi>=0.111
|
||||
uvicorn>=0.30
|
||||
Executable
+132
@@ -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
|
||||
Executable
+36
@@ -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 "✓ 已停止全部服务"
|
||||
@@ -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 ==='
|
||||
Executable
+82
@@ -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
|
||||
@@ -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")
|
||||
@@ -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">← 返回客户列表</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>
|
||||
Vendored
+1
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user