在私域运营渗透率持续提升的今天,企业微信SCRM已成为连接企业与客户的“核心枢纽”——从客户添加、标签管理到SOP推送、聊天存档,每一个环节都直接影响运营效率。然而,商业化SCRM系统普遍存在三大痛点:一是定制化成本高(单模块定制费用常超10万元),二是高并发场景下扩展性差(如大促期间客户咨询量激增导致接口超时),三是数据私有化需求难以满足(敏感客户数据需部署在企业内网)。
开源SCRM源码则通过“技术自主可控、成本透明、可深度扩展”三大优势,成为中大型企业及技术型团队的首选。企业微信API兼容性(确保能稳定调用客户联系、消息推送等核心接口)和高并发架构适配性(应对十万级客户、千级并发请求的场景)。
源码:c.xsymz.icu
本文将从“七大开源系统深度解析”“高并发架构核心设计”“二次开发实战案例”三个维度,提供一份可落地的技术指南,包含关键代码片段与架构图,帮助技术团队快速完成“选型-架构优化-定制开发”全流程。
以下系统覆盖“轻量型-中大型-垂直领域”三类需求,技术栈包含Java、Go、Node.js,均已通过企业微信API官方兼容性测试,且社区活跃度(GitHub星标+Issue响应速度)达标。

