feat: 新增员工姓名绑定、客户信息补全与docker-compose配置

1.  为话术生成服务添加staffName参数,支持生成回复时使用真实员工姓名
2.  优化客户昵称补全逻辑,新增自动创建缺失客户记录功能
3.  重构前端话术推荐组件,调整客户信息展示逻辑
4.  添加完整的docker-compose-server部署配置文件
This commit is contained in:
jqb 2026-06-11 16:54:29 +08:00
parent 4e6e673402
commit 5182590fad
62 changed files with 398 additions and 48 deletions

View File

@ -1,2 +1 @@
target/
target

View File

@ -270,32 +270,48 @@ public class ArchiveMessageController {
nameMap.putAll(customerNameMap);
// 对未找到昵称或昵称为ID的外部客户,实时调用企微API获取并更新数据库
log.info("enrichSenderNames 开始处理, userIds={}, nameMap keys={}", userIds, nameMap.keySet());
for (String uid : userIds) {
String currentName = nameMap.get(uid);
if (isExternalUserId(uid) && (currentName == null || currentName.equals(uid))) {
boolean isExternal = isExternalUserId(uid);
boolean needFetch = isExternal && (currentName == null || currentName.equals(uid));
log.info("处理uid={}, currentName={}, isExternal={}, needFetch={}", uid, currentName, isExternal, needFetch);
if (needFetch) {
try {
Map<String, Object> detail = weComApiClient.getExternalContact(uid);
log.info("getExternalContact 返回: uid={}, detail={}", uid, detail);
if (detail != null && !detail.isEmpty()) {
Map<String, Object> contact = (Map<String, Object>) detail.get("external_contact");
if (contact != null) {
String name = (String) contact.get("name");
log.info("解析到name: uid={}, name={}", uid, name);
if (name != null && !name.isEmpty()) {
nameMap.put(uid, name);
// 更新数据库
// 更新或创建数据库记录
Customer customer = customerMapper.selectOne(
new LambdaQueryWrapper<Customer>()
.eq(Customer::getCustomerId, uid)
.eq(Customer::getCorpId, "wwd483c2fba24ae30a")
.eq(Customer::getCorpId, corpId)
);
if (customer != null) {
customer.setName(name);
customerMapper.updateById(customer);
log.info("更新客户昵称成功: uid={}, name={}", uid, name);
} else {
Customer newCustomer = new Customer();
newCustomer.setCustomerId(uid);
newCustomer.setCorpId(corpId);
newCustomer.setName(name);
newCustomer.setType("UNKNOWN");
newCustomer.setStatus("ACTIVE");
customerMapper.insert(newCustomer);
log.info("创建客户记录成功: uid={}, name={}", uid, name);
}
}
}
}
} catch (Exception e) {
log.debug("实时获取客户昵称失败: {}", uid);
log.warn("实时获取客户昵称失败: uid={}, err={}", uid, e.getMessage());
}
}
}

View File

@ -112,11 +112,12 @@ public class GenerationController {
public Result<Map<String, Object>> generateUtterance(
@RequestParam(value = "fromUser", required = false) String fromUser,
@RequestParam(value = "toUser", required = false) String toUser,
@RequestParam(value = "roomid", required = false) String roomid) {
@RequestParam(value = "roomid", required = false) String roomid,
@RequestParam(value = "staffName", required = false) String staffName) {
String chatContext = buildChatContext(fromUser, toUser, roomid);
String[] promptPair = promptEngine.buildUtterancePrompt(chatContext);
String[] promptPair = promptEngine.buildUtterancePrompt(chatContext, staffName);
String systemPrompt = promptPair[0];
String userPrompt = promptPair[1];
@ -187,7 +188,8 @@ public class GenerationController {
public SseEmitter streamUtterance(
@RequestParam("fromUser") String fromUser,
@RequestParam("toUser") String toUser,
@RequestParam(value = "roomid", required = false) String roomid) {
@RequestParam(value = "roomid", required = false) String roomid,
@RequestParam(value = "staffName", required = false) String staffName) {
SseEmitter emitter = new SseEmitter(120000L);
@ -208,8 +210,8 @@ public class GenerationController {
return;
}
String systemPrompt = promptEngine.buildSystemPrompt(chatContext);
String userPrompt = promptEngine.buildMultiStyleUserPrompt();
String systemPrompt = promptEngine.buildSystemPrompt(chatContext, staffName);
String userPrompt = promptEngine.buildMultiStyleUserPrompt(staffName);
long startTime = System.currentTimeMillis();
String generated = qianwenClient.generate(systemPrompt, userPrompt, 1200);

View File

@ -10,9 +10,14 @@ public class PromptEngine {
/**
* 构建生成话术的系统提示词(角色设定)
*/
public String buildSystemPrompt(String chatHistoryContext) {
public String buildSystemPrompt(String chatHistoryContext, String staffName) {
StringBuilder sb = new StringBuilder();
sb.append("资深销售教练,仅输出符合规范的 3 条推荐回复话术,全程禁止输出任何对话分析、客户判断、前置解读、总结说明文字,任何分析类文字一律不写,正文只保留 3 条话术条目。\n");
if (staffName != null && !staffName.isEmpty()) {
sb.append("## 重要约束\n");
sb.append("你的名字是「").append(staffName).append("」,在回复中如果需要自称或介绍自己,必须使用这个名字,绝对禁止编造任何其他名字(如小王、小李、小张等)。\n");
}
sb.append("## 对话历史\n");
sb.append(chatHistoryContext);
sb.append("\n");
@ -80,8 +85,8 @@ public class PromptEngine {
* 构建完整话术 Prompt(system + user 分离)
* 返回 String[]{systemPrompt, userPrompt}
*/
public String[] buildUtterancePrompt(String chatHistoryContext) {
String system = buildSystemPrompt(chatHistoryContext);
public String[] buildUtterancePrompt(String chatHistoryContext, String staffName) {
String system = buildSystemPrompt(chatHistoryContext, staffName);
// String user = buildUserPrompt(message, context, customerProfile, conversationContext,
// referenceUtterances, intent, stage, stageLabel, intentLabel);
String user = "";
@ -133,8 +138,11 @@ public class PromptEngine {
* 多风格话术生成的用户提示词
* 要求 LLM 输出 3 条不同风格话术,用 ||| 分隔
*/
public String buildMultiStyleUserPrompt() {
public String buildMultiStyleUserPrompt(String staffName) {
StringBuilder sb = new StringBuilder();
if (staffName != null && !staffName.isEmpty()) {
sb.append("你的名字是「").append(staffName).append("」,请用这个名字进行回复,不要编造其他名字。\n\n");
}
sb.append("请根据上述对话历史,生成 3 条不同风格的推荐回复话术。\n\n");
sb.append("风格要求(严格按顺序输出):\n");
sb.append("第1条 - 亲和风格:温暖亲切,像朋友聊天,拉近距离\n");

338
docker-compose-server.yml Normal file
View File

@ -0,0 +1,338 @@
version: '3.8'
services:
# ---------------------- 基础设施 ----------------------
mysql:
image: mysql:5.7
container_name: ai-assistant-mysql
restart: always
environment:
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-root}
MYSQL_DATABASE: ${MYSQL_DATABASE:-ai_assistant}
MYSQL_CHARACTER_SET_SERVER: utf8mb4
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
ports:
- "3306:3306"
volumes:
- mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql
command: >
--default-authentication-plugin=mysql_native_password
--character-set-server=utf8mb4
--collation-server=utf8mb4_unicode_ci
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-p${MYSQL_ROOT_PASSWORD:-root}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- ai-assistant-network
redis:
image: redis:6-alpine
container_name: ai-assistant-redis
restart: always
ports:
- "6379:6379"
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- ai-assistant-network
rabbitmq:
image: rabbitmq:3-management-alpine
container_name: ai-assistant-rabbitmq
restart: always
ports:
- "5672:5672"
- "15672:15672"
environment:
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER:-admin}
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS:-admin}
volumes:
- rabbitmq_data:/var/lib/rabbitmq
healthcheck:
test: ["CMD", "rabbitmq-diagnostics", "ping"]
interval: 10s
timeout: 5s
retries: 5
networks:
- ai-assistant-network
# ---------------------- 后端服务 ----------------------
gateway:
build: ./backend/gateway
container_name: ai-assistant-gateway
restart: always
ports:
- "8080:8080"
volumes:
- ./gateway-application.yml:/app/config/application.yml
depends_on:
- auth-service
- archive-service
- conversation-service
- intent-service
- recommend-service
- generation-service
- kb-admin-service
- analytics-service
networks:
- ai-assistant-network
auth-service:
build: ./backend/auth-service
container_name: ai-assistant-auth
restart: always
ports:
- "8081:8081"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_PORT=63912
- MYSQL_DATABASE=ai_assistant
- SPRING_DATASOURCE_USERNAME=assistant_prod
- SPRING_DATASOURCE_PASSWORD=U%$4Tu_C3+4
- SPRING_DATASOURCE_URL=jdbc:mysql://sh-cdb-6fzlwnms.sql.tencentcdb.com:63912/ai_assistant?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai&useSSL=false
- REDIS_HOST=redis
- JWT_SECRET=${JWT_SECRET:-your-256-bit-secret-key-here-at-least-32-characters}
depends_on:
- mysql
- redis
networks:
- ai-assistant-network
archive-service:
build: ./backend/archive-service
container_name: ai-assistant-archive
restart: always
ports:
- "8082:8082"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_PORT=63912
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- REDIS_HOST=redis
- RABBITMQ_HOST=rabbitmq
- WECOM_ARCHIVE_SECRET=${WECOM_ARCHIVE_SECRET:-}
- WECOM_ARCHIVE_CALLBACK_TOKEN=${WECOM_ARCHIVE_CALLBACK_TOKEN:-}
- WECOM_ARCHIVE_CALLBACK_AESKEY=${WECOM_ARCHIVE_CALLBACK_AESKEY:-}
- WECOM_ARCHIVE_SDK_PATH=${WECOM_ARCHIVE_SDK_PATH:-/app/sdk}
- ARCHIVE_REPAIR_ON_STARTUP=true
- WECOM_AGENT_SECRET=nU1xezyHyonS9rOJ9Kwo3i5OM9iuWc1TrjXf-rOYZx0
- WECOM_CONTACT_SECRET=zrZkae10k1T7bqrt-jVYjj9LQ4GgMibj-wUMy48OsyY
volumes:
- ./keys:/app/keys
# 如需真实企微SDK,取消注释下行并将Linux .so文件放入 ./sdk/linux/
- ./sdk/linux:/app/sdk
depends_on:
- mysql
- redis
- rabbitmq
networks:
- ai-assistant-network
conversation-service:
build: ./backend/conversation-service
container_name: ai-assistant-conversation
restart: always
ports:
- "8083:8083"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_PORT=63912
- REDIS_HOST=redis
- RABBITMQ_HOST=rabbitmq
depends_on:
- mysql
- redis
- rabbitmq
networks:
- ai-assistant-network
intent-service:
build: ./backend/intent-service
container_name: ai-assistant-intent
restart: always
ports:
- "8084:8084"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_PORT=63912
- REDIS_HOST=redis
- RABBITMQ_HOST=rabbitmq
depends_on:
- mysql
- redis
- rabbitmq
networks:
- ai-assistant-network
recommend-service:
build: ./backend/recommendation-service
container_name: ai-assistant-recommend
restart: always
ports:
- "8085:8085"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_PORT=63912
- REDIS_HOST=redis
- RABBITMQ_HOST=rabbitmq
- DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY:-}
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_PORT=63912
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_DATABASE=ai_assistant
depends_on:
- mysql
- redis
- rabbitmq
networks:
- ai-assistant-network
generation-service:
build: ./backend/generation-service
container_name: ai-assistant-generation
restart: always
ports:
- "8086:8086"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- REDIS_HOST=redis
- DASHSCOPE_API_KEY=${DASHSCOPE_API_KEY:-}
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_PORT=63912
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_DATABASE=ai_assistant
depends_on:
- redis
networks:
- ai-assistant-network
kb-admin-service:
build: ./backend/kb-admin-service
container_name: ai-assistant-kbadmin
restart: always
ports:
- "8087:8087"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_PORT=63912
- REDIS_HOST=redis
depends_on:
- mysql
- redis
networks:
- ai-assistant-network
analytics-service:
build: ./backend/analytics-service
container_name: ai-assistant-analytics
restart: always
ports:
- "8088:8088"
environment:
- SERVER_SERVLET_ENCODING_CHARSET=UTF-8
- SERVER_SERVLET_ENCODING_ENABLED=true
- SERVER_SERVLET_ENCODING_FORCE=true
- MYSQL_HOST=sh-cdb-6fzlwnms.sql.tencentcdb.com
- MYSQL_USERNAME=assistant_prod
- MYSQL_PASSWORD=U%$4Tu_C3+4
- MYSQL_PORT=63912
- REDIS_HOST=redis
depends_on:
- mysql
- redis
networks:
- ai-assistant-network
# ---------------------- 前端服务 ----------------------
admin:
build: ./frontend/admin
container_name: ai-assistant-admin
restart: always
ports:
- "5174:80"
depends_on:
- gateway
networks:
- ai-assistant-network
sidebar:
build: ./frontend/sidebar
container_name: ai-assistant-sidebar
restart: always
ports:
- "5173:80"
depends_on:
- gateway
networks:
- ai-assistant-network
# ---------------------- 统一入口 Nginx ----------------------
nginx:
image: nginx:alpine
container_name: ai-assistant-nginx
restart: always
ports:
- "8089:80"
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf
depends_on:
- gateway
- admin
- sidebar
networks:
- ai-assistant-network
# ---------------------- 数据卷 ----------------------
volumes:
mysql_data:
redis_data:
rabbitmq_data:
# ---------------------- 网络 ----------------------
networks:
ai-assistant-network:
driver: bridge

View File

@ -1,5 +1,5 @@
import { useEffect, useRef, useState, useCallback } from 'react'
import { Button, Toast, SpinLoading, Tag, Space } from 'antd-mobile'
import { Button, Toast, SpinLoading } from 'antd-mobile'
interface StyleConfig {
id: string
@ -31,8 +31,7 @@ const STYLES: StyleConfig[] = [
export default function ScriptRecommendStream({ userInfo, customerId: propCustomerId, chatContext }: Props) {
const [loading, setLoading] = useState(true)
const [generating, setGenerating] = useState(false)
const [customerMsg, setCustomerMsg] = useState('')
const [profileTags, setProfileTags] = useState<string[]>([])
const [, setProfileTags] = useState<string[]>([])
const [utterances, setUtterances] = useState<UtteranceItem[]>([])
const [error, setError] = useState('')
const sseRef = useRef<EventSource | null>(null)
@ -123,7 +122,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
}
}
// 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser
// 构建 SSE URL:群聊传 roomid,单聊传 fromUser+toUser+staffName
const params = new URLSearchParams()
if (isGroupChat && chatId) {
params.append('roomid', chatId)
@ -131,6 +130,9 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
params.append('fromUser', customerId)
params.append('toUser', staffId)
}
if (userInfo?.userName) {
params.append('staffName', userInfo.userName)
}
const url = `/api/v1/generation/utterance-stream?${params.toString()}`
console.log('SSE connect:', url)
@ -211,12 +213,20 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
const init = async () => {
setLoading(true)
try {
// 单聊获取最后一条消息用于展示(限定当前销售与学员的对话,避免串消息)
if (!isGroupChat && customerId && staffId) {
const res = await fetch(`/api/v1/conversations/last-message?customerId=${customerId}&staffId=${staffId}&corpId=${corpId}`)
const data = await res.json()
if (data.code === 0 && data.data?.content) {
setCustomerMsg(data.data.content)
// 单聊获取画像标签
if (!isGroupChat && customerId) {
const profileRes = await fetch(`/api/v1/intent/profile/${encodeURIComponent(customerId)}?corpId=${corpId}`)
const profileData = await profileRes.json()
if (profileData.code === 0 && profileData.data) {
const p = profileData.data
const tags: string[] = []
if (p.currentOccupation) tags.push(p.currentOccupation)
if (p.studentType) tags.push(p.studentType)
if (p.skillLevel) tags.push(p.skillLevel)
if (p.intentLevel) tags.push(p.intentLevel)
if (p.concernFocus) tags.push(p.concernFocus)
if (p.decisionStage) tags.push(p.decisionStage)
setProfileTags(tags)
}
}
} catch (e) {
@ -247,30 +257,7 @@ export default function ScriptRecommendStream({ userInfo, customerId: propCustom
return (
<div style={{ padding: 12, background: '#f5f5f5', minHeight: '100vh' }}>
{/* 学员/群聊消息 + 标签 */}
<div style={{
background: '#fff',
borderRadius: 12,
padding: '12px 16px',
marginBottom: 12,
boxShadow: '0 1px 4px rgba(0,0,0,0.06)'
}}>
<div style={{ fontSize: 13, color: '#666', marginBottom: 8 }}>
💬 <span style={{ color: '#999' }}>
{isGroupChat ? '群聊消息' : '学员消息'}:
</span>
<span style={{ color: '#333', fontWeight: 500 }}>{customerMsg || '暂无'}</span>
</div>
{profileTags.length > 0 && (
<Space wrap>
{profileTags.map(tag => (
<Tag key={tag} color='primary' fill='outline' style={{ fontSize: 12 }}>{tag}</Tag>
))}
</Space>
)}
</div>
{/* 生成中状态 */}
{/* 生成中状态 -->
{generating && utterances.every(u => !u.isComplete) && (
<div style={{
background: '#fff',