即时通讯我们需要用到websocketwebsockethtml5提供的一种在单个TCP连接上进行全双工通讯的协议。

# 一、安装websocket插件

我们的eggjs框架也可以使用websocket,但是需要安装一个插件,插件地址:https://www.npmjs.com/package/egg-websocket-plugin (opens new window)

# 1. 安装插件

npm i egg-websocket-plugin --save

或者 通过 yarn安装

yarn add egg-websocket-plugin

# 2. 配置插件

config/plugin.js 文件中添加如下内容:

...
//配置websocket插件
websocket : {
    enable: true,
    package: 'egg-websocket-plugin',
},

# 3. 配置websocket路由和中间件

说明:由于websocket 需要配合前端一起使用,大家暂时先讲下面大代码复制到项目,后面测试时候会讲这些代码。

  1. 新建路由文件: app/router/api/chat/websocket.js
module.exports = app => {
    const { router, controller } = app;
    //配置websocket路由
    //配置websocket全局中间件
    app.ws.use(async (ctx, next) => {
        if (ctx.path !== '/ws') return next();

        console.log('WebSocket连接请求:', ctx.path, ctx.query);
        // 获取参数 ws://localhost:7001/ws?token=123456
        // ctx.query.token
        // 验证用户token
        const token = ctx.query.token;
        if (!token) {
            ctx.websocket.send(JSON.stringify({
                msg: "fail",
                data: '缺少 token 参数'
            }));
            return ctx.websocket.close();
        }
        try {
            const user = ctx.checkToken(token);
            const userCheck = await ctx.app.model.User.findByPk(user.id);

            if (!userCheck) {
                ctx.websocket.send(JSON.stringify({
                    msg: "fail",
                    data: '用户不存在'
                }));
                return ctx.websocket.close();
            }

            if (!userCheck.status) {
                ctx.websocket.send(JSON.stringify({
                    msg: "fail",
                    data: '你已被管理员禁用'
                }));
                return ctx.websocket.close();
            }

            // 单设备登录逻辑
            if (ctx.app.ws.chatuser && ctx.app.ws.chatuser[user.id]) {
                ctx.app.ws.chatuser[user.id].send(JSON.stringify({
                    msg: "force_logout",
                    data: '你的账号在其他设备登录'
                }));
                ctx.app.ws.chatuser[user.id].close();
            }

            // 存储连接
            ctx.app.ws.chatuser = ctx.app.ws.chatuser || {};
            ctx.websocket.chatuser_id = user.id;
            ctx.app.ws.chatuser[user.id] = ctx.websocket;

            // ctx.online(user.id);
            console.log(`用户 ${user.id} 连接成功`);

            // 进入控制器
            await next();

        } catch (err) {
            console.error('WebSocket中间件错误:', err);
            const message = err.name === 'TokenExpiredError'
                ? 'token 已过期'
                : 'Token 不合法';

            ctx.websocket.send(JSON.stringify({
                msg: "fail",
                data: message
            }));
            ctx.websocket.close();
        }
        
      });
    // 链接websocket
    app.ws.route('/ws', controller.api.chat.chatwebsocket.connect);
    //发送消息 (游客,登录用户均可,只要token是正确的就行)
    //router.post('/api/chat/socket/sendmessage', controller.api.chat.chatwebsocket.sendmessage);
    //获取离线消息 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/getmessage', controller.api.chat.chatwebsocket.getmessage);
    //上传文件 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/upload', controller.api.chat.chatwebsocket.upload);
    //撤回消息 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/recall', controller.api.chat.chatwebsocket.recall);
    
};
  1. 主路由引入这个路由文件 在 app/router.js 文件中添加如下代码:
/**
 * @param {Egg.Application} app - egg application
 */
module.exports = app => {
  const { router, controller } = app;

  //分组
  ...
  //即时通讯的websocket路由
  require('./router/api/chat/websocket')(app);
  //即时通讯前端路由
  ...
  //即时通讯后端路由
  ...
  
  
  // 上传单个文件流模式到本地服务器
  ...
  // 自定义上传路径上传单个文件流模式到本地服务器
  ...
};

# 4. 新建控制器链接websocket

说明:由于websocket 需要配合前端一起使用,大家暂时先讲下面大代码复制到项目,后面测试时候会讲这些代码。

新建控制器 app/controller/api/chat/chatwebsocket.js

'use strict';

const Controller = require('egg').Controller;

class ChatwebsocketController extends Controller {
    // 链接websocket
    async connect() {
        const { ctx, app, service } = this;
        console.log('链接websocket方法', ctx.websocket.chatuser_id);
        // 确保连接存在
        if (!ctx.websocket) {
            return ctx.apiFail('非法访问');
        }
        // console.log(`clients链接数: ${app.ws.clients.size}`);

        // 添加心跳机制
        let heartbeatInterval = setInterval(() => {
            try {
                if (ctx.websocket.readyState === 1) { // OPEN
                    ctx.websocket.send(JSON.stringify({ type: 'ping' }));
                }
            } catch (e) {
                clearInterval(heartbeatInterval);
            }
        }, 30000); // 每30秒发送一次心跳


        // 监听消息
        ctx.websocket.on('message', msg => {
            // 1. 添加消息类型检查
            if (typeof msg !== 'string') {
                if (Buffer.isBuffer(msg)) {
                    msg = msg.toString('utf-8'); // 转换二进制数据
                } else {
                    console.log('收到非文本消息,已忽略', msg);
                    return; // 忽略非文本消息
                }
            }
        
            console.log('监听消息', msg);
            
            try {
                const data = JSON.parse(msg);
        
                // 2. 添加对客户端心跳响应的处理
                if (data.type === 'pong') {
                    console.log('收到客户端心跳响应');
                    return;
                }
        
                // 3. 添加对客户端心跳请求的响应
                if (data.type === 'ping') {
                    ctx.websocket.send(JSON.stringify({ type: 'pong' }));
                    console.log('响应客户端心跳请求');
                    return;
                }
        
                // 处理其他消息类型...
            } catch (e) {
                // 4. 增强错误处理
                if (msg.includes('undefined')) {
                    console.warn('收到无效消息,可能来自连接断开事件');
                } else {
                    console.error('消息解析错误', e, '原始消息:', msg);
                }
            }
        });

        // 监听关闭
        ctx.websocket.on('close', (code, reason) => {
            console.log('用户下线', code, reason);
            clearInterval(heartbeatInterval); // 清除心跳

            const user_id = ctx.websocket.chatuser_id;
            //移除redis中的用户上线记录
            if (!user_id) return;

            // 安全移除用户记录
            if (ctx.app.ws.chatuser && ctx.app.ws.chatuser[user_id]) {
                delete ctx.app.ws.chatuser[user_id];
            }

            // 异步移除redis记录
            service.cache.remove('online_' + user_id).catch(console.error);
        });

        // 发送欢迎消息
        ctx.websocket.send(JSON.stringify({
            type: 'system',
            data: '欢迎您,登录可获取更多功能!',
            timestamp: Date.now()
        }));
    }
}

module.exports = ChatwebsocketController;

# 5. 讲新内容前的说明

  1. 我们在上节课补充了关于未登录注册的用户,我们给他注册一个游客身份(具体查看:三、给未登录用户创建一个游客身份),来满足他和我们的客服进行即时通讯的需求。在数据库我们新增了几个字段,我们重点讲了 devicefingeruuid 设备标识(游客标识),关于其他几个字段都是做统计用的,为可选字段,大家如果有需要可以通过前端传过来,然后在数据库更新这几个字段,具体看控制器代码 ① 游客用户注册身份
  2. 游客用户实际上就走了一遍注册用户和登录用户的流程,实际上会在本地存储相关信息,其中就包括token,但是我们的很多接口游客是没有权限的,但是游客有token,那么他可以操作这些接口显然是不对的,因此我们需要在中间件中新增一个判断:

# ① 中间件调整

在中间件 app/middleware/chat_user_auth.js 代码

