在上一篇文章中(juejin.cn/post/757352…),已经实现了电销APP的基础功能:通时通次记录、通话录音上传。 已经能在工作中进行应用了,但是离成熟的电销APP还是差了不少,还得继续开发。
电销APP大都还有一个与之对应的CRM系统,所以另一个常见的需求,就是通过CRM后台直接控制APP拨号。
相关代码和电销APP已经开源:github.com/friend-nice…
开发思路
常规需求用常规的办法:在保证消息收发高效实时的前提下,后端实现一个Websocket服务用于和APP用户端实时通信,再通过http提供接口给CRM后台调用,触发APP端拨号。从技术栈匹配度来看,用Node实现这个需求再合适不过了。
1.实现Websocket服务
考虑到我的需求面向的是Saas多公司的系统,所以先实现一个方便存取ws链接的连接池对象,嵌套的两层Map对象,第一层是区分哪个公司,然后是哪个用户:
1export const corpClients = new Map(); 2
通过ws来实现Websocket服务,简单的几行代码,再加上自己的业务逻辑。
1const wss = new WebSocketServer({host: '0.0.0.0', port: CONFIG.ports.ws}); 2 Logger.info(`WebSocket服务器运行在 ws://localhost:${CONFIG.ports.ws}`); 3 4 wss.on('connection', (ws, req) => { 5 6 let ip = req.headers['x-real-ip'] 7 || req.headers['x-forwarded-for']?.split(',')[0].trim() 8 || req.socket.remoteAddress; 9 10 if (ip.substr(0, 7) === '::ffff:') ip = ip.substr(7); 11 12 Logger.debug(`新的WebSocket连接,${JSON.stringify({remoteAddress: ip, url: req.url})}`); 13 Logger.info(`当前连接数: ${getConnectionCount()}`); 14 15 ws.on('close', () => { 16 if (ws.corp && ws.phone) { 17 const companyMap = corpClients.get(ws.corp); 18 if (companyMap) { 19 companyMap.delete(ws.phone); 20 if (companyMap.size === 0) corpClients.delete(ws.corp); 21 } 22 Logger.debug('WebSocket连接关闭', {phone: ws.phone, corp: ws.corp, remainingClients: getConnectionCount()}); 23 } 24 }); 25 26 ws.on('error', (error) => { 27 Logger.error('WebSocket连接错误', error); 28 if (ws.corp && ws.phone) { 29 const companyMap = corpClients.get(ws.corp); 30 if (companyMap) { 31 companyMap.delete(ws.phone); 32 if (companyMap.size === 0) corpClients.delete(ws.corp); 33 } 34 } 35 }); 36 }); 37
考虑到用户连接之后,得区分这个连接是哪个用户,所以还得有一个类似登录的过程,登录之后的链接才会保存到连接池。
1ws.on('message', (body) => { 2 3 const data = body.toString(); 4 Logger.info([`收到消息:${data},${JSON.stringify({remoteAddress: ip})}`](https://xplanc.org/primers/document/zh/03.HTML/EX.HTML%20%E5%85%83%E7%B4%A0/EX.data.md)); 5 6 /* 维持心跳 */ 7 if (data === 'ping') { 8 ws.send("1"); 9 return; 10 } 11 12 try { 13 const msg = JSON.parse(data); 14 if (!ws.phone && msg.type === 'login') { 15 if (msg?.name && msg?.corp) { 16 const phone = msg.name; 17 const corp = String(msg.corp); 18 let companyMap = corpClients.get(corp); 19 if (!companyMap) { 20 companyMap = new Map(); 21 corpClients.set(corp, companyMap); 22 } 23 const existingClient = companyMap.get(phone); 24 if (existingClient) { 25 Logger.info(`禁止重复登录,通知${phone}自动退出!`); 26 existingClient.send(JSON.stringify({type: 'quit', name: phone})); 27 companyMap.delete(phone); 28 } 29 Logger.info(`新消息:${phone}上线`); 30 ws.phone = phone; 31 ws.corp = corp; 32 companyMap.set(phone, ws); 33 } 34 } 35 } catch (error) { 36 Logger.error('处理WebSocket消息失败', error); 37 } 38 }); 39
提示:考虑到安全性的问题,可以继续完善做进一步的认证
2.实现http接口
CRM后台唤起拨号的这个操作是不定时不定量的,所以不需要考虑到实时性的问题,所以通过提供http接口来实现是完全够用的。
通过Hono来快速实现接口,然后调用上面保存的连接池来向APP发送实时消息:
1const app = new Hono(); 2 3const param = async (c) => { 4 let data = {}; 5 const queryParams = c.req.query(); 6 if (Object.keys(queryParams).length > 0) data = {...data, ...queryParams}; 7 if (c.req.method === 'POST') { 8 const contentType = c.req.header('content-type') || ''; 9 try { 10 if (contentType.includes('application/json')) { 11 const jsonData = await c.req.json(); 12 data = {...data, ...jsonData}; 13 } else if (contentType.includes('multipart/form-data') || contentType.includes('application/x-www-form-urlencoded')) { 14 const formData = await c.req.parseBody(); 15 data = {...data, ...formData}; 16 } 17 } catch (parseError) { 18 Logger.warn('解析请求数据失败', parseError); 19 } 20 } 21 return data; 22}; 23 24app.all('/send', async (c) => { 25 try { 26 const data = await param(c); 27 Logger.debug('收到请求', data); 28 if (!!data.phone && !!data.name && !!data.type && !!data.corp) { 29 const companyMap = corpClients.get(String(data.corp)); 30 const client = companyMap ? companyMap.get(data.name) : null; 31 if (!client) return c.json({code: 0, message: '你的APP账号没有上线!'}, 200); 32 client.send(JSON.stringify(data)); /* 操作 */ 33 return c.json({code: 1, message: 'ok'}); 34 } else { 35 return c.json({code: 0, message: 'fail'}); 36 } 37 } catch (error) { 38 Logger.error('处理webhook请求失败', error); 39 return c.json({code: 0, message: 'error'}, 200); 40 } 41}); 42 43serve({fetch: app.fetch, port: CONFIG.ports.http}, (info) => { 44 Logger.info(`HTTP服务器运行在 http://localhost:${info.port}`); 45 Logger.info(`调试模式: ${CONFIG.debug ? '开启' : '关闭'}`); 46}); 47 48return app; 49
3.APP端初始化Ws链接
App端运行的稳定性没法保证,所以需要考虑各种极端条件,保证Ws连接的稳定性。下面是通过Vueuse来实现的Ws,Vuesuse已经封装好了自动重连、心跳包等逻辑:
1/* 链接websocket */ 2const {status, data, close, open, send, ws} = useWebSocket(api.ws, { 3 onConnected(ws) { 4 ws.send(JSON.stringify({ 5 type: 'login', 6 corp: user.corp.id, 7 name: user.username 8 })); 9 }, 10 onMessage(ws, event) { 11 12 /* 接受数据 */ 13 if (event.data === "1") return; 14 const data = JSON.parse(event.data); 15 16 /* 处理,后台应用弹出 */ 17 moveTop(); 18 19 /* 登录同一个账号,退出登录 */ 20 if (data.type === "quit") { 21 quit________system(); 22 return load.info('您的账号在别处登录'); 23 } else if (data.type === "call") { 24 call({phone: data.phone}); 25 } 26 27 }, 28 heartbeat: { 29 interval: 5000 30 }, 31 immediate: true, 32 autoClose: false, 33 autoReconnect: true 34}); 35
继续封装,用户登录的时候初始化链接,用户退出的时候自动断开:
1/* 初始化监听:监控用户 token 登录与退出,自动建立/关闭 ws */ 2export default function initWsWatch() { 3 /* 用户信息 */ 4 const user = _user(); 5 /* 监听 token 变化:有值则连接,无值则关闭并重置 */ 6 watch(() => user.token, (token) => { 7 if (token) { 8 connectWs(); 9 } else { 10 disconnectWs(true); 11 } 12 }, {immediate: true}); 13} 14
《基于UniappX开发电销APP,实现CRM后台控制APP自动拨号》 是转载文章,点击查看原文。