# 一、uni-app项目发送文件[图片视频等]到服务器或者阿里云OSS

# 1. uni-app项目发送文件[图片视频等]到服务器

#关于上传文件的功能说明

  1. 上传图片(文件)到服务器 ,这个内容在我们课程:[第二学期第三季]讲网站前后台开发的时候已经讲过了。
  2. 对应的文档内容:[一、Stream 流模式上传文件(单个文件),文件存储在你自己的服务器上]

# ② uni-app项目发送文件[图片视频等]到服务器的接口说明

接口说明文档内容:三十、上传图片等文件到服务器或者阿里云

# 2. uni-app项目发送文件[图片视频等]到阿里云OSS

# ① 新建扩展方法

在扩展 app/extend/context.js


/*
//通用文件上传到阿里云OSS方法--File模式
// 阿里云OSS SDK
const OSS = require('ali-oss');
// node系统模块
const path = require('path');
const fs = require('fs/promises');
const crypto = require('crypto');
*/

//通用文件上传到阿里云OSS方法--Stream 流模式
// 阿里云OSS SDK
const OSS = require('ali-oss');
// node系统模块
const path = require('path');
const crypto = require('crypto');
const fs = require('fs');
//使用 pump 确保流正确写入,必须写入临时文件后才能上传到 OSS
//内存管理优化,使用 pump 控制流式写入,每个文件单独处理,避免内存峰值,及时清理临时文件
const pump = require('mz-modules/pump');


// 引入二维码插件
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;
    },


    // 针对uni-app项目上传文件到阿里云存储单文件处理
    // 针对uni-app项目通用单文件处理文件上传到阿里云OSS方法--Stream 流模式--单文件--写入本地临时文件--在上传
    /**
     * 通用文件上传到阿里云OSS方法--Stream 流模式
     * @param {string} fieldName - 上传文件的字段名
     * @param {number} imageClassId - 图片分类ID,默认为0
     * @param {string} prefix - 阿里云oss的Bucket中最外层文件夹名称,默认为'images'
     * @returns {Array} - 上传结果数组,每个元素对象包含上传文件的URL、路径、分类ID和创建时间
     */
    async uploadOSS_Stream_uniapp_singleFile_temp(fieldName, imageClassId = 0, prefix = 'images') {
        const { app } = this;
        const client = new OSS(app.config.oss.client);
        const results = [];

        try {
        // 获取文件流
        const stream = await this.getFileStream();

        // 检查是否是目标字段
        if (stream.fieldname !== fieldName) {
            throw new Error(`Expected field '${fieldName}', but got '${stream.fieldname}'`);
        }

        // 生成唯一文件名
        const timestamp = Date.now();
        const randomStr = crypto.randomBytes(6).toString('hex');
        const extname = path.extname(stream.filename).toLowerCase();
        const filename = `${timestamp}_${randomStr}${extname}`;

        // 创建日期目录
        const now = new Date();
        const datePath = [
            now.getFullYear(),
            String(now.getMonth() + 1).padStart(2, '0'),
            String(now.getDate()).padStart(2, '0')
        ].join('');

        // 构造完整路径
        const ossPath = `${prefix}/${datePath}/${filename}`;

        // 创建临时文件路径
        const tmpFilePath = path.join(
            app.config.multipart.tmpdir,
            `${timestamp}_${randomStr}${extname}`
        );

        // 写入临时文件
        const writeStream = fs.createWriteStream(tmpFilePath);
        await pump(stream, writeStream);

        // 上传到OSS
        const ossRes = await client.put(
            ossPath,
            fs.createReadStream(tmpFilePath)
        );

        // 清理临时文件
        await fs.promises.unlink(tmpFilePath).catch(() => { });

        results.push({
            url: ossRes.url,
            path: ossPath,
            image_class_id: Number(imageClassId),
            create_time: Math.floor(Date.now() / 1000)
        });

        return results;
        } catch (err) {
        // 异常时清理临时文件
        if (tmpFilePath) {
            await fs.promises.unlink(tmpFilePath).catch(() => { });
        }
        throw err;
        }
    },

    // 针对uni-app项目单文件处理通用文件上传到阿里云OSS方法--Stream 流模式--单文件--不写入本地临时文件--直接流上传
    /**
     * 通用文件上传到阿里云OSS方法--Stream 流模式
     * @param {string} fieldName - 上传文件的字段名
     * @param {number} imageClassId - 图片分类ID,默认为0
     * @param {string} prefix - 阿里云oss的Bucket中最外层文件夹名称,默认为'images'
     * @returns {Array} - 上传结果数组,每个元素对象包含上传文件的URL、路径、分类ID和创建时间
     */
    async uploadOSS_Stream_uniapp_singleFile(fieldName, imageClassId = 0, prefix = 'images') {
        const { app } = this;
        const client = new OSS(app.config.oss.client);
        const results = [];

        try {
        // 获取文件流
        const stream = await this.getFileStream();

        // 检查是否是目标字段
        if (stream.fieldname !== fieldName) {
            throw new Error(`Expected field '${fieldName}', but got '${stream.fieldname}'`);
        }

        // 生成唯一文件名
        const timestamp = Date.now();
        const randomStr = crypto.randomBytes(6).toString('hex');
        const extname = path.extname(stream.filename).toLowerCase();
        const filename = `${timestamp}_${randomStr}${extname}`;

        // 创建日期目录
        const now = new Date();
        const datePath = [
            now.getFullYear(),
            String(now.getMonth() + 1).padStart(2, '0'),
            String(now.getDate()).padStart(2, '0')
        ].join('');

        // 构造完整路径
        const ossPath = `${prefix}/${datePath}/${filename}`;

        // 直接使用putStream方法上传流
        const ossRes = await client.putStream(ossPath, stream);

        results.push({
            url: ossRes.url,
            path: ossPath,
            image_class_id: Number(imageClassId),
            create_time: Math.floor(Date.now() / 1000)
        });

        return results;
        } catch (err) {
        throw err;
        }
    }

};