module.exports = (option, app) => {
    return async function chatUserAuth(ctx, next) {
        // 针对游客的操作
        //...
        
        // 即时通讯注册用户和游客的登录验证
        // 1. 获取header头的token
        const token = ctx.header.token || ctx.query.token || ctx.request.body.token;
        if (!token) {
            ctx.throw(400, '您没有权限访问即时通讯接口');
        }

        //2. 根据token解密,换取用户信息,失败则要么Token已过期或者不合法,抛出错误,终止程序
        let tokenUser = {};
        try {
            tokenUser = ctx.checkToken(token);
        } catch (error) {
            let fail = (error.name == 'TokenExpiredError') ? 'Token已过期!请重新获取令牌' : 'Token 令牌不合法!';
            ctx.throw(400, fail);
        }

        //3. 说明token解密正确,此时判断用户是否登录过
        // 根据当前解密的用户信息的id,去缓存拿一下该id的信息
        let t = await ctx.service.cache.get('chat_user_' + tokenUser.id);
        //console.log('打印t',tokenUser.id);
        if (!t || t != token) {
            ctx.throw(400, 'Token 令牌不合法!');
        }

        // 4. 说明当前用户之前登录过了,缓存里有他的数据,比如说已经登录过3天了
        // 但有一种情况,这三天内,他发了违规信息,已经被超级管理员禁用了或者被超级管理员把他从数据库删除了
        // 那么即使现在他传的token有效,也没有用,依旧不能让他操作
        let user = await app.model.User.findByPk(tokenUser.id);
        if (!user || user.status == 0) {
            ctx.throw(400, '当前用户不存在或者已被禁用');
        }

        // 新增:针对游客的操作
        //console.log('数据库的用户信息',JSON.parse(JSON.stringify(user)));
        //console.log('token的用户信息',JSON.parse(JSON.stringify(tokenUser)));
        if(tokenUser.role == 'visitor'){
            // 给游客开放一些接口
            // 根据接口设置:<https://docs.51yrc.com/fourthless/w-a/eggjs.即时通讯接口.html>
            // 获取接口地址做判断
            // ctx.throw(400, '您没有权限访问,请先注册或登录');  
            // 获取接口地址做判断
            const { path, method } = ctx;
            
            // 定义游客允许访问的接口白名单
            const visitorWhitelist = [
                { path: '/api/chat/socket/sendmessage', method: 'POST' },    // 给服务器发消息(单聊)
                { path: '/api/chat/chatGetmessageOffLine', method: 'POST' },    // 获取离线消息
                { path: '/api/chat/grouplist', method: 'GET' },    // 我的群聊列表
                { path: '/api/chat/groupinfo', method: 'GET' },    // 获取群资料信息
                { path: '/api/chat/groupnickname', method: 'POST' },    // 修改我在群里面的昵称
                { path: '/api/chat/groupDeleteOrQuit', method: 'POST' },    // 退出群
            ];
            
            // 检查当前请求是否在白名单中
            const isAllowed = visitorWhitelist.some(item => 
                path.startsWith(item.path) && method === item.method
            );
            
            if (!isAllowed) {
                ctx.throw(400, '您没有权限访问,请先注册或登录');  
            }
        }

        // 5. 没什么问题了,把用户信息挂载到ctx上,方便调用
        ctx.chat_user = user;

        await next();
    }
}

# ② 查看用户资料

说明:

  1. 在进行即时通讯之前,有用户申请加你为好友,你可以查看用户资料(查看用户资料属于公共接口,游客和登录用户都可以访问)。
  2. 关于搜索用户,我们搜索的结果应该去掉游客自己,注意调整搜索用户的条件 一、 搜索用户(好友)

# 1. 接口说明

具体查看接口说明: 十六、查看用户资料

# 2. 代码和路由

  1. 在控制器 app/controller/api/chat/chatuser.js
    // 查看用户信息(公共接口,游客和登录用户都可以访问)
    async userinfo(){
        const { ctx, app } = this;
        //1.参数验证
        this.ctx.validate({
            uuid: {
                type: 'string',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: 'uuid值', //字段含义
                range: {
                    min: 36,
                    max: 36
                }
            },
        });
        const { uuid } = ctx.params;
        let user = await app.model.User.findOne({
            where: {
                uuid,
                status: 1,
            },
            attributes:["id","uuid","username","nickname","avatar","role","userset"],
            include:[
                {
                    model:app.model.UserInfo,
                    as:'userinfo',
                    attributes:{
                        exclude: ['user_id','order','create_time','update_time'],
                    },
                }
            ],
        });
        user = JSON.parse(JSON.stringify(user));
        /*
        // 看一下是不是我的好友:--- 需要这个字段那么这个方法要走中间件
        // 因为走了中间件 ctx.chat_user 才有值
        let me_id = ctx.chat_user ? ctx.chat_user.id : 0;
        // console.log('我的id', ctx.chat_user.id);
        // console.log('user的id', user.id);
        let goodfriend = await app.model.Goodfriend.findOne({
            where:{
                user_id:me_id,
                friend_id:user.id,
                isblack:0, //不是黑名单
            }
        });
        // 新增字段:是否是好友
        user.myfriend = goodfriend ? true : false;
        */
        //delete user.id;
        /*
        // 模拟用户对聊天的一些设置(会取自数据库userset字段)
        user.chatset = {
            // 对游客(未登录用户)聊天的设置
            visitor: {
                // 是否允许游客聊天
                // 0 禁止(需先登录) 1 可以发一条消息 2 可以聊天
                sendCount: 0,
                // 是否需要关注
                needFollow: true,
            },
            // 对user登录用户
            // 0 禁止(需先加为好友) 1 可以发一条消息 2 可以聊天
            user:{
                sendCount: 2,
                needFollow: true,
            },
        };
        */

        return ctx.apiSuccess(user);
    }
  1. 路由 app/router/api/chat/router.js
module.exports = app => {
    const { router, controller } = app;
    ...
    //搜索用户(登录用户才能搜索用户,未登录用户(游客)不能搜索用户)
    ...
    // 查看用户信息(公共接口,游客和登录用户都可以访问)
    router.get('/api/userinfo/:uuid', controller.api.chat.chatuser.userinfo);

    //申请添加好友 (登录用户才能申请添加好友,(游客)不能申请添加好友)
    ...
};   

# ③ 新增接口:查看用户是否申请加我为好友

在处理添加好友的过程中,我们需要新增加一个接口:查看用户是否申请加我为好友(即我有没有权限处理这个申请)(登录用户有这个权限,游客无权限)

# 1. 接口说明

具体查看接口说明: 十七、查看用户是否申请加我为好友(即我有没有权限处理这个申请)

# 2. 代码和路由

  1. 在控制器 app/controller/api/chat/chatuser.js
    // 查看用户是否申请加我为好友(登录用户有这个权限,游客无权限)
    async isApplyfriend(){
        const { ctx, app } = this;
        //1.参数验证
        this.ctx.validate({
            uuid: {
                type: 'string',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: 'uuid值', //字段含义
                range: {
                    min: 36,
                    max: 36
                }
            },
        });
        const { uuid } = ctx.params;
        // 查用户
        let user = await app.model.User.findOne({
            where: {
                uuid,
                status: 1,
            },
            attributes:["id"],
        });
        if (!user) {
            return this.ctx.apiFail('用户不存在');
        }
        // 查申请表, 为了判断我是否有权限处理这个申请
        let goodfriendapply = await app.model.Goodfriendapply.findOne({
            where: {
                friend_id: ctx.chat_user.id, // 加的我,朋友id是我
                user_id: user.id, // 谁:用户
                status: 'pending',
            },
            attributes:{
                exclude:['order','create_time','update_time'],
            },
        });
        if (!goodfriendapply) {
            return this.ctx.apiFail('用户申请信息不存在');
        }
        return this.ctx.apiSuccess(goodfriendapply);
    }
  1. 路由 app/router/api/chat/router.js
module.exports = app => {
    const { router, controller } = app;
    ...
    //搜索用户(登录用户才能搜索用户,未登录用户(游客)不能搜索用户)
    ...
    // 查看用户信息(公共接口,游客和登录用户都可以访问)
    router.get('/api/userinfo/:uuid', controller.api.chat.chatuser.userinfo);
    // 查看用户是否申请加我为好友(登录用户有这个权限,游客无权限)
    router.post('/api/chat/isApplyfriend/:uuid', controller.api.chat.chatuser.isApplyfriend);

    //申请添加好友 (登录用户才能申请添加好友,(游客)不能申请添加好友)
    ...
};   

# ④ 获取好友列表转字母的判断