中小型企业的核心痛点是“客户添加峰值时DB压力过大”(如门店活动单日新增5000+客户),该系统通过Redis缓存客户基础信息,将DB查询QPS从1000+降至100以下。
关键代码:客户信息缓存工具类
/**
* 客户信息缓存服务:解决客户查询高频访问DB问题
*/
@Service
public class CustomerCacheService {
@Autowired
private RedisTemplate<String, CustomerDTO> redisTemplate;
@Autowired
private CustomerMapper customerMapper;
// 缓存Key前缀:wxwork_customer:{external_userid}
private static final String CUSTOMER_CACHE_KEY_PREFIX = "wxwork_customer:";
// 缓存过期时间:24小时(避免缓存数据过旧)
private static final long CACHE_EXPIRE_SECONDS = 86400;
/**
* 从缓存获取客户信息,缓存不存在则查DB并更新缓存
*/
public CustomerDTO getCustomerByExternalUserId(String externalUserId) {
String cacheKey = CUSTOMER_CACHE_KEY_PREFIX + externalUserId;
// 1. 先查缓存
CustomerDTO customer = redisTemplate.opsForValue().get(cacheKey);
if (customer != null) {
log.info("从缓存获取客户信息:externalUserId={}", externalUserId);
return customer;
}
// 2. 缓存不存在,查DB
customer = customerMapper.selectByExternalUserId(externalUserId);
if (customer != null) {
// 3. 更新缓存(设置过期时间,避免缓存雪崩)
redisTemplate.opsForValue().set(cacheKey, customer, CACHE_EXPIRE_SECONDS, TimeUnit.SECONDS);
log.info("DB查询客户信息并更新缓存:externalUserId={}", externalUserId);
}
return customer;
}
/**
* 客户信息更新后,主动删除缓存(避免缓存脏数据)
*/
public void deleteCustomerCache(String externalUserId) {
String cacheKey = CUSTOMER_CACHE_KEY_PREFIX + externalUserId;
redisTemplate.delete(cacheKey);
log.info("删除客户缓存:externalUserId={}", externalUserId);
}
}
针对中大型企业“多部门数据隔离+百万级客户数据”需求,该系统采用按部门分库+按客户ID分表策略,解决单库单表性能瓶颈。
关键代码:分库分表配置(Sharding-JDBC)
spring:
shardingsphere:
datasource:
# 数据源配置:按部门ID分库(如dept_100、dept_101)
names: dept_100,dept_101
dept_100:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/dept_100?useSSL=false&serverTimezone=UTC
username: root
password: 123456
dept_101:
type: com.zaxxer.hikari.HikariDataSource
driver-class-name: com.mysql.cj.jdbc.Driver
jdbc-url: jdbc:mysql://localhost:3306/dept_101?useSSL=false&serverTimezone=UTC
username: root
password: 123456
rules:
sharding:
# 分库策略:按部门ID(dept_id)路由
default-database-strategy:
standard:
sharding-column: dept_id
sharding-algorithm-name: dept_db_inline
# 分表策略:客户表(customer)按external_userid哈希分表(4张表)
tables:
customer:
actual-data-nodes: ${spring.shardingsphere.datasource.names}.customer_${0..3}
table-strategy:
standard:
sharding-column: external_userid
sharding-algorithm-name: customer_table_inline
# 分库分表算法(Inline表达式)
sharding-algorithms:
dept_db_inline:
type: INLINE
props:
algorithm-expression: dept_${dept_id}
customer_table_inline:
type: INLINE
props:
algorithm-expression: customer_${external_userid.hashCode() % 4}
props:
sql:
show: true # 打印分库分表SQL,便于调试Go语言的协程模型(轻量级线程)比Java线程更节省资源,结合Kafka高吞吐特性,可应对“秒杀活动单日10万+客户咨询”场景。
关键代码:Gin异步接口+Kafka消息发送
package handler
import (
"context"
"github.com/gin-gonic/gin"
"github.com/segmentio/kafka-go"
"go-scrm/service"
"net/http"
"time"
)
// 全局Kafka写入器(初始化时创建)
var kafkaWriter *kafka.Writer
func init() {
// 初始化Kafka连接(3个broker节点,确保高可用)
kafkaWriter = &kafka.Writer{
Addr: kafka.TCP("kafka-node1:9092", "kafka-node2:9092", "kafka-node3:9092"),
Topic: "customer_consult_topic", // 客户咨询消息主题
Balancer: &kafka.LeastBytes{}, // 负载均衡策略:最少字节数
}
}
// 客户咨询接口:异步处理,避免阻塞主线程
func CustomerConsult(c *gin.Context) {
// 1. 解析请求参数
var req service.ConsultReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "msg": "参数错误"})
return
}
// 2. 异步处理业务逻辑(Gin协程,避免阻塞)
c.Copy().Async(func() {
// 3. 发送消息到Kafka(削峰:高并发时先存队列,再异步消费)
msg := kafka.Message{
Key: []byte(req.ExternalUserID), // 按客户ID分区,保证同一客户消息顺序
Value: []byte(req.Content),
Time: time.Now(),
}
if err := kafkaWriter.WriteMessages(context.Background(), msg); err != nil {
// 日志记录:失败后可重试(如用Redis记录失败消息,定时重试)
service.Log.Error("Kafka发送消息失败", err, "externalUserID", req.ExternalUserID)
return
}
// 4. 记录咨询日志(非核心逻辑,异步执行)
service.SaveConsultLog(req)
})
// 5. 立即返回响应(无需等待业务处理完成)
c.JSON(http.StatusOK, gin.H{"code": 200, "msg": "咨询已收到,客服将尽快回复"})
}
针对“客户与客服实时聊天”场景,采用Socket.io实现长连接,支持断线重连与消息重发。
关键代码:Socket.io实时聊天实现
// server.js:Socket.io服务端
const express = require('express');
const http = require('http');
const { Server } = require('socket.io');
const redis = require('redis');
const app = express();
const server = http.createServer(app);
// 初始化Socket.io:允许跨域(企业微信H5页面需跨域)
const io = new Server(server, {
cors: {
origin: ["https://your-scrm-domain.com"], // 企业微信H5域名
methods: ["GET", "POST"]
}
});
// Redis客户端:存储用户Socket连接映射(分布式场景下共享连接)
const redisClient = redis.createClient({
url: 'redis://localhost:6379'
});
redisClient.connect().catch(console.error);
// 1. 客户端连接时触发
io.on('connection', (socket) => {
console.log('客户端连接:', socket.id);
// 2. 客户绑定:关联externalUserID与socket.id
socket.on('bind_customer', async (externalUserID) => {
// 存储映射关系到Redis(过期时间2小时,避免无效连接)
await redisClient.set(
`socket:customer:${externalUserID}`,
socket.id,
'EX',
7200
);
console.log('客户绑定:', externalUserID, '->', socket.id);
});
// 3. 接收客户消息:转发给客服
socket.on('customer_msg', async (data) => {
const { externalUserID, content, deptID } = data;
// 查找该部门的客服Socket(简化逻辑:实际需客服负载均衡)
const csSocketID = await redisClient.get(`socket:cs:dept:${deptID}`);
if (csSocketID) {
// 转发消息给客服
io.to(csSocketID).emit('cs_msg', {
from: externalUserID,
content: content,
time: new Date().toISOString()
});
} else {
// 客服不在线,回复客户
socket.emit('system_msg', {
content: '当前客服繁忙,请稍后再试',
time: new Date().toISOString()
});
}
});
// 4. 客户端断开连接
socket.on('disconnect', async () => {
console.log('客户端断开:', socket.id);
// 清理Redis映射(简化逻辑:实际可通过定时任务清理过期连接)
const keys = await redisClient.keys(`socket:customer:*`);
for (const key of keys) {
const val = await redisClient.get(key);
if (val === socket.id) {
await redisClient.del(key);
break;
}
}
});
});
// 启动服务
server.listen(3000, () => {
console.log('Socket.io服务启动:http://localhost:3000');
});
针对政企“数据安全”需求,系统内置客户数据脱敏功能,同时通过RBAC权限模型实现细粒度访问控制。
关键代码:客户数据脱敏拦截器
/**
* 客户数据脱敏拦截器:返回给前端时自动脱敏敏感信息(如手机号、邮箱)
*/
@Component
public class CustomerDataMaskInterceptor implements HandlerInterceptor {
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response,
Object handler, ModelAndView modelAndView) throws Exception {
// 1. 判断是否为客户相关接口(如/api/customer/*)
String requestURI = request.getRequestURI();
if (!requestURI.startsWith("/api/customer/")) {
return;
}
// 2. 获取响应数据(需自定义ResponseWrapper,捕获响应体)
if (!(response instanceof ResponseWrapper)) {
return;
}
ResponseWrapper responseWrapper = (ResponseWrapper) response;
String responseBody = new String(responseWrapper.getBytes(), StandardCharsets.UTF_8);
// 3. 解析JSON并脱敏
JSONObject jsonObject = JSON.parseObject(responseBody);
if (jsonObject.getInteger("code") == 200 && jsonObject.containsKey("data")) {
Object data = jsonObject.get("data");
if (data instanceof JSONObject) {
// 单个客户数据脱敏
maskCustomerData((JSONObject) data);
} else if (data instanceof JSONArray) {
// 客户列表数据脱敏
JSONArray array = (JSONArray) data;
for (Object obj : array) {
maskCustomerData((JSONObject) obj);
}
}
// 4. 重新设置响应体
responseWrapper.reset();
responseWrapper.getWriter().write(jsonObject.toJSONString());
}
}
/**
* 脱敏逻辑:手机号中间4位替换为*,邮箱@前3位保留,其余替换为*
*/
private void maskCustomerData(JSONObject customer) {
// 手机号脱敏:138****1234
if (customer.containsKey("phone")) {
String phone = customer.getString("phone");
if (phone != null && phone.length() == 11) {
String maskedPhone = phone.substring(0, 3) + "****" + phone.substring(7);
customer.put("phone", maskedPhone);
}
}
// 邮箱脱敏:123****@qq.com
if (customer.containsKey("email")) {
String email = customer.getString("email");
if (email != null && email.contains("@")) {
String[] parts = email.split("@");
if (parts[0].length() > 3) {
String maskedEmail = parts[0].substring(0, 3) + "****@" + parts[1];
customer.put("email", maskedEmail);
}
}
}
}
}教育行业“开学季/招生季”需推送大量课程通知(如单日10万+学员),系统通过XXL-Job分片任务避免单节点压力过大。
关键代码:XXL-Job分片推送SOP任务
/**
* 课程SOP推送任务:按学员ID分片执行,支持多节点并行
*/
@XxlJob("courseSopPushJob")
public void courseSopPushJob() throws Exception {
// 1. 获取分片信息(XXL-Job自动分配:分片索引、总分片数)
ShardingUtil.ShardingVO shardingVO = ShardingUtil.getShardingVo();
int shardIndex = shardingVO.getIndex(); // 当前分片索引(0,1,2...)
int shardTotal = shardingVO.getTotal(); // 总分片数(如3)
log.info("SOP推送任务分片执行:当前分片={}/总分片={}", shardIndex, shardTotal);
// 2. 分片查询学员:按学员ID取模,确保每个分片数据不重复
List<String> studentIds = studentMapper.selectShardingStudentIds(shardIndex, shardTotal);
if (studentIds.isEmpty()) {
log.info("当前分片无学员数据:分片={}", shardIndex);
return;
}
log.info("当前分片需推送学员数:{}", studentIds.size());
// 3. 批量推送SOP(企业微信应用消息接口)
for (List<String> batch : CollUtil.split(studentIds, 100)) { // 分批:每批100个学员(避免接口限流)
// 构造企业微信消息
WxMaTemplateMessage templateMessage = WxMaTemplateMessage.builder()
.templateId("SOP_TEMPLATE_ID") // 课程SOP模板ID
.toUsers(String.join(",", batch)) // 学员externalUserID列表
.build();
// 调用企业微信API推送
try {
wxMaService.getMsgService().sendTemplateMsg(templateMessage);
log.info("SOP推送成功:批次学员数={}", batch.size());
// 记录推送日志
sopPushLogMapper.batchInsert(batch.stream().map(studentId -> {
SopPushLog log = new SopPushLog();
log.setStudentId(studentId);
log.setPushStatus(1); // 1=成功
log.setPushTime(new Date());
return log;
}).collect(Collectors.toList()));
} catch (WxErrorException e) {
log.error("SOP推送失败:批次学员数={}", batch.size(), e);
// 记录失败日志(后续可重试)
sopPushLogMapper.batchInsert(batch.stream().map(studentId -> {
SopPushLog log = new SopPushLog();
log.setStudentId(studentId);
log.setPushStatus(0); // 0=失败
log.setPushTime(new Date());
log.setErrorMsg(e.getError().getErrorMsg());
return log;
}).collect(Collectors.toList()));
}
}
}针对“小程序客户授权高频访问”场景,通过缓存微信授权Token,减少重复调用微信API。
关键代码:微信授权Token缓存
/**
* 微信授权Token服务:缓存access_token,避免重复调用API(微信限制每日调用次数)
*/
@Service
public class WxAuthService {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private WxMaConfig wxMaConfig; // 微信小程序配置(appId、appSecret)
// 缓存Key:微信access_token
private static final String WX_ACCESS_TOKEN_KEY = "wx:access_token:" + "${wx.ma.appId}";
// 微信access_token有效期:7200秒(微信官方规定),缓存设置7000秒避免过期
private static final long ACCESS_TOKEN_EXPIRE = 7000;
/**
* 获取微信access_token:优先从缓存取,过期则重新获取
*/
public String getAccessToken() throws WxErrorException {
// 1. 查缓存
String accessToken = redisTemplate.opsForValue().get(WX_ACCESS_TOKEN_KEY);
if (StringUtils.hasText(accessToken)) {
log.info("从缓存获取微信access_token");
return accessToken;
}
// 2. 缓存过期,调用微信API重新获取
WxMaService wxMaService = new WxMaServiceImpl();
wxMaService.setWxMaConfig(wxMaConfig);
WxAccessTokenResult result = wxMaService.getAccessToken(true);
accessToken = result.getAccessToken();
// 3. 更新缓存
redisTemplate.opsForValue().set(WX_ACCESS_TOKEN_KEY, accessToken, ACCESS_TOKEN_EXPIRE, TimeUnit.SECONDS);
log.info("重新获取微信access_token并更新缓存,有效期={}秒", ACCESS_TOKEN_EXPIRE);
return accessToken;
}
}无论选择哪款开源系统,高并发场景下需重点优化三大核心模块:客户数据读写、消息推送、报表统计。以下提供通用架构设计方案与代码示例。
/**
* 多级缓存客户服务:Caffeine本地缓存 + Redis分布式缓存
*/
@Service
public class MultiLevelCustomerService {
// 1. 本地缓存:Caffeine(热点数据,过期时间5分钟)
private final LoadingCache<String, CustomerDTO> localCache = Caffeine.newBuilder()
.maximumSize(10000) // 最大缓存数量(热点客户上限)
.expireAfterWrite(5, TimeUnit.MINUTES) // 写入后5分钟过期
.build(new CacheLoader<String, CustomerDTO>() {
// 本地缓存未命中时,从Redis加载
@Override
public CustomerDTO load(String externalUserId) throws Exception {
log.info("本地缓存未命中,从Redis加载客户:{}", externalUserId);
return redisCustomerService.getCustomerByExternalUserId(externalUserId);
}
});
@Autowired
private RedisCustomerService redisCustomerService; // Redis缓存服务
@Autowired
private CustomerShardingService shardingService; // 分库分表服务
/**
* 获取客户信息:优先本地缓存,再Redis,最后DB
*/
public CustomerDTO getCustomer(String externalUserId, Integer deptId) {
try {
// 1. 本地缓存查询(最快,耗时<1ms)
return localCache.get(externalUserId);
} catch (Exception e) {
log.warn("本地缓存查询失败, fallback到Redis", e);
try {
// 2. Redis缓存查询(耗时<10ms)
CustomerDTO customer = redisCustomerService.getCustomerByExternalUserId(externalUserId);
if (customer != null) {
// 回写本地缓存(修复缓存击穿)
localCache.put(externalUserId, customer);
return customer;
}
// 3. DB查询(分库分表,耗时<100ms)
customer = shardingService.getCustomerFromDB(externalUserId, deptId);
if (customer != null) {
// 回写Redis和本地缓存
redisCustomerService.setCustomerCache(externalUserId, customer);
localCache.put(externalUserId, customer);
}
return customer;
} catch (Exception ex) {
log.error("客户查询失败", ex);
throw new BusinessException("客户查询异常");
}
}
}
}/**
* 消息重试服务:Redis存储失败消息,定时重试
*/
@Service
public class MessageRetryService {
@Autowired
private RedisTemplate<String, MessageDTO> redisTemplate;
@Autowired
private WxMessageService wxMessageService; // 企业微信消息推送服务
// Redis Key:消息重试队列(ZSet:score=重试时间戳)
private static final String MESSAGE_RETRY_KEY = "message:retry:queue";
// 最大重试次数
private static final int MAX_RETRY_COUNT = 3;
// 重试间隔(秒):10s、30s、60s
private static final int[] RETRY_INTERVALS = {10, 30, 60};
/**
* 记录失败消息,加入重试队列
*/
public void addRetryMessage(MessageDTO message) {
// 1. 检查重试次数是否超限
if (message.getRetryCount() >= MAX_RETRY_COUNT) {
log.error("消息重试次数超限,放弃:externalUserID={}, content={}",
message.getExternalUserID(), message.getContent());
// 存入死信队列,人工处理
redisTemplate.opsForValue().set(
"message:dead:queue:" + message.getId(),
message,
7,
TimeUnit.DAYS
);
return;
}
// 2. 计算下次重试时间(当前时间 + 对应间隔)
int nextRetryInterval = RETRY_INTERVALS[message.getRetryCount()];
long nextRetryTime = System.currentTimeMillis() + nextRetryInterval * 1000;
// 3. 加入Redis ZSet(按重试时间排序)
redisTemplate.opsForZSet().add(
MESSAGE_RETRY_KEY,
message,
nextRetryTime
);
log.info("消息加入重试队列:ID={}, 下次重试时间={}",
message.getId(), new Date(nextRetryTime));
}
/**
* 定时重试任务:每10秒执行一次,处理到期的重试消息
*/
@Scheduled(fixedRate = 10000)
public void retryMessageTask() {
// 1. 查询当前时间前的所有重试消息(score ≤ 当前时间戳)
long currentTime = System.currentTimeMillis();
Set<ZSetOperations.TypedTuple<MessageDTO>> tuples = redisTemplate.opsForZSet()
.rangeByScoreWithScores(MESSAGE_RETRY_KEY, 0, currentTime);
if (tuples == null || tuples.isEmpty()) {
return;
}
// 2. 遍历处理每条消息
for (ZSetOperations.TypedTuple<MessageDTO> tuple : tuples) {
MessageDTO message = tuple.getValue();
if (message == null) {
continue;
}
// 3. 重试推送
try {
wxMessageService.send(message);
log.info("消息重试成功:ID={}, externalUserID={}",
message.getId(), message.getExternalUserID());
// 移除重试队列
redisTemplate.opsForZSet().remove(MESSAGE_RETRY_KEY, message);
} catch (Exception e) {
log.error("消息重试失败:ID={}", message.getId(), e);
// 重试次数+1,重新加入队列
message.setRetryCount(message.getRetryCount() + 1);
addRetryMessage(message);
// 移除原消息
redisTemplate.opsForZSet().remove(MESSAGE_RETRY_KEY, message);
}
}
}
}/**
* 客户指标计数器:Redis实时统计,避免DB聚合查询
*/
@Service
public class CustomerMetricCounter {
@Autowired
private StringRedisTemplate redisTemplate;
// 缓存Key前缀:当日新增客户数(按部门)
private static final String DAILY_NEW_CUSTOMER_KEY = "metric:customer:daily:new:dept:{deptId}:{date}";
// 缓存Key前缀:咨询转化率(按部门)
private static final String CONSULT_CONVERT_RATE_KEY = "metric:customer:consult:rate:dept:{deptId}:{date}";
/**
* 新增客户计数:原子递增,避免并发问题
*/
public void incrementDailyNewCustomer(Integer deptId) {
// 1. 生成日期(格式:yyyyMMdd)
String date = new SimpleDateFormat("yyyyMMdd").format(new Date());
// 2. 替换占位符,生成Key
String key = DAILY_NEW_CUSTOMER_KEY.replace("{deptId}", deptId.toString())
.replace("{date}", date);
// 3. 原子递增(Redis INCR命令,线程安全)
redisTemplate.opsForValue().increment(key);
// 4. 设置过期时间:7天(保留7天数据)
redisTemplate.expire(key, 7, TimeUnit.DAYS);
log.info("新增客户计数:deptId={}, date={}, key={}", deptId, date, key);
}
/**
* 获取当日新增客户数:直接从Redis获取,无需查DB
*/
public Long getDailyNewCustomer(Integer deptId) {
String date = new SimpleDateFormat("yyyyMMdd").format(new Date());
String key = DAILY_NEW_CUSTOMER_KEY.replace("{deptId}", deptId.toString())
.replace("{date}", date);
String value = redisTemplate.opsForValue().get(key);
return value == null ? 0 : Long.parseLong(value);
}
/**
* 计算咨询转化率:(咨询客户数 / 新增客户数) * 100%
*/
public BigDecimal getConsultConvertRate(Integer deptId) {
String date = new SimpleDateFormat("yyyyMMdd").format(new Date());
// 1. 获取咨询客户数
String consultKey = CONSULT_CONVERT_RATE_KEY.replace("{deptId}", deptId.toString())
.replace("{date}", date);
Long consultCount = Long.parseLong(redisTemplate.opsForValue().getOrDefault(consultKey, "0"));
// 2. 获取新增客户数
Long newCount = getDailyNewCustomer(deptId);
if (newCount == 0) {
return BigDecimal.ZERO; // 避免除零异常
}
// 3. 计算转化率(保留2位小数)
return BigDecimal.valueOf(consultCount)
.divide(BigDecimal.valueOf(newCount), 4, RoundingMode.HALF_UP)
.multiply(BigDecimal.valueOf(100))
.setScale(2, RoundingMode.HALF_UP);
}
}以“扩展客户标签功能”为例(支持按“客户价值”自动打标签,如“高价值客户”“潜力客户”),提供完整开发流程与代码。
-- 1. 新增客户消费记录表(若已有则跳过)
CREATE TABLE `customer_consume` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`external_userid` varchar(64) NOT NULL COMMENT '企业微信客户ID',
`dept_id` int NOT NULL COMMENT '部门ID',
`amount` decimal(10,2) NOT NULL COMMENT '消费金额(元)',
`consume_time` datetime NOT NULL COMMENT '消费时间',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
PRIMARY KEY (`id`),
KEY `idx_external_userid` (`external_userid`),
KEY `idx_consume_time` (`consume_time`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户消费记录表';
-- 2. 新增客户自动标签表(关联客户与标签)
CREATE TABLE `customer_auto_tag` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT 'ID',
`external_userid` varchar(64) NOT NULL COMMENT '企业微信客户ID',
`tag_code` varchar(32) NOT NULL COMMENT '标签编码(HIGH_VALUE/POTENTIAL/NORMAL)',
`tag_name` varchar(64) NOT NULL COMMENT '标签名称',
`create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_external_tag` (`external_userid`,`tag_code`) COMMENT '同一客户同一标签唯一'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='客户自动标签表';/**
* 客户标签规则服务:定义标签规则,计算客户应打标签
*/
@Service
public class CustomerTagRuleService {
// 标签规则枚举
public enum AutoTagEnum {
HIGH_VALUE("HIGH_VALUE", "高价值客户", 5000.00, null), // 金额≥5000
POTENTIAL("POTENTIAL", "潜力客户", 1000.00, 4999.99), // 1000≤金额≤4999.99
NORMAL("NORMAL", "普通客户", 0.00, 999.99); // 金额<1000
private final String code; // 标签编码
private final String name; // 标签名称
private final Double minAmt; // 最小消费金额(含)
private final Double maxAmt; // 最大消费金额(含,null表示无上限)
AutoTagEnum(String code, String name, Double minAmt, Double maxAmt) {
this.code = code;
this.name = name;
this.minAmt = minAmt;
this.maxAmt = maxAmt;
}
// 根据消费金额匹配标签
public static List<AutoTagEnum> matchTags(BigDecimal totalAmount) {
List<AutoTagEnum> matchedTags = new ArrayList<>();
double amt = totalAmount.doubleValue();
for (AutoTagEnum tag : values()) {
// 满足条件:金额≥minAmt 且(maxAmt为null 或 金额≤maxAmt)
boolean meetMin = amt >= tag.minAmt;
boolean meetMax = tag.maxAmt == null || amt <= tag.maxAmt;
if (meetMin && meetMax) {
matchedTags.add(tag);
}
}
return matchedTags;
}
// getter
public String getCode() { return code; }
public String getName() { return name; }
}
@Autowired
private CustomerConsumeMapper consumeMapper;
/**
* 计算客户应打标签:查询客户累计消费金额,匹配标签规则
*/
public List<AutoTagEnum> calculateCustomerTags(String externalUserId) {
// 1. 查询客户累计消费金额(近12个月,避免历史数据影响)
Date startTime = DateUtils.addMonths(new Date(), -12);
BigDecimal totalAmount = consumeMapper.selectTotalAmountByExternalUserId(externalUserId, startTime);
if (totalAmount == null) {
totalAmount = BigDecimal.ZERO;
}
log.info("计算客户标签:externalUserID={}, 累计消费金额={}", externalUserId, totalAmount);
// 2. 匹配标签规则
return AutoTagEnum.matchTags(totalAmount);
}
}/**
* 客户标签更新服务:消费后实时更新 + 每日定时更新
*/
@Service
public class CustomerTagUpdateService {
@Autowired
private CustomerTagRuleService tagRuleService;
@Autowired
private CustomerAutoTagMapper autoTagMapper;
@Autowired
private WeChatWorkService weChatWorkService; // 企业微信API服务:同步标签到企业微信
/**
* 消费后实时更新标签
*/
@Transactional
public void updateTagAfterConsume(String externalUserId) {
// 1. 计算应打标签
List<CustomerTagRuleService.AutoTagEnum> targetTags = tagRuleService.calculateCustomerTags(externalUserId);
if (targetTags.isEmpty()) {
log.info("客户无匹配标签:externalUserID={}", externalUserId);
return;
}
// 2. 删除客户现有自动标签(避免旧标签残留)
autoTagMapper.deleteByExternalUserId(externalUserId);
// 3. 新增目标标签
List<CustomerAutoTag> autoTags = targetTags.stream().map(tag -> {
CustomerAutoTag autoTag = new CustomerAutoTag();
autoTag.setExternalUserid(externalUserId);
autoTag.setTagCode(tag.getCode());
autoTag.setTagName(tag.getName());
return autoTag;
}).collect(Collectors.toList());
autoTagMapper.batchInsert(autoTags);
// 4. 同步标签到企业微信(确保企业微信端标签一致)
List<String> tagNames = targetTags.stream().map(CustomerTagRuleService.AutoTagEnum::getName).collect(Collectors.toList());
weChatWorkService.batchAddCustomerTag(externalUserId, tagNames);
log.info("客户标签更新完成:externalUserID={}, 标签={}", externalUserId, tagNames);
}
/**
* 每日凌晨2点定时更新所有客户标签(应对规则调整或漏更)
*/
@Scheduled(cron = "0 0 2 * * ?")
@Transactional
public void scheduledUpdateAllCustomerTags() {
log.info("开始定时更新所有客户标签");
// 1. 分页查询所有客户(避免一次性加载过多数据,导致内存溢出)
int pageNum = 1;
int pageSize = 1000;
while (true) {
// 查询当前页客户
PageHelper.startPage(pageNum, pageSize);
List<String> externalUserIds = autoTagMapper.selectDistinctExternalUserIds();
if (externalUserIds.isEmpty()) {
break; // 无更多数据,退出循环
}
// 2. 遍历更新每个客户标签
for (String externalUserId : externalUserIds) {
try {
updateTagAfterConsume(externalUserId);
} catch (Exception e) {
log.error("定时更新客户标签失败:externalUserID={}", externalUserId, e);
// 记录失败日志,后续人工处理
continue;
}
}
pageNum++;
}
log.info("定时更新所有客户标签完成");
}
}/**
* 客户标签API接口
*/
@RestController
@RequestMapping("/api/v1/customer/tag")
public class CustomerTagController {
@Autowired
private CustomerTagUpdateService tagUpdateService;
@Autowired
private CustomerAutoTagMapper autoTagMapper;
/**
* 查询客户自动标签
*/
@GetMapping("/list")
public Result<List<CustomerAutoTagVO>> getCustomerTags(@RequestParam String externalUserId) {
// 查询客户自动标签
List<CustomerAutoTag> autoTags = autoTagMapper.selectByExternalUserId(externalUserId);
// 转换为VO(隐藏数据库字段,只返回前端需要的信息)
List<CustomerAutoTagVO> voList = autoTags.stream().map(tag -> {
CustomerAutoTagVO vo = new CustomerAutoTagVO();
vo.setTagCode(tag.getTagCode());
vo.setTagName(tag.getTagName());
vo.setUpdateTime(tag.getUpdateTime());
return vo;
}).collect(Collectors.toList());
return Result.success(voList);
}
/**
* 手动触发客户标签更新(用于运维或测试)
*/
@PostMapping("/update")
@PreAuthorize("hasRole('ADMIN')") // 仅管理员可操作
public Result<Void> manualUpdateTag(@RequestParam String externalUserId) {
tagUpdateService.updateTagAfterConsume(externalUserId);
return Result.success();
}
}<template>
<div class="customer-tag-container">
<!-- 客户标签列表 -->
<el-card shadow="hover" class="tag-card">
<template #header>
<div class="card-header">
<span>客户自动标签</span>
<el-button
type="primary"
size="small"
@click="manualUpdateTag"
:disabled="loading"
>
手动更新标签
</el-button>
</div>
</template>
<el-table :data="tagList" border :loading="loading">
<el-table-column label="标签编码" prop="tagCode" />
<el-table-column label="标签名称" prop="tagName" />
<el-table-column label="更新时间" prop="updateTime">
<template #default="scope">
{{ formatDate(scope.row.updateTime) }}
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue';
import { ElMessage, ElLoading } from 'element-plus';
import { getCustomerTags, manualUpdateTag } from '@/api/customer/tag';
import { formatDate } from '@/utils/date';
// 外部传入:客户externalUserID
const props = defineProps({
externalUserId: {
type: String,
required: true
}
});
// 状态管理
const tagList = ref([]);
const loading = ref(false);
// 页面挂载时查询标签
onMounted(() => {
fetchCustomerTags();
});
// 查询客户标签
const fetchCustomerTags = async () => {
loading.value = true;
try {
const res = await getCustomerTags(props.externalUserId);
tagList.value = res.data;
} catch (err) {
ElMessage.error('查询标签失败:' + (err.msg || '网络异常'));
} finally {
loading.value = false;
}
};
// 手动更新标签
const handleManualUpdate = async () => {
const loadingInstance = ElLoading.service({
lock: true,
text: '正在更新标签...',
background: 'rgba(0, 0, 0, 0.3)'
});
try {
await manualUpdateTag(props.externalUserId);
ElMessage.success('标签更新成功');
// 重新查询标签
fetchCustomerTags();
} catch (err) {
ElMessage.error('更新标签失败:' + (err.msg || '网络异常'));
} finally {
loadingInstance.close();
}
};
</script>
<style scoped>
.customer-tag-container {
padding: 16px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.tag-card {
margin-bottom: 16px;
}
</style>企业规模 | 核心需求 | 推荐系统 | 技术栈偏好 |
|---|---|---|---|
初创企业(<50人) | 轻量化客户管理 | Mini-SCRM/WeChatWork-SCRM | Java/Node.js |
中小型企业(50-200人) | 多部门协作+10万级客户 | OpenSCRM | Java(Spring Cloud) |
大型企业(>200人) | 高并发+国产化合规 | Go-SCRM/政企SCRM | Go/Java |
垂直领域(教育) | 学员管理+课程SOP | Edu-SCRM | Java |
全栈团队 | 小程序/公众号集成 | Node-SCRM | Node.js/Vue3 |
随着企业微信SCRM功能深化,未来架构将向“云原生+AI”方向发展:
通过本文推荐的开源系统与高并发设计方案,技术团队可快速落地企业微信SCRM,并基于业务需求灵活扩展,实现“技术自主可控+业务快速迭代”的双重目标。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。