# ② uni-app项目发送文件[图片视频等]到阿里云OSS功能实现

在控制器 app/controller/admin/image.js

'use strict';

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

class ImageController extends Controller {

    // uniapp项目图片上传阿里云
    async uniapp_uploadAliyunOSS() {
        const { ctx, app } = this;
        try {
            // 获取分类ID(通过字段传递)
            const imageClassId = ctx.query.imageClassId || ctx.request.body.image_class_id || 0;
    
            // 针对uni-app项目通用单文件处理文件上传到阿里云OSS方法--Stream 流模式--单文件--写入本地临时文件--在上传
            // const result = await ctx.uploadOSS_Stream_uniapp_singleFile_temp('img', imageClassId, 'images');
            // 针对uni-app项目单文件处理通用文件上传到阿里云OSS方法--Stream 流模式--单文件--不写入本地临时文件--直接流上传
            const result = await ctx.uploadOSS_Stream_uniapp_singleFile('img', imageClassId, 'images');
            result.forEach(v => {
                v.url = v.url.replace('http:','https:');
            });
            //console.log('阿里云OSS上传result',result);
            ctx.apiSuccess(result);
        } catch (error) {
            app.logger.error('阿里云OSS上传失败:', error);
            ctx.apiFail('上传失败: ' + error.message);
        }
    }
    // 图片上传阿里云
    async uploadAliyunOSS() {
        ...
    }

    ...
}

module.exports = ImageController;

# 3. 路由

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

module.exports = app => {
    ...

    // 生成群二维码(登录用户和游客都有这个功能)
    ...

    // 上传文件(图片视频等)到本地服务器(自定义文件路径)(登录用户和游客都有这个功能)
    router.post('/api/chat/uploadStreamSingleToServerDiy/:diydir', 
                controller.upload.uploadStreamSingleToServerDiy); 
    // 上传文件(图片视频等)到阿里云存储(登录用户和游客都有这个功能)
    router.post('/api/chat/uploadAliyun',controller.admin.image.uniapp_uploadAliyunOSS);

};   

# 4. 接口说明

接口说明文档内容:三十、上传图片等文件到服务器或者阿里云

# 5. 阿里云OSS视频封面获取

阿里云OSS提供了视频封面截取功能,方便快速获取视频封面。

# 1. 阿里云OSS视频封面截取方法