在前端开发中,发现如果用户没有好友会报错,原因是没有好友,无法转换好友昵称首字母,需要加个判断,并新增uuid的字段。
app/controller/api/chat/goodfriend.js控制器

    //好友列表(登录用户才行,(游客)不能)
    async goodfriendlist() {
        const { ctx,app } = this;
        //1.参数验证
        ctx.validate({
            page: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '页码', //字段含义
                range:{
                    min:1,
                }
            },
            limit: {
                type: 'int',  //参数类型
                required: false, //是否必须
                defValue: 2000, 
                desc: '每页多少条', //字段含义
            },
        });
        // 当前用户: 我
        const me = ctx.chat_user;
        const me_id = me.id;
        // 拿页码
        let page = ctx.params.page ? parseInt(ctx.params.page) : 1;
        // 每页多少条
        let limit = ctx.query.limit ? parseInt(ctx.query.limit) : 2000;
        // 偏移量
        let offset = (page - 1) * limit; 
        // 拿数据
        let data = await app.model.Goodfriend.findAll({
            where:{
                user_id:me_id,// 当事人
            },
            offset,
            limit,
            include:[
                {
                    model:app.model.User,// 关联用户表
                    as:'friendinfo', //别名
                    attributes:['id','username','avatar','nickname','uuid'],
                }
            ]
        });

        // ctx.apiSuccess(data);return;
        
        // 返回一些指定数据
        let rows = data.map(v=>{
            return {
               id:v.id,
               beizhu:v.nickname,
               username:v.friendinfo.username,
               avatar:v.friendinfo.avatar,
               friend_nickname:v.friendinfo.nickname,
               friend_id:v.friendinfo.id,
               name:v.nickname || v.friendinfo.nickname || v.friendinfo.username,
               uuid:v.friendinfo.uuid, //新增uuid字段
            }
        });

        // ctx.apiSuccess(rows);return;

        //昵称备注将首个中文字转成英文字母,然后进行分组排序
        let newArr = [];
        if(rows.length){
            newArr = new SortWord(rows, 'name');
        }
        
        ctx.apiSuccess({
            count:rows.length,
            rows:newArr,
        });
    }

# ⑤ 新增接口:用户设置更新

给数据库user表新增了一个字段:userset, 存储用户的设置信息,说明:登录用户有这个权限,游客无权限

# 1. 接口说明

具体查看接口说明: 十八、用户设置更新

# 2. 代码和路由

  1. 在控制器 app/controller/api/chat/chatuser.js
    // 用户设置更新(登录用户有这个权限,游客无权限)
    async userset(){
        const { ctx, app } = this;
        //1.参数验证
        this.ctx.validate({
            userset: {
                type: 'string',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '用户设置', //字段含义
                range: {
                    min: 2,
                }
            },
        });
        const { userset } = ctx.request.body;
        // 我
        let me = ctx.chat_user;
        let me_id = me.id;
        // 更新
        let user = await app.model.User.findOne({
            where: {
                id: me_id,
                status: 1,
            },
        });
        await user.update({
            userset
        });
        //返回
        return this.ctx.apiSuccess('ok');
    }
  1. 路由 app/router/api/chat/router.js
module.exports = app => {
    const { router, controller } = app;
    ...
    //搜索用户(登录用户才能搜索用户,未登录用户(游客)不能搜索用户)
    ...
    // 查看用户信息(公共接口,游客和登录用户都可以访问)
    router.get('/api/userinfo/:uuid', controller.api.chat.chatuser.userinfo);
    // 查看用户是否申请加我为好友(登录用户有这个权限,游客无权限)
    router.post('/api/chat/isApplyfriend/:uuid', controller.api.chat.chatuser.isApplyfriend);
    // 用户设置更新(登录用户有这个权限,游客无权限)
    router.post('/api/chat/userset', controller.api.chat.chatuser.userset);

    //申请添加好友 (登录用户才能申请添加好友,(游客)不能申请添加好友)
    ...
};   

# ⑥ 新增接口:查询一下对方是否是我的好友

# 1. 接口说明

具体查看接口说明: 十九、查询一下对方是否是我的好友

# 2. 代码和路由

  1. 在控制器 app/controller/api/chat/goodfriend.js
    // 查看对方是否是我的好友(登录用户才可以查看好友资料信息,(游客)没有这个功能)
    async ismygoodfriend(){
        const { ctx,app } = this;
        //1.参数验证
        ctx.validate({
            id: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '朋友id', //字段含义
                range:{
                    min:1,
                }
            },
        });
        // 拿参数
        const id = parseInt(ctx.params.id);
        // 当前用户: 我
        const me = ctx.chat_user;
        const me_id = me.id;
        // 获取好友信息
        let data = await app.model.Goodfriend.findOne({
            where:{
                friend_id:id, // 好友id
                user_id:me_id,// 我
                isblack:0, // 没有拉黑
            },
            attributes:{
                exclude:['order','create_time','update_time'],
            },
        });
        if(!data){
            return ctx.apiFail('不是好友');
        }
        // return ctx.apiSuccess('goodfriend');
        return ctx.apiSuccess(data);
    }
  1. 路由 app/router/api/chat/router.js
module.exports = app => {
    const { router, controller } = app;
    ...
    // 设置我和朋友是否可以互相查看对方发布的信息或者朋友圈(登录用户有这个功能,(游客)没有这个功能),传好友id
    router.post('/api/chat/setmeOrfriendCanSee/:id', controller.api.chat.goodfriend.setmeOrfriendCanSee);
    // 查看对方是否是我的好友(登录用户才可以查看好友资料信息,(游客)没有这个功能),传好友id
    router.post('/api/chat/ismygoodfriend/:id', controller.api.chat.goodfriend.ismygoodfriend);
};   

# 二、连接websocket并测试websocket

考虑到后期我们的项目扩展,接下来大家跟着老师一次操作。

# 1. 将路由和中间件分开写

# 1. 新建中间件 app/middleware/chatwebsocket.js

module.exports = () => {
    return async (ctx, next) => {
        if (ctx.path !== '/ws') return next();

        console.log('WebSocket连接请求:', ctx.path, ctx.query);

        const token = ctx.query.token;
        if (!token) {
            ctx.websocket.send(JSON.stringify({
                msg: "fail",
                data: '缺少 token 参数'
            }));
            return ctx.websocket.close();
        }

        try {
            const user = ctx.checkToken(token);
            const userCheck = await ctx.app.model.User.findByPk(user.id);

            if (!userCheck) {
                ctx.websocket.send(JSON.stringify({
                    msg: "fail",
                    data: '用户不存在'
                }));
                return ctx.websocket.close();
            }

            if (!userCheck.status) {
                ctx.websocket.send(JSON.stringify({
                    msg: "fail",
                    data: '你已被管理员禁用'
                }));
                return ctx.websocket.close();
            }

            // 单设备登录逻辑
            if (ctx.app.ws.chatuser && ctx.app.ws.chatuser[user.id]) {
                ctx.app.ws.chatuser[user.id].send(JSON.stringify({
                    msg: "force_logout",
                    data: '你的账号在其他设备登录'
                }));
                ctx.app.ws.chatuser[user.id].close();
            }

            // 存储连接
            ctx.app.ws.chatuser = ctx.app.ws.chatuser || {};
            ctx.app.ws.chatuser[user.id] = ctx.websocket;
            ctx.websocket.chatuser_id = user.id;

            // ctx.online(user.id);
            console.log(`用户 ${user.id} 连接成功`);

            // 进入控制器
            await next();

        } catch (err) {
            console.error('WebSocket中间件错误:', err);
            const message = err.name === 'TokenExpiredError'
                ? 'token 已过期'
                : 'Token 不合法';

            ctx.websocket.send(JSON.stringify({
                msg: "fail",
                data: message
            }));
            ctx.websocket.close();
        }
    };
};

# 2. 路由 app/router/api/chat/websocket.js

module.exports = app => {
    const { router, controller } = app;
    //配置websocket路由
    //配置websocket全局中间件
    const WebSocketMiddleware = require('../../../middleware/chatwebsocket');
    // app.ws.use(WebSocketMiddleware());
    // 链接websocket
    // app.ws.route('/ws', controller.api.chat.chatwebsocket.connect);
    // 只应用中间件到特定路由
    app.ws.route('/ws', WebSocketMiddleware(), controller.api.chat.chatwebsocket.connect);


    //发送消息 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/sendmessage', controller.api.chat.chatwebsocket.sendmessage);
    //获取离线消息 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/getmessage', controller.api.chat.chatwebsocket.getmessage);
    //上传文件 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/upload', controller.api.chat.chatwebsocket.upload);
    //撤回消息 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/recall', controller.api.chat.chatwebsocket.recall);
    
};

