chore: 从汽车AI客服项目拷贝骨架并清理(blog页/redis)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-09-29 14:15:12 +08:00
parent 90860b8bfd
commit 49c0e5559c
97 changed files with 19131 additions and 0 deletions
@@ -0,0 +1,293 @@
# 二手汽车AI智能客服系统 - 设计文档
## 概述
为二手车贸易公司构建AI智能客服系统,核心功能是通过自然对话收集客户信息、评估购买意向,将有意向客户推送至邮箱。
### 核心需求
- AI自然对话收集客户信息,不预设固定字段,自主判断信息是否足够
- 简单意向分级:interested / not_interested / pending
- 有意向客户自动发送邮件通知
- 前端支持三种使用方式:独立页面、iframe嵌入、JS Widget悬浮窗
### 约束
- 单租户,贸易公司自用
- 初期使用智谱GLM模型,架构支持多模型切换
- Docker部署
- 客户主要来自自有网站
## 1. 系统架构
采用**全栈一体化**架构,FastAPI托管所有服务。
```
┌─────────────────────────────────────────────────┐
│ Docker Compose │
│ │
│ ┌──────────────┐ ┌──────────────┐ ┌────────┐ │
│ │ FastAPI │ │ Redis │ │SQLite/ │ │
│ │ (后端+前端) │←→│ (会话缓存) │ │Postgres│ │
│ │ │ │ │ │(持久化) │ │
│ │ - REST API │ └──────────────┘ └────────┘ │
│ │ - WebSocket │ │
│ │ - 静态页面 │ │
│ │ - JS Widget │ │
│ └──────┬───────┘ │
│ │ │
│ ┌──────▼───────┐ ┌──────────────┐ │
│ │ AI Service │ │ Email Service│ │
│ │ (多模型适配) │ │ (SMTP推送) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────┘
```
### 模块职责
- **FastAPI主服务** — REST API + WebSocket + 静态文件托管
- **AI Service** — 多模型适配层(初期智谱GLM),统一prompt管理和对话管理
- **Email Service** — SMTP发送,意向客户自动推送
- **Redis** — 活跃WebSocket会话、对话上下文缓存
- **SQLite/PostgreSQL** — 持久化对话记录、客户信息、评估结果
### 数据流
客户打开聊天框 → WebSocket建立连接 → AI自然对话收集信息 → AI判断信息足够 → 评估意向 → 有意向则推送邮件
## 2. 数据模型
### Conversation(对话)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| session_id | string | 会话标识 |
| status | enum | active / completed / abandoned |
| source | enum | standalone / embedded / widget |
| client_info | JSON | IP、浏览器、来源页面等 |
| created_at | datetime | 创建时间 |
| updated_at | datetime | 更新时间 |
### Message(消息)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| conversation_id | UUID | 外键 |
| role | enum | user / assistant / system |
| content | text | 消息内容 |
| created_at | datetime | 创建时间 |
### CustomerProfile(客户画像 — AI逐步填充)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| conversation_id | UUID | 外键(一对一) |
| raw_info | JSON | AI自然收集的所有信息,灵活结构 |
| summary | text | AI生成的客户画像摘要 |
| updated_at | datetime | 更新时间 |
### Evaluation(意向评估 — 对话结束时生成)
| 字段 | 类型 | 说明 |
|------|------|------|
| id | UUID | 主键 |
| conversation_id | UUID | 外键(一对一) |
| intent_level | enum | interested / not_interested / pending |
| reasoning | text | AI的判断依据 |
| contact_info | JSON | 提取的联系方式(手机/微信/邮箱) |
| email_sent | boolean | 是否已发送邮件 |
| evaluated_at | datetime | 评估时间 |
## 3. 会话流程
```
客户进入 → 创建 Conversation
│
▼
[对话阶段] AI自然对话,不预设字段
│ 每轮对话后,AI在后台提取信息填充 raw_info
│ AI自主判断:信息是否足够评估意向?
│
├── 信息不足 → 继续对话(引导提问)
│
└── 信息足够 → [评估阶段]
AI生成客户画像 summary
判定 intent_level
│
├── interested → 提取联系方式 → 发送邮件 → 结束
├── not_interested → 记录归档 → 结束
└── pending → 标记待定,不发邮件(信息不足无法判断) → 结束
```
### 关键设计点
- `raw_info` 是灵活JSON,不锁死字段,AI对话中发现什么就记什么
- 联系方式由AI从对话中自然提取,不强求客户填写表单
- 对话可随时中断,系统保存已有信息,客户回来可继续
## 4. 前端设计
### 三种使用场景
#### 独立聊天页面
- 完整的全屏聊天界面
- FastAPI直接托管静态HTML
- 访问域名(如 chat.yourdomain.com)直接使用
#### iframe嵌入
- 在网站任意位置用 `<iframe>` 标签嵌入完整聊天界面
- 适合官网内嵌
#### JS Widget悬浮窗
- 一段JS代码嵌入任意网站
- 右下角悬浮气泡,点击展开聊天窗口
- 使用 Shadow DOM 做样式隔离,不影响宿主页面
- 可配置参数:位置(四角)、主题色、标题文案、自动展开等
```html
<!-- 其他网站只需加这段代码 -->
<script>
(function(w,d,s,o){
var j=d.createElement(s);j.async=true;
j.src="https://your-domain.com/widget.js";
j.setAttribute('data-position','bottom-right');
j.setAttribute('data-theme','light');
j.setAttribute('data-title','AI汽车顾问');
d.head.appendChild(j);
})(window,document,'script');
</script>
```
### Widget技术实现
Widget JS加载后:创建 Shadow DOM(样式隔离) → 注入悬浮气泡按钮 → 点击后通过WebSocket连接后端 → 渲染聊天界面。
## 5. AI服务设计
### 模型适配层
```python
class AIProvider(ABC):
async def chat(messages, system_prompt) -> str
async def chat_with_extraction(messages, system_prompt, extract_fields) -> tuple[str, dict]
class GLMProvider(AIProvider) # 智谱GLM(初期)
class OpenAIProvider(AIProvider) # 预留
class DeepSeekProvider(AIProvider) # 预留
```
### 三轮调用机制
每轮客户消息到达后,后端做三件事(对客户透明):
1. **主对话调用** → AI回复客户(自然对话,引导收集信息)
2. **信息提取调用**(后台静默)→ 从对话历史中提取已获得的信息,更新raw_info
3. **意向判断调用**(后台静默)→ 评估当前信息是否足够,决定是否结束对话
第2、3步使用同一个GLM模型和完整prompt,确保提取和判断质量。第3步判断信息足够时,主对话引导收尾并提取联系方式。
### Prompt策略
- **System Prompt** — AI角色为「二手车顾问」,对话风格专业但亲切,发问策略自然引导
- **信息提取Prompt** — 从对话历史中提取结构化信息,输出JSON
- **评估Prompt** — 根据已有信息判断意向等级,输出intent_level + 理由
### 会话上下文管理
- 短期上下文:Redis缓存最近N轮对话(实时对话用)
- 完整历史:每轮对话后持久化到数据库
- 上下文窗口控制:对话过长时自动摘要早期对话,避免token超限
### 防流失机制
- 客户关闭页面后,Redis会话保留24小时
- 同一浏览器再次打开,自动恢复上次对话
- 对话中断但已收集部分信息,标记为`pending`,不发邮件但记录归档
## 6. 邮件推送
### 流程
```
AI判定意向 = interested
│
▼
组装邮件内容
├── 客户画像摘要(AI生成)
├── 关键需求提取(预算、偏好、用途等)
├── 联系方式(手机/微信/邮箱)
├── 完整对话记录(可折叠)
└── 评估理由
│
▼
SMTP发送 → 目标邮箱
│
▼
记录发送状态(成功/失败/重试)
```
### 配置(环境变量)
- `SMTP_HOST` / `SMTP_PORT` — SMTP服务器
- `SMTP_USER` / `SMTP_PASS` — 认证信息
- `NOTIFY_EMAIL` — 目标通知邮箱
## 7. 项目结构
```
cc_ai_kefu/
├── docker-compose.yml
├── Dockerfile
├── .env.example
├── app/
│ ├── main.py # FastAPI入口
│ ├── config.py # 配置管理
│ ├── api/
│ │ ├── routes/
│ │ │ ├── chat.py # WebSocket聊天端点
│ │ │ └── webhook.py # 回调/状态查询API
│ │ └── deps.py # 依赖注入
│ ├── services/
│ │ ├── ai_service.py # AI模型适配层
│ │ ├── conversation.py # 对话管理(三轮调用)
│ │ ├── extractor.py # 信息提取
│ │ ├── evaluator.py # 意向评估
│ │ └── email_service.py # 邮件发送
│ ├── models/
│ │ ├── conversation.py # SQLAlchemy模型
│ │ └── schemas.py # Pydantic schemas
│ ├── providers/
│ │ ├── base.py # AIProvider基类
│ │ └── glm.py # 智谱GLM实现
│ └── prompts/
│ ├── system.md # 主对话system prompt
│ ├── extractor.md # 信息提取prompt
│ └── evaluator.md # 评估prompt
├── static/ # 前端静态文件
│ ├── chat.html # 独立聊天页面
│ ├── css/
│ ├── js/
│ │ └── chat.js # 聊天核心逻辑
│ └── widget.js # 外部嵌入Widget脚本
├── migrations/ # 数据库迁移
└── tests/
```
## 8. 技术栈
| 层 | 选型 |
|---|---|
| 后端框架 | FastAPI + Uvicorn |
| 数据库 | SQLite(初版)/ PostgreSQL(生产) |
| 缓存 | Redis |
| ORM | SQLAlchemy + Alembic |
| AI SDK | 智谱GLM SDK(zhipuai) |
| 邮件 | aiosmtplib(异步SMTP) |
| 前端 | 原生HTML/CSS/JS,无框架 |
| 部署 | Docker Compose(FastAPI + Redis) |
@@ -0,0 +1,40 @@
# AI对话风格优化 + 联系方式引导
## 背景
用户反馈AI客服回复"AI味重",需要更自然;同时需要在对话中拿到客户联系方式才算有效客户。
## 问题
1. **客服套话多** — "感谢咨询""很高兴为您服务"等模板话术
2. **回复太长** — 经常写2-3个长句,不像即时聊天
3. **要联系方式不够主动** — 只有一句模糊的"自然引导",没有具体策略
## 方案
只改 `app/prompts/system.md`,不动代码。
### 改动内容
#### 1. 加禁止套话规则
明确列出禁止使用的句式:
- "感谢/谢谢您的咨询/关注/提问"
- "我很高兴/荣幸为您服务/帮助"
- "请随时/不要犹豫联系我"
- "希望以上信息对您有帮助"
- "如果您有任何其他问题"
- 任何以"您好"开头的句子(开场白除外)
#### 2. 加联系方式引导策略
- 3-4轮对话后,以"转接专业销售"为理由要联系方式
- 给具体话术示例,让模型知道怎么自然地说
- 拿到联系方式后不再重复要
#### 3. 加强长度控制
- 从"2-3句话"改为"1-2个短句"
- 明确要求像 WhatsApp/微信聊天的长度
#### 4. 加正例/反例
让模型直观理解什么是自然、什么是套话。
## 不改动的部分
- `extractor.md` — 提取逻辑不变
- `evaluator.md` — 评估逻辑不变(已有联系方式作为interested必要条件)
- 任何代码文件
@@ -0,0 +1,458 @@
# 聊天窗口和入口按钮重新设计
**日期**: 2026-04-22
**项目**: AI 客服系统 - VOLT/HESU
**目标**: 重新设计聊天窗口入口按钮和聊天界面样式
---
## 一、设计概述
### 1.1 项目背景
当前 AI 客服系统的聊天窗口样式需要更新,以提供更专业、现代的用户体验。主要涉及:
- 悬浮入口按钮样式优化
- 聊天窗口布局重新设计
- 交互细节完善
### 1.2 设计目标
- 提供专业、友好的客服入口体验
- 优化聊天窗口的信息层级
- 改善用户交互流程
- 保持与品牌橙色主题的一致性
---
## 二、入口按钮设计
### 2.1 样式规范
| 属性 | 值 |
|------|-----|
| 类型 | 横条卡片式 |
| 尺寸 | 宽度约 180px,高度自动适应内容 |
| 圆角 | 30px |
| 背景 | #f08a31(品牌橙色) |
| 阴影 | 0 4px 16px rgba(240,138,49,0.4) |
| 悬停效果 | 放大 1.1 倍 |
### 2.2 内容构成
```
┌─────────────────────────────┐
│ [📞 图标] 在线咨询 │
│ 即时回复 │
└─────────────────────────────┘
```
- **左侧**: 圆形图标区域(36x36px,半透明白色背景)
- **右侧**:
- 主标题:"在线咨询"(14px,加粗,白色)
- 副标题:"即时回复"(11px,白色,90% 透明度)
### 2.3 位置规范
- 默认位置:右下角(bottom: 20px, right: 20px)
- 可配置位置:bottom-left, top-right, top-left
- z-index: 99999(确保在最顶层)
---
## 三、聊天窗口设计
### 3.1 窗口规格
| 属性 | 值 |
|------|-----|
| 宽度 | 400px |
| 高度 | 自适应,最小高度 480px |
| 圆角 | 16px |
| 阴影 | 0 10px 40px rgba(0,0,0,0.15) |
| 最大高度 | 80vh |
| 字体 | -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto |
### 3.2 头部区域
#### 3.2.1 布局
- 高度:约 68px(padding 14px 18px)
- 背景:#f08a31(品牌橙色渐变)
- 文字颜色:白色
#### 3.2.2 元素构成
```
┌─────────────────────────────────────┐
│ [🚗 头像+绿点] HESU Support [−] [✕] │
│ Online now · Usually │
│ replies in minutes │
└─────────────────────────────────────┘
```
| 元素 | 规格 |
|------|------|
| 头像 | 48x48px 圆形,汽车 emoji 🚗,半透明白色背景 |
| 在线状态点 | 14px,绿色 #22c55e,3px 橙色边框,右下角定位 |
| 标题 | 17px,加粗,"HESU Support" |
| 状态文字 | 13px,"Online now · Usually replies in minutes",95% 透明度 |
| 操作按钮 | 32x32px,圆角 8px,半透明白色背景 15% |
### 3.3 消息区域
#### 3.3.1 基础规范
- 内边距:20px
- 背景:#ffffff 白色
- 最小高度:260px
- 圆角:0(与窗口顶部对齐)
#### 3.3.2 "How can we help?" 区块
```
┌─────────────────────────────────────┐
│ How can we help? │
│ Ask about vehicles, market support, │
│ quotation, shipping and delivery. │
│ [📞 Call us] │
└─────────────────────────────────────┘
```
| 属性 | 值 |
|------|-----|
| 背景 | #fef9f3(浅黄橙色) |
| 边框 | 1px,#fef3c7 |
| 圆角 | 12px |
| 内边距 | 16px |
| 外边距 | 底部 20px |
| 布局 | 左侧内容 + 右侧按钮 |
| 元素 | 规格 |
|------|------|
| 标题 | 17px,加粗,#111827 |
| 描述 | 13px,#6b7280,行高 1.4 |
| Call us 按钮 | #f08a31 背景,9px 18px 内边距,10px 圆角,📞 图标 15px |
**交互**: Call us 按钮点击跳转到 https://wa.me/+8619037179250
#### 3.3.3 欢迎消息
```
┌─────────────────────────────────────┐
│ [🚗] Hello. Welcome to HESU. I can │
│ help with stock vehicles, │
│ quotation, delivery timing │
│ and distributor cooperation. │
└─────────────────────────────────────┘
```
| 属性 | 值 |
|------|-----|
| AI 头像 | 32x32px 圆形,#f08a31 背景 |
| 消息气泡 | #f3f4f6 背景,16px 圆角,左下角 4px 直角 |
| 文字 | 14px,#1f2937,行高 1.5 |
| 最大宽度 | 280px |
| 间距 | 头像与气泡 10px |
### 3.4 分隔线
| 属性 | 值 |
|------|-----|
| 高度 | 1px |
| 颜色 | #f08a31(橙色) |
| 透明度 | 30% |
| 位置 | 消息区与输入区之间 |
### 3.5 输入区域
#### 3.5.1 容器规范
| 属性 | 值 |
|------|-----|
| 外边距 | 16px 18px |
| 背景 | 白色 |
| 内边距 | 0(容器本身无内边距) |
#### 3.5.2 输入容器
```
┌─────────────────────────────────────┐
│ [ 输入框... ] │ [➤ Send ] │
└─────────────────────────────────────┘
```
| 属性 | 值 |
|------|-----|
| 背景 | #fef9f3(浅黄橙色) |
| 圆角 | 28px |
| 内边距 | 12px 16px |
| 边框 | 无 |
| 阴影 | 0 1px 3px rgba(0,0,0,0.05) |
| 间距 | 输入框与分隔线 8px |
#### 3.5.3 输入框
| 属性 | 值 |
|------|-----|
| 背景 | 透明 |
| 边框 | 无 |
| 内边距 | 0 |
| 字号 | 14px |
| 颜色 | #1f2937 |
| 占位符 | "Type a message...",#9ca3af |
| 最小高度 | 20px |
| outline | none(聚焦时无特殊样式) |
#### 3.5.4 分隔线
| 属性 | 值 |
|------|-----|
| 宽度 | 1px |
| 高度 | 24px |
| 颜色 | #e5e7eb |
| 位置 | 输入框与按钮之间 |
#### 3.5.5 发送按钮
| 属性 | 值 |
|------|-----|
| 文字 | "➤ Send" |
| 背景 | linear-gradient(135deg, #f08a31, #f59e0b) |
| 内边距 | 8px 16px |
| 圆角 | 12px |
| 字号 | 14px,字重 500 |
| 阴影 | 0 2px 8px rgba(240,138,49,0.25) |
| 箭头图标 | 16px |
| white-space | nowrap(防止文字换行) |
### 3.6 底部信息栏
```
┌─────────────────────────────────────┐
│ By sending a message, you agree to │
│ the privacy policy. AI + human support │
└─────────────────────────────────────┘
```
| 属性 | 值 |
|------|-----|
| 背景 | 白色 |
| 内边距 | 10px 18px |
| 布局 | justify-content: space-between |
| 间距 | gap: 20px |
| 字号 | 11px |
| 颜色 | #d97706(深橙色) |
| 元素 | 规格 |
|------|------|
| 左侧 | "By sending a message, you agree to the privacy policy.",flex: 1 |
| 右侧 | "AI + human support",flex-shrink: 0 |
---
## 四、配色方案
### 4.1 主色调
| 颜色名称 | HEX 用途 | 说明 |
|----------|---------|------|
| 品牌橙 | #f08a31 | 主色、头部背景、按钮背景 |
| 品牌橙深 | #e07a20 | 悬停状态 |
| 橙黄渐变 | #f08a31 → #f59e0b | 发送按钮渐变 |
### 4.2 辅助色
| 颜色名称 | HEX 用途 | 说明 |
|----------|---------|------|
| 浅黄橙 | #fef9f3 | Help 区块、输入容器背景 |
| 浅黄边 | #fef3c7 | Help 区块边框 |
| 橙色文字 | #d97706 | 底部信息栏文字 |
| 浅橙分隔 | #fed7aa | 辅助橙色 |
### 4.3 中性色
| 颜色名称 | HEX 用途 | 说明 |
|----------|---------|------|
| 纯白 | #ffffff | 聊天背景、输入容器 |
| 浅灰 | #f9fafb | 输入框背景、快捷按钮背景 |
| 消息气泡灰 | #f3f4f6 | AI 消息气泡 |
| 边框灰 | #e5e7eb | 分隔线、边框 |
| 分隔线灰 | #e5e7eb | 细线分隔 |
### 4.4 文字色
| 颜色名称 | HEX 用途 | 说明 |
|----------|---------|------|
| 深黑 | #111827 | 标题 |
| 中灰 | #6b7280 | 描述文字 |
| 深灰 | #1f2937 | 正文 |
| 浅灰 | #9ca3af | 占位符 |
### 4.5 功能色
| 颜色名称 | HEX 用途 | 说明 |
|----------|---------|------|
| 在线绿 | #22c55e | 在线状态点 |
---
## 五、交互说明
### 5.1 悬浮入口按钮
| 交互 | 行为 |
|------|------|
| 悬停 | 放大 1.1 倍 |
| 点击 | 展开/收起聊天窗口 |
### 5.2 聊天窗口头部
| 元素 | 行为 |
|------|------|
| 最小化按钮 (−) | 折叠聊天窗口,保留悬浮入口 |
| 关闭按钮 (✕) | 关闭聊天窗口 |
### 5.3 Call us 按钮
| 交互 | 行为 |
|------|------|
| 点击 | 新标签页打开 WhatsApp 链接:https://wa.me/+8619037179250 |
| target | _blank |
### 5.4 发送按钮
| 交互 | 行为 |
|------|------|
| 点击 | 发送消息到 AI 客服 |
| 悬停 | 可选:放大效果 |
---
## 六、响应式设计
### 6.1 移动端适配
| 断点 | 调整 |
|------|------|
| 最大宽度 400px | 窗口宽度 |
| 高度 | calc(100vh - 100px) |
| 底部位置 | 距底部 20px |
### 6.2 字体缩放
| 元素 | 移动端 |
|------|--------|
| 标题 | 保持不变 |
| 正文 | 保持不变 |
| 按钮 | 保持不变 |
---
## 七、技术实现要点
### 7.1 CSS 关键样式
```css
/* 悬浮入口按钮 */
.toggle-btn {
width: auto;
height: auto;
background: #f08a31;
border-radius: 30px;
padding: 8px 20px;
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 4px 16px rgba(240,138,49,0.4);
cursor: pointer;
transition: transform 0.2s;
}
/* 聊天窗口 */
.chat-window {
width: 400px;
background: white;
border-radius: 16px;
box-shadow: 0 10px 40px rgba(0,0,0,0.15);
}
/* 输入容器 */
.input-container {
background: #fef9f3;
border-radius: 28px;
padding: 12px 16px;
display: flex;
align-items: center;
box-shadow: 0 1px 3px rgba(0,0,0,0.05);
}
```
### 7.2 需要修改的文件
1. **[static/widget.js](static/widget.js)** - 入口按钮样式
2. **[static/css/style.css](static/css/style.css)** - 聊天窗口样式
3. **[static/js/chat.js](static/js/chat.js)** - 交互逻辑(如需要)
---
## 八、验收标准
### 8.1 视觉验收
- [ ] 入口按钮为横条卡片式,包含图标和双行文字
- [ ] 聊天窗口宽度 400px,圆角 16px
- [ ] 头部橙色背景,包含头像+在线状态+标题+操作按钮
- [ ] "How can we help?" 区块浅黄背景,左内容右按钮布局
- [ ] 欢迎消息包含 AI 头像和问候语
- [ ] 橙色分隔线(1px,30% 透明度)
- [ ] 输入容器浅黄背景,圆角 28px,无边框
- [ ] 发送按钮为圆角长方形,包含箭头图标和"Send"文字
- [ ] 底部信息栏橙色文字,左右分开布局
### 8.2 交互验收
- [ ] 入口按钮悬停放大
- [ ] Call us 按钮跳转到 WhatsApp
- [ ] 最小化/关闭按钮功能正常
- [ ] 发送按钮功能正常
### 8.3 响应式验收
- [ ] 移动端窗口正确显示
- [ ] 文字大小适配合理
---
## 九、实施计划
### 9.1 实施步骤
1. 修改悬浮入口按钮样式(widget.js)
2. 更新聊天窗口 CSS 样式(style.css)
3. 调整输入区域 HTML 结构(chat.html)
4. 更新交互逻辑(chat.js)
5. 测试所有交互功能
6. 移动端兼容性测试
### 9.2 兼容性
- 现代浏览器(Chrome, Firefox, Safari, Edge)
- 移动端浏览器(iOS Safari, Chrome Mobile)
- Shadow DOM 支持(widget 嵌入模式)
---
## 十、附录
### 10.1 设计参考
- 入口按钮:现代客服系统的横条卡片样式
- 聊天窗口:IM 应用(WhatsApp、Telegram)的对话界面
- 配色方案:品牌橙色主题的延伸
### 10.2 设计资产
- 头像:汽车 emoji 🚗
- 在线图标:绿点 + "Online" 文字
- 操作图标:−(最小化)、✕(关闭)
- 功能图标:📞(电话)、➤(发送箭头)
---
**文档版本**: 1.0
**最后更新**: 2026-04-22
@@ -0,0 +1,248 @@
# 自动弹出邀请卡片设计规范
**日期:** 2026-04-23
**状态:** 已批准
**相关:** 2026-04-22-chat-widget-redesign.md
---
## 概述
为 Widget 模式添加自动弹出邀请卡片,页面加载 1.8 秒后显示。邀请卡片提供快速访问常见话题的入口,提升用户参与度。
### 目标
- 通过主动邀请提升用户参与度
- 提供热门话题的快速访问
- 保持每页独立的状态管理
- 与现有聊天组件无缝集成
---
## 组件设计
### 邀请卡片
**位置**: 右下角,悬浮按钮上方
**尺寸**:
- 宽度: 360px
- 移动端最大宽度: calc(100vw - 24px)
**样式**:
- 圆角: 26px
- 头部: 橙色渐变 `linear-gradient(135deg, #F08A34 0%, #EC8C2F 100%)`
- 阴影: `0 28px 80px rgba(196,91,25,0.22)`
- 动画: 淡入 + 从底部滑入 (300ms)
**结构**:
```
┌──────────────────────────────────────┐
│ 头部 (橙色渐变) │
│ 🤖 Get stock list or quotation now │
│ Chat with us for available vehicles, │
│ delivery timing and distributor... │
├──────────────────────────────────────┤
│ 话题按钮 (2x1 网格) │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │Get Quotation│ │Distributor Coopt│ │
│ └─────────────┘ └─────────────────┘ │
├──────────────────────────────────────┤
│ 操作按钮 │
│ [Start Chat →] [ No Thanks ] │
└──────────────────────────────────────┘
```
**话题列表**:
1. Get Quotation
2. Distributor Cooperation
---
## 状态管理
### 存储方案
**键名格式**: `widget_invite_dismissed_${pathname}`
**示例**:
- 页面 `/home` → `widget_invite_dismissed_/home`
- 页面 `/products` → `widget_invite_dismissed_/products`
**存储类型**: `sessionStorage`
### 状态流程
```
页面加载
↓
启动 1.8 秒定时器
↓
检查 sessionStorage
↓
┌─────────────┬─────────────┐
│ 未关闭 │ 已关闭 │
│ ↓ │ ↓ │
│ 显示邀请 │ 跳过 │
└─────────────┴─────────────┘
```
### 关闭类型
| 类型 | 触发条件 | 存储行为 | 刷新后行为 |
|------|---------|---------|-----------|
| 暂时关闭 | 点击话题、Start Chat、悬浮按钮 | 设置 dismissed = true | 重置(再次显示) |
| 永久关闭 | 点击 No Thanks | 设置 dismissed = true | 持续(保持隐藏) |
---
## 交互矩阵
| 用户操作 | 邀请卡片 | 聊天面板 | 自动发送消息 |
|---------|---------|---------|-------------|
| 点击 "Get Quotation" | 关闭(暂时) | 打开 | 是 |
| 点击 "Distributor Cooperation" | 关闭(暂时) | 打开 | 是 |
| 点击 "Start Chat" | 关闭(暂时) | 打开 | 否 |
| 点击 "No Thanks" | 关闭(永久) | - | 否 |
| 点击悬浮按钮(邀请可见时) | 关闭(暂时) | 打开 | 否 |
| 点击悬浮按钮(邀请隐藏时) | - | 打开 | 否 |
### 显示逻辑
**互斥关系**:
- 邀请卡片可见 → 聊天面板隐藏
- 聊天面板打开 → 邀请卡片隐藏
**层级 (Z-Index)**:
- 邀请卡片: 99998
- 聊天面板: 99999
- 悬浮按钮: 99997
---
## 聊天面板中的热门话题
### 位置
消息区域,初始消息上方。仅在对话初始阶段显示。
### 样式
```
┌──────────────────────────────────────┐
│ POPULAR TOPICS │
│ ┌─────────────┐ ┌─────────────────┐ │
│ │Get Quotation│ │Distributor Coopt│ │
│ └─────────────┘ └─────────────────┘ │
└──────────────────────────────────────┘
```
### 行为
- 点击话题 → 自动发送消息到聊天
- 用户发送第一条消息后隐藏
---
## 每页独立运行
### 规则
1. **独立定时器**: 每个页面加载时启动自己的 1.8 秒定时器
2. **独立存储**: 每个页面单独存储关闭状态
3. **无跨页面通信**: 状态不在页面间同步
4. **会话重置**: 关闭网站清空所有状态
### 示例流程
```
1. 用户打开 /home
→ 1.8 秒 → 显示邀请
→ 用户点击 "Get Quotation"
→ /home 标记为已关闭
2. 用户导航到 /products
→ 1.8 秒 → 显示邀请(独立)
→ 用户点击 "No Thanks"
→ /products 标记为已关闭
3. 用户导航到 /about
→ 1.8 秒 → 显示邀请(独立)
→ 用户没有操作
→ /about 未标记为已关闭
4. 用户回到 /home
→ 检查存储 → 已关闭
→ 不显示邀请
5. 用户回到 /about
→ 检查存储 → 未关闭
→ 邀请保持显示
6. 用户刷新 /about
→ 存储持续 → 邀请保持显示
7. 用户关闭并重新打开网站
→ 所有存储清空
→ 所有页面重新开始
```
---
## 实现文件
### 修改
- `static/widget.js` - 添加邀请卡片组件和逻辑(约 250-300 行)
### 新增代码段
1. **邀请 HTML 生成** - 创建邀请卡片 DOM 结构
2. **邀请 CSS 样式** - 添加到 Shadow DOM 的样式
3. **定时器逻辑** - 1.8 秒延迟机制
4. **存储管理** - 每页状态管理
5. **事件处理** - 话题按钮、操作按钮
6. **自动发送消息** - 注入消息到聊天的函数
---
## 技术考虑
### Shadow DOM
所有邀请卡片样式都在 Shadow DOM 内部作用域,防止与宿主页面样式冲突。
### 性能
- 定时器是简单的 `setTimeout()` - 开销极小
- 存储检查是同步且快速的
- 初始渲染后 DOM 操作最少
### 可访问性
- 邀请卡片可用 Escape 键关闭(未来增强)
- 所有按钮都有正确的 `aria-label` 属性
- 支持键盘导航
### 浏览器兼容性
- `sessionStorage` 在所有现代浏览器中支持
- Shadow DOM 在 Chrome 53+、Firefox 63+、Safari 10+ 中支持
---
## 未来增强
1. **可配置延迟**: 通过 data 属性自定义 1.8 秒延迟
2. **A/B 测试**: 支持不同的邀请变体
3. **分析**: 跟踪邀请关闭和转化率
4. **Escape 键**: 用键盘关闭邀请
5. **智能时机**: 用户主动滚动时延迟邀请
---
## 参考资料
- 父规范: `2026-04-22-chat-widget-redesign.md`
- 参考设计: `livechat_ui_widget (5).jsx` (StrongInvite 组件)
@@ -0,0 +1,160 @@
# WhatsApp / Email 联系方式动态配置
**日期**: 2026-06-18
**项目**: AI 客服系统 - VOLT/HESU
**目标**: 将写死的 WhatsApp 号码和 Email 地址改为通过 `<script>` 标签的 `data-*` 属性动态配置
---
## 一、设计概述
### 1.1 项目背景
当前 AI 客服组件([static/widget.js](../../../static/widget.js))和独立聊天页([static/chat.html](../../../static/chat.html))中的 WhatsApp 跳转链接和 Email 链接是**固定写死**在源码里的:
- WhatsApp:`https://wa.me/+8619037179250`
- Email:`mailto:info@borderlesscar.com`
这些值是 `borderlesscar` 这一个部署的专属联系方式。当组件需要复用到其它品牌/站点时,必须改源码、重新部署,无法按部署点灵活配置。
### 1.2 设计目标
- 接入方在引入 `<script>` 标签时,可通过 `data-whatsapp`、`data-email` 属性传入自己的联系方式。
- **完全向后兼容**:不传任何属性时,行为与现在线上完全一致(回退到现有默认值)。
- 复用组件已有的 `data-*` 配置机制(`data-position` / `data-theme` / `data-title` / `data-auto-open` 已经走这条路径),不引入新的配置范式。
- 嵌入式组件(widget.js)和独立页(chat.html)使用**同一套**配置约定,只需学习一种机制。
### 1.3 非目标(Out of Scope)
- 后端 `.env` / [app/config.py](../../../app/config.py) / [app/services/email_service.py](../../../app/services/email_service.py) 不在本次范围内——那是服务端逻辑,与浏览器端组件配置无关。
- 后台管理面板([static/admin.html](../../../static/admin.html))里展示的联系方式是 AI 从对话中抽取的数据,不属于本次「部署级配置」范畴,不改。
- 不做按钮显隐配置(即不引入「未配置则隐藏按钮」的能力)。本次确认的策略是「未配置则回退默认值」。
---
## 二、配置约定(契约)
### 2.1 属性定义
| 属性 | 含义 | 取值格式 | 默认值(未提供时) |
|------|------|----------|--------------------|
| `data-whatsapp` | WhatsApp 联系方式跳转链接 | **完整 URL**,如 `https://wa.me/8619037179250` | `https://wa.me/+8619037179250` |
| `data-email` | Email 联系方式链接 | **完整 URL**,如 `mailto:sales@example.com` | `mailto:info@borderlesscar.com` |
### 2.2 取值格式说明
采用**完整 URL** 而非「只写号码/邮箱」:
- 组件内部不做协议拼接,直接把属性值作为 `<a href>` 使用。
- 更灵活:WhatsApp 将来可换成非 `wa.me` 的跳转地址,Email 也可带 `?subject=` 等参数。
- 接入方需自行保证带上协议前缀(`https://` / `mailto:`)。
### 2.3 缺省 / 空值处理
- 属性**缺失**:使用上表默认值。
- 属性为**空字符串**(`data-whatsapp=""`):与缺失等价,使用默认值。
- 不存在「隐藏按钮」的语义——本次范围内联系方式按钮始终展示。
### 2.4 接入示例
```html
<!-- 嵌入式组件:自定义联系方式 -->
<script
src="https://chat.example.com/static/widget.js"
data-position="bottom-right"
data-theme="light"
data-whatsapp="https://wa.me/8613800138000"
data-email="mailto:support@mybrand.com"
></script>
<!-- 独立页 chat.html:同样在它的 chat.js script 标签上传参 -->
<script
src="/static/js/chat.js"
data-whatsapp="https://wa.me/8613800138000"
data-email="mailto:support@mybrand.com"
></script>
```
---
## 三、改动详情
### 3.1 `static/widget.js`(嵌入式组件)
**读取配置**:在 [widget.js:8-12](../../../static/widget.js#L8-L12) 现有 `getAttribute` 调用旁,新增两行(沿用现有写法):
```js
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8619037179250";
var email = script.getAttribute("data-email") || "mailto:info@borderlesscar.com";
```
**使用配置**:把以下 3 处写死的 `href` 替换为字符串拼接 `+ whatsapp +` / `+ email +`:
| 位置 | 当前写死值 | 改为 |
|------|------------|------|
| [widget.js:170](../../../static/widget.js#L170)(面板 help-block,含 WhatsApp 与 Email 两个 `href`) | `https://wa.me/+8619037179250` / `mailto:info@borderlesscar.com` | `+ whatsapp +` / `+ email +` |
| [widget.js:199](../../../static/widget.js#L199)(邀请卡 WhatsApp) | `https://wa.me/+8619037179250` | `+ whatsapp +` |
| [widget.js:203](../../../static/widget.js#L203)(邀请卡 Email) | `mailto:info@borderlesscar.com` | `+ email +` |
> 注意:第 170 行是单行内同时包含 WhatsApp 与 Email 两个 `<a>`,需保证两处拼接都正确,不要漏改其中一个。
### 3.2 `static/js/chat.js` + `static/chat.html`(独立页,方案 A)
`chat.html` 是独立整页,加载 `/static/js/chat.js`(**不**加载 widget.js)。为保持与组件**同一套**配置机制,让 `chat.js` 读取自身 `<script>` 标签上的 `data-whatsapp` / `data-email` 并应用到页面两个锚点上。
**`static/js/chat.js` 改动**:
- 在 IIFE 顶部,获取自身 script 标签并读取配置(与 widget.js 同样的 `document.currentScript` 写法;为兼容异步加载,回退用 `querySelector('script[src*="chat.js"]')`):
```js
var chatScript = document.currentScript || document.querySelector('script[src*="chat.js"]');
var whatsapp = (chatScript && chatScript.getAttribute("data-whatsapp")) || "https://wa.me/+8619037179250";
var email = (chatScript && chatScript.getAttribute("data-email")) || "mailto:info@borderlesscar.com";
// 应用到页面两个锚点
var waBtn = document.querySelector('.whatsapp-btn');
var emBtn = document.querySelector('.email-btn');
if (waBtn) waBtn.href = whatsapp;
if (emBtn) emBtn.href = email;
```
**`static/chat.html` 改动**:
- 在 [chat.html:80](../../../static/chat.html#L80) 的 `<script src="/static/js/chat.js"></script>` 标签上添加 `data-whatsapp` / `data-email` 属性,使演示页展示配置能力。
- 第 35、39 行原有的静态 `href` **保留为默认值不动**,作为 `chat.js` 执行前的 fallback(避免 JS 未执行时出现空链接),`chat.js` 启动后会按配置覆盖。
- **执行时序安全**:`chat.js` 在 `<body>` 末尾加载([chat.html:80](../../../static/chat.html#L80),晚于聊天容器 DOM),故 IIFE 顶部同步 `querySelector('.whatsapp-btn' / '.email-btn')` 时两个锚点已存在,同步赋值 `href` 安全。这两个 class 仅出现在 `chat.html` 的 light DOM 中(widget.js 里同名 class 在其 shadow DOM 内,互不干扰)。
### 3.3 `static/widget-test.html`(组件测试/演示页)
- 在 [widget-test.html:69](../../../static/widget-test.html#L69) 的组件 `<script>` 标签上添加 `data-whatsapp` / `data-email` 属性,便于本地验证自定义配置生效。
---
## 四、向后兼容性
- 不传任何新属性时:widget.js 与 chat.html 的链接与当前线上**完全一致**(默认值即现有写死值)。
- 不删除任何现有 `data-*` 配置,不改变现有 DOM 结构、样式或交互。
- `chat.js` 新增逻辑仅在选中到对应元素时才设置 `href`,对无这两个按钮的页面无副作用。
---
## 五、验收 / 测试
本仓库为纯静态 JS,无自动化测试框架,采用**手动测试清单**(建议补充到 [docs/test-checklist.md](../../test-checklist.md)):
1. **默认值(不传属性)**:widget-test.html 与 chat.html 的 WhatsApp / Email 按钮分别指向 `wa.me/+8619037179250` 与 `mailto:info@borderlesscar.com`,与改动前一致。
2. **自定义值**:在 widget-test.html 组件 script 标签上传入自定义 `data-whatsapp` / `data-email`,组件内三处链接(面板 help-block、邀请卡 WhatsApp、邀请卡 Email)均指向新地址。
3. **chat.html 自定义值**:在 chat.js script 标签上传入自定义值,独立页两个按钮指向新地址。
4. **空字符串**:`data-whatsapp=""` 等价于未传,回退默认值。
5. **禁用 JS**:chat.html 在 JS 未执行时,静态 `href` 仍为默认值(不出现空链接)。
---
## 六、影响文件清单
| 文件 | 改动类型 |
|------|----------|
| `static/widget.js` | 读取 2 个新属性 + 替换 3 处 href |
| `static/js/chat.js` | 新增读取自身 script 标签配置 + 设置 2 个锚点 href |
| `static/chat.html` | chat.js script 标签加 2 个 data 属性(静态 href 保留为默认) |
| `static/widget-test.html` | 组件 script 标签加 2 个 data 属性(演示用) |
@@ -0,0 +1,215 @@
# 邀请卡自动弹出开关 + 移动端悬浮按钮配置
**日期**: 2026-06-23
**项目**: AI 客服系统 - VOLT/HESU
**目标**: 为嵌入式组件(widget.js)新增两个 `data-*` 配置开关——控制「邀请卡是否自动弹出」、以及「是否使用移动端悬浮按钮样式」,并让移动端按钮采用「图标在上、文字在下」的竖排样式(D2)。
---
## 一、设计概述
### 1.1 项目背景
当前嵌入式组件 [static/widget.js](../../../static/widget.js) 有两处行为是写死、无法按部署点配置的:
1. **邀请卡固定 1.8s 后自动弹出**:[widget.js:306-316](../../../static/widget.js#L306-L316) 的 `startInviteTimer()` 无条件启动,所有嵌入站点都会弹。部分站点不希望主动打扰用户,但目前无法关闭。
2. **悬浮按钮只有桌面款一种样式**:[widget.js:233-244](../../../static/widget.js#L233-L244) 的 `toggle-btn` 是一个「图标在左、文字在右」的宽药丸。在移动端窄屏上偏宽、略挡内容。
本次复用上一轮「联系方式动态配置」已建立的 `data-*` 配置范式(见 [2026-06-18-dynamic-contact-config-design.md](./2026-06-18-dynamic-contact-config-design.md)),把上述两处行为也变成可配置。
### 1.2 设计目标
- 接入方可通过 `data-auto-invite` 控制邀请卡是否自动弹出:默认弹(保持现状),设 `false` 则不弹。
- 接入方可通过 `data-mobile` 切换悬浮按钮到移动端竖排样式(D2:圆钮在上、文字外置在下)。
- **完全向后兼容**:不传任何新属性时,行为与现在线上完全一致。
- 复用已有 `data-*` 机制,不引入新的配置范式。
### 1.3 非目标(Out of Scope)
- `data-mobile` **只影响悬浮按钮样式**,不改聊天面板、不改邀请卡。聊天面板已有自己的 `@media(max-width:450px)` 响应式([widget.js:152](../../../static/widget.js#L152)),各管各的,本次不统一。
- **不做自动检测**:移动端判定采用纯手动标识(方案 A),不引入 UA 嗅探、不读 `window.innerWidth`。接入方按部署点自行打标。
- **独立聊天页(chat.html / chat.js)不在本次范围**:独立页没有邀请卡逻辑,悬浮按钮也是 widget 独有的概念。本次只改 widget.js。
- 后端、admin、配置文件均不动。
---
## 二、配置约定(契约)
### 2.1 属性定义
| 属性 | 含义 | 取值 | 默认值(未提供时) |
|------|------|------|--------------------|
| `data-auto-invite` | 邀请卡是否在 1.8s 后自动弹出 | `"false"` 关闭;其它/缺省为开启 | 开启(保持现状) |
| `data-mobile` | 是否使用移动端竖排悬浮按钮(D2) | `"true"` 启用;其它/缺省为桌面款 | `false`(桌面款) |
### 2.2 读取语义
沿用 widget.js 现有的字符串判定写法(与 [widget.js:11](../../../static/widget.js#L11) 的 `data-auto-open` 一致):
```js
var autoInvite = script.getAttribute("data-auto-invite") !== "false"; // 默认 true
var isMobile = script.getAttribute("data-mobile") === "true"; // 默认 false
```
- `data-auto-invite` 采用**负向逻辑**(设 `false` 关闭),是为了**保持现有默认行为不变**——现在线上就是会弹,缺省必须仍是「弹」。这与 `data-auto-open`(设 `true` 才开)方向相反,但两者默认状态不同,不冲突。
- 属性缺失、空字符串、或非 `"false"`/`"true"` 的任意值:`autoInvite` 视为 `true`、`isMobile` 视为 `false`,即回退默认。
### 2.3 接入示例
```html
<!-- 桌面端、保留邀请卡自动弹(什么都不加,现状) -->
<script src="https://chat.example.com/static/widget.js"></script>
<!-- 移动端站点:竖排按钮 + 关闭邀请卡自动弹 -->
<script
src="https://chat.example.com/static/widget.js"
data-mobile="true"
data-auto-invite="false"
></script>
<!-- 仅关闭邀请卡自动弹,按钮仍为桌面款 -->
<script
src="https://chat.example.com/static/widget.js"
data-auto-invite="false"
></script>
```
两个开关互相独立,可任意组合。
---
## 三、改动详情
全部改动集中在 [static/widget.js](../../../static/widget.js) 与测试页 [static/widget-test.html](../../../static/widget-test.html)。
### 3.1 `static/widget.js` — 读取两个新属性
在 [widget.js:8-14](../../../static/widget.js#L8-L14) 现有 `getAttribute` 调用旁,新增两行:
```js
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var isMobile = script.getAttribute("data-mobile") === "true";
```
### 3.2 `static/widget.js` — 邀请卡自动弹出开关
在 [widget.js:306](../../../static/widget.js#L306) 的 `startInviteTimer()` 函数**最开头**加一句短路:
```js
function startInviteTimer() {
if (!autoInvite) return; // ← 新增:配置关闭则不启动计时器
if (checkInviteStorage()) {
return; // 已被永久关闭
}
inviteTimer = window.setTimeout(function () { ... }, 1800);
}
```
效果:`data-auto-invite="false"` 时计时器根本不启动,邀请卡永不自动弹出。点悬浮按钮仍照常打开聊天面板;邀请卡内部的 "Start Chat" / "No Thanks" 等逻辑因卡片不出现而自然不触发,无需额外改动。
### 3.3 `static/widget.js` — 移动端悬浮按钮(D2)
悬浮按钮当前在 [widget.js:233-244](../../../static/widget.js#L233-L244) 一处生成,`toggleBtn.innerHTML` 为桌面款模板。改动为**按 `isMobile` 分两套模板**,点击事件不变。
**实现路径选择(见第四节):采用「独立移动端 DOM 模板」**,桌面款模板逐字不动搬入 `else` 分支。
**DOM 结构(D2,竖排:圆钮在上、文字在外在下)**:
```js
var toggleBtn = document.createElement("button");
toggleBtn.className = "toggle-btn" + (isMobile ? " mobile" : "");
if (isMobile) {
toggleBtn.innerHTML = `
<div class="mobile-circle">🤖<span class="mobile-dot"></span></div>
<span class="mobile-label">Chat with us</span>
`;
} else {
toggleBtn.innerHTML = `/* 现有桌面款模板(widget.js:235-244),原样照搬、逐字不动 */`;
}
```
`toggleBtn` 仍是接收点击的元素([widget.js:367](../../../static/widget.js#L367) 的 `toggleBtn.addEventListener("click", toggle)` 不变),点击圆钮或文字均触发 `toggle()`。
**新增 CSS(追加到 [widget.js:40](../../../static/widget.js#L40) 注入的样式数组中)**:
```js
// 移动端竖排按钮(D2):覆盖桌面款药丸背景,把橙色挪到圆钮上
".toggle-btn.mobile { flex-direction:column; gap:5px; padding:0; background:transparent; box-shadow:none; border-radius:0; }",
".toggle-btn.mobile:hover { transform:scale(1.05); }",
".toggle-btn.mobile .mobile-circle { position:relative; width:56px; height:56px; border-radius:50%; background:#f08a31; display:flex; align-items:center; justify-content:center; font-size:28px; box-shadow:0 4px 16px rgba(240,138,49,0.45); }",
".toggle-btn.mobile .mobile-dot { position:absolute; bottom:2px; right:2px; width:13px; height:13px; background:#22c55e; border-radius:50%; border:2px solid #f08a31; }",
".toggle-btn.mobile .mobile-label { color:#8C4D1D; font-size:10px; font-weight:600; line-height:1.1; }",
```
> **位置**:桌面端位置逻辑不变——按钮仍位于由 `positionStyles` 决定的容器锚点(默认右下)。移动端采用更贴角的位置表(见下「位置偏移」)。
>
> **颜色与主题**:移动端按钮颜色硬编码固定橙色(`#f08a31` 圆钮 + `#8C4D1D` 文字),**不随 `data-theme` 切换**——这与现有桌面款按钮(同样固定橙色、忽略 `data-theme`)行为一致,实现时无需特殊处理主题。
**位置偏移(移动端贴角)**:
现有 `positionStyles`([widget.js:16-21](../../../static/widget.js#L16-L21))的 `bottom-right` 取 `bottom:80px`(为高药丸按钮预留)。移动端圆钮仅 56px,沿用 80px 会在手机上离底边偏高、显得"飘"。故 `isMobile` 时改用一份更贴角的位置表 `positionStylesMobile`:
```js
var positionStylesMobile = {
"bottom-right": { bottom: "20px", right: "16px" },
"bottom-left": { bottom: "20px", left: "16px" },
"top-right": { top: "20px", right: "16px" },
"top-left": { top: "20px", left: "16px" },
};
var pos = (isMobile ? positionStylesMobile : positionStyles)[position]
|| (isMobile ? positionStylesMobile : positionStyles)["bottom-right"];
```
桌面端 `positionStyles` 逐字不动;仅当 `data-mobile="true"` 时切换到 `positionStylesMobile`。聊天面板/邀请卡因相对容器定位(`bottom:80px`),随容器下移后仍开在按钮上方,不重叠。
### 3.4 `static/widget-test.html` — 演示 / 测试属性
在 [widget-test.html:69](../../../static/widget-test.html#L69) 的组件 `<script>` 标签上,新增 `data-mobile` / `data-auto-invite` 属性以便本地验证;或保留默认、另起一个测试用例覆盖移动端。具体由实现阶段在测试清单中明确。
---
## 四、实现路径:独立移动端 DOM 模板 vs CSS 类切换
移动端按钮(D2)与桌面款 DOM 结构差异较大:桌面是「图标+文字都在一个橙色药丸里」,D2 是「圆钮在上、文字在按钮外面」。两种实现法:
| 方案 | 做法 | 取舍 |
|------|------|------|
| **① 独立移动端 DOM 模板(采用)** | `isMobile` 时拼一段不同的 `innerHTML`,桌面模板原样搬进 `else` | 结构清晰、互不干扰;改一边不影响另一边;与「桌面分支逐字不变」的兼容性目标天然吻合 |
| ② 同一 DOM + CSS 类切换 | 保留一份结构,加 `.mobile` class 用 CSS 把文字挪到按钮外 | 代码略少,但要硬掰布局、容易出现重叠/错位,调试成本高 |
**采用 ①**:D2 结构差异够大,独立模板最干净,且桌面分支零改动 = 现有嵌入零风险。
---
## 五、向后兼容性
- **不传任何新属性**:`autoInvite=true`、`isMobile=false`。
- 邀请卡仍 1.8s 自动弹(`startInviteTimer` 正常执行)。
- 悬浮按钮仍走桌面款模板(`toggle-btn`,无 `.mobile` class,样式与现状完全一致)。
- → 现有所有嵌入脚本(均未写这两个新属性)行为**与线上完全一致**。
- 不删除、不修改任何现有 `data-*` 配置(`data-position` / `data-theme` / `data-title` / `data-auto-open` / `data-whatsapp` / `data-email`)。
- **唯一风险点**:悬浮按钮那段是所有嵌入共享的代码路径。规避方式 = 桌面分支逐字不动、移动 D2 完全独立成分支(见第四节)。
- 邀请卡改动仅为函数开头加一句 `if (!autoInvite) return;`,极小、极孤立。
---
## 六、验收 / 测试
本仓库为纯静态 JS,无自动化测试框架,采用**手动测试清单**(建议补充到 [docs/test-checklist.md](../../test-checklist.md)):
1. **默认值回归(不传新属性)**:用 widget-test.html 验证——邀请卡仍 1.8s 后弹出;悬浮按钮仍为桌面款宽药丸;点击按钮正常开关聊天面板。与改动前逐项一致。
2. **`data-auto-invite="false"`**:邀请卡不再自动弹出;悬浮按钮(桌面款)点击仍能打开聊天面板;聊天面板内交互正常。
3. **`data-mobile="true"`**:悬浮按钮变为 D2 竖排样式(56px 圆钮在上、"Chat with us" 文字外置在下、非橙色底);点击圆钮或文字均能开关聊天面板;邀请卡仍按默认自动弹(两者独立)。
4. **组合 `data-mobile="true" data-auto-invite="false"`**:竖排按钮 + 不弹邀请卡,两者互不影响。
5. **空值/非法值**:`data-mobile=""` 或 `data-mobile="yes"` 等价于未传(回退桌面款);`data-auto-invite="0"` 等价于未传(仍弹)。
6. **位置配置**:`data-mobile="true"` 配合 `data-position` 各取值,按钮锚点位置正确;尤其验证 `bottom-right` 下移动端按钮贴角(约 `bottom:20px`)而非沿用桌面 `80px`。
---
## 七、影响文件清单
| 文件 | 改动类型 |
|------|----------|
| `static/widget.js` | 读取 2 个新属性 + 邀请卡短路 1 行 + 悬浮按钮分两套模板 + 新增移动端 CSS |
| `static/widget-test.html` | 组件 script 标签加 `data-mobile` / `data-auto-invite`(演示/测试用) |
@@ -0,0 +1,249 @@
# 设计文档:客服挂件动态主题色(品牌色)
- 日期:2026-08-31
- 状态:设计定稿,待实施
- 分支:chany_dev_dev
## 1. 背景与目标
当前客服挂件(widget.js)与独立聊天页(chat.html)的配色为固定橙色,
约 40 处(widget.js)+ 24 处(style.css)硬编码色值散落在样式字符串/CSS 中。
目标:像 Intercom / WhatsApp Business 那样,让每个嵌入网站传入自己的品牌色,
整套配色(深色文字、浅背景、边框、阴影、渐变)自动从这一个颜色派生,
不传则回退默认橙,视觉与现状一致(唯一例外见 §9.1 Send 渐变简化)。
### 非目标
- 不做多站点/多租户后台配置(单租户部署,嵌入方在 data 属性里自行控制)
- 不做完整 dark 模式(现状 `data-theme="dark"` 本就只影响气泡/面板背景,维持现状)
- 不做品牌色之外的多色自定义(YAGNI,派生已覆盖)
- admin.html(内部后台,蓝色系)不在范围内
## 2. 需求决策记录
| 决策点 | 结论 |
|---|---|
| 配置入口 | 嵌入方 script 标签 `data-color` 属性;独立页 URL 参数 `?color=` |
| 生效范围 | widget.js 挂件 + chat.html 独立聊天页 |
| 颜色派生 | 单品牌色 + HSL 规则自动派生全套色板 |
| 技术路线 | CSS 自定义属性(语义槽位)+ JS 派生注入(方案 A) |
| Send 渐变 | 接受简化:与邀请卡渐变统一,近乎纯色(用户已确认) |
| 输入格式 | 仅 3/6 位 hex(`#` 可选);其他格式告警并回退默认 |
| 头像图标 | 🤖 emoji 全部替换为图片 `static/kefu.png`,挂件侧支持 `data-avatar` 覆盖(默认 kefu-small.png,原图 kefu.png 为素材),详见 §12 |
## 3. 架构与数据流
```
客户网站
┌──────────────────────────────────────┐
│ <script src=".../static/widget.js" │
│ data-color="#1a73e8"> │
│ │
│ widget.js(自包含,内联派生函数) │
│ 1. 读 data-color(缺失/非法→默认) │
│ 2. derivePalette(hex) → 7 槽位色板 │
│ 3. Shadow DOM style 字符串头部注入 │
│ :host { --w-primary: ...; ... } │
│ 4. 全部 CSS 引用 var(--w-*) │
└──────────────────────────────────────┘
独立聊天页(我们部署)
chat.html?color=1a73e8
→ static/js/theme.js(同一派生函数)
→ 写入 document.documentElement(:root)变量
→ style.css 中 :root 自带默认橙字面量(JS 失败也有兜底)
```
要点:
- widget.js 保持**单文件自包含**(嵌入方只加一个 script 标签,不多发请求),
派生函数在 widget.js 内联一份;独立页用 `static/js/theme.js` 共享一份。
两份同步由 pytest 源码级断言保证(§7.2)。
- 变量块与样式字符串**同一次注入**,结构上不存在"挂件显示了但变量丢了"的中间态。
- 后端零改动:WebSocket/REST 协议、会话、邀请卡、流式逻辑全部不动。
## 4. 语义槽位清单(7 个)
现状 20 个相近色值合并为 7 个语义槽位:
| 槽位 | 默认值(现状) | 吸收的现状颜色 | 用途 |
|---|---|---|---|
| `--w-primary` | `#f08a31` | 原值 | 头部背景、按钮、头像、分隔线、用户气泡 |
| `--w-primary-rgb` | `240,138,49` | `rgba(240,138,49,α)`、`rgba(196,91,25,.22)` | 阴影色(alpha 留在 CSS 原处) |
| `--w-gradient-end` | 派生 | `#F08A34→#EC8C2F`、`#f59e0b` | 渐变副色(两处渐变统一) |
| `--w-deep-text` | `#8C4D1D` | `#8C4D1D`、`#A8591F` | 图标描边(SVG→`currentColor`)、按钮文字、移动端标签 |
| `--w-caption-text` | `#C16C2E` | `#C16C2E`、`#d97706` | POPULAR TOPICS 标签、页脚小字 |
| `--w-soft-bg` | `#FFF8F3` | `#fef9f3`、`#FFF9F4`、`#FFF7F1`、`#faf7f3` | 卡片底、输入框底、hover 背景 |
| `--w-border-soft` | `#F0D5BF` | `#EECFB6`、`#E8C4A9`、`#F1CCAE`、`#fef3c7` | 按钮/卡片/邀请卡描边 |
**不派生、保持固定**(语义色而非品牌色):在线绿点 `#22c55e`、AI 气泡灰
`#f3f4f6`、深灰文字 `#1f2937`、结束横幅绿 `#34a853`、白色遮罩 `rgba(255,255,255,x)`。
## 5. 派生规则(derivePalette,纯函数)
实测现状橙 `#f08a31` = hsl(28, 86%, 57%),各派生色与主色的关系:
| 槽位 | 规则(相对输入 h,s,l) | 亮度钳制 |
|---|---|---|
| primary | 原样 | 不动(品牌色必须精确) |
| gradient-end | h+2, s 不变, l-1 | — |
| deep-text | h-2, s×0.76, l-24 | l ∈ [28, 40] |
| caption-text | h-2, s×0.72, l-10 | l ∈ [40, 52] |
| soft-bg | h 不变, s×0.9, l=97.5 | l 固定 |
| border-soft | h-2, s×0.72, l=84 | l 固定 |
- 亮度钳制保证**任意色相输入都产出可读配色**(蓝/绿/紫/红均安全)
- 色相加减需 mod 360(红区 h≈0/360 边界)
- 默认输入 `#f08a31` 的派生结果与现状各槽位代表值偏差 ≤ 每通道 8/255
- border-soft 参数为实测校准值:原设计 `s×0.75, l=82` 对默认橙派生的
蓝通道与现状代表值差 12,超出每通道 8/255 容差,校准为 `s×0.72, l=84`
## 6. 兜底策略(4 层)
| 层 | 场景 | 行为 |
|---|---|---|
| ① 属性缺失 | 无 `data-color` / `?color=` | `#f08a31` 走同一条派生管线(默认是一等公民,非特例) |
| ② 格式非法 | 非 3/6 位 hex | `console.warn` + 回退默认 |
| ③ 派生异常 | 意外输入绕过校验、HSL 计算抛错 | derivePalette 整体 try-catch,catch 返回**预计算静态色板常量**(字面量,零计算) |
| ④ 结构性 | — | widget.js:变量与样式同字符串同注入;chat.html:style.css `:root` 自带默认字面量,JS 全挂也正常显示默认橙 |
不存在"未设置颜色导致挂件不显示/无样式白板"的路径。
## 7. 文件级改动清单
| 文件 | 改动 |
|---|---|
| `static/js/theme.js`(新,~60 行) | derivePalette 纯函数、DEFAULT_COLOR、静态 DEFAULT_PALETTE 常量 |
| `static/widget.js` | ① 内联同款派生函数+静态色板;② style 字符串 ~40 处换 `var(--w-*)`;③ 头部注入 `:host` 变量块;④ SVG `stroke="#8C4D1D"` → `currentColor`(父级已设 color);⑤ toggle 模板 inline `#f08a31` 移入 CSS 类;⑥ 4 处 🤖 emoji → `<img>` 头像(§12) |
| `static/css/style.css` | `:root` 补全 7 槽位默认值,清除散落硬编码(`.chat-header` 等);清理旧变量:`--primary`/`--primary-dark` 为死代码(无规则引用)直接删除,`--user-bubble` 被 `.msg.user` 引用,迁移为 `var(--w-primary)`,避免两套变量并存 |
| `static/chat.html` | 引入 theme.js;SVG → currentColor;头部 🤖 → `<img>` 头像(§12) |
| `static/js/chat.js` | 头部 ~10 行 bootstrap:读 `?color=` → 派生 → `documentElement.style.setProperty`(chat.js 现无任何颜色代码,已核实) |
| `static/widget-test.html` | 颜色选择器 + 预设色(橙/蓝/绿/紫)动态重建 script 标签;内嵌断言面板 |
| `docs/2026-08-31-dynamic-theme-changelog.md` | 按仓库惯例记录改动与嵌入方用法(`data-color` 使用说明) |
### 7.1 测试页重建陷阱(实施注意)
widget-test.html 动态换色预览时,必须先移除旧 `#cc-widget-host`、
移除旧 script 标签、清掉 sessionStorage 的邀请卡标记
(`widget_invite_dismissed_<pathname>`),再注入新 script;
否则重复挂载/邀请卡不弹,会误判为功能损坏。
### 7.2 两份拷贝防漂移(同步断言)
pytest 用正则分别提取 widget.js 与 theme.js 中的 `derivePalette`
函数源码,归一化空白后要求**逐字相等**——单边改动必红。
(比"比对规则常量"强,算法级修改也无法漏检。)
## 8. 测试与验收
1. `tests/test_theme_static.py`(pytest,随现有测试套件本地跑;仓库暂无 CI 配置,未来接入时一并纳入):
- 扫描 widget.js / style.css:所有 `var(--w-*)` 引用均有定义(防漏注入)
- 橙色字面量只允许出现在默认值定义/静态色板/断言用例处(防漏改残留,带行级允许清单;扫描范围仅 widget.js 与 style.css——widget-test.html 自身的 `#f08a31` 不在此网内,随该页重写自然消化)
- 两份 derivePalette 源码归一化后逐字相等(§7.2)
2. widget-test.html 断言面板(浏览器跑,PASS/FAIL 可视):
`derivePalette("#f08a31")` 输出 vs 现状硬编码值表,偏差容忍 ≤ 每通道 8/255
3. 手动验收:
- 默认不传参,改造前后截图对比,除 §9.1 外无可见差异
- 蓝/绿各截一组(挂件+邀请卡+独立页)确认派生效果
- 非法色值(`data-color="red"`)回退默认橙
## 9. 已知限制与风险
### 9.1 Send 按钮渐变简化(唯一默认视觉可见变化)
现状 Send 按钮 `#f08a31→#f59e0b`(双色相差 10°),统一为
`primary→gradient-end` 后接近平纯色。用户已确认接受;
实施后截图验收,若不满意再拆独立槽位 `--w-gradient-accent`(h+8, l-3)。
### 9.2 其余已知项
- 槽位合并决定默认模式是"肉眼不可辨"(ΔRGB≤8/通道),非像素级一致;
若个别合并处(如帮助块边框 `#fef3c7`)实际可见,拆回独立槽位
- caption 色现状 ~3.9:1 本就低于 WCAG AA,派生钳制保持同水平,非回归;
可选把钳制上限收到 48 略改善(实施时定)
- 极浅品牌色(l>65)配白字对比度低,v1 不做自动黑白切换,文档注明
- 视觉正确性最终靠人工截图对比,浏览器断言面板不进 CI(项目无 Node 设施的现实妥协)
## 10. 回滚
纯静态文件改动(widget.js/theme.js/style.css/chat.html/chat.js/
新增 kefu-small.png),无数据迁移、无协议变更;`git revert` 即完整回滚。
(kefu.png 素材已在库中,不涉回滚。)
另:chat.html 第 50 行存在一处注释掉的 `<div class="avatar">🤖</div>`
(零行为影响),实施时顺手删除。
## 11. 明确排除的风险面(不因本设计引入)
- 已嵌入客户网站无需改嵌入代码(不传 `data-color` 即默认橙,向后兼容)
- widget.js URL 不变;浏览器旧缓存=旧样式自包含文件,无新旧行为撕裂
- 聊天功能(WebSocket 消息、会话、邀请卡、断线重连)零改动
## 12. 增补:客服头像图片化(2026-08-31)
用图片替换所有 🤖 emoji 头像。素材:`static/kefu.png`
(1297×1213,RGBA 透明底,近方形)。
### 12.1 替换位置(5 处,全部当前为 🤖)
| 位置 | 文件 | 容器现状 |
|---|---|---|
| 聊天面板头部头像 | widget.js `.panel-header .avatar` | 48px 圆,rgba 白底,::after 绿点 |
| 邀请卡图标 | widget.js `.invite-icon` | 40px 圆,rgba 白底,.invite-status-dot 绿点 |
| 桌面 toggle 按钮 | widget.js inline `36px` 圆 | inline 样式,绝对定位绿点 |
| 移动端 toggle 按钮 | widget.js `.mobile-circle` | 56px 圆,橙底,.mobile-dot 绿点 |
| 独立页头部头像 | chat.html `.chat-header .avatar` | 48px 圆,rgba 白底,::after 绿点 |
(消息气泡 `.msg-wrapper .avatar` 的 CSS 存在但当前未渲染任何元素,不在替换范围。)
### 12.2 URL 解析(关键差异)
- **widget.js 嵌入在客户网站,相对路径会 404**:头像 src 必须以
`wsBase`(`new URL(script.src).origin`,代码已有)拼接默认路径;
单一常量 `DEFAULT_AVATAR_PATH = "/static/kefu-small.png"` 落在挂件侧
widget.js(跨域场景所在)(见 §12.6)
- chat.html 为同源直接字面量 `/static/kefu-small.png`,不跨文件共享常量
### 12.3 可配置性
- 挂件新增 `data-avatar` 属性(与 data-color 同模式):
值为完整 URL(http/https 开头)则原样使用;否则视为服务端相对路径按 wsBase 拼接;
缺省 → 默认 `kefu-small.png`(由原图 kefu.png 压缩派生,§12.6)
- 独立页不做 `?avatar=` 参数(我们自己的页面,YAGNI)
### 12.4 兜底(与本文档分层兜底哲学一致)
emoji 不删除,作为 `<img>` 的底层兄弟节点保留;`<img onerror>` 时移除自身
→ 🤖 露出。覆盖场景:客户站 CSP `img-src` 拦截、图片 404、网络失败。
实际遮盖机制(实施校准):load 成功后隐藏 emoji 兄弟节点;error 移除 img
露出 emoji——透明底素材无法物理遮盖,绝对定位(§12.5)仅为视觉层叠。
定位上下文核查(复审结论):5 个容器中 4 个已是 `position:relative`;
桌面 toggle 的 36px 内圆(inline)无定位,其外层 wrapper 才是 relative——
实施时给该内圆补 `position:relative`,否则 img 的 `inset:0` 会锚到
wrapper(box 恰好重合但不显式,易错位)。
### 12.5 CSS 与视觉
- `<img>` 规则:`position:absolute; inset:0; width/height:100%;
object-fit:contain; border-radius:50%`
(img 绝对定位叠于 emoji 之上,load 后 emoji 隐藏,见 §12.4;透明底近方形素材用 contain 不裁切;
容器圆底色透过透明区显示,任意主题色下均成立——头像不参与颜色派生)
- 各绿点(在线状态)为绝对定位兄弟节点/伪元素,不受 emoji→img 替换影响
- alt=""(装饰性),避免读屏重复播报
### 12.6 素材体积(已定决策,非建议)
原图 682KB/1297px,而最大显示尺寸为移动端圆 56px(@3x≈168px),对挂件过重。
实施时产出压缩版 `static/kefu-small.png`(168×168,保留透明通道)供代码
统一引用(§12.2 常量);`kefu.png` 原图保留不动,仅作素材。
若倾向直接用原图,改 `DEFAULT_AVATAR_PATH` 一行常量即可。
### 12.7 测试增补
- pytest 静态断言:widget.js 中头像 src 必须经 wsBase 拼接
(防相对路径在客户站 404 的回归)
- widget-test.html:4 处挂件头像 + data-avatar 覆盖用例 +
故意错误 URL 验证 emoji 兜底
- 手动截图:默认橙 + 蓝/绿主题各确认头像与绿点位置正常
@@ -0,0 +1,237 @@
# 询盘推送 UniService 系统 · 设计文档
- 日期:2026-09-28
- 状态:已评审确认(触发时机、契约适配、失败处理、推送范围、架构选型均经用户确认)
- 对接契约:UniService《AI 客服询盘推送接口 v1》(repo 内 `docs/AI_INQUIRY_PUSH_API.md`,2026-09-28 版)
- 关联现状文档:`docs/2026-08-18-contact-driven-lead-email-changelog.md`(询盘邮件链路)
## 1. 背景与目标
询盘邮件(`_maybe_send_lead_email`)触发的询盘,需在触发同时**主动推送**到 UniService 新系统归档。核心要求:
1. **同刻触发**:联系方式确认、询盘邮件触发的同一时刻推送,fire-and-forget
2. **不阻塞**:推送(含失败)绝不影响对话流和邮件发送
3. **结果可查**:每次推送落库记录状态、次数、错误
4. **跟随邮件范围**:首询盘 / 回访 / 更正三种邮件触发都推(带类型标记)
### 用户确认的决策记录
| 决策点 | 结论 |
|---|---|
| 触发时机 | 与询盘邮件同刻触发,fire-and-forget |
| 接口契约 | 对方已定,我方适配 |
| 失败处理 | 任务内自动重试(≤3 次指数退避),仍败标 failed |
| 推送范围 | 跟随邮件:每次邮件触发都推(lead / returning / correction) |
| 架构 | 方案 A:create_task 后台任务 + 推送日志表,零新基础设施 |
| 结果查看 | admin 查询接口 `GET /api/admin/push-logs`(第 9 节,用户评审时新增) |
| SMTP 失败时推送 | **照推**——与邮件发送成败解耦,只看"进入发信分支"(终审新增) |
## 2. 对接契约要点(UniService v1)
| 项 | 值 |
|---|---|
| 端点 | `POST https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push`(已开通,用户提供) |
| 鉴权 | 请求头 `X-Api-Key: <开通时分配>` |
| 格式 | JSON UTF-8,`{"messages": [...]}`,单次 ≤50 条(我方每次 1 条) |
| 幂等 | 按每条 `external_key`,重复推送不重复入库——**重试安全** |
| event | `new`=新询盘;`update`=按 external_key 更新同一条询盘 |
| 时间 | **一律正 8 区**(`2026-09-28T10:30:00+08:00`),不要发 UTC |
| contact 校验 | `phone` / `whatsapp` / `email` 至少一个非空且有效,否则整条被拒 |
| 响应 | HTTP 200 仍可能逐条 `failed`;`accepted` / `duplicated` / `failed[]` 逐条返回 |
| 错误码 | `401`/`400` 不可重试;`5xx`/超时可重试 |
| 联调 | 先推 1-2 条真实样本,双方核对 accepted/duplicated/failed 与归档内容 |
## 3. 总体架构与数据流
```
对话流 stream_process_message (conversation.py)
└─ _maybe_send_lead_email (conversation.py:416)
├─ [现有] 发邮件 (send_lead_email / send_returning_lead_email / send_correction_email)
├─ [新增①] dispatch_lead_push(): 与 evaluation 更新同事务写入 pending 推送日志(不依赖 SMTP 成败)
└─ [新增②] 事务提交后 asyncio.create_task(_run_push(...)) ← 纳秒级返回
└─ 后台任务(独立 DB session):
├─ contact 三选一预检 → 不满足则标 skipped(不发 HTTP)
├─ httpx POST 对方接口 (X-Api-Key)
├─ 5xx/超时/网络异常:指数退避重试 ≤3 次
├─ 401/400:立即 failed 不重试
└─ 解析 200 响应体逐条结果 → success/failed 落库
```
选型理由:项目无任何后台任务设施(无 create_task/APScheduler/队列,Redis 配而未用),方案 A 仅用标准库 asyncio 即满足"不阻塞 + 记结果 + 重试";可靠性短板(进程崩溃丢 in-flight 任务)用"启动清扫 stale pending"廉价补齐,不引入完整队列。
## 4. 数据模型:新表 `lead_push_logs`
加在 `app/models/database.py`(`Base.metadata.create_all` 启动自动建新表,不影响旧表旧数据):
| 字段 | 类型 | 说明 |
|---|---|---|
| id | String(36) UUID PK | 与既有四表主键风格一致(实施定案) |
| evaluation_id | FK → evaluations.id,索引 | 溯源到询盘评估 |
| conversation_id | FK → conversations.id,索引 | external_key 的构成来源 |
| push_type | String | 我方触发类型:`lead` / `returning` / `correction` |
| status | String,默认 `pending` | `pending` / `success` / `failed` / `skipped` |
| attempts | Integer,默认 0 | 实际尝试次数 |
| request_payload | JSON | 实际发出的 payload 快照(排障/未来补推用) |
| response_status | Integer,可空 | 对方返回 HTTP 状态码 |
| outcome | String,可空 | `accepted` / `duplicated`(200 时对方逐条结论) |
| error_message | Text,可空 | 最后一次错误(或对方 failed[].reason / skipped 原因) |
| created_at / updated_at | DateTime | |
索引:`status` 单列(筛选/清扫用)。
状态语义:
- `skipped`:wechat/skype-only 询盘,对方必然拒收"无有效联系方式",预检后直接跳过,但仍落日志留审计痕迹
- `success` + `outcome=duplicated`:不是错误,但运营上值得知道(对方已有该询盘)
- `failed`:含 401/400(配置/报文问题)、重试耗尽、对方逐条拒收
## 5. 配置项(`app/config.py`,照现有扁平大写风格,`.env.example` 同步)
```
PUSH_ENABLED=false # 总开关,联调前不推
PUSH_API_URL=https://uniservice.server.hnxinyouhe.com/open/v1/inquiry/push
PUSH_API_KEY= # X-Api-Key 头(对方开通时分配)
PUSH_EXTERNAL_KEY_PREFIX=ai-kefu # 幂等键前缀,防联调/生产环境撞键
PUSH_TIMEOUT_SECONDS=10
PUSH_MAX_ATTEMPTS=3
PUSH_RETRY_BACKOFF_BASE=1.0 # 尝试间隔 1s/2s(3 次尝试、2 次间隔)
```
## 6. 新服务 `app/services/lead_push_service.py`
### 6.1 `dispatch_lead_push(db, evaluation, push_type)`
触发点唯一入口:组 payload、写 pending、派任务。**整体 try/except,任何异常只记日志,绝不向上抛**——这是"不阻塞邮件主流程"的隔离保证。
触发条件 = **进入发信分支**(联系方式确认 + 24h 去重通过),与 SMTP 发送成败**完全解耦**——邮件失败询盘照推(归档不丢询盘,用户终审定案)。
### 6.2 `_build_payload(...) -> dict`
按第 7 节映射表组装。contact 三选一预检(phone/whatsapp/email 均空或无效)→ 写 `skipped` 日志行,不派任务不发 HTTP。
### 6.3 `_run_push(push_log_id, payload) -> None`
后台任务体:
- **自建独立 session**(严禁复用请求 session——请求结束即关闭;本接线的关键正确性点)
- session 经模块级 factory 获取(照 `chat_rest.async_session` 的引用模式),保持测试可 monkeypatch 替换——**不得私建 engine**
- `async with httpx.AsyncClient(timeout=PUSH_TIMEOUT_SECONDS)` 每次新建(流量小,不引入 lifespan 管理负担)
- 重试循环:仅对 5xx/超时/网络异常,`PUSH_MAX_ATTEMPTS` 次,退避 `base * 2**(n-1)`;每次尝试后更新 `attempts`/`error_message`
- 401/400:立即 `failed` 不重试,logger 高亮(密钥/报文问题需人工介入)
- 200:解析响应体,本条 `accepted`/`duplicated` → `success` + `outcome`;本条在 `failed[]` → `failed` + 对方 reason,不重试(数据问题)
- 全套 try/except 包裹,异常 `logger.exception`,绝不逃逸
- 任务引用存模块级 set,done callback 移除(防 GC,asyncio 官方要求)
## 7. 契约映射
### 7.1 external_key 与 event(核心规则)
`external_key = f"{PUSH_EXTERNAL_KEY_PREFIX}-{conversation_id}"`——询盘=一次会话,键随会话稳定。
| 我方邮件类型 | event | 规则 |
|---|---|---|
| lead(首询盘) | `new` | 新询盘入库 |
| returning(回访,新会话) | `new` | 同一人再次询盘=新询盘事件,对方按询盘归档,语义正确 |
| correction(更正) | 同会话此前有 success 推送 → `update`(同键更新);否则 `new` | **保护规则**:查我方 `lead_push_logs` 判断,不依赖对方"update 未知键"的未定义行为 |
### 7.2 字段映射表
| 对方字段 | 我方来源 |
|---|---|
| `event` / `external_key` / `occurred_at` | 按 7.1 生成;occurred_at = 推送派发时刻(内存取 now;`evaluated_at` 无 onupdate、更正场景滞留旧值,不用——实施定案);统一 +08:00 格式化 |
| `contact` | `evaluations.contact_info` 快照(name/wechat/skype 可空直传;skype 我方无则 null) |
| `intent_level` | `interested→high`、`pending→medium`、`not_interested→low`(映射取"销售跟进优先级"解读:明确无意向=最低优先级,适配对方归档+销售导入用途;用户终审定案),模块级 dict 常量,便于调整 |
| `intent_reason` | `evaluations.reasoning` 透传(database.py:119 定义,conversation.py:368 已持久化) |
| `profile` | `customer_profiles.raw_info` 原样透传 + IP→国家(iptocc)+ source/语言(对方声明任意 JSON 原样归档) |
| `conversation` | 该会话全部 messages:role `user→customer`、`assistant→ai`,text=content,at=+08:00 |
| `summary` | **不传**(我方无摘要生成,选填) |
| `attachments` | **不传**(我方无附件体系,选填) |
## 8. 触发接线(`app/services/conversation.py`)
`_maybe_send_lead_email`(L416-463)三个发信分支各加一行 dispatch,传对应 `push_type`。合计约 10 行。`email_service.py` **不动**(单一职责)。
dispatch 的调用位置在**分支进入处**,不依赖 `send_*` 返回值;实施时核对 `email_sent` 置位逻辑,确保 pending 写入不被误绑到邮件成功路径。
### 8.1 已知坑:事务边界与后台任务竞争
`create_task` 立即执行,而后台任务若读库会撞上主事务未提交。设计定为:
- **首选**:payload 内存传递,任务不读库只写结果;`create_task` 派生点放在主事务提交之后。实施时确认 `_maybe_send_lead_email` 返回后的 commit 边界(stream_process_message 的提交时机),将派生挪到提交后
- **备选**(代码结构不便时):任务首步以短退避重读该行直至可见(上限 2s)
### 8.2 崩溃恢复
`app/main.py` lifespan 启动清扫:pending 且 `updated_at` 超时(建议 30 分钟,可与退避上限联动)→ 标 `failed`,error_message 记 "interrupted by restart"。约 10 行。清扫自身失败则应用启动失败(fail-fast 定案:任何真实 DB 故障也会让紧邻的 create_all 先失败,属既有启动语义;未清扫的行下次成功启动时按时间窗补扫)。
## 9. Admin 查询接口
`GET /api/admin/push-logs`(加在 `app/api/routes/admin.py`,router 已在 main.py 挂载,无需改注册):
| 项 | 设计 |
|---|---|
| 鉴权 | 复用现有 `_check_auth` cookie 会话(与 conversations 接口同套路) |
| 查询参数 | `status`(pending/success/failed/skipped,可空)、`conversation_id`(可空)、`page`(默认 1)、`size`(默认 20,与 /conversations 一致) |
| 排序 | `created_at DESC`(最新在前;id 为 UUID 无时序,不用 id 排序) |
| 响应 | 照 `GET /conversations` 现有风格(items + total);**列表不返回 `request_payload`**(含完整对话,体积大),只返回核心字段:id / push_type / status / attempts / outcome / response_status / error_message / created_at / updated_at / evaluation_id / conversation_id |
## 10. 错误处理矩阵
| 故障 | 行为 |
|---|---|
| 对方 5xx / 超时 / 网络异常 | 指数退避重试 ≤3 次 → `failed` + 错误详情 |
| 对方 401 / 400 | 立即 `failed` 不重试,日志高亮(配置/报文问题) |
| 200 + 本条在 failed[] | `failed` + 对方 reason,不重试 |
| 200 + accepted/duplicated | `success` + outcome |
| contact 三选一不满足 | 预检 `skipped`,不发 HTTP |
| dispatch 自身异常(写库失败等) | 吞掉记日志,**邮件照发、对话不受影响** |
| 进程重启丢 in-flight 任务 | lifespan 启动清扫 stale pending → failed |
| SQLite 写锁竞争 | 后台写结果套在重试/异常兜底内,小事务快进快出 |
对方建议的"24 小时重试窗口"**本期不做**(需常驻队列);`failed` 行已存 payload 快照 + 幂等键保证将来补推绝对安全,需要时加重推入口即可。
## 11. 测试计划
复用 conftest 现有范式(Fake 桩 + monkeypatch + pytest asyncio auto):
1. 三种邮件触发 → 各写一条 pending,push_type 正确
2. contact 预检:wechat-only → `skipped`,无 HTTP 调用
3. 桩 httpx 成功(accepted / duplicated)→ `success` + outcome + response_status 落库
4. 桩连续 5xx → attempts==3、`failed`、错误落库;退避序列正确(monkeypatch sleep 记录)
5. 桩 401 → 立即 `failed`,attempts==1,不重试
6. 桩 200+failed[](本条被拒)→ `failed` + 对方 reason,不重试
7. dispatch 内部炸 → 邮件照发、无异常上抛(不阻塞验证)
8. 慢推送(假 sleep)不影响 `stream_process_message` 总耗时(断言 create_task 被调用而非 await)
9. PUSH_ENABLED=false → 无日志无任务
10. external_key/event 规则:同会话第二次 correction 推送 event=update;新会话 returning event=new
11. 时区:所有出站时间字段均带 +08:00(非 UTC、非 naive)
12. admin 查询接口:未登录被拒、status 筛选与分页正确、列表响应不含 request_payload
13. 桩 `send_lead_email` 返回 False(SMTP 失败)→ 推送照常派发(解耦验证)
## 12. 实施文件清单(三阶段)
| 阶段 | 内容 | 文件 |
|---|---|---|
| 1 | 表 + 配置 + 服务(纯新增,独立可测) | `app/models/database.py`、`app/config.py`、`app/services/lead_push_service.py`(新)、`requirements.txt`(httpx 转正式依赖)、`.env.example` |
| 2 | 触发接线 + 不阻塞验证 | `app/services/conversation.py`(~10 行) |
| 3 | admin 查询接口 + 启动清扫 + changelog | `app/api/routes/admin.py`(GET /push-logs)、`app/main.py`、`docs/2026-09-28-lead-push-changelog.md` |
## 13. 明确不做(YAGNI)
- ❌ 不引入 Redis / Celery / APScheduler / 队列
- ❌ 不做推送去重(邮件逻辑已去重,跟随即可;对方幂等键再兜一层)
- ❌ 不做手动重推接口(查询接口见第 9 节;补推有幂等键保证安全,需要时再加)
- ❌ 不做 24h 持久重试窗口
- ❌ 不传 summary / attachments(无数据来源)
- ❌ 不动 `email_service.py` 和现有邮件逻辑
## 14. 风险清单
| 风险 | 应对 |
|---|---|
| **时区(最高)**:DB 写入为 UTC-aware(`datetime.now(timezone.utc)`,database.py:54 等),但 SQLite 读回可能退化为 naive 字符串;发 UTC 会被对方拒 | 实施第一步实测读回值;出站统一按"UTC 语义 → +08:00"转换,helper 单点收口 |
| 对方"update 未知键"行为未定义 | 7.1 保护规则:仅同会话已 success 才发 update |
| SQLite 并发写锁 | 小事务 + 异常兜底;必要时 WAL |
| 联调环境键冲突 | PUSH_EXTERNAL_KEY_PREFIX 可配 |
| phone/whatsapp 区号格式未核验:对方要求带国际区号且"有效",我方 contact_info 快照格式未核对 | 联调真实样本首先验证号码格式,不符会被整条拒收 |
| 推送 HTTP 成功但结果写回失败(SQLite 锁)→ 行停留 pending → 清扫误标 failed | 已知限制:标签不准确但幂等键保证补推安全(对方返回 duplicated);概率极低,接受 |