https://thinkphp-eggjs.oss-cn-hangzhou.aliyuncs.com/images/20250826/xxxx_xxxx.mp4?x-oss-process=video/snapshot,t_20,m_fast,w_260,f_png

# 2. 方法说明

参数 参数解释 单位说明 取值范围
t 截图时间(视频播放到那个时间的帧的画面) 单位:ms(毫秒) (视频时长范围内)[0,视频时长]
m 截图模式,不指定则为默认模式,根据时间精确截图,如果指定为fast则截取该时间点之前的最近的一个关键帧 枚举值: fast
w 截图宽度,如果指定为0则自动计算 单位:px(像素值) (视频宽度)[0,视频宽度]
h 截图高度,如果指定为0则自动计算,如果w和h都为0则输出为原视频宽高 单位:px(像素值) (视频高度)[0,视频高度]
f 输出截图图片格式 枚举值:jpg、png

# 6. 视频上传到服务器获取视频封面

接口说明文档内容:三十一、视频上传到服务器获取视频封面

# ① 安装视频封面获取插件

首先,我们需要安装fluent-ffmpeg@ffmpeg-installer/ffmpeg

npm install fluent-ffmpeg @ffmpeg-installer/ffmpeg --save

# ② 控制器代码

在控制器 app/controller/video.js

'use strict';

const Controller = require('egg').Controller;
const ffmpeg = require('fluent-ffmpeg');
const ffmpegInstaller = require('@ffmpeg-installer/ffmpeg');
const fs = require('fs');
const path = require('path');

// 设置ffmpeg路径
ffmpeg.setFfmpegPath(ffmpegInstaller.path);

class VideoController extends Controller {
  // 获取视频截图并返回JSON对象
  async getVideoScreenshot() {
    const { ctx, app } = this;
    const { videoUrl, time, width, format = 'png' } = ctx.request.body;

    // 参数验证
    if (!videoUrl) {
      ctx.apiFail('视频地址不能为空');
      return;
    }
    if (time === undefined || time < 0) {
      ctx.apiFail('时间点参数无效');
      return;
    }
    if (width && (width <= 0 || width > 3840)) {
      ctx.apiFail('图片宽度应在1-3840之间');
      return;
    }

    try {
      // 将毫秒转换为秒
      const seconds = time / 1000;
      
      // 创建保存截图的目录
      const screenshotDir = path.join('app', 'public', 'uploads', 'Diy', 'VideoScreenshot');
      if (!fs.existsSync(screenshotDir)) {
        fs.mkdirSync(screenshotDir, { recursive: true });
      }
      
      // 生成唯一文件名
      const timestamp = Date.now();
      const randomStr = Math.random().toString(36).substring(2, 8);
      const fileName = `screenshot_${timestamp}_${randomStr}.${format}`;
      const filePath = path.join(screenshotDir, fileName);
      
      // 创建ffmpeg命令
      const command = ffmpeg(videoUrl)
        .seekInput(seconds)
        .outputOptions('-vframes 1') // 只捕获一帧
        .output(filePath);

      // 添加宽度设置(如果提供)
      if (width) {
        command.size(`${width}x?`);
      }

      // 根据格式设置编码器
      if (format === 'png') {
        command.outputOptions('-vcodec png');
      } else if (format === 'jpg' || format === 'jpeg') {
        command.outputOptions('-vcodec mjpeg');
      }

      // 执行截图命令
      await new Promise((resolve, reject) => {
        command
          .on('end', () => {
            resolve();
          })
          .on('error', (err) => {
            reject(err);
          })
          .run();
      });

      // 检查文件是否生成成功
      if (!fs.existsSync(filePath)) {
        ctx.apiFail('截图生成失败');
        return;
      }

      // 读取文件并转换为base64
      const buffer = fs.readFileSync(filePath);
      const base64 = buffer.toString('base64');
      const base64Data = `data:image/${format};base64,${base64}`;
      
      // 生成可访问的URL
      const fileUrl = `/public/uploads/Diy/VideoScreenshot/${fileName}`;

      // 返回JSON对象
      ctx.apiSuccess({
        base64: base64Data,
        url: fileUrl,
        format,
        time,
        width: width || '原始宽度'
      });
    } catch (error) {
      ctx.logger.error('视频截图错误:', error);
      ctx.apiFail(`截图处理失败: ${error.message}`);
    }
  }
}