# 2. [重要]在配置文件中对websocket通讯路径进行设置

config/config.default.js 中添加如下配置

// 对中间件adminAuth进一步配置
  config.adminAuth = {
    ignore: [
      ...,
      "/ws",
    ],
  };
  // 对中间件adminMenu进一步配置
  config.adminMenu = {
    ignore: [
       ...,
      "/ws",
    ],
  };

# 3. 控制器代码

在文件 app/controller/api/chat/chatwebsocket.js

'use strict';

const Controller = require('egg').Controller;

class ChatwebsocketController extends Controller {
    // 链接websocket
    async connect() {
        const { ctx, app, service } = this;
        console.log('链接websocket方法', ctx.websocket.chatuser_id);
        // 确保连接存在
        if (!ctx.websocket) {
            return ctx.apiFail('非法访问');
        }
        // console.log(`clients链接数: ${app.ws.clients.size}`);

        // 添加心跳机制
        let heartbeatInterval = setInterval(() => {
            try {
                if (ctx.websocket.readyState === 1) { // OPEN
                    ctx.websocket.send(JSON.stringify({ type: 'ping' }));
                }
            } catch (e) {
                clearInterval(heartbeatInterval);
            }
        }, 30000); // 每30秒发送一次心跳


        // 监听消息
        ctx.websocket.on('message', msg => {
            // 1. 添加消息类型检查
            if (typeof msg !== 'string') {
                if (Buffer.isBuffer(msg)) {
                    msg = msg.toString('utf-8'); // 转换二进制数据
                } else {
                    console.log('收到非文本消息,已忽略', msg);
                    return; // 忽略非文本消息
                }
            }
        
            console.log('监听消息', msg);
            
            try {
                const data = JSON.parse(msg);
        
                // 2. 添加对客户端心跳响应的处理
                if (data.type === 'pong') {
                    console.log('收到客户端心跳响应');
                    return;
                }
        
                // 3. 添加对客户端心跳请求的响应
                if (data.type === 'ping') {
                    ctx.websocket.send(JSON.stringify({ type: 'pong' }));
                    console.log('响应客户端心跳请求');
                    return;
                }
        
                // 处理其他消息类型...
            } catch (e) {
                // 4. 增强错误处理
                if (msg.includes('undefined')) {
                    console.warn('收到无效消息,可能来自连接断开事件');
                } else {
                    console.error('消息解析错误', e, '原始消息:', msg);
                }
            }
        });

        // 监听关闭
        ctx.websocket.on('close', (code, reason) => {
            console.log('用户下线', code, reason);
            clearInterval(heartbeatInterval); // 清除心跳

            const user_id = ctx.websocket.chatuser_id;
            //移除redis中的用户上线记录
            if (!user_id) return;

            // 安全移除用户记录
            if (ctx.app.ws.chatuser && ctx.app.ws.chatuser[user_id]) {
                delete ctx.app.ws.chatuser[user_id];
            }

            // 异步移除redis记录
            service.cache.remove('online_' + user_id).catch(console.error);
        });

        // 发送欢迎消息
        ctx.websocket.send(JSON.stringify({
            type: 'system',
            data: '欢迎您,登录可获取更多功能!',
            timestamp: Date.now()
        }));
    }
}

module.exports = ChatwebsocketController;

# 三、发送消息(单聊)

# 1. 服务端发送消息方法

在控制器 app/controller/api/chat/chatwebsocket.js

'use strict';

const Controller = require('egg').Controller;

// 引入 uuid 库 `npm install uuid`
const { v4: uuidv4 } = require('uuid');

class ChatwebsocketController extends Controller {
    // 链接websocket
    async connect(){
        ...
    }

    //发送消息
    async sendmessage() {
        const { ctx, app, service } = this;
        //参数验证
        ctx.validate({
            sendto_id: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '接收人/群的id值', //字段含义
                range: {
                    min: 1,
                }
            },
            chatType: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '接收类型', // 单聊 single 群聊 group
                range: {
                    in: ['single', 'group'],
                }
            },
            type: {
                type: 'string',
                required: true,
                // defValue: '', 
                // 'text'|'iconMenus'|'image'|'audio'|'video' 等等
                desc: '消息类型',
            },
            data: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '消息内容',
            },
            options: {
                type: 'string',
                required: false, //选填
                defValue: '', 
                desc: '额外参数json字符串',
            },
        });
        // 获取参数
        const { sendto_id, chatType, type, data, options } = ctx.request.body;
        // 我的信息
        const me = ctx.chat_user;
        const me_id = me.id;
        // 单聊还是群聊chatType
        if (chatType == 'single') {
            // 单聊
            // 1. 看聊天的人是否存在(可以是游客可以是登录用户可以是好友)
            let chater = await app.model.User.findOne({
                where: {
                    id: sendto_id,
                    status: 1
                }
            });
            if (!chater) {
                return ctx.apiFail('对方不存在或者被禁用,不能发消息');
            }
            // 2. 看一下对方的设置是否容许聊天
            chater = JSON.parse(JSON.stringify(chater));
            console.log('聊天对象数据库信息', chater);
            // 信息设置大的对象
            let allset = {};
            // 用户设置信息
            let userset = chater.userset;
            // 对方没有任何设置
            if (!userset) {
                // 包括没有聊天设置,则对于聊天,给它默认聊天设置
                allset.chatset = {
                    visitor: {
                        sendCount: 1, //可以发一条
                        needFollow: false  // 无需关注
                    },
                    user: {
                        sendCount: 1, //可以发一条
                        needFollow: false // 无需关注
                    }
                };
                // 其它设置信息的初始默认值
                // ...
            } else {
                // 有设置信息 存储的是json字符串转对象
                allset = JSON.parse(userset);
                // 看一下有没有聊天设置信息
                if (!allset.chatset) {
                    // 没有聊天设置信息 则给它默认的聊天设置
                    allset.chatset = {
                        visitor: {
                            sendCount: 1, //可以发一条
                            needFollow: false  // 无需关注
                        },
                        user: {
                            sendCount: 1, //可以发一条
                            needFollow: false // 无需关注
                        }
                    };
                } else {
                    // 有聊天设置信息 用用户自己的设置
                    allset.chatset = allset.chatset;
                }
            }
            console.log('用户设置的信息包括默认值', allset);
            // 针对对方聊天设置做相应的判断
            // 是要求先登录、先成为好友,才能发送
            // 这个时候要看我的身份
            const me_role = me.role;
            // 定义一下昵称(主要针对我和对方是好友关系的时候各自拿备注昵称)
            let me_friend_nickname = ''; // 我在对方的好友备注
            let you_friend_nickname = ''; // 对方在我的好友备注
            // 如果我是游客,则看对方怎么设置的
            if (me_role == 'visitor') {
                // 关于关注方面
                if (allset.chatset.visitor.needFollow) {
                    //需要游客关注
                    //然后看一下我有没有关注他,没有关注则提示先关注
                }
                // 关于发送条数方面
                let sendCount = allset.chatset.visitor.sendCount;
                if (sendCount == 0) {
                    return ctx.apiFail('对方设置成:需要您先登录才能发消息');
                }else if(sendCount == 1){
                    // 查一下已经发了几条,如果已经发了一条则不能再发送了
                }else if(sendCount == 2){
                    // 随便发,没有限制
                }
            }else if(me_role == 'user'){
                // 如果我是登录用户
                // 如果对方是我的好友,可以拿一下对方在我的好友备注
                let mefriend = await app.model.Goodfriend.findOne({
                    where: {
                        user_id: me_id,  // 我
                        friend_id: sendto_id, //对方
                    }
                });
                if(mefriend){
                    you_friend_nickname = mefriend.nickname; // 对方在我的好友备注
                }
                // 看一下我是不是对方的好友
                let friend = await app.model.Goodfriend.findOne({
                    where: {
                        user_id: sendto_id,  // 对方
                        friend_id: me_id, //我
                    }
                });
                // 如果是好友,但是如果对方把我拉黑了,则不能发消息
                if(friend && friend.isblack == 1){
                    return ctx.apiFail('对方把你拉黑了,不能发送消息');
                }
                // 如果我是对方的好友,可以拿一下我在对方的好友昵称备注
                if(friend){
                    me_friend_nickname = friend.nickname; // 我在对方的好友备注
                }else{
                    // 不是对方好友,则按照对方聊天设置处理
                    // 关于关注方面
                    if (allset.chatset.user.needFollow) {
                        //需要用户关注
                        //然后看一下我有没有关注他,没有关注则提示先关注
                    }
                    // 关于发送条数方面
                    let sendCount = allset.chatset.user.sendCount;
                    if (sendCount == 0) {
                        return ctx.apiFail('对方设置成:需要您先成为他的好友才能发消息');
                    }else if(sendCount == 1){
                        // 查一下已经发了几条,如果已经发了一条则不能再发送了
                    }else if(sendCount == 2){
                        // 随便发,没有限制
                    }
                }
            }

            // 3. 过了聊天设置这一关, 则发送消息,构建消息格式
            let optionsObj = null;
            // 额外参数json字符串options
            try{
                optionsObj = JSON.parse(decodeURIComponent(options));
            } catch {
                optionsObj = null;
            }
            let message = { 
                id: uuidv4(), // 自动生成 UUID,唯一id, 聊天记录id,方便撤回消息
                from_avatar: me.avatar, // 发送者头像
                from_name: me_friend_nickname || me.nickname || me.username, // 发送者名称
                from_id: me.id, // 发送者id
                to_id: sendto_id, // 接收者id
                to_name: you_friend_nickname || chater.nickname || chater.username, // 接收者名称
                to_avatar: chater.avatar, // 接收者头像
                chatType: chatType, // 聊天类型 单聊
                type: type, // 消息类型
                data: data, // 消息内容
                options: optionsObj, // 其它参数
                create_time: (new Date()).getTime(), // 创建时间
                isremove: 0, // 0未撤回 1已撤回
            };

            // 4. 拿到对方的socket
            let you_socket = ctx.app.ws.chatuser[sendto_id];
            // 如果拿不到对方的socket, 则把消息放在redis队列中, 等待对方上线时,再发送
            if(!you_socket){
                // 放到reids,设置消息列表中:key值是:'chat_getmessage_' + sendto_id(用户id)
                ctx.service.cache.setList('chat_getmessage_' + sendto_id, message);
            }else{
                // 如果对方在线,则直接推送给对方
                you_socket.send(JSON.stringify({
                    type: 'singleChat',
                    data: message,
                    timestamp: Date.now(),
                }));
                // 存储到对方redis历史记录中
                // key: `chatlog_对方id_[single|group]_我的id`
                ctx.service.cache.setList(`chatlog_${sendto_id}_${message.chatType}_${me.id}`, 
                message);
            }
            // 存储到我的redis历史记录中
            // key: `chatlog_我的id_[single|group]_对方id`
            ctx.service.cache.setList(`chatlog_${me.id}_${message.chatType}_${sendto_id}`, 
            message);

            // 返回
            return ctx.apiSuccess(message);

        } else if (chatType == 'group') {
            // 群聊
        }

    }
}

module.exports = ChatwebsocketController;

# 2. 路由

在路由 app/router/api/chat/websocket.js

module.exports = app => {
    const { router, controller } = app;
    //配置websocket路由
    //配置websocket全局中间件
    const WebSocketMiddleware = require('../../../middleware/chatwebsocket');
    // app.ws.use(WebSocketMiddleware());
    // 链接websocket
    // app.ws.route('/ws', controller.api.chat.chatwebsocket.connect);
    // 只应用中间件到特定路由
    app.ws.route('/ws', WebSocketMiddleware(), controller.api.chat.chatwebsocket.connect);
    
    //发送消息 (游客,登录用户均可,只要token是正确的就行)
    router.post('/api/chat/socket/sendmessage', controller.api.chat.chatwebsocket.sendmessage);
    //获取离线消息 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/getmessage', controller.api.chat.chatwebsocket.getmessage);
    //上传文件 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/upload', controller.api.chat.chatwebsocket.upload);
    //撤回消息 (游客,登录用户均可,只要token是正确的就行)
    // router.post('/api/chat/socket/recall', controller.api.chat.chatwebsocket.recall);
    
};

# 3. 接口说明

具体查看给服务器发消息(单聊)(发送消息给对方)的接口说明:二十、给服务器发消息(单聊)(发送消息给对方)

# 四、创建群聊(成功后通过webSocket通知群聊用户)

# 1. 群聊相关表信息

具体查看文档: 群聊相关表信息

# 2. 关于websocket发送消息并保存到redis中很多地方用到,将它封装到扩展中

实际是对控制器 app/controller/api/chat/chatwebsocket.js中方法 发送消息 async sendmessage() 部分代码做了封装
在扩展 app/extend/context.js

    ...
    // 针对即时通讯发送消息,用户不在线则将消息存储在消息队列,等用户上线再发
    chatWebsocketSendOrSaveMessage(sendto_id, message){
      // 注意此处的this指的是ctx
      const { app, service } = this;
      // 我的信息
      const me = this.chat_user;
      const me_id = me.id;
      // 拿到对方的socket
      let you_socket = this.app.ws.chatuser[sendto_id];
      // 如果拿不到对方的socket, 则把消息放在redis队列中, 等待对方上线时,再发送
      if(!you_socket){
          // 放到reids,设置消息列表中:key值是:'chat_getmessage_' + sendto_id(用户id)
          this.service.cache.setList('chat_getmessage_' + sendto_id, message);
      }else{
          // 如果对方在线,则直接推送给对方
          you_socket.send(JSON.stringify({
              type: 'singleChat',
              data: message,
              timestamp: Date.now(),
          }));
          // 存储到对方redis历史记录中
          // key: `chatlog_对方id_[single|group]_我的id`
          this.service.cache.setList(`chatlog_${sendto_id}_${message.chatType}_${me.id}`, message);
      }
    },

# 3. 简化控制器发送消息代码(新增群聊发消息)

在控制器app/controller/api/chat/chatwebsocket.js

'use strict';

const Controller = require('egg').Controller;

// 引入 uuid 库 `npm install uuid`
const { v4: uuidv4 } = require('uuid');

class ChatwebsocketController extends Controller {
    // 链接websocket
    async connect() {
        const { ctx, app, service } = this;
        console.log('链接websocket用户的id', ctx.websocket.chatuser_id);
        // 确保连接存在
        if (!ctx.websocket) {
            return ctx.apiFail('非法访问');
        }
        console.log(`clients链接数: ${app.ws.clients.size}`);

        // 添加心跳机制
        let heartbeatInterval = setInterval(() => {
            try {
                if (ctx.websocket.readyState === 1) { // OPEN
                    ctx.websocket.send(JSON.stringify({ type: 'ping' }));
                }
            } catch (e) {
                clearInterval(heartbeatInterval);
            }
        }, 30000); // 每30秒发送一次心跳


        // 监听消息
        ctx.websocket.on('message', msg => {
            // 1. 添加消息类型检查
            if (typeof msg !== 'string') {
                if (Buffer.isBuffer(msg)) {
                    msg = msg.toString('utf-8'); // 转换二进制数据
                } else {
                    console.log('收到非文本消息,已忽略', msg);
                    return; // 忽略非文本消息
                }
            }

            console.log('监听消息', msg);

            try {
                const data = JSON.parse(msg);

                // 2. 添加对客户端心跳响应的处理
                if (data.type === 'pong') {
                    console.log('收到客户端心跳响应');
                    return;
                }

                // 3. 添加对客户端心跳请求的响应
                if (data.type === 'ping') {
                    ctx.websocket.send(JSON.stringify({ type: 'pong' }));
                    console.log('响应客户端心跳请求');
                    return;
                }

                // 处理其他消息类型...
            } catch (e) {
                // 4. 增强错误处理
                if (msg.includes('undefined')) {
                    console.warn('收到无效消息,可能来自连接断开事件');
                } else {
                    console.error('消息解析错误', e, '原始消息:', msg);
                }
            }
        });

        // 监听关闭
        ctx.websocket.on('close', (code, reason) => {
            console.log('用户下线', code, reason);
            clearInterval(heartbeatInterval); // 清除心跳

            const user_id = ctx.websocket.chatuser_id;
            //移除redis中的用户上线记录
            if (!user_id) return;

            // 安全移除用户记录
            if (ctx.app.ws.chatuser && ctx.app.ws.chatuser[user_id]) {
                delete ctx.app.ws.chatuser[user_id];
            }

            // 异步移除redis记录
            service.cache.remove('online_' + user_id).catch(console.error);
        });

        // 发送欢迎消息-所有人都发
        ctx.websocket.send(JSON.stringify({
            type: 'system',
            data: '欢迎您访问我们的系统!',
            timestamp: Date.now()
        }));
        
    }

    //发送消息
    async sendmessage() {
        const { ctx, app, service } = this;
        //参数验证
        ctx.validate({
            sendto_id: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '接收人/群的id值', //字段含义
                range: {
                    min: 1,
                }
            },
            chatType: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '接收类型', // 单聊 single 群聊 group
                range: {
                    in: ['single', 'group'],
                }
            },
            type: {
                type: 'string',
                required: true,
                // defValue: '', 
                // 'text'|'iconMenus'|'image'|'audio'|'video' 等等
                desc: '消息类型',
            },
            data: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '消息内容',
            },
            options: {
                type: 'string',
                required: false, //选填
                defValue: '', 
                desc: '额外参数json字符串',
            },
        });
        // 获取参数
        const { sendto_id, chatType, type, data, options } = ctx.request.body;
        // 我的信息
        const me = ctx.chat_user;
        const me_id = me.id;
        // 单聊还是群聊chatType
        if (chatType == 'single') {
            // 单聊
            // 1. 看聊天的人是否存在(可以是游客可以是登录用户可以是好友)
            let chater = await app.model.User.findOne({
                where: {
                    id: sendto_id,
                    status: 1
                }
            });
            if (!chater) {
                return ctx.apiFail('对方不存在或者被禁用,不能发消息');
            }
            // 2. 看一下对方的设置是否容许聊天
            chater = JSON.parse(JSON.stringify(chater));
            console.log('聊天对象数据库信息', chater);
            // 信息设置大的对象
            let allset = {};
            // 用户设置信息
            let userset = chater.userset;
            // 对方没有任何设置
            if (!userset) {
                // 包括没有聊天设置,则对于聊天,给它默认聊天设置
                allset.chatset = {
                    visitor: {
                        sendCount: 1, //可以发一条
                        needFollow: false  // 无需关注
                    },
                    user: {
                        sendCount: 1, //可以发一条
                        needFollow: false // 无需关注
                    }
                };
                // 其它设置信息的初始默认值
                // ...
            } else {
                // 有设置信息 存储的是json字符串转对象
                allset = JSON.parse(userset);
                // 看一下有没有聊天设置信息
                if (!allset.chatset) {
                    // 没有聊天设置信息 则给它默认的聊天设置
                    allset.chatset = {
                        visitor: {
                            sendCount: 1, //可以发一条
                            needFollow: false  // 无需关注
                        },
                        user: {
                            sendCount: 1, //可以发一条
                            needFollow: false // 无需关注
                        }
                    };
                } else {
                    // 有聊天设置信息 用用户自己的设置
                    allset.chatset = allset.chatset;
                }
            }
            console.log('用户设置的信息包括默认值', allset);
            // 针对对方聊天设置做相应的判断
            // 是要求先登录、先成为好友,才能发送
            // 这个时候要看我的身份
            const me_role = me.role;
            // 定义一下昵称(主要针对我和对方是好友关系的时候各自拿备注昵称)
            let me_friend_nickname = ''; // 我在对方的好友备注
            let you_friend_nickname = ''; // 对方在我的好友备注
            // 如果我是游客,则看对方怎么设置的
            if (me_role == 'visitor') {
                // 关于关注方面
                if (allset.chatset.visitor.needFollow) {
                    //需要游客关注
                    //然后看一下我有没有关注他,没有关注则提示先关注
                }
                // 关于发送条数方面
                let sendCount = allset.chatset.visitor.sendCount;
                if (sendCount == 0) {
                    return ctx.apiFail('对方设置成:需要您先登录才能发消息');
                }else if(sendCount == 1){
                    // 查一下已经发了几条,如果已经发了一条则不能再发送了
                }else if(sendCount == 2){
                    // 随便发,没有限制
                }
            }else if(me_role == 'user'){
                // 如果我是登录用户
                // 如果对方是我的好友,可以拿一下对方在我的好友备注
                let mefriend = await app.model.Goodfriend.findOne({
                    where: {
                        user_id: me_id,  // 我
                        friend_id: sendto_id, //对方
                    }
                });
                if(mefriend){
                    you_friend_nickname = mefriend.nickname; // 对方在我的好友备注
                }
                // 看一下我是不是对方的好友
                let friend = await app.model.Goodfriend.findOne({
                    where: {
                        user_id: sendto_id,  // 对方
                        friend_id: me_id, //我
                    }
                });
                // 如果是好友,但是如果对方把我拉黑了,则不能发消息
                if(friend && friend.isblack == 1){
                    return ctx.apiFail('对方把你拉黑了,不能发送消息');
                }
                // 如果我是对方的好友,可以拿一下我在对方的好友昵称备注
                if(friend){
                    me_friend_nickname = friend.nickname; // 我在对方的好友备注
                }else{
                    // 不是对方好友,则按照对方聊天设置处理
                    // 关于关注方面
                    if (allset.chatset.user.needFollow) {
                        //需要用户关注
                        //然后看一下我有没有关注他,没有关注则提示先关注
                    }
                    // 关于发送条数方面
                    let sendCount = allset.chatset.user.sendCount;
                    if (sendCount == 0) {
                        return ctx.apiFail('对方设置成:需要您先成为他的好友才能发消息');
                    }else if(sendCount == 1){
                        // 查一下已经发了几条,如果已经发了一条则不能再发送了
                    }else if(sendCount == 2){
                        // 随便发,没有限制
                    }
                }
            }

            // 3. 过了聊天设置这一关, 则发送消息,构建消息格式
            let optionsObj = null;
            // 额外参数json字符串options
            try{
                optionsObj = JSON.parse(decodeURIComponent(options));
            } catch {
                optionsObj = null;
            }
            let message = { 
                id: uuidv4(), // 自动生成 UUID,唯一id, 聊天记录id,方便撤回消息
                from_avatar: me.avatar, // 发送者头像
                from_name: me_friend_nickname || me.nickname || me.username, // 发送者名称
                from_id: me.id, // 发送者id
                to_id: sendto_id, // 接收者id
                to_name: you_friend_nickname || chater.nickname || chater.username, // 接收者名称
                to_avatar: chater.avatar, // 接收者头像
                chatType: chatType, // 聊天类型 单聊
                type: type, // 消息类型
                data: data, // 消息内容
                options: optionsObj, // 其它参数
                create_time: (new Date()).getTime(), // 创建时间
                isremove: 0, // 0未撤回 1已撤回
            };

            // 注释内容已经封装到了 `/app/extend/context.js`
            /*
            // 4. 拿到对方的socket
            let you_socket = ctx.app.ws.chatuser[sendto_id];
            // 如果拿不到对方的socket, 则把消息放在redis队列中, 等待对方上线时,再发送
            if(!you_socket){
                // 放到reids,设置消息列表中:key值是:'chat_getmessage_' + sendto_id(用户id)
                ctx.service.cache.setList('chat_getmessage_' + sendto_id, message);
            }else{
                // 如果对方在线,则直接推送给对方
                you_socket.send(JSON.stringify({
                    type: 'singleChat',
                    data: message,
                    timestamp: Date.now(),
                }));
                // 存储到对方redis历史记录中
                // key: `chatlog_对方id_[single|group]_我的id`
                ctx.service.cache.setList(`chatlog_${sendto_id}_${message.chatType}_${me.id}`, message);
            }
            */
            // 直接调用 `/app/extend/context.js` 封装的方法 chatWebsocketSendOrSaveMessage(sendto_id, message)
            ctx.chatWebsocketSendOrSaveMessage(sendto_id, message);

            // 存储到我的redis历史记录中
            // key: `chatlog_我的id_[single|group]_对方id`
            ctx.service.cache.setList(`chatlog_${me.id}_${message.chatType}_${sendto_id}`, message);
            
            // 返回
            return ctx.apiSuccess(message);

        } else if (chatType == 'group') {
            // 群聊
            // 1. 先判断这个群是否存在,并且我是否在群里
            let group = await app.model.Group.findOne({
                where:{
                    id: sendto_id, //群id
                    status: 1, // 群状态 1正常 2锁定 0解散
                },
                // 关联查询群成员
                include:[
                    {
                        // 关联模型
                        model: app.model.GroupUser,
                        // 关联条件
                        where: {
                            // 选取状态正常的群成员
                            status: 1, // 状态 1正常 2锁定 0禁言
                        },
                        // 读取字段
                        attributes: ['id', 'group_id', 'user_id', 'nickname', 'avatar', 'status', 'order'],
                    }
                ],
            });
            if(!group){
                return ctx.apiFail('群不存在或者已解散或者被封禁,无法发消息');
            }
            // 2. 存在看一下群信息,我在不在群里
            // group = JSON.parse(JSON.stringify(group));
            // console.log('存在看一下群信息', group);
            // 查一下我在不在群里
            let me_index = group.group_users.findIndex(item => item.user_id == me.id);
            if(me_index == -1){
                return ctx.apiFail('您不在群里,无法发消息');
            }
            // 3. 我在群里
            // 我在群里的昵称: 优先拿我在群里设置的昵称,没有则拿我自己的昵称,在没有则拿账号名
            let me_group_nickname = group.group_users[me_index].nickname || me.nickname || me.username;
            // 我在群聊的头像:优先拿我在群里的头像,没有则拿我自己的头像
            let me_group_avatar = group.group_users[me_index].avatar || me.avatar;

            // 4. 定义消息格式
            let optionsObj = null;
            // 额外参数json字符串options
            try{
                optionsObj = JSON.parse(decodeURIComponent(options));
            } catch {
                optionsObj = null;
            }
            let message = { 
                id: uuidv4(), // 自动生成 UUID,唯一id, 聊天记录id,方便撤回消息
                from_avatar: me_group_avatar || me.avatar, // 发送者头像
                from_name: me_group_nickname || me.nickname || me.username, // 发送者名称
                from_id: me.id, // 发送者id
                to_id: group.id, // 群id
                to_name: group.name, // 群名称
                to_avatar: group.avatar, // 群头像
                chatType: chatType, // 聊天类型 群聊
                type: type, // 消息类型 
                data: data, // 消息内容
                options: optionsObj, // 其它参数
                create_time: (new Date()).getTime(), // 创建时间
                isremove: 0, // 0未撤回 1已撤回
                // 群相关信息
                group: group, 
            };

            // 循环推送给群成员 不用推送给自己
            group.group_users.forEach(v => {
                if(v.user_id != me.id){
                   // 直接调用 `/app/extend/context.js` 封装的方法 chatWebsocketSendOrSaveMessage(sendto_id, message)
                   ctx.chatWebsocketSendOrSaveMessage(v.user_id, message);
                }
            });

            // 返回
            return ctx.apiSuccess(message);

        }

    }

    //用户(我)上线后获取离线消息(登录用户和游客都有这个功能)
    async chat_getmessage_OffLine() { 
        const { ctx, app, service } = this;
        // 我的信息
        const me = ctx.chat_user;
        const me_id = me.id;
        // 获取离线消息
        let key = `chat_getmessage_${me_id}`;
        let msgList = await service.cache.getList(key); // 获取离线消息
        // 推送离线消息给用户(我)
        msgList.forEach(async (msg) => {
            // 离线消息存的是字符串,需要转成对象
            msg = JSON.parse(msg);
            // 直接调用 `/app/extend/context.js` 封装的方法 chatWebsocketSendOrSaveMessage(sendto_id, message)
            ctx.chatWebsocketSendOrSaveMessage(me_id, msg);
        });
        // 拿到之后则可以清除离线消息
        await service.cache.remove(key);

        // 返回
        return ctx.apiSuccess('ok');
    }


}

module.exports = ChatwebsocketController;

# 4. 群聊相关方法

由于群聊相关方法比较多,代码比较复杂,放在单独页面供大家查看:一、群聊相关方法汇总

# 5. 群聊相关路由及获取离线消息路由

在路由文件 app/router/api/chat/router.js 中添加

module.exports = app => {
    const { router, controller } = app;
    //用户登录
    ...

    //申请添加好友 (登录用户才能申请添加好友,(游客)不能申请添加好友)
    ...

    // 好友列表(登录用户才行,(游客)不能)
    ...
    // 查看对方是否是我的好友(登录用户才可以查看好友资料信息,(游客)没有这个功能),传好友id
    ...

    // 创建群聊(登录用户有这个功能,(游客)没有这个功能)
    router.post('/api/chat/group/create', controller.api.chat.chatgroup.creategroup);

    // 用户(我)上线后获取离线消息(登录用户和游客都有这个功能)
    router.post('/api/chat/chatGetmessageOffLine', controller.api.chat.chatwebsocket.chat_getmessage_OffLine);

    // 群聊列表(登录用户和游客都有这个功能)
    router.get('/api/chat/grouplist/:page', controller.api.chat.chatgroup.grouplist);

    // 获取群资料信息(登录用户和游客都有这个功能)
    router.get('/api/chat/groupinfo/:id', controller.api.chat.chatgroup.groupinfo);

    // 修改群名称(群主才有这个功能)
    router.post('/api/chat/groupUpdateName', controller.api.chat.chatgroup.groupUpdateName);

    // 修改群公告(群主才有这个功能)
    router.post('/api/chat/groupremark', controller.api.chat.chatgroup.groupremark);

    // 删除群成员(群主才有这个功能)
    router.post('/api/chat/groupDeleteUser', controller.api.chat.chatgroup.groupDeleteUser);

    // 进群设置  (群主才有这个功能)
    router.post('/api/chat/groupAddUserSet', controller.api.chat.chatgroup.groupAddUserSet);

    // 同意或者拒绝用户进群(群主才有这个功能)
    router.post('/api/chat/groupAgreeOrNo', controller.api.chat.chatgroup.groupAgreeOrNo);

    // 邀请人进群(群主直接邀请,群成员邀请、游客自己进群根据群设置来处理)
    router.post('/api/chat/groupInviteUser', controller.api.chat.chatgroup.groupInviteUser);

    // 修改我在群里面的昵称(登录用户和游客都有这个功能)
    router.post('/api/chat/groupnickname', controller.api.chat.chatgroup.groupnickname);

    // 删除群(群主可操作)或退出群(群成员可操作)
    router.post('/api/chat/groupDeleteOrQuit', controller.api.chat.chatgroup.groupDeleteOrQuit);

    // 生成群二维码(登录用户和游客都有这个功能)
    router.get('/api/chat/groupQrcode/:id', controller.api.chat.chatgroup.groupQrcode);

};   

# 6. 群聊相关接口说明

  1. 创建群聊接口,具体查看: 二十一、创建群聊(成功后通过webSocket通知群聊用户)

# 7. 补充:对游客用户,系统发送一个登录提醒消息

# ① 在控制器 app/controller/api/chat/chatuser.js

'use strict';
//哈希函数 
const crypto = require('node:crypto');
// 引入 uuid 库 `npm install uuid`
const { v4: uuidv4 } = require('uuid');

const Controller = require('egg').Controller;

class ChatuserController extends Controller {
    ...

    // 比对信息,自动登录
    async compareData(option) {
        const { ctx, app } = this;
        // 判断是否存在
        let user = await app.model.User.findOne({
            where: {
                username: option.username,
                status: 1,
            },
        });
        if (!user) {
            // ctx.throw(400, '账号不存在或者被禁用');
            return ctx.apiFail('账号不存在或者被禁用');
        }
        // 如果比对传递了role, 且role = 'visitor' 则表示是游客登录,不需要验证密码
        if(!(option.role && option.role == 'visitor')){
            // 验证密码
            await this.checkPassword(option.password, user.password);
        }
        
        //存储在session中,定义session中的一个属性authuser存储用户登录信息
        // ctx.session.authuser = user; //存储到session

        // 根据业务需要,这里可以更新一些信息,比如:登录时间、登录ip等
        await user.update({
            last_login: Date.now(),//登录时间
        });
        // 把user转换成json对象,进行后续一些处理和返回给客户端
        user = JSON.parse(JSON.stringify(user));
        // 注意注册用户退出登录后,应该以游客模式返回前端访问
        if(option.role && option.role == 'visitor'){
            user.role = 'visitor'; // 给前端返回游客模式
        }
        //console.log('用户比对信息', user);
        // 生成唯一token
        let token = ctx.getToken(user);
        user.token = token;
        // console.log('即时通讯登录用户信息', user);
        // 加入到缓存中(保证用户提交验证的token来自我们服务器生成的)
        // 即加入redis中, 注意缓存时间的设置 60 * 60 * 24 * 365 * 100 为100年
        // 第三个参数为0,则是用不过期
        let user_token = await this.service.cache.set('chat_user_' + user.id,
            token, 60 * 60 * 24 * 365 * 100);
        if (!user_token) {
            ctx.throw(400, '登录失败');
        }


        // 如果比对传递了role, 且role = 'visitor' 则表示是游客
        if (option.role && option.role == 'visitor') {
            // 新增给当前游客用户,由系统发一个登录提醒
            const startTime = Date.now();
            const maxWait = 20000; // 20秒超时
            const checkInterval = 500; // 每500ms检查一次
            
            const checkWebSocket = () => {
              // 1. 检查WebSocket是否存在
              if (this.ctx.app.ws.chatuser && this.ctx.app.ws.chatuser[user.id]) {
                // WebSocket已就绪,立即执行
                this.systemSendLoginRemind(user);
              } 
              // 2. 检查是否超时
              else if (Date.now() - startTime < maxWait) {
                // 未超时则继续轮询
                setTimeout(checkWebSocket, checkInterval);
              }
              // 超时后自动结束(不执行任何操作)
            };
          
            // 首次检查(立即开始)
            setTimeout(checkWebSocket, 0);
        }


        // 返回
        delete user.password;
        return ctx.apiSuccess(user);
    }

    ...

    // 系统给游客发送登录提醒消息
    async systemSendLoginRemind(user) {
        const { ctx, app, service } = this;
        // 消息格式
        // console.log('要发登录提示消息的用户user', user); return;
        let message = { 
            id: uuidv4(), // 自动生成 UUID,唯一id, 聊天记录id,方便撤回消息
            from_avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/kefu.png', // 发送者头像
            from_name: '系统消息', // 发送者名称
            from_id: 0, // 发送者id 系统id
            to_id: user.id, // 接收者id  
            to_name: user.nickname || user.username, // 接收者名称
            to_avatar: user.avatar, // 接收者头像
            chatType: 'single', // 聊天类型 单聊
            type: 'text', // 消息类型 系统通知消息
            data: {
                data: "欢迎您,登录可以获取更多功能!",
                dataType: false, 
                otherData: null,
            }, // 消息内容
            options: {}, // 其它参数
            create_time: (new Date()).getTime(), // 创建时间
            isremove: 0, // 0未撤回 1已撤回
            // 群相关信息
            // group: {
            //     user_id: 'fromSystemId',
            //     remark: '',
            //     invite_confirm: 0,
            // }, 
        };
        // 推送给游客
        // 拿到游客的socket
        let you_socket = ctx.app.ws.chatuser[user.id];
        // 如果对方在线,则直接推送给对方
        you_socket.send(JSON.stringify({
            type: 'singleChat',
            data: message,
            timestamp: Date.now(),
        }));
        
    }
    
}

module.exports = ChatuserController;

# 五、二维码(后端生成二维码)(群二维码和个人二维码生成)

# 1. 安装二维码生成插件

生成二维码的库和插件有很多

  1. 前端生成二维码的插件很多,如:qrcode.js,使用说明:https://www.runoob.com/w3cnote/javascript-qrcodejs-library.html (opens new window)
  2. 后端生成二维码的插件也很多,如:https://www.npmjs.com/package/qr-image (opens new window)
    大家可以自行安装别的自己喜欢的插件和库。

安装命令

npm i qr-image  --save

# 2. 在扩展写一个生成二维码的方法

app/extend/context.js 中添加如下方法,用法:https://www.npmjs.com/package/qr-image (opens new window)

...
// 引入二维码插件
var qr = require('qr-image');

module.exports = {
    ...,
    // 生成二维码
    createQrcode(url){
      var img = qr.image(url, { size: 10 });
      // 类型:image/png | svg
      this.response.type = 'image/png';
      // img.pipe(this.response); 
      this.body = img;
    },
};

# 3. 生成群二维码

在控制器 app/controller/api/chat/chatgroup.js

    // 生成群二维码(登录用户和游客都有这个功能)
    async groupQrcode(){
        const { ctx,app } = this;
        //1.参数验证
        ctx.validate({
            id: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '群id', //字段含义
                range:{
                    min:1,
                }
            },
            type:{
                type: 'string',
                required: false,
                defValue: '',
                desc: '平台类型', // 针对h5端使用
            },
            http:{
                type: 'string',
                required: false,
                defValue: '',
                desc: '网址域名', // 针对h5端使用
            },
            chatType:{
                type: 'string',
                required: false,
                defValue: '',
                desc: '聊天类型', // 针对h5端使用
            }
        });
        // 当前用户: 我
        const me = ctx.chat_user;
        const me_id = me.id;
        // 拿id
        let { id } = ctx.params;
        let type = ctx.query.type ? ctx.query.type : '';
        let http = ctx.query.http ? ctx.query.http : '';
        let chatType = ctx.query.chatType ? ctx.query.chatType : '';
        // 查看群是否存在并且我是否在群里
        let group = await app.model.Group.findOne({
            where:{
                id: id, // 群id
                status:1, // 状态
            },
            attributes:{
                exclude:['update_time'],
            },
            include:[{
                //关联群用户表
                model:app.model.GroupUser,
                attributes:{
                    exclude:['update_time'],
                },
                where:{
                    user_id: me_id, // 用户id
                    group_id: id, // 群id
                    status:1, // 状态
                },
                // 根据user_id 关联用户表,因为可能GroupUser中没有设置昵称和头像
                include:[{
                    model:app.model.User,
                    attributes:['id','username','avatar','nickname'],
                }],
            }],
        });

        if(!group){
            return ctx.apiFail('群不存在或被封禁或者您不在该群聊中');
        }


        // 返回二维码
        if(type && type == 'H5' && http && chatType){
            // 生成H5端的二维码,即完整的网页地址
            if(chatType == 'group'){
               // 生成添加群的二维码地址
               let url = `${http}#/pages/setpageInfo/setpageInfo?action=autoAddGroup&title=${encodeURIComponent('群介绍')}&id=${group.id}&chatType=${chatType}`;
               console.log('生成添加群的二维码地址',url);
               ctx.createQrcode(url);
            }else if(chatType == 'single'){
               // 添加添加个人的二维码地址
            }
        }else{
            // 生成app和小程序端的二维码
            ctx.createQrcode(JSON.stringify({
                id: group.id,
                name: group.name,
            }));
        }
    }

# 4. 生成群二维码路由

在文件 app/router/api/chat/router.js

module.exports = app => {
    const { router, controller } = app;
    //用户登录
    ...

    //申请添加好友 (登录用户才能申请添加好友,(游客)不能申请添加好友)
    ...

    // 好友列表(登录用户才行,(游客)不能)
    ...
    // 查看对方是否是我的好友(登录用户才可以查看好友资料信息,(游客)没有这个功能),传好友id
    ...

    // 创建群聊(登录用户有这个功能,(游客)没有这个功能)
    ...

    // 用户(我)上线后获取离线消息(登录用户和游客都有这个功能)
    ...

    // 群聊列表(登录用户和游客都有这个功能)
    ...

    // 获取群资料信息(登录用户和游客都有这个功能)
    ...

    // 修改群名称(群主才有这个功能)
    ...

    // 修改群公告(群主才有这个功能)
    ...

    // 修改我在群里面的昵称(登录用户和游客都有这个功能)
    ...

    // 删除群(群主可操作)或退出群(群成员可操作)
    ...

    // 生成群二维码(登录用户和游客都有这个功能)
    router.get('/api/chat/groupQrcode/:id', controller.api.chat.chatgroup.groupQrcode);

};   

# 5. 群二维码在前端的接口使用

群二维码在前端的接口使用,具体查看: 二十九、生成获取群二维码

# 六、聊天页发[图片、视频、音频等]服务器交互处理

由于内容较多,在新页面查看:即时通讯发图片视频等交互处理

更新时间: 2025年9月12日星期五晚上10点29分