在上一篇文章中(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对象,第一层是区分哪个公司,然后是哪个用户:
javascript
export const corpClients = new Map();
通过ws来实现Websocket服务,简单的几行代码,再加上自己的业务逻辑。
javascript
const wss = new WebSocketServer({host: '0.0.0.0', port: CONFIG.ports.ws});
Logger.info(`WebSocket服务器运行在 ws://localhost:${CONFIG.ports.ws}`);
wss.on('connection', (ws, req) => {
let ip = req.headers['x-real-ip']
|| req.headers['x-forwarded-for']?.split(',')[0].trim()
|| req.socket.remoteAddress;
if (ip.substr(0, 7) === '::ffff:') ip = ip.substr(7);
Logger.debug(`新的WebSocket连接,${JSON.stringify({remoteAddress: ip, url: req.url})}`);
Logger.info(`当前连接数: ${getConnectionCount()}`);
ws.on('close', () => {
if (ws.corp && ws.phone) {
const companyMap = corpClients.get(ws.corp);
if (companyMap) {
companyMap.delete(ws.phone);
if (companyMap.size === 0) corpClients.delete(ws.corp);
}
Logger.debug('WebSocket连接关闭', {phone: ws.phone, corp: ws.corp, remainingClients: getConnectionCount()});
}
});
ws.on('error', (error) => {
Logger.error('WebSocket连接错误', error);
if (ws.corp && ws.phone) {
const companyMap = corpClients.get(ws.corp);
if (companyMap) {
companyMap.delete(ws.phone);
if (companyMap.size === 0) corpClients.delete(ws.corp);
}
}
});
});
考虑到用户连接之后,得区分这个连接是哪个用户,所以还得有一个类似登录的过程,登录之后的链接才会保存到连接池。
javascript
ws.on('message', (body) => {
const data = body.toString();
Logger.info(`收到消息:${data},${JSON.stringify({remoteAddress: ip})}`);
/* 维持心跳 */
if (data === 'ping') {
ws.send("1");
return;
}
try {
const msg = JSON.parse(data);
if (!ws.phone && msg.type === 'login') {
if (msg?.name && msg?.corp) {
const phone = msg.name;
const corp = String(msg.corp);
let companyMap = corpClients.get(corp);
if (!companyMap) {
companyMap = new Map();
corpClients.set(corp, companyMap);
}
const existingClient = companyMap.get(phone);
if (existingClient) {
Logger.info(`禁止重复登录,通知${phone}自动退出!`);
existingClient.send(JSON.stringify({type: 'quit', name: phone}));
companyMap.delete(phone);
}
Logger.info(`新消息:${phone}上线`);
ws.phone = phone;
ws.corp = corp;
companyMap.set(phone, ws);
}
}
} catch (error) {
Logger.error('处理WebSocket消息失败', error);
}
});
提示:考虑到安全性的问题,可以继续完善做进一步的认证
2.实现http接口
CRM后台唤起拨号的这个操作是不定时不定量的,所以不需要考虑到实时性的问题,所以通过提供http接口来实现是完全够用的。
通过Hono来快速实现接口,然后调用上面保存的连接池来向APP发送实时消息:
javascript
const app = new Hono();
const param = async (c) => {
let data = {};
const queryParams = c.req.query();
if (Object.keys(queryParams).length > 0) data = {...data, ...queryParams};
if (c.req.method === 'POST') {
const contentType = c.req.header('content-type') || '';
try {
if (contentType.includes('application/json')) {
const jsonData = await c.req.json();
data = {...data, ...jsonData};
} else if (contentType.includes('multipart/form-data') || contentType.includes('application/x-www-form-urlencoded')) {
const formData = await c.req.parseBody();
data = {...data, ...formData};
}
} catch (parseError) {
Logger.warn('解析请求数据失败', parseError);
}
}
return data;
};
app.all('/send', async (c) => {
try {
const data = await param(c);
Logger.debug('收到请求', data);
if (!!data.phone && !!data.name && !!data.type && !!data.corp) {
const companyMap = corpClients.get(String(data.corp));
const client = companyMap ? companyMap.get(data.name) : null;
if (!client) return c.json({code: 0, message: '你的APP账号没有上线!'}, 200);
client.send(JSON.stringify(data)); /* 操作 */
return c.json({code: 1, message: 'ok'});
} else {
return c.json({code: 0, message: 'fail'});
}
} catch (error) {
Logger.error('处理webhook请求失败', error);
return c.json({code: 0, message: 'error'}, 200);
}
});
serve({fetch: app.fetch, port: CONFIG.ports.http}, (info) => {
Logger.info(`HTTP服务器运行在 http://localhost:${info.port}`);
Logger.info(`调试模式: ${CONFIG.debug ? '开启' : '关闭'}`);
});
return app;
3.APP端初始化Ws链接
App端运行的稳定性没法保证,所以需要考虑各种极端条件,保证Ws连接的稳定性。下面是通过Vueuse来实现的Ws,Vuesuse已经封装好了自动重连、心跳包等逻辑:
javascript
/* 链接websocket */
const {status, data, close, open, send, ws} = useWebSocket(api.ws, {
onConnected(ws) {
ws.send(JSON.stringify({
type: 'login',
corp: user.corp.id,
name: user.username
}));
},
onMessage(ws, event) {
/* 接受数据 */
if (event.data === "1") return;
const data = JSON.parse(event.data);
/* 处理,后台应用弹出 */
moveTop();
/* 登录同一个账号,退出登录 */
if (data.type === "quit") {
quit________system();
return load.info('您的账号在别处登录');
} else if (data.type === "call") {
call({phone: data.phone});
}
},
heartbeat: {
interval: 5000
},
immediate: true,
autoClose: false,
autoReconnect: true
});
继续封装,用户登录的时候初始化链接,用户退出的时候自动断开:
javascript
/* 初始化监听:监控用户 token 登录与退出,自动建立/关闭 ws */
export default function initWsWatch() {
/* 用户信息 */
const user = _user();
/* 监听 token 变化:有值则连接,无值则关闭并重置 */
watch(() => user.token, (token) => {
if (token) {
connectWs();
} else {
disconnectWs(true);
}
}, {immediate: true});
}