背景痛点:传统客服接入的“三座大山”
做电商小程序时,我接过第一版客服需求:把网页版在线客服代码直接嵌到web-view里。结果上线当天就翻车:
- H5 端偶尔收不到消息,用户刷新页面会话直接“人间蒸发”
- 小程序切后台 5 秒再回来,WebSocket 断得比恋爱还干脆
- App 端 iOS 锁屏后重连,历史消息顺序乱成麻花,客服以为用户“穿越”了
一句话总结:跨端兼容差、消息延迟、会话状态维护复杂,三座大山把开发周期活活拖成两周。于是我把目标拆成三步:先跑三天跑通,再一周优化,最后平稳上线。下面把踩过的坑一次性摊开。
技术选型:三条路线谁更适合新手
纯原生 WebSocket
优点:零依赖、包体小。
缺点:心跳、重连、鉴权、分端兼容全自己写,代码量≈一个小项目。第三方 SDK(腾讯云智聆、环信、七鱼)
优点:UI 组件拿来即用,后台稳定。
缺点:包体积 +200 KB 起步,免费额度有限,定制化需要 VIP。Serverless 方案(uniCloud 云函数 + 自建消息网关)
优点:前后端同语言(js/ts),一键部署;云函数按量计费,流量小几乎 0 成本;可插拔第三方 NLP 能力。
缺点:需要理解 uniCloud 运行时效、冷启动。
结论:新手想三天落地,路线 3 最香——把重连、幂等、敏感词过滤放在云函数,客户端只负责“收、发、渲染”,后期也能平滑迁移到路线 2。
核心实现:一条消息的生命周期
1. 云函数搭“中转层”
在uniCloud/cloudfunctions/im-router/index.ts里新建一个消息路由器:
// 类型定义 interface MsgDoc { msgId: string // 幂等键 from: 'user' | 'cs' content: string ts: number } exports.main = async (event: { action: string; body: any }, ctx: any) => { const db = uniCloud.database() switch (event.action) { case 'send': // 敏感词过滤 const filter = /垃圾|广告|加微信/g if (filter.test(event.body.content)) { throw new Error('消息包含敏感词') } // 幂等写入 await db.collection('msg').doc(event.body.msgId).set({ ...event.body, ts: Date.now() }) // 推送到客服后台(这里调用第三方 webhook) await uniCloud.httpclient.request('https://your-cs-api/send', { method: 'POST', data: event.body, dataType: 'json' }) return { code: 0 } case 'pull': // 客户端轮询 or WebSocket 回包 const list = await db.collection('msg') .where({ target: event.body.uid }) .orderBy('ts', 'desc') .limit(20) .get() return { code: 0, data: list.data } } }部署后拿到云函数 URL:https://xxx.bspapp.com/im-router
2. 客户端封装 WebSocket 组件
im.ts统一收口,支持 TypeScript:
type OnMsg = (payload: MsgDoc) => void class IM { private url = 'wss://xxx.bspapp.com/im-router' private ws: UniApp.SocketTask | null = null private heartbeatTimer: any = null private reconnectCount = 0 private onMsgList: OnMsg[] = [] connect() { this.ws = uni.connectSocket({ url: this.url }) this.ws.onOpen(() => { this.reconnectCount = 0 // 30s 心跳 this.heartbeatTimer = setInterval(() => { this.ws!.send({ data: JSON.stringify({ action: 'ping' }) }) }, 30000) }) this.ws.onMessage((res) => { const msg: MsgDoc = JSON.parse(res.data as string) this.onMsgList.forEach(fn => fn(msg)) }) this.ws.onClose(() => { clearInterval(this.heartbeatTimer) // 指数退避重连 if (this.reconnectCount < 5) { this.reconnectCount++ setTimeout(() => this.connect(), 1000 * Math.pow(2, this.reconnectCount)) } }) } send(msg: Partial<MsgDoc>) { if (this.ws && this.ws.readyState === 1) { this.ws.send({ data: JSON.stringify({ action: 'send', body: msg }) }) } else { uni.showToast({ title: '网络开小差', icon: 'none' }) } } onMessage(fn: OnMsg) { this.onMsgList.push(fn) } close() { this.ws?.close() } } export default new IM()在App.vue的onLaunch里IM.connect()一次即可全局复用。
3. 多端适配差异
- H5:浏览器原生 WebSocket,支持最完整,注意 https 页面只能连 wss
- 小程序:合法域名需到后台配置,真机调试记得打开“不校验域名”
- App-iOS:切后台 180s 系统会挂起 Socket,需监听
onShow再close+connect - App-Android:厂商 ROM 可能杀后台,推荐集成
unipush走离线通知,把“新消息”通过推送唤醒
消息体字段统一用msgId + ts排序,客户端再做一次归并,保证乱序也能排好。
生产考量:上线前必须扣的 3 个细节
消息幂等
云函数层用msgId做唯一键,写入前doc(msgId).get()若存在直接返回,防止用户端重试导致重复。冷启动优化
在uniCloud/cloudfunctions/im-router/package.json里加"preload": true,并给云函数配置最小实例数 1,保证客服高峰 0 冷启动。敏感信息过滤
正则示例已在上面云函数代码给出,实际业务可把 2W+ 敏感词放云数据库,定时同步到云函数内存,降低 IO。
避坑指南:3 个高频故障场景
iOS 退后台断连
表现:用户锁屏 3 分钟再解锁,消息断层。
解决:App 端在onShow里主动IM.close(); IM.connect(),并清空本地消息列表重新拉 20 条历史。安卓消息乱序
表现:客服回了两条,客户端展示颠倒。
解决:统一用服务器时间ts排序,客户端收到后插入数组前对比ts,不再相信本地时间。云函数 504
表现:高峰并发 200 时偶现 504。
解决:把pull接口改走uniCloud.database().limit(20).get()的游标分页,并在前端做 250ms 防抖,避免狂点。
性能优化小贴士
- 图片/语音先传 uniCloud 云存储拿到 CDN 地址,再发文本消息,减少 WebSocket 大数据帧
- 客服输入状态用节流 800ms 一次,降低上行
- 列表渲染用
virtual-list或scroll-view的scroll-into-view,保证 500 条消息不卡顿
互动环节
客服系统上线后,老板马上追问:“用户满意度怎么量化?”
我目前只记录了会话时长、解决率,还想加上情绪分析、星级打分。
如果你做过满意度模型,欢迎到示例仓库提 PR,一起聊聊「如何设计客服满意度评价体系」!
(示例仓库地址在评论区置顶,冲!)