电商客服聊天 ≠ 通用 IM。难点不在"发消息",而在三件事:会话如何路由、商品订单如何结构化表达、客服系统如何与业务系统联动。本文基于 ZEGO ZIM(ZEGO 即时通讯 IM)以 Android 为例,从架构到落代码讲清楚一套可上线的电商客服方案。
一、客服聊天到底难在哪
在实现聊天功能之前,先沿着用户的一次真实咨询走一遍:
用户在商品详情页点了"联系客服" → 发了一张尺码表截图 → 客服秒回一条商品卡片 → 用户问了句"发什么快递"就下单了 → 两天后用户没取件,客服自动跟进了一条物流提醒。
把这条链路拆开,电商客服有四件事是通用聊天工具解决不了的:
- 会话入口是业务上下文:用户是带着"哪个商品、哪个订单"来的,聊天窗口必须能携带这些上下文,而不是直接开聊;
- 消息不只是文字:尺码表是图片,商品是卡片,物流是模板消息——富媒体和结构化消息是刚需;
- 客服是被分配的,不是被找的:几百个客服同时在线,用户发来咨询,谁来接、怎么接,由路由策略决定;
- 聊天记录是要审计的:交易纠纷、客诉仲裁都依赖聊天留存,且必须过内容安全审核。
自研 IM?长连接保活、多端同步、离线推送、消息可靠性,每一项都是持续的工程债。接在线客服 SaaS?数据不在自己手里,也无法深度嵌入商品流程。这中间的平衡点,是选择一个成熟的 IM SDK 做消息底座,把路由、卡片、审计这些业务逻辑留在自己的服务端,ZIM 就是典型的这种底座。
二、整体架构:三个角色,一条链路
先建立一个共识:客服也是一个 ZIM 用户。
这样整个系统不需要引入任何新的消息模型:用户和客服之间就是一条单聊会话(Peer 会话),会话 ID 天然可以绑定工单号,所有 ZIM 的会话能力(未读数、已读回执、历史消息、置顶)全部直接复用。
┌─────────────┐ 单聊(Peer) ┌─────────────┐
│ 用户 App │ ◄─────────────────► │ 客服工作台 │
│ (ZIM 用户) │ sendMessage/回调 │ (ZIM 用户) │
└──────┬──────┘ └──────┬──────┘
│ 业务 API(登录态/路由请求) │
▼ ▼
┌────────────────────────────────────────────────────┐
│ 业务服务端(你的系统) │
│ 账号体系 │ Token 签发 │ 客服路由 │ 工单 │ 审计归档 │
└────────────────────────────────────────────────────┘
两个关键设计决策:
- 路由放在服务端,不在客户端。用户点"联系客服"时,客户端只做一件事:调你的业务 API 拿"分配给哪个客服"。路由策略(按商品品类、按客服负载、按用户 VIP 等级)随时调整,客户端零改动;
- 用户与客服直接 1v1 单聊,不引入客服群组。群聊模型会带来成员管理、权限、@ 规则等一堆和客服场景无关的复杂度。单聊模型下,用户侧始终看到同一个"客服"入口,客服侧转接时只需要在服务端把工单重新绑定到另一个客服的会话,对用户完全透明。
三、前置准备:开通服务与鉴权
在ZEGO 控制台创建项目并自助开通 ZIM 服务(ZIM 服务权限不是默认开启的),获取 AppID 和 AppSign。
ZIM 2.3.0 及以上版本同时支持两种鉴权方式:
| 鉴权方式 | 说明 | 适用场景 |
|---|---|---|
| AppSign 鉴权 | 客户端内置 AppSign,登录时 Token 传空串 | 开发调试、App 安全性要求不高的场景 |
| Token 鉴权 | Token 由服务端生成,客户端登录时携带 | 正式上线推荐。AppSign 一旦泄露可被逆向拿到,Token 由服务端控制有效期 |
建议直接上 Token 鉴权:客服系统的服务端是必须存在的,Token 签发不过是几十行代码。
四、用户端:把聊天做进商品流程
4.1 集成、初始化与登录
在项目级 build.gradle 配置 ZEGO 的 Maven 仓库,应用级添加依赖(版本号以官方发布日志为准):
allprojects {
repositories {
maven { url 'https://maven.zego.im' }
mavenCentral()
google()
}
}
dependencies {
implementation 'im.zego:zim:x.y.z' // 具体版本见官方发布日志
}
初始化并登录(登录后 ZIM 自动处理长连接、消息同步、离线消息补拉):
// 1. 创建实例(静态方法 create,Android 必须传入 Application)
ZIMAppConfig appConfig = new ZIMAppConfig();
appConfig.appID = 1234567890L; // 控制台获取(long 类型)
appConfig.appSign = "your_app_sign"; // 控制台获取
ZIM zim = ZIM.create(appConfig, getApplication());
// 同一进程内可通过 ZIM.getInstance() 复用该实例
// 2. 注册事件回调——消息、连接状态都从这里进来
zim.setEventHandler(new ZIMEventHandler() {
@Override
public void onMessageReceived(ZIM zim, ZIMMessageReceivedEventResult result) {
// 收到新消息:刷新聊天列表 / 更新会话未读数
}
@Override
public void onConnectionStateChanged(ZIM zim, ZIMConnectionState state,
ZIMConnectionEvent event, JSONObject extendedData) {
// 连接状态变化:可提示用户网络异常
}
});
// 3. 登录(userID 最大 32 字节,建议与业务账号系统关联,如 "user_10086")
String userID = "user_10086";
ZIMLoginConfig config = new ZIMLoginConfig();
config.userName = "小米";
config.token = token; // Token 鉴权:服务端签发的 Token
// AppSign 鉴权:传空字符串
zim.login(userID, config, new ZIMLoggedInCallback() {
@Override
public void onLoggedIn(ZIMError error) {
if (error.code == ZIMErrorCode.SUCCESS) {
// 登录成功,可以开始拉会话列表了
}
}
});
两个登录细节值得注意:
- userID 别用手机号、身份证这类敏感信息,与业务系统关联即可;
- 支持离线登录(
ZIMLoginConfig.isOffline = true),App 启动时可以先用本地缓存的 userID 恢复登录态,聊天记录秒开,再在后台切回在线状态。
4.2 会话列表与未读数:消息中心的正确姿势
客服消息的入口通常是一个"消息中心"列表。ZIM 的会话列表天然支持这些需求:
// 拉取会话列表
ZIMConversationQueryConfig queryConfig = new ZIMConversationQueryConfig();
queryConfig.count = 50; // 分页大小
queryConfig.nextConversation = null; // 分页锚点,首次传 null
zim.queryConversationList(queryConfig, new ZIMConversationListQueriedCallback() {
@Override
public void onConversationListQueried(ArrayList conversationList,
ZIMError errorInfo) {
// conversationList:会话列表(含未读数、最后一条消息、置顶状态)
// 分页:把列表最后一条会话赋给 nextConversation,继续拉更早的会话
}
});
// 如需按标记/会话类型过滤,使用带 ZIMConversationFilterOption 的重载
// 查询总未读数,用于 Tab 角标
zim.queryConversationTotalUnreadMessageCount(new ZIMConversationTotalUnreadMessageCountQueriedCallback() {
@Override
public void onConversationTotalUnreadMessageCountQueried(int unreadMessageCount, ZIMError errorInfo) {
// 展示在"消息"Tab 的角标上
}
});
// 用户点进会话后,清除该会话未读数
zim.clearConversationUnreadMessageCount(conversationID, ZIMConversationType.PEER, callback);
电商场景有个细节:服务通知(物流、优惠券)也建议用单聊会话承载,但要区分"用户主动咨询的客服会话"和"系统通知会话"。做法是在自定义消息里带 msgType 字段,消息中心按类型分组展示,避免把通知和客服消息混在一起影响已读语义。
4.3 消息收发:先选对消息类型
ZIM 的消息类型很全,客服场景里常用的这几类要记牢:
| 消息类型 | 大小限制 | 客服场景用途 |
|---|---|---|
| ZIMTextMessage | 2KB(可申请至 32KB) | 常规文字对话 |
| ZIMImageMessage | 10MB(JPG/PNG/GIF 等) | 用户发截图、尺码表 |
| ZIMAudioMessage | 6MB / 300 秒(MP3/M4A) | 语音回复,客服效率利器 |
| ZIMFileMessage | 100MB | 售后单、发票文件 |
| ZIMCustomMessage | 2KB(可申请至 32KB) | 商品卡片、订单卡片、评价消息 |
| ZIMCombineMessage | 无限制 | 合并转发多条消息 |
发消息统一走 sendMessage,重点看发送配置和回调:
// 发送文本消息
ZIMTextMessage textMessage = new ZIMTextMessage("亲,这款支持 7 天无理由退货哦~");
ZIMMessageSendConfig config = new ZIMMessageSendConfig();
config.priority = ZIMMessagePriority.LOW; // 消息优先级
config.hasReceipt = true; // 开启已读回执(见 4.5)
config.pushConfig = buildPushConfig(); // 离线推送配置(见 4.7)
zim.sendMessage(textMessage, serviceUserID, ZIMConversationType.PEER, config,
new ZIMMessageSentFullCallback() {
@Override
public void onMessageAttached(ZIMMessage zimMessage) {
// 消息已进入发送管道:立即把消息插进 UI,做"发送中"状态
// 这样弱网时用户也立刻看到气泡,体验更跟手
}
@Override
public void onMessageSent(ZIMMessage zimMessage, ZIMError error) {
// 发送结果:成功则更新状态,失败按 error.code 处理
}
});
这里有个体验细节:onMessageAttached 回调是给你做 UI 的——消息一旦通过本地参数校验就先上屏,而不是等网络往返。电商客服里用户发完消息最怕"没反应",这个回调直接决定体感。
4.4 商品卡片:自定义消息的协议设计
商品卡片是电商客服和通用 IM 的分水岭。做法是用 ZIM 自定义消息,把业务结构化数据放进 message 字段,subType 区分消息类别:
// 发送商品卡片(客服端)
String cardJson = new JSONObject()
.put("cardType", "PRODUCT") // 卡片类型
.put("productId", "P20240815001")
.put("title", "轻量羽绒服 男女同款")
.put("price", 399)
.put("imageUrl", "https://cdn.example.com/p1.jpg")
.put("action", "buy") // 点击行为:跳商品详情
.toString();
ZIMCustomMessage customMessage = new ZIMCustomMessage(cardJson, 1); // subType=1: 商品卡片
customMessage.searchedContent = "羽绒服"; // 消息搜索关键词
zim.sendMessage(customMessage, userID, ZIMConversationType.PEER, config, callback);
自定义消息的协议必须两端先行约定,因为 ZIM SDK 不负责解析自定义消息的内容,收到后完全靠你解析。建议在一开始就把 subType 枚举定死:
subType 1: 商品卡片(productId / title / price / imageUrl / action)
subType 2: 订单卡片(orderId / status / amount / trackingUrl)
subType 3: 评价邀请(orderId / rating)
subType 4: 系统通知(type / content / action)
两点提醒:
- 自定义消息默认 2KB,商品卡片 JSON 里别塞大字段(详情、长文案都别放,卡片只放展示数据和跳转参数);
- 自定义消息 2.8.0+ 支持发送,接收端低于该版本会显示为未知消息类型,用户端的 SDK 版本要设定最低门槛。
4.5 已读回执:把"已读"变成业务信号
客服场景里,"客服已读"和"客服已回复"是两个不同的安抚信号,用户看到"已读"时焦虑就消了一半。ZIM 的已读回执正好可以拆出这两层语义。
发送方在 ZIMMessageSendConfig.hasReceipt = true 标记消息带回执;接收方在 onMessageReceived 里判断消息的 receiptStatus,为 PROCESSING 时调用已读接口:
// 接收方:收到消息后标记已读
List messages = new ArrayList<>();
messages.add(message);
zim.sendMessageReceiptsRead(messages, conversationID, ZIMConversationType.PEER,
new ZIMMessageReceiptsReadSetCallback() {
@Override
public void onMessageReceiptsReadSet(String conversationID, ZIMConversationType conversationType,
ArrayList errorMessageIDs, ZIMError errorInfo) {
// 标记已读成功
}
});
// 更简单的做法:整个会话已读(把该会话对方发来的所有消息都标记已读)
zim.sendConversationMessageReceiptRead(conversationID, ZIMConversationType.PEER, callback);
发送方监听 onMessageReceiptChanged 回调,把气泡从"已送达"改成"客服已读":
@Override
public void onMessageReceiptChanged(ZIM zim, ArrayList infos) {
for (ZIMMessageReceiptChangedInfo info : infos) {
// info.messageIDs + info.conversationID:定位到对应气泡,更新"已读"状态
}
}
注意两个工程细节:sendMessageReceiptsRead 一次最多传 10 条消息(超出报错误码 6000282),批量已读时按 10 条切片;需要为历史消息补已读时,先查历史消息、判断回执状态再设置。
4.6 历史消息:换设备、杀进程都不怕
ZIM 的 queryHistoryMessage 查询历史消息时优先从本地数据库缓存检索,本地不完整再自动向服务端拉取。这意味着:
- 用户杀掉 App 再进来,聊天记录秒开(本地缓存不受服务端存储时长限制);
- 换设备、重新登录,服务端历史消息自动补全(存储天数与套餐版本相关,正式上线前确认所选套餐);
- 分页加载通过
nextMessage消息锚点,支持上滑加载更早的消息。
ZIMMessageQueryConfig queryConfig = new ZIMMessageQueryConfig();
queryConfig.nextMessage = null; // 分页锚点,首次查询传 null
queryConfig.messageCount = 30; // 每页条数
queryConfig.reverse = true; // 从后往前取(新→旧)
zim.queryHistoryMessage(conversationID, ZIMConversationType.PEER, queryConfig,
new ZIMMessageQueriedCallback() {
@Override
public void onMessageQueried(ArrayList messageList, ZIMError errorInfo) {
// 上滑加载更多时,把当前列表最后一条消息作为锚点继续拉取
queryConfig.nextMessage = messageList.get(messageList.size() - 1);
}
});
4.7 离线推送:客服消息的最后一公里
用户离开聊天页,客服回复了——这时候 App 进程可能都死了。ZIM 的离线推送(ZPNs)覆盖 FCM 和国内厂商通道(小米、华为、OPPO、vivo 等),在控制台配置好推送证书后,发消息时携带 ZIMPushConfig 即可:
ZIMPushConfig pushConfig = new ZIMPushConfig();
pushConfig.title = "客服小云"; // 通知标题
pushConfig.content = "亲,您咨询的尺码问题回复啦"; // 通知内容
pushConfig.payload = "{\"conversationID\":\"user_10086\"}"; // 自定义数据:点击跳转用
pushConfig.threadID = "customer_service"; // iOS 通知分组(可选)
客户端配合"自定义点击跳转":用户点击推送后,通过 onNotificationClicked 回调拿到 payload,直接跳进对应会话页,而不是回到首页再找,这个细节直接决定推送转化率。
五、客服端:工作台不是聊天室
客服同时接待几十个会话,工作台的核心诉求是排序和标记,这恰好是 ZIM 会话能力的主场:
// 会话标记:把"待处理/已处理/加急"做成分组
// markType 为整型标记值,含义由业务自定义,如 1=待处理、2=已处理
Integer markType = 1;
boolean enable = true;
ArrayList convList = new ArrayList<>();
ZIMConversationBaseInfo conversation = new ZIMConversationBaseInfo(); // 无参构造
conversation.conversationID = conversationID;
conversation.conversationType = ZIMConversationType.PEER;
convList.add(conversation);
zim.setConversationMark(markType, enable, convList, new ZIMConversationMarkSetCallback() {
@Override
public void onConversationMarkSet(ArrayList failedConversationInfos,
ZIMError errorInfo) {
// 部分会话操作失败时,errorInfo 仍为 SUCCESS,失败列表在 failedConversationInfos 中
}
});
// 查询时按标记过滤(只拉"待处理"会话):调用带 ZIMConversationFilterOption 的
// queryConversationList(config, option, callback) 重载
客服端值得用的会话能力清单:
| 能力 | 接口 | 客服工作台用途 |
|---|---|---|
| 会话标记 | setConversationMark(2.17.0+,单会话标记上限:2.27.0+ 为 60 个) | “待处理/已处理"分组、加急 |
| 会话置顶 | 服务端置顶会话 API | 重要用户置顶 |
| 会话草稿 | setConversationDraft(2.14.0+) | 客服输入一半的回复,切走再回来不丢 |
| 引用回复 | replyMessage | 引用用户原话回复,纠纷场景必备 |
| 消息撤回 | revokeMessage | 客服发错商品链接,撤回重发 |
| 单聊免打扰 | setConversationNotificationStatus | 下班时段静音指定用户 |
六、服务端:客服系统"活"起来的另一半
6.1 服务端主动发消息
不是所有消息都来自人工客服。物流更新、优惠券发放、客服超时未回复的自动提醒,都由服务端通过 ZIM 服务端 API 直接发送单聊消息,客户端无需感知发送方是人是机。注意服务端发送的消息同样支持离线推送,且国内 Android 厂商通道支持无限频推送。
6.2 回调:业务系统与聊天系统的数据桥
在控制台配置回调地址后,ZIM 服务端会把关键事件 POST 给你的业务后台。客服场景必须接的两个:
- 消息发送前回调(
before_send_msg):消息发出前先问业务后台,可以用来记录聊天、拦截违规发言、实现黑白名单; - 消息发送后回调(
send_msg):消息发出后实时同步到业务服务器,这是聊天记录归档审计的主路径。客诉仲裁时,全量聊天记录从你库里出,而不是依赖 IM 服务端的存储套餐。
另外还有登录登出回调,可用于统计在线客服人数、空闲客服排班。
官方提醒:回调服务不能保证完全可靠。所以审计归档可以依赖回调 + 定期全量对账兜底,但"路由分配"这类核心流程不要设计成"只靠回调驱动"。
6.3 内容审核与合规
电商客服是高危场景,广告、导流、违禁词随时可能出现。ZIM 的内容审核服务直接覆盖文本、图片、语音、视频消息:
- 先审后发 / 先发后审两种策略可选:客服场景建议文本消息先审后发(拦截导流),图片先发后审(不阻塞用户体验);
- 支持自定义敏感词(竞品名、微信号、手机号等导流黑话);
- 审核结果可配合消息发送前回调做二次决策。
加上 6.2 的全量消息归档,客服系统的合规三件套(内容审核、全程留存、数据可查)就齐了。
七、注意事项清单
- 限频:可靠消息单客户端 10 次/秒。恶意刷屏不要指望 SDK 挡,服务端在回调里做频控和黑名单;
- 大小边界:文本/自定义消息默认 2KB,商品卡片别塞详情;图片 10MB、语音 300 秒/6MB,发送前客户端先压缩和预检;
- 自定义消息协议先行:ZIM 不解析自定义内容,两端
subType枚举和 JSON schema 必须在联调前定死并做版本兼容; - SDK 版本门槛:自定义消息、会话标记、草稿等能力有版本要求(分别是 2.8.0+、2.17.0+、2.14.0+),老版本用户会静默丢失能力,升级策略要提前定;
- 已读回执批量限制:
sendMessageReceiptsRead一次 ≤10 条; - 回调不可靠:审计对账要兜底,核心流程别依赖回调;
- 历史消息时长按套餐计:正式上线前确认所选套餐的存储天数,长期留存以服务端归档为准。
八、总结:一张表看完整个方案
| 业务诉求 | 实现方式 | 关键能力 |
|---|---|---|
| 用户与客服对话 | 单聊会话(客服即 ZIM 用户) | sendMessage / onMessageReceived |
| 商品/订单卡片 | 自定义消息(subType 协议) | ZIMCustomMessage |
| 消息中心与角标 | 会话列表 + 未读数 | queryConversationList / 未读总数 |
| “客服已读"安抚 | 已读回执 | hasReceipt / sendMessageReceiptsRead |
| 客服工作台分组 | 会话标记 + 置顶 | setConversationMark |
| 离线触达 | 离线推送 + 自定义跳转 | ZIMPushConfig / onNotificationClicked |
| 客服分配与转接 | 服务端路由(业务自实现) | 服务端 API + 回调 |
| 机器人/超时提醒 | 服务端发消息 | 发送单聊消息 API |
| 聊天审计归档 | 发送后回调同步业务库 | send_msg 回调 |
| 内容安全 | 内容审核(先审后发/先发后审) | 控制台开通,自定义敏感词 |
后续扩展方向:ZIM 呼叫邀请可直接把"打字聊不明白"的会话升级成语音通话;再往后可以接入 ZEGO 的实时互动 AI Agent,让机器人先接一轮,答不了再转人工——路由策略不变,加一层前置即可。
核心判断:客服系统的技术选型,本质是"消息底座"和"业务逻辑"的分工。把消息可靠送达、多端同步、离线推送交给 ZIM 这种成熟底座,把路由、卡片、审计握在自己手里,是投入产出比最高的做法。