module.exports = VideoController;

# ③ 路由

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

module.exports = app => {
    ...

    // 生成群二维码(登录用户和游客都有这个功能)
    ...

    // 上传文件(图片视频等)到本地服务器(自定义文件路径)(登录用户和游客都有这个功能)
    router.post('/api/chat/uploadStreamSingleToServerDiy/:diydir', 
    controller.upload.uploadStreamSingleToServerDiy); 
    // 上传文件(图片视频等)到阿里云存储(登录用户和游客都有这个功能)
    router.post('/api/chat/uploadAliyun',controller.admin.image.uniapp_uploadAliyunOSS);

    // 根据视频地址获取视频截图
    router.post('/api/chat/getVideoScreenshot',controller.video.getVideoScreenshot);

};   

# 二、撤回消息后端文档

# 1. 撤回消息接口说明

接口说明:三十二、撤回消息接口说明

# 2. 控制器代码

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

    ...
    // 撤回消息(游客和登录用户都有这个权限)
    async revokeMessage() {
        const { ctx, app } = this;
        //1.参数验证
        this.ctx.validate({
            to_id: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '接收者id或者群id', //字段含义
                range: {
                    min: 1,
                }
            },
            to_name: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '接收者或者群名称',
                range: {
                    min: 1,
                    max: 50,
                },
            },
            to_avatar: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '接收者或者群头像',
                range: {
                    min: 10,
                    max: 1000,
                },
            },
            id: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '消息uuid',
            },
            chatType: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '聊天类型',
                range: {
                    in: ['single', 'group'],
                },
            },
            create_time: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '消息发送时间', //字段含义
                range: {
                    min: 1000000000000,
                    max: (new Date()).getTime(),
                }
            },
        });
        const { to_id, to_name, to_avatar, id, chatType, create_time } = ctx.request.body;
        // 首先验证消息是否过期,超过5分钟消息不能撤回
        if ((new Date()).getTime() - create_time >  5 * 60 * 1000 ) {
            return this.ctx.apiFail('消息超过5分钟,不能撤回');
        }
        // 我
        let me = ctx.chat_user;
        let me_id = me.id;
        // 消息格式
        let message = {
            id: id, // 撤回的消息id
            from_avatar: me.avatar, // 发送者头像
            from_name: me.nickname || me.username, // 发送者名称
            from_id: me.id, // 发送者id
            to_id: to_id, // 群id|接收者id
            to_name: to_name, // 群名称|接收者 名称
            to_avatar: to_avatar, // 群头像| 接收者头像
            chatType: chatType, // 聊天类型 群聊 | 单聊
            type: 'systemNotice', // 消息类型 系统通知消息
            actionType: 'revoke', // 操作类型 撤回
            data: {
                data: `${me.nickname || me.username}撤回了一条消息`,
                dataType: false,
                otherData: null,
            }, // 消息内容
            options: {}, // 其它参数
            create_time: (new Date()).getTime(), // 创建时间
            isremove: 0, // 0未撤回 1已撤回
            // 群相关信息
            group: null,
        };


        if (chatType == 'single') {
            // 单聊处理
            // 直接调用 `/app/extend/context.js` 封装的方法 chatWebsocketSendOrSaveMessage(sendto_id, message)
            ctx.chatWebsocketSendOrSaveMessage(to_id, message);
            return ctx.apiSuccess(message);
        } else if (chatType == 'group') {
            // 群聊处理
            // 1. 看一下群是否存在
            let group = await app.model.Group.findOne({
                where: {
                    id: to_id, // 群id
                    status: 1, // 状态
                },
                attributes: {
                    exclude: ['update_time'],
                },
                include: [{
                    //关联群用户表
                    model: app.model.GroupUser,
                    attributes: {
                        exclude: ['update_time'],
                    },
                    // 根据user_id 关联用户表,因为可能GroupUser中没有设置昵称和头像
                    include: [{
                        model: app.model.User,
                        attributes: ['id', 'username', 'avatar', 'nickname'],
                    }],
                }],
            });
            if (!group) {
                return ctx.apiFail('群不存在');
            }

            // 2. 我是否在群里
            let me_index = group.group_users.findIndex(v => v.user_id == me_id);
            if (me_index == -1) {
                return ctx.apiFail('你不在该群聊中,操作失败');
            }

            // 拿一下我的昵称和头像
            // 我在群里的昵称: 优先拿我在群里设置的昵称,没有则拿我自己的昵称,在没有则拿账号名
            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;

            // 优化通知消息
            message.from_avatar = me_group_avatar;
            message.from_name = me_group_nickname;
            message.data.data = `${me_group_nickname} 撤回了一条消息`;

            // 发给群成员
            group.group_users.forEach(v => {
                // 直接调用 `/app/extend/context.js` 封装的方法 chatWebsocketSendOrSaveMessage(sendto_id, message)
                ctx.chatWebsocketSendOrSaveMessage(v.user_id, message);
            });

            return ctx.apiSuccess(message);
        }

    }

# 3. 路由

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

module.exports = app => {
    ...

    // 生成群二维码(登录用户和游客都有这个功能)
    ...

    // 上传文件(图片视频等)到本地服务器(自定义文件路径)(登录用户和游客都有这个功能)
    ...
    // 上传文件(图片视频等)到阿里云存储(登录用户和游客都有这个功能)
    ...

    // 根据视频地址获取视频截图
    ...

    // 撤回消息(游客和登录用户都有这个权限)
    router.post('/api/chat/revokeMessage',controller.api.chat.chatuser.revokeMessage);

};   

# 三、申请添加好友进行实时通知(后端文档)

我们在前面写了添加好友的方法 [三、添加好友(申请添加好友)],但是当时讲的时候没有讲websocket,现在既然已经学习了websocket, 那么我们可以在用户申请添加好友后,通知给对方,让对方能及时处理。

# 1. 申请添加好友进行实时通知方法完善

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

'use strict';

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

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

class GoodfriendapplyController extends Controller {
    //申请添加好友 (登录用户才能申请添加好友,(游客)申请添加好友)
    async applyfriend() {
        const { ctx,app } = this;
        //1.参数验证
        ctx.validate({
            friend_id: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '我申请添加的好友id', //字段含义
                range:{
                    min:1,
                }
            },
            nickname: {
                type: 'string',  //参数类型
                required: false, //是否必须
                defValue: '', 
                desc: '我的昵称或者说明', //字段含义
                range:{
                    min:1,
                    max:50,
                }
            },
        });
        // 拿数据
        let {friend_id,nickname} = ctx.request.body;
        // 当前用户: 我
        const me = ctx.chat_user;
        const me_id = me.id;
        // 不能添加自己为好友
        if(me_id == friend_id){
            return ctx.apiFail('不能添加自己为好友');
        }
        // 添加的好友是否存在
        const friend = await app.model.User.findOne({
            where:{
                id:friend_id,
                status:1, // 用户状态 1正常 0禁用
            }
        });
        if(!friend){
            return ctx.apiFail('添加的好友不存在');
        }
        // 添加的申请记录是否存在
        const apply = await app.model.Goodfriendapply.findOne({
            where:{
                user_id:me_id,
                friend_id,
                // 正在申请的,和通过申请的没必要再次申请
                status:['pending','agree'], 
            }
        });
        if(apply){
            return ctx.apiFail('您已经申请过了,请勿重复申请');
        }
        // 创建申请记录
        await app.model.Goodfriendapply.create({
            user_id:me_id,
            friend_id,
            nickname,
            status:'pending',
        });
        // return ctx.apiSuccess('ok');
        ctx.apiSuccess('ok');

        // websocket 通知
        // 消息格式
        let message = {
            id: uuidv4(), // 自动生成 UUID,唯一id, 聊天记录id,方便撤回消息
            from_avatar: 'https://docs-51yrc-com.oss-cn-hangzhou.aliyuncs.com/chat/group.png', // 发送者头像
            from_name: '好友申请提醒', // 发送者名称
            from_id: `redirect-applyFriend-${me_id}`, // 发送者id 系统id或者类型
            to_id: friend_id, // 接收者id  
            to_name: friend.nickname || friend.username, // 接收者名称
            to_avatar: friend.avatar, // 接收者头像
            chatType: 'single', // 聊天类型 单聊
            type: 'text', // 消息类型 系统通知消息
            data: {
                data: `用户[${me.nickname || me.username}]申请添加您为好友,请尽快处理`,
                dataType: false,
                otherData: null,
            }, // 消息内容
            // 新增处理链接
            redirect: {
                url:'/pages/applyMyfriend/applyMyfriend', // 处理链接地址
                type: 'navigateTo', // 处理链接类型
            }, // 处理链接
            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[friend_id];
        // 申请添加的好友正好在线  推送给对方
        // 如果拿不到对方的socket, 则把消息放在redis队列中, 等待对方上线时,再发送
        if (!you_socket) {
            // 放到reids,设置消息列表中:key值是:'chat_getmessage_' + friend_id(用户id)
            this.service.cache.setList('chat_getmessage_' + friend_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_${friend_id}_${message.chatType}_${me.id}`, message);
        }

    }

    // 获取别人申请我为好友的列表数据(登录用户才行,(游客)不能)
    ...


    // 对申请加我为好友的信息进行处理(登录用户才行,(游客)不能)
    ...

}

module.exports = GoodfriendapplyController;

# 四、同意添加为好友实时通知(后端文档)

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

    ...
    // 对申请加我为好友的信息进行处理(登录用户才行,(游客)不能)
    // 实际是同意的情况下,向 goodfriend表插入数据
    async handleapply(){ 
        const { ctx,app } = this;
        //1.参数验证
        ctx.validate({
            id: {
                type: 'int',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '申请表的id', //字段含义
                range:{
                    min:1,
                }
            },
            nickname: {
                type: 'string',  //参数类型
                required: false, //是否必须
                defValue: '', 
                desc: '好友备注', //字段含义
                range:{
                    min:1,
                    max:50,
                }
            },
            status: {
                type: 'string',  //参数类型
                required: true, //是否必须
                // defValue: '', 
                desc: '处理状态', //字段含义
                range:{
                    in:['pending', 'refuse', 'agree', 'ignore'],
                }
            },
        });
        // 当前用户: 我
        const me = ctx.chat_user;
        const me_id = me.id;
        // 当前申请id的数据是否存在
        let id = parseInt(ctx.params.id);
        let goodfriendapply = await app.model.Goodfriendapply.findOne({
            where:{
                id,
                friend_id:me_id,  // 谁可以处理:我
                status:'pending', // 状态需是 'pending'
            },
            include:[
                {
                    model:app.model.User,// 关联用户表,查的是申请加我为好友的用户信息user_id
                    attributes:['id','username','avatar','nickname','uuid'],
                }
            ],
        });
        if(!goodfriendapply){
            return ctx.apiFail('申请不存在或已处理');
        }
        // console.log('处理的申请信息',goodfriendapply);
        // 拿参数
        let { nickname, status} = ctx.request.body;
        // 接下来处理这么几步:
        // 1. 设置申请表`goodfriendapply`的状态
        // 2. 将同意状态下的信息写入我的好友表 `goodfriend`
        // 3. 同时将我的信息写入对方的好友表 `goodfriend`
        // 4. 上面三步要全部能够完成,如果其中一步出现错误,则应该回滚操作、
        // 因此上述操作,我们应该用事务进行处理
        // 定义事务
        let tansaction;
        try {
            // 开启事务
            tansaction = await app.model.transaction();
            // 事务处理逻辑
            // 1. 设置申请表`goodfriendapply`的状态
            //goodfriendapply.status = status; await goodfriendapply.save();
            await goodfriendapply.update({
                status:status,
            },{transaction:tansaction});
            // 2. 将同意状态下的对方信息写入我的好友表 `goodfriend`
            // 3. 同时将我的信息写入对方的好友表 `goodfriend`
            if(status == 'agree'){
                // 先判断一下我的好友表`goodfriend`中有没有对方
                let meHasHim = await app.model.Goodfriend.findOne({
                    where:{
                        user_id:me_id, // 我
                        friend_id: goodfriendapply.user_id, // 申请人
                    }
                });
                // 如果我的好友中没有对方
                if(!meHasHim){
                    // 则将对方的信息异步写入我的好友表
                    await app.model.Goodfriend.create({
                        user_id:me_id, // 我
                        friend_id: goodfriendapply.user_id, // 申请人
                        nickname:nickname, // 申请人昵称
                    },{transaction:tansaction});
                }

                // 先判断一下对方好友表`goodfriend`中有没有我
                let himHasMe = await app.model.Goodfriend.findOne({
                    where:{
                        user_id:goodfriendapply.user_id, // 申请人,对方好友
                        friend_id: me_id, // 我
                    }
                });
                // console.log('对方好友有没有我', himHasMe);
                // 如果对方好友没有我
                if(!himHasMe){
                    // 则将我的信息异步写入对方的好友表
                    await app.model.Goodfriend.create({
                        user_id:goodfriendapply.user_id, // 申请人,对方好友
                        friend_id: me_id, // 我
                        nickname:me.nickname, // 我的昵称
                    },{transaction:tansaction});
                }
            }

            //提交事务
            await tansaction.commit();
            // 反馈
            // return ctx.apiSuccess('ok');
            ctx.apiSuccess('ok');

            // websocket 通知
            if(status == 'agree'){
                // 消息格式 --- 先推给对方(申请加我的好友)
                let message = {
                    id: uuidv4(), // 自动生成 UUID,唯一id, 聊天记录id,方便撤回消息
                    from_avatar: me.avatar, // 发送者头像
                    from_name: me.nickname || me.username, // 发送者名称
                    from_id:  me_id, // 发送者id 系统id或者类型
                    to_id: goodfriendapply.user_id, // 接收者id  
                    to_name: nickname ||  goodfriendapply.user.nickname || goodfriendapply.user.username, // 接收者名称
                    to_avatar: goodfriendapply.user.avatar, // 接收者头像
                    chatType: 'single', // 聊天类型 单聊
                    type: 'systemNotice', // 消息类型 系统通知消息
                    data: {
                        data: `我们已经是好友了,可以开始聊天了`,
                        dataType: false,
                        otherData: null,
                    }, // 消息内容
                    options: {}, // 其它参数
                    create_time: (new Date()).getTime(), // 创建时间
                    isremove: 0, // 0未撤回 1已撤回
                    // 群相关信息
                    // group: {
                    //     user_id: 'fromSystemId',
                    //     remark: '',
                    //     invite_confirm: 0,
                    // }, 
                };
                // 直接调用 `/app/extend/context.js` 封装的方法 chatWebsocketSendOrSaveMessage(sendto_id, message)
                ctx.chatWebsocketSendOrSaveMessage(goodfriendapply.user_id, message);

                // 消息格式 --- 再推给我自己(跟上面刚好相反, 发送人成了对方,我成了接收方)
                message.from_avatar = goodfriendapply.user.avatar; // 发送者头像
                message.from_name = nickname ||  goodfriendapply.user.nickname || goodfriendapply.user.username; // 发送者名称
                message.from_id = goodfriendapply.user_id; // 发送者id 系统id或者类型
                message.to_id = me_id ; // 接收者id  
                message.to_name = me.nickname || me.username; // 接收者名称
                message.to_avatar = me.avatar; // 接收者头像
                // 直接调用 `/app/extend/context.js` 封装的方法 chatWebsocketSendOrSaveMessage(sendto_id, message)
                ctx.chatWebsocketSendOrSaveMessage(me_id, message);
            }
            
        } catch (error) {
            // 失败则回滚
            await tansaction.rollback();
            // 反馈
            return ctx.apiFail('系统异常,请稍后再试');
        }
        
    }

# 五、删除好友后端文档

# 1. 删除好友接口说明

接口说明可查看:[三十三、删除好友]

# 2. 控制器代码

在控制器 app/controller/api/chat/goodfriend.js 中添加如下代码

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

    // 删除好友(登录用户有这个功能,(游客)没有这个功能)
    async deletegoodfriend(){
        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('不是好友关系,无权操作');
        }

        // 删除他在我的朋友记录
        await app.model.Goodfriend.destroy({
            where:{
                friend_id:id, // 朋友id
                user_id:me_id,// 我
                // isblack:0, // 没有拉黑
            },
        });

        // 删除我在他的朋友记录
        await app.model.Goodfriend.destroy({
            where:{
                friend_id:me_id, // 朋友id
                user_id:id,// 我
                // isblack:0, // 没有拉黑
            },
        });

        // 删除申请记录(防止以后又要加回来)
        // 我加他的记录
        await app.model.Goodfriendapply.destroy({
            where:{
                friend_id:id, // 朋友id
                user_id:me_id,// 我
            },
        });
        // 他加我的记录
        await app.model.Goodfriendapply.destroy({
            where:{
                friend_id:me_id, // 朋友id
                user_id:id,// 我
            },
        });


        return ctx.apiSuccess('删除好友成功');
    }

# 3. 路由

在文件 app/router/api/chat/router.js 中添加如下代码

module.exports = app => {
    ...

    // 撤回消息(游客和登录用户都有这个权限)
    ...

    // 删除好友(登录用户有这个功能,(游客)没有这个功能),传好友id
    router.post('/api/chat/deletegoodfriend/:id', controller.api.chat.goodfriend.deletegoodfriend);

};   

# 六、修改我的头像昵称等信息后端文档

# 1. 修改我的头像昵称接口说明

接口说明可查看:[三十四、修改我的信息(修改我的头像昵称等信息)]

# 2. 控制器代码

在控制器 app/controller/api/chat/chatuser.js 中添加如下代码

    ...
    // 修改账号信息(登录用户都有这个权限,游客根据情况有部分权限)
    async updateUserinfo() {
        const { ctx, app } = this;
        //1.参数验证
        this.ctx.validate({
            fieldname: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '修改类型',
                range: {
                    max: 30,
                    min: 1,
                },
            },
            fieldValue: {
                type: 'string',
                required: true,
                // defValue: '', 
                desc: '修改结果',
                range: {
                    min: 1,
                },
            },
        });
        const { fieldname, fieldValue } = ctx.request.body;
        // 我
        let me = ctx.chat_user;
        let me_id = me.id;

        // 获取我的信息
        let myinfo = await app.model.User.findByPk(me_id);
        if (!myinfo) {
            return ctx.apiFail('用户不存在,无法修改信息');
        }

        // 返回说明
        let returnMsg = ``;
        // 修改内容判断
        if(fieldname == 'username'){
            return ctx.apiFail('账号不可修改');
        }
        if(fieldname == 'nickname'){
            // 昵称(游客和登录用户都可以修改)
            if(fieldValue.length > 30) return ctx.apiFail('昵称长度不能超过30个字符');
            myinfo.nickname = fieldValue;
            returnMsg = '修改昵称成功';
        }
        if(fieldname == 'avatar'){
            // 昵称(游客和登录用户都可以修改)
            if(fieldValue.length > 1000) return ctx.apiFail('头像地址过长不能超过1000个字符');
            myinfo.avatar = fieldValue;
            returnMsg = '头像更新成功';
        }

        if(fieldname == 'invite_confirm'){
            // 添加我为好友设置(游客和登录用户都可以修改)
            if(fieldValue != 0 && fieldValue != 1) return ctx.apiFail('添加我为好友设置值错误');
            myinfo.invite_confirm = fieldValue;
            returnMsg = '设置成功';
        }

        // 修改
        await myinfo.save();

        // 设置redis标记(这些修改不需要webscoket推送),有效期60秒
        await this.app.redis.setex(`user:modify:${me_id}`, 60, '1');

        // 返回
        return ctx.apiSuccess(returnMsg);
    }

# 3. 路由

在文件 app/router/api/chat/router.js 中添加如下代码

module.exports = app => {
    ...

    // 撤回消息(游客和登录用户都有这个权限)
    ...

    // 删除好友(登录用户有这个功能,(游客)没有这个功能),传好友id
    ...

    // 修改账号信息(登录用户都有这个权限,游客根据情况有部分权限)
    router.post('/api/chat/updateUserinfo', controller.api.chat.chatuser.updateUserinfo);

};   

更新时间: 2025年9月22日星期一晚上7点43分