From 3539eab924c9a522b91487a92fc3e109533e0466 Mon Sep 17 00:00:00 2001 From: geht <2947093423@qq.com> Date: Thu, 28 May 2026 14:37:05 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9EIM=E8=81=8A=E5=A4=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/jeecg/common/util/oConvertUtils.java | 3 +- .../im/controller/SysImChatController.java | 139 +++ .../modules/im/dto/SysImSendMessageDTO.java | 19 + .../modules/im/entity/SysImConversation.java | 43 + .../im/entity/SysImConversationMember.java | 32 + .../jeecg/modules/im/entity/SysImMessage.java | 32 + .../im/mapper/SysImConversationMapper.java | 19 + .../mapper/SysImConversationMemberMapper.java | 16 + .../modules/im/mapper/SysImMessageMapper.java | 10 + .../im/mapper/xml/SysImConversationMapper.xml | 26 + .../xml/SysImConversationMemberMapper.xml | 12 + .../modules/im/service/ISysImChatService.java | 54 + .../im/service/impl/SysImChatServiceImpl.java | 436 ++++++++ .../jeecg/modules/im/vo/SysImContactVO.java | 31 + .../modules/im/vo/SysImConversationVO.java | 37 + .../jeecg/modules/im/vo/SysImMessageVO.java | 37 + .../system/mapper/SysUserDepartMapper.java | 14 +- .../system/mapper/xml/SysUserDepartMapper.xml | 36 + .../sql/mysql/V3.9.2_109__sys_im_chat.sql | 70 ++ jeecgboot-vue3/src/hooks/web/useWebSocket.ts | 39 + .../header/components/RefreshCache.vue | 42 + .../header/components/im-chat/index.vue | 94 ++ .../default/header/components/index.ts | 4 + .../header/components/notify/index.vue | 14 +- .../header/components/useRefreshCache.ts | 46 + .../header/components/user-dropdown/index.vue | 24 +- .../src/layouts/default/header/index.vue | 8 +- jeecgboot-vue3/src/store/modules/user.ts | 5 + jeecgboot-vue3/src/views/system/im/ImChat.vue | 993 ++++++++++++++++++ .../src/views/system/im/ImChatModal.vue | 62 ++ .../views/system/im/ImChatSettingsModal.vue | 76 ++ jeecgboot-vue3/src/views/system/im/im.api.ts | 41 + jeecgboot-vue3/src/views/system/im/imCache.ts | 299 ++++++ .../src/views/system/im/imSettings.ts | 48 + .../src/views/system/im/useImUnread.ts | 39 + 35 files changed, 2864 insertions(+), 36 deletions(-) create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/controller/SysImChatController.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/dto/SysImSendMessageDTO.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversation.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversationMember.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImMessage.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMapper.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMemberMapper.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImMessageMapper.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMapper.xml create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMemberMapper.xml create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImConversationVO.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java create mode 100644 jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_109__sys_im_chat.sql create mode 100644 jeecgboot-vue3/src/layouts/default/header/components/RefreshCache.vue create mode 100644 jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue create mode 100644 jeecgboot-vue3/src/layouts/default/header/components/useRefreshCache.ts create mode 100644 jeecgboot-vue3/src/views/system/im/ImChat.vue create mode 100644 jeecgboot-vue3/src/views/system/im/ImChatModal.vue create mode 100644 jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue create mode 100644 jeecgboot-vue3/src/views/system/im/im.api.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imCache.ts create mode 100644 jeecgboot-vue3/src/views/system/im/imSettings.ts create mode 100644 jeecgboot-vue3/src/views/system/im/useImUnread.ts diff --git a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oConvertUtils.java b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oConvertUtils.java index c032325..5715534 100644 --- a/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oConvertUtils.java +++ b/jeecg-boot/jeecg-boot-base-core/src/main/java/org/jeecg/common/util/oConvertUtils.java @@ -22,6 +22,7 @@ import java.lang.reflect.Array; import java.lang.reflect.Field; import java.math.BigDecimal; import java.math.BigInteger; +import java.math.RoundingMode; import java.net.*; import java.sql.Date; import java.util.*; @@ -1046,7 +1047,7 @@ public class oConvertUtils { BigDecimal bigDecimal = new BigDecimal(uploadCount); //换算成MB BigDecimal divide = bigDecimal.divide(new BigDecimal(1048576)); - count = divide.setScale(2, BigDecimal.ROUND_HALF_UP).doubleValue(); + count = divide.setScale(2, RoundingMode.HALF_UP).doubleValue(); return count; } return count; diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/controller/SysImChatController.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/controller/SysImChatController.java new file mode 100644 index 0000000..5606159 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/controller/SysImChatController.java @@ -0,0 +1,139 @@ +package org.jeecg.modules.im.controller; + +import com.baomidou.mybatisplus.core.metadata.IPage; +import org.jeecg.modules.im.vo.SysImMessageVO; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authz.annotation.RequiresPermissions; +import org.jeecg.common.api.vo.Result; +import org.jeecg.common.system.vo.LoginUser; +import org.jeecg.common.util.TokenUtils; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.im.dto.SysImSendMessageDTO; +import org.jeecg.modules.im.service.ISysImChatService; +import org.jeecg.modules.im.vo.SysImContactVO; +import org.jeecg.modules.im.vo.SysImConversationVO; +import org.jeecg.modules.im.vo.SysImMessageVO; +import org.jeecg.modules.system.entity.SysUser; +import org.jeecg.modules.system.service.ISysUserService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.List; + +/** + * 租户 IM 聊天 + */ +@Tag(name = "IM聊天") +@RestController +@RequestMapping("/sys/im/chat") +@Slf4j +public class SysImChatController { + + @Autowired + private ISysImChatService imChatService; + @Autowired + private ISysUserService sysUserService; + + private LoginUser currentUser() { + return (LoginUser) SecurityUtils.getSubject().getPrincipal(); + } + + private Integer resolveTenantId(LoginUser user, HttpServletRequest request) { + Integer tenantId = oConvertUtils.getInt(TokenUtils.getTenantIdByRequest(request), 0); + if (tenantId != null && tenantId > 0) { + return tenantId; + } + SysUser sysUser = sysUserService.getById(user.getId()); + if (sysUser != null && sysUser.getLoginTenantId() != null && sysUser.getLoginTenantId() > 0) { + return sysUser.getLoginTenantId(); + } + return 0; + } + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表接口----------- + @Operation(summary = "IM聊天-会话列表") + @RequiresPermissions("sys:im:chat:list") + @GetMapping("/conversations") + public Result> conversations(HttpServletRequest request) { + LoginUser user = currentUser(); + return Result.OK(imChatService.listConversations(user.getId(), resolveTenantId(user, request))); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表接口----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊接口----------- + @Operation(summary = "IM聊天-打开单聊") + @RequiresPermissions("sys:im:chat:list") + @PostMapping("/open") + public Result open(@RequestParam(name = "targetUserId") String targetUserId, HttpServletRequest request) { + LoginUser user = currentUser(); + return Result.OK(imChatService.openSingleConversation(user.getId(), resolveTenantId(user, request), user.getOrgCode(), targetUserId)); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊接口----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表接口----------- + @Operation(summary = "IM聊天-消息列表") + @RequiresPermissions("sys:im:chat:list") + @GetMapping("/messages") + public Result> messages( + @RequestParam(name = "conversationId") String conversationId, + @RequestParam(name = "pageNo", defaultValue = "1") Integer pageNo, + @RequestParam(name = "pageSize", defaultValue = "20") Integer pageSize, + @RequestParam(name = "startTime", required = false) String startTime, + @RequestParam(name = "beforeTime", required = false) String beforeTime) { + LoginUser user = currentUser(); + return Result.OK(imChatService.listMessages(user.getId(), conversationId, pageNo, pageSize, startTime, beforeTime)); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表接口----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息接口----------- + @Operation(summary = "IM聊天-发送消息") + @RequiresPermissions("sys:im:chat:send") + @PostMapping("/send") + public Result send(@RequestBody SysImSendMessageDTO dto, HttpServletRequest request) { + LoginUser user = currentUser(); + return Result.OK(imChatService.sendMessage(user.getId(), resolveTenantId(user, request), dto)); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息接口----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读接口----------- + @Operation(summary = "IM聊天-标记已读") + @RequiresPermissions("sys:im:chat:list") + @PutMapping("/read") + public Result read(@RequestParam(name = "conversationId") String conversationId) { + LoginUser user = currentUser(); + imChatService.markRead(user.getId(), conversationId); + return Result.OK(); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读接口----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员接口----------- + @Operation(summary = "IM聊天-本部门成员") + @RequiresPermissions("sys:im:chat:list") + @GetMapping("/deptMembers") + public Result> deptMembers(@RequestParam(name = "keyword", required = false) String keyword, HttpServletRequest request) { + LoginUser user = currentUser(); + return Result.OK(imChatService.listDeptMembers(user.getId(), resolveTenantId(user, request), user.getOrgCode(), keyword)); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员接口----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】联系人接口(兼容保留,同本部门)----------- + @Operation(summary = "IM聊天-租户联系人") + @RequiresPermissions("sys:im:chat:list") + @GetMapping("/contacts") + public Result> contacts(@RequestParam(name = "keyword", required = false) String keyword, HttpServletRequest request) { + LoginUser user = currentUser(); + return Result.OK(imChatService.listDeptMembers(user.getId(), resolveTenantId(user, request), user.getOrgCode(), keyword)); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】联系人接口(兼容保留,同本部门)----------- +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/dto/SysImSendMessageDTO.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/dto/SysImSendMessageDTO.java new file mode 100644 index 0000000..3a2275d --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/dto/SysImSendMessageDTO.java @@ -0,0 +1,19 @@ +package org.jeecg.modules.im.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * 发送 IM 消息 DTO + */ +@Data +@Schema(description = "发送IM消息") +public class SysImSendMessageDTO { + + @Schema(description = "会话ID") + private String conversationId; + @Schema(description = "消息内容") + private String content; + @Schema(description = "消息类型 text/image/file") + private String msgType; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversation.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversation.java new file mode 100644 index 0000000..14ed062 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversation.java @@ -0,0 +1,43 @@ +package org.jeecg.modules.im.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * IM 会话 + */ +@Data +@TableName("sys_im_conversation") +public class SysImConversation implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + private String id; + /** 会话类型 single单聊 */ + private String convType; + /** 单聊唯一键 */ + private String userPairKey; + /** 租户ID */ + private Integer tenantId; + /** 最后一条消息摘要 */ + private String lastContent; + /** 最后消息时间 */ + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastTime; + private String createBy; + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; + private String updateBy; + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date updateTime; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversationMember.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversationMember.java new file mode 100644 index 0000000..12936ba --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImConversationMember.java @@ -0,0 +1,32 @@ +package org.jeecg.modules.im.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * IM 会话成员 + */ +@Data +@TableName("sys_im_conversation_member") +public class SysImConversationMember implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + private String id; + private String conversationId; + private String userId; + private Integer unreadCount; + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastReadTime; + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImMessage.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImMessage.java new file mode 100644 index 0000000..dc1a1b8 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/entity/SysImMessage.java @@ -0,0 +1,32 @@ +package org.jeecg.modules.im.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.io.Serializable; +import java.util.Date; + +/** + * IM 消息 + */ +@Data +@TableName("sys_im_message") +public class SysImMessage implements Serializable { + private static final long serialVersionUID = 1L; + + @TableId(type = IdType.ASSIGN_ID) + private String id; + private String conversationId; + private String senderId; + private String content; + /** 消息类型 text/image/file */ + private String msgType; + private Integer tenantId; + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMapper.java new file mode 100644 index 0000000..09d4b73 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMapper.java @@ -0,0 +1,19 @@ +package org.jeecg.modules.im.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.jeecg.modules.im.entity.SysImConversation; +import org.jeecg.modules.im.vo.SysImConversationVO; + +import java.util.List; + +/** + * IM 会话 Mapper + */ +public interface SysImConversationMapper extends BaseMapper { + + /** + * 查询当前用户的会话列表 + */ + List listMyConversations(@Param("userId") String userId, @Param("tenantId") Integer tenantId); +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMemberMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMemberMapper.java new file mode 100644 index 0000000..e6e8856 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImConversationMemberMapper.java @@ -0,0 +1,16 @@ +package org.jeecg.modules.im.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.apache.ibatis.annotations.Param; +import org.jeecg.modules.im.entity.SysImConversationMember; + +/** + * IM 会话成员 Mapper + */ +public interface SysImConversationMemberMapper extends BaseMapper { + + /** + * 未读数 +1(排除发送人) + */ + int incrementUnreadExceptSender(@Param("conversationId") String conversationId, @Param("senderId") String senderId); +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImMessageMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImMessageMapper.java new file mode 100644 index 0000000..f652754 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/SysImMessageMapper.java @@ -0,0 +1,10 @@ +package org.jeecg.modules.im.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import org.jeecg.modules.im.entity.SysImMessage; + +/** + * IM 消息 Mapper + */ +public interface SysImMessageMapper extends BaseMapper { +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMapper.xml new file mode 100644 index 0000000..92eed95 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMapper.xml @@ -0,0 +1,26 @@ + + + + + + + diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMemberMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMemberMapper.xml new file mode 100644 index 0000000..8c3740c --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/mapper/xml/SysImConversationMemberMapper.xml @@ -0,0 +1,12 @@ + + + + + + UPDATE sys_im_conversation_member + SET unread_count = unread_count + 1 + WHERE conversation_id = #{conversationId} + AND user_id != #{senderId} + + + diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java new file mode 100644 index 0000000..3440ec7 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/ISysImChatService.java @@ -0,0 +1,54 @@ +package org.jeecg.modules.im.service; + + + +import com.baomidou.mybatisplus.core.metadata.IPage; + +import org.jeecg.modules.im.dto.SysImSendMessageDTO; + +import org.jeecg.modules.im.vo.SysImContactVO; + +import org.jeecg.modules.im.vo.SysImConversationVO; + +import org.jeecg.modules.im.vo.SysImMessageVO; + + + +import java.util.List; + + + +/** + + * IM 聊天服务 + + */ + +public interface ISysImChatService { + + + + List listConversations(String userId, Integer tenantId); + + + + SysImConversationVO openSingleConversation(String userId, Integer tenantId, String orgCode, String targetUserId); + + + + IPage listMessages(String userId, String conversationId, Integer pageNo, Integer pageSize, String startTime, String beforeTime); + + + + SysImMessageVO sendMessage(String userId, Integer tenantId, SysImSendMessageDTO dto); + + + + void markRead(String userId, String conversationId); + + + + List listDeptMembers(String userId, Integer tenantId, String orgCode, String keyword); + +} + diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java new file mode 100644 index 0000000..d953730 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/service/impl/SysImChatServiceImpl.java @@ -0,0 +1,436 @@ +package org.jeecg.modules.im.service.impl; + +import com.alibaba.fastjson.JSONObject; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.IPage; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import lombok.extern.slf4j.Slf4j; +import org.jeecg.common.constant.WebsocketConst; +import org.jeecg.common.exception.JeecgBootException; +import org.jeecg.common.util.DateUtils; +import org.jeecg.common.util.oConvertUtils; +import org.jeecg.modules.im.dto.SysImSendMessageDTO; +import org.jeecg.modules.im.entity.SysImConversation; +import org.jeecg.modules.im.entity.SysImConversationMember; +import org.jeecg.modules.im.entity.SysImMessage; +import org.jeecg.modules.im.mapper.SysImConversationMapper; +import org.jeecg.modules.im.mapper.SysImConversationMemberMapper; +import org.jeecg.modules.im.mapper.SysImMessageMapper; +import org.jeecg.modules.im.service.ISysImChatService; +import org.jeecg.modules.im.vo.SysImContactVO; +import org.jeecg.modules.im.vo.SysImConversationVO; +import org.jeecg.modules.im.vo.SysImMessageVO; +import org.jeecg.modules.message.websocket.WebSocket; +import org.jeecg.modules.system.entity.SysDepart; +import org.jeecg.modules.system.entity.SysUser; +import org.jeecg.modules.system.entity.SysUserDepart; +import org.jeecg.modules.system.mapper.SysDepartMapper; +import org.jeecg.modules.system.mapper.SysUserDepartMapper; +import org.jeecg.modules.system.mapper.SysUserMapper; +import org.jeecg.modules.system.mapper.SysUserTenantMapper; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Comparator; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * IM 聊天服务实现 + */ +@Slf4j +@Service +public class SysImChatServiceImpl implements ISysImChatService { + + private static final String CONV_TYPE_SINGLE = "single"; + private static final String MSG_TYPE_TEXT = "text"; + + @Autowired + private SysImConversationMapper conversationMapper; + @Autowired + private SysImConversationMemberMapper memberMapper; + @Autowired + private SysImMessageMapper messageMapper; + @Autowired + private SysUserMapper userMapper; + @Autowired + private SysUserTenantMapper userTenantMapper; + @Autowired + private SysUserDepartMapper userDepartMapper; + @Autowired + private SysDepartMapper departMapper; + @Autowired + private WebSocket webSocket; + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表----------- + @Override + public List listConversations(String userId, Integer tenantId) { + if (oConvertUtils.isEmpty(userId) || tenantId == null || tenantId <= 0) { + return Collections.emptyList(); + } + return conversationMapper.listMyConversations(userId, tenantId); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】会话列表----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊会话----------- + @Override + @Transactional(rollbackFor = Exception.class) + public SysImConversationVO openSingleConversation(String userId, Integer tenantId, String orgCode, String targetUserId) { + String pairKey = buildPairKey(userId, targetUserId); + SysImConversation conversation = conversationMapper.selectOne(new LambdaQueryWrapper() + .eq(SysImConversation::getTenantId, tenantId) + .eq(SysImConversation::getUserPairKey, pairKey)); + Date now = new Date(); + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开----------- + if (conversation != null) { + return buildConversationVo(conversation, userId, targetUserId); + } + validateTenantChat(userId, tenantId, orgCode, targetUserId); + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开----------- + conversation = new SysImConversation(); + conversation.setConvType(CONV_TYPE_SINGLE); + conversation.setUserPairKey(pairKey); + conversation.setTenantId(tenantId); + conversation.setCreateBy(userId); + conversation.setCreateTime(now); + conversation.setUpdateTime(now); + conversationMapper.insert(conversation); + createMember(conversation.getId(), userId, now); + createMember(conversation.getId(), targetUserId, now); + return buildConversationVo(conversation.getId(), userId, tenantId, targetUserId); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】打开单聊会话----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】消息分页----------- + @Override + public IPage listMessages(String userId, String conversationId, Integer pageNo, Integer pageSize, String startTime, String beforeTime) { + assertMember(userId, conversationId); + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】默认本周消息+向上翻页加载----------- + Date start = parseMessageTime(startTime); + Date before = parseMessageTime(beforeTime); + int currentPage = (start != null || before != null) ? 1 : (pageNo == null || pageNo < 1 ? 1 : pageNo); + int size = pageSize == null || pageSize < 1 ? 20 : pageSize; + Page page = new Page<>(currentPage, size); + if (currentPage == 1) { + page.setSearchCount(false); + } + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(SysImMessage::getConversationId, conversationId); + if (start != null) { + wrapper.ge(SysImMessage::getCreateTime, start); + } + if (before != null) { + wrapper.lt(SysImMessage::getCreateTime, before); + } + wrapper.orderByDesc(SysImMessage::getCreateTime); + IPage messagePage = messageMapper.selectPage(page, wrapper); + Page voPage = new Page<>(currentPage, size, messagePage.getTotal()); + List records = toMessageVoList(messagePage.getRecords(), userId); + Collections.reverse(records); + voPage.setRecords(records); + return voPage; + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】默认本周消息+向上翻页加载----------- + } + + private Date parseMessageTime(String timeText) { + if (oConvertUtils.isEmpty(timeText)) { + return null; + } + Date date = DateUtils.parseDatetime(timeText); + if (date == null) { + date = DateUtils.str2Date(timeText, DateUtils.date_sdf.get()); + } + return date; + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】消息分页----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息----------- + @Override + @Transactional(rollbackFor = Exception.class) + public SysImMessageVO sendMessage(String userId, Integer tenantId, SysImSendMessageDTO dto) { + if (dto == null || oConvertUtils.isEmpty(dto.getConversationId()) || oConvertUtils.isEmpty(dto.getContent())) { + throw new JeecgBootException("消息内容不能为空"); + } + assertMember(userId, dto.getConversationId()); + Date now = new Date(); + SysImMessage message = new SysImMessage(); + message.setConversationId(dto.getConversationId()); + message.setSenderId(userId); + message.setContent(dto.getContent().trim()); + message.setMsgType(oConvertUtils.isEmpty(dto.getMsgType()) ? MSG_TYPE_TEXT : dto.getMsgType()); + message.setTenantId(tenantId); + message.setCreateTime(now); + messageMapper.insert(message); + + SysImConversation conversation = conversationMapper.selectById(dto.getConversationId()); + conversation.setLastContent(truncate(message.getContent(), 200)); + conversation.setLastTime(now); + conversation.setUpdateTime(now); + conversationMapper.updateById(conversation); + + memberMapper.incrementUnreadExceptSender(dto.getConversationId(), userId); + SysImMessageVO messageVo = toMessageVo(message, userId); + pushChatMessage(dto.getConversationId(), userId, messageVo); + return messageVo; + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】发送消息----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读----------- + @Override + @Transactional(rollbackFor = Exception.class) + public void markRead(String userId, String conversationId) { + SysImConversationMember member = getMember(userId, conversationId); + if (member == null) { + return; + } + member.setUnreadCount(0); + member.setLastReadTime(new Date()); + memberMapper.updateById(member); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】标记已读----------- + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员列表(含会话摘要)----------- + @Override + public List listDeptMembers(String userId, Integer tenantId, String orgCode, String keyword) { + String resolvedOrgCode = resolveOrgCode(userId, tenantId, orgCode); + if (oConvertUtils.isEmpty(resolvedOrgCode)) { + throw new JeecgBootException("未获取到当前部门,请切换部门后重试"); + } + List users = userDepartMapper.querySameDepartUserList(resolvedOrgCode, userId, tenantId, keyword); + if (users == null || users.isEmpty()) { + return Collections.emptyList(); + } + Map convMap = new HashMap<>(16); + if (tenantId != null && tenantId > 0) { + for (SysImConversationVO conv : conversationMapper.listMyConversations(userId, tenantId)) { + if (oConvertUtils.isNotEmpty(conv.getTargetUserId())) { + convMap.put(conv.getTargetUserId(), conv); + } + } + } + List result = users.stream().map(user -> { + SysImContactVO vo = toContactVo(user); + SysImConversationVO conv = convMap.get(user.getId()); + if (conv != null) { + vo.setConversationId(conv.getConversationId()); + vo.setLastContent(conv.getLastContent()); + vo.setLastTime(conv.getLastTime()); + vo.setUnreadCount(conv.getUnreadCount()); + } + return vo; + }).collect(Collectors.toList()); + result.sort(Comparator + .comparing(SysImContactVO::getLastTime, Comparator.nullsLast(Comparator.reverseOrder())) + .thenComparing(item -> oConvertUtils.getString(item.getRealname(), item.getUsername()))); + return result; + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】本部门成员列表(含会话摘要)----------- + + private SysImContactVO toContactVo(SysUser user) { + SysImContactVO vo = new SysImContactVO(); + vo.setId(user.getId()); + vo.setUsername(user.getUsername()); + vo.setRealname(user.getRealname()); + vo.setAvatar(user.getAvatar()); + vo.setOrgCodeTxt(user.getOrgCodeTxt()); + vo.setUnreadCount(0); + return vo; + } + + private String resolveOrgCode(String userId, Integer tenantId, String orgCode) { + if (oConvertUtils.isNotEmpty(orgCode)) { + return orgCode; + } + if (tenantId != null && tenantId > 0) { + List departs = userDepartMapper.getTenantUserDepart(userId, String.valueOf(tenantId)); + if (departs != null && !departs.isEmpty()) { + SysDepart depart = departMapper.selectById(departs.get(0).getDepId()); + if (depart != null && oConvertUtils.isNotEmpty(depart.getOrgCode())) { + return depart.getOrgCode(); + } + } + } + List departs = userDepartMapper.getUserDepartByUid(userId); + if (departs != null && !departs.isEmpty()) { + SysDepart depart = departMapper.selectById(departs.get(0).getDepId()); + if (depart != null) { + return depart.getOrgCode(); + } + } + return null; + } + + private void createMember(String conversationId, String userId, Date now) { + SysImConversationMember member = new SysImConversationMember(); + member.setConversationId(conversationId); + member.setUserId(userId); + member.setUnreadCount(0); + member.setCreateTime(now); + memberMapper.insert(member); + } + + private SysImConversationVO buildConversationVo(String conversationId, String userId, Integer tenantId, String targetUserId) { + SysImConversation conversation = conversationMapper.selectById(conversationId); + if (conversation == null) { + return new SysImConversationVO(); + } + return buildConversationVo(conversation, userId, targetUserId); + } + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开----------- + private SysImConversationVO buildConversationVo(SysImConversation conversation, String userId, String targetUserId) { + SysUser target = userMapper.selectById(targetUserId); + SysImConversationMember member = getMember(userId, conversation.getId()); + SysImConversationVO vo = new SysImConversationVO(); + vo.setConversationId(conversation.getId()); + vo.setConvType(CONV_TYPE_SINGLE); + vo.setLastContent(conversation.getLastContent()); + vo.setLastTime(conversation.getLastTime()); + vo.setUnreadCount(member == null ? 0 : member.getUnreadCount()); + if (target != null) { + vo.setTargetUserId(target.getId()); + vo.setTargetRealname(target.getRealname()); + vo.setTargetUsername(target.getUsername()); + vo.setTargetAvatar(target.getAvatar()); + } + return vo; + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】已有会话快速打开----------- + + private void validateTenantChat(String userId, Integer tenantId, String orgCode, String targetUserId) { + if (oConvertUtils.isEmpty(targetUserId)) { + throw new JeecgBootException("请选择聊天对象"); + } + if (userId.equals(targetUserId)) { + throw new JeecgBootException("不能与自己聊天"); + } + if (tenantId == null || tenantId <= 0) { + throw new JeecgBootException("请先选择租户"); + } + Integer selfExist = userTenantMapper.userTenantIzExist(userId, tenantId); + Integer targetExist = userTenantMapper.userTenantIzExist(targetUserId, tenantId); + if (selfExist == null || selfExist <= 0 || targetExist == null || targetExist <= 0) { + throw new JeecgBootException("仅支持与当前租户内用户聊天"); + } + SysUser target = userMapper.selectById(targetUserId); + if (target == null) { + throw new JeecgBootException("聊天对象不存在"); + } + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】限制同部门聊天----------- + String resolvedOrgCode = resolveOrgCode(userId, tenantId, orgCode); + if (oConvertUtils.isEmpty(resolvedOrgCode)) { + throw new JeecgBootException("未获取到当前部门,请切换部门后重试"); + } + if (userDepartMapper.countUserInDepartOrgCode(targetUserId, resolvedOrgCode, tenantId) <= 0) { + throw new JeecgBootException("仅支持与同部门用户聊天"); + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】限制同部门聊天----------- + } + + private String buildPairKey(String userId1, String userId2) { + if (userId1.compareTo(userId2) <= 0) { + return userId1 + "_" + userId2; + } + return userId2 + "_" + userId1; + } + + private SysImConversationMember getMember(String userId, String conversationId) { + return memberMapper.selectOne(new LambdaQueryWrapper() + .eq(SysImConversationMember::getConversationId, conversationId) + .eq(SysImConversationMember::getUserId, userId)); + } + + private void assertMember(String userId, String conversationId) { + if (getMember(userId, conversationId) == null) { + throw new JeecgBootException("无权访问该会话"); + } + } + + private SysImMessageVO toMessageVo(SysImMessage message, String currentUserId) { + return toMessageVo(message, currentUserId, null); + } + + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表批量填充发送人----------- + private List toMessageVoList(List messages, String currentUserId) { + if (messages == null || messages.isEmpty()) { + return Collections.emptyList(); + } + List senderIds = messages.stream() + .map(SysImMessage::getSenderId) + .filter(oConvertUtils::isNotEmpty) + .distinct() + .collect(Collectors.toList()); + Map userMap = new HashMap<>(senderIds.size()); + if (!senderIds.isEmpty()) { + List users = userMapper.selectBatchIds(senderIds); + if (users != null) { + for (SysUser user : users) { + userMap.put(user.getId(), user); + } + } + } + List result = new ArrayList<>(messages.size()); + for (SysImMessage message : messages) { + result.add(toMessageVo(message, currentUserId, userMap)); + } + return result; + } + + private SysImMessageVO toMessageVo(SysImMessage message, String currentUserId, Map userMap) { + SysImMessageVO vo = new SysImMessageVO(); + vo.setId(message.getId()); + vo.setConversationId(message.getConversationId()); + vo.setSenderId(message.getSenderId()); + vo.setContent(message.getContent()); + vo.setMsgType(message.getMsgType()); + vo.setCreateTime(message.getCreateTime()); + vo.setMine(currentUserId.equals(message.getSenderId())); + SysUser sender = userMap == null ? null : userMap.get(message.getSenderId()); + if (sender == null && userMap == null) { + sender = userMapper.selectById(message.getSenderId()); + } + if (sender != null) { + vo.setSenderName(sender.getRealname()); + vo.setSenderAvatar(sender.getAvatar()); + } + return vo; + } + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】消息列表批量填充发送人----------- + + private void pushChatMessage(String conversationId, String senderId, SysImMessageVO messageVo) { + List members = memberMapper.selectList(new LambdaQueryWrapper() + .eq(SysImConversationMember::getConversationId, conversationId)); + for (SysImConversationMember member : members) { + if (senderId.equals(member.getUserId())) { + continue; + } + JSONObject obj = new JSONObject(); + obj.put(WebsocketConst.MSG_CMD, WebsocketConst.MSG_CHAT); + obj.put(WebsocketConst.MSG_USER_ID, member.getUserId()); + obj.put("conversationId", conversationId); + obj.put("messageId", messageVo.getId()); + obj.put("senderId", messageVo.getSenderId()); + //update-begin---author:cursor ---date:20260528 for:【IM聊天-OA】WebSocket推送补全头像字段----------- + obj.put("senderName", messageVo.getSenderName()); + obj.put("senderAvatar", messageVo.getSenderAvatar()); + //update-end---author:cursor ---date:20260528 for:【IM聊天-OA】WebSocket推送补全头像字段----------- + obj.put("content", messageVo.getContent()); + obj.put("msgType", messageVo.getMsgType()); + obj.put("createTime", messageVo.getCreateTime()); + webSocket.sendMessage(member.getUserId(), obj.toJSONString()); + } + } + + private String truncate(String content, int maxLen) { + if (content == null) { + return null; + } + return content.length() <= maxLen ? content : content.substring(0, maxLen); + } +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java new file mode 100644 index 0000000..e002086 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImContactVO.java @@ -0,0 +1,31 @@ +package org.jeecg.modules.im.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +/** + * IM 联系人 VO + */ +@Data +@Schema(description = "IM联系人") +public class SysImContactVO { + + @Schema(description = "用户ID") + private String id; + @Schema(description = "账号") + private String username; + @Schema(description = "姓名") + private String realname; + @Schema(description = "头像") + private String avatar; + @Schema(description = "部门") + private String orgCodeTxt; + @Schema(description = "会话ID") + private String conversationId; + @Schema(description = "最后消息摘要") + private String lastContent; + @Schema(description = "最后消息时间") + private java.util.Date lastTime; + @Schema(description = "未读数") + private Integer unreadCount; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImConversationVO.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImConversationVO.java new file mode 100644 index 0000000..96997df --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImConversationVO.java @@ -0,0 +1,37 @@ +package org.jeecg.modules.im.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * IM 会话列表 VO + */ +@Data +@Schema(description = "IM会话") +public class SysImConversationVO { + + @Schema(description = "会话ID") + private String conversationId; + @Schema(description = "会话类型") + private String convType; + @Schema(description = "最后消息摘要") + private String lastContent; + @Schema(description = "最后消息时间") + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date lastTime; + @Schema(description = "未读数") + private Integer unreadCount; + @Schema(description = "对方用户ID") + private String targetUserId; + @Schema(description = "对方姓名") + private String targetRealname; + @Schema(description = "对方账号") + private String targetUsername; + @Schema(description = "对方头像") + private String targetAvatar; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java new file mode 100644 index 0000000..6210939 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/im/vo/SysImMessageVO.java @@ -0,0 +1,37 @@ +package org.jeecg.modules.im.vo; + +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import org.springframework.format.annotation.DateTimeFormat; + +import java.util.Date; + +/** + * IM 消息 VO + */ +@Data +@Schema(description = "IM消息") +public class SysImMessageVO { + + @Schema(description = "消息ID") + private String id; + @Schema(description = "会话ID") + private String conversationId; + @Schema(description = "发送人ID") + private String senderId; + @Schema(description = "发送人姓名") + private String senderName; + @Schema(description = "发送人头像") + private String senderAvatar; + @Schema(description = "消息内容") + private String content; + @Schema(description = "消息类型") + private String msgType; + @Schema(description = "是否本人发送") + private Boolean mine; + @Schema(description = "发送时间") + @JsonFormat(timezone = "GMT+8", pattern = "yyyy-MM-dd HH:mm:ss") + @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private Date createTime; +} diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserDepartMapper.java b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserDepartMapper.java index 1f0a390..74de891 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserDepartMapper.java +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/SysUserDepartMapper.java @@ -102,9 +102,17 @@ public interface SysUserDepartMapper extends BaseMapper{ /** * 通过用户id集合获取用户id和部门code - * - * @param userIdList - * @return */ List getUserDepPostByUserIds(@Param("userIdList") List userIdList); + + /** + * 查询同部门用户(精确 orgCode,不含下级部门) + */ + List querySameDepartUserList(@Param("orgCode") String orgCode, @Param("excludeUserId") String excludeUserId, + @Param("tenantId") Integer tenantId, @Param("keyword") String keyword); + + /** + * 用户是否属于指定 orgCode 部门 + */ + int countUserInDepartOrgCode(@Param("userId") String userId, @Param("orgCode") String orgCode, @Param("tenantId") Integer tenantId); } diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml index 237d2e6..743e220 100644 --- a/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml +++ b/jeecg-boot/jeecg-module-system/jeecg-system-biz/src/main/java/org/jeecg/modules/system/mapper/xml/SysUserDepartMapper.xml @@ -142,4 +142,40 @@ #{userId} + + + + + \ No newline at end of file diff --git a/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_109__sys_im_chat.sql b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_109__sys_im_chat.sql new file mode 100644 index 0000000..cf45ff6 --- /dev/null +++ b/jeecg-boot/jeecg-module-system/jeecg-system-start/src/main/resources/flyway/sql/mysql/V3.9.2_109__sys_im_chat.sql @@ -0,0 +1,70 @@ +-- 租户 IM 聊天:表结构 + 我的租户菜单 +SET NAMES utf8mb4; + +CREATE TABLE IF NOT EXISTS `sys_im_conversation` ( + `id` varchar(32) NOT NULL COMMENT '主键', + `conv_type` varchar(10) NOT NULL DEFAULT 'single' COMMENT '会话类型 single单聊', + `user_pair_key` varchar(80) DEFAULT NULL COMMENT '单聊唯一键(较小userId_较大userId)', + `tenant_id` int DEFAULT NULL COMMENT '租户ID', + `last_content` varchar(500) DEFAULT NULL COMMENT '最后一条消息摘要', + `last_time` datetime DEFAULT NULL COMMENT '最后消息时间', + `create_by` varchar(50) DEFAULT NULL COMMENT '创建人', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + `update_by` varchar(50) DEFAULT NULL COMMENT '更新人', + `update_time` datetime DEFAULT NULL COMMENT '更新时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_conv_pair` (`tenant_id`, `user_pair_key`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM会话表'; + +CREATE TABLE IF NOT EXISTS `sys_im_conversation_member` ( + `id` varchar(32) NOT NULL COMMENT '主键', + `conversation_id` varchar(32) NOT NULL COMMENT '会话ID', + `user_id` varchar(32) NOT NULL COMMENT '用户ID', + `unread_count` int NOT NULL DEFAULT 0 COMMENT '未读数', + `last_read_time` datetime DEFAULT NULL COMMENT '最后已读时间', + `create_time` datetime DEFAULT NULL COMMENT '创建时间', + PRIMARY KEY (`id`), + UNIQUE KEY `uk_im_member` (`conversation_id`, `user_id`), + KEY `idx_im_member_user` (`user_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM会话成员表'; + +CREATE TABLE IF NOT EXISTS `sys_im_message` ( + `id` varchar(32) NOT NULL COMMENT '主键', + `conversation_id` varchar(32) NOT NULL COMMENT '会话ID', + `sender_id` varchar(32) NOT NULL COMMENT '发送人ID', + `content` text COMMENT '消息内容', + `msg_type` varchar(20) NOT NULL DEFAULT 'text' COMMENT '消息类型 text/image/file', + `tenant_id` int DEFAULT NULL COMMENT '租户ID', + `create_time` datetime DEFAULT NULL COMMENT '发送时间', + PRIMARY KEY (`id`), + KEY `idx_im_msg_conv_time` (`conversation_id`, `create_time`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='IM消息表'; + +INSERT IGNORE INTO `sys_permission` ( + `id`, `parent_id`, `name`, `url`, `component`, `is_route`, `component_name`, `redirect`, + `menu_type`, `perms`, `perms_type`, `sort_no`, `always_show`, `icon`, `is_leaf`, `keep_alive`, + `hidden`, `hide_tab`, `description`, `create_by`, `create_time`, `update_by`, `update_time`, + `del_flag`, `rule_flag`, `status`, `internal_or_external` +) VALUES ( + '1995000000000000110', '1674708136602542082', 'IM聊天', '/my/ImChat', + 'system/im/ImChat', 1, 'ImChat', NULL, + 1, NULL, '0', 1.10, 0, 'ant-design:message-outlined', 0, 1, + 0, 0, '租户内用户即时聊天', 'admin', NOW(), 'admin', NOW(), + 0, 0, '1', 0 +); + +INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`) +VALUES ('1995000000000000111', '1995000000000000110', '查询', 2, 'sys:im:chat:list', '1', 1.00, 0, 1, 0, '1', 0, 'admin', NOW()); + +INSERT IGNORE INTO `sys_permission` (`id`, `parent_id`, `name`, `menu_type`, `perms`, `perms_type`, `sort_no`, `is_route`, `is_leaf`, `hidden`, `status`, `del_flag`, `create_by`, `create_time`) +VALUES ('1995000000000000112', '1995000000000000110', '发送', 2, 'sys:im:chat:send', '1', 2.00, 0, 1, 0, '1', 0, 'admin', NOW()); + +INSERT INTO `sys_role_permission` (`id`, `role_id`, `permission_id`, `data_rule_ids`, `operate_date`, `operate_ip`) +SELECT REPLACE(UUID(), '-', ''), r.id, p.id, NULL, NOW(), '127.0.0.1' +FROM `sys_role` r +CROSS JOIN `sys_permission` p +WHERE r.`role_code` = 'admin' + AND p.`id` IN ('1995000000000000110', '1995000000000000111', '1995000000000000112') + AND NOT EXISTS ( + SELECT 1 FROM `sys_role_permission` rp WHERE rp.`role_id` = r.`id` AND rp.`permission_id` = p.`id` + ); diff --git a/jeecgboot-vue3/src/hooks/web/useWebSocket.ts b/jeecgboot-vue3/src/hooks/web/useWebSocket.ts index 76bfbce..1b31be6 100644 --- a/jeecgboot-vue3/src/hooks/web/useWebSocket.ts +++ b/jeecgboot-vue3/src/hooks/web/useWebSocket.ts @@ -2,16 +2,55 @@ import { unref } from 'vue'; import { useWebSocket, WebSocketResult } from '@vueuse/core'; +import md5 from 'crypto-js/md5'; import { getToken } from '/@/utils/auth'; +import { useGlobSetting } from '/@/hooks/setting'; +import { useUserStore } from '/@/store/modules/user'; let result: WebSocketResult; const listeners = new Map(); +let connectedUrl = ''; + +/** + * 构建系统 WebSocket 地址(含 context-path,如 /jeecg-boot) + */ +export function buildSystemWebSocketUrl(): string { + const glob = useGlobSetting(); + const userStore = useUserStore(); + const userInfo = unref(userStore.getUserInfo); + if (!userInfo?.id) { + return ''; + } + const token = getToken() || ''; + const wsClientId = md5(token).toString(); + const wsUserId = `${userInfo.id}_${wsClientId}`; + let base = (glob.domainUrl || '').replace('https://', 'wss://').replace('http://', 'ws://'); + base = base.replace(/\/$/, ''); + const apiPath = (glob.apiUrl || '/jeecg-boot').replace(/\/$/, ''); + const prefix = apiPath.startsWith('/') ? apiPath : `/${apiPath}`; + return `${base}${prefix}/websocket/${wsUserId}`; +} + +/** + * 确保 WebSocket 已连接(聊天页等场景可主动调用) + */ +export function ensureWebSocketConnected(): void { + const url = buildSystemWebSocketUrl(); + if (!url) { + return; + } + if (result?.status?.value === 'OPEN' && connectedUrl === url) { + return; + } + connectWebSocket(url); +} /** * 开启 WebSocket 链接,全局只需执行一次 * @param url */ export function connectWebSocket(url: string) { + connectedUrl = url; // 代码逻辑说明: v2.4.6 的 websocket 服务端,存在性能和安全问题。 #3278 const token = (getToken() || '') as string; result = useWebSocket(url, { diff --git a/jeecgboot-vue3/src/layouts/default/header/components/RefreshCache.vue b/jeecgboot-vue3/src/layouts/default/header/components/RefreshCache.vue new file mode 100644 index 0000000..deaf3d7 --- /dev/null +++ b/jeecgboot-vue3/src/layouts/default/header/components/RefreshCache.vue @@ -0,0 +1,42 @@ + + + + + diff --git a/jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue b/jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue new file mode 100644 index 0000000..d11531f --- /dev/null +++ b/jeecgboot-vue3/src/layouts/default/header/components/im-chat/index.vue @@ -0,0 +1,94 @@ + + + + + diff --git a/jeecgboot-vue3/src/layouts/default/header/components/index.ts b/jeecgboot-vue3/src/layouts/default/header/components/index.ts index 1256a19..90367b2 100644 --- a/jeecgboot-vue3/src/layouts/default/header/components/index.ts +++ b/jeecgboot-vue3/src/layouts/default/header/components/index.ts @@ -14,3 +14,7 @@ export const ErrorAction = createAsyncComponent(() => import('./ErrorAction.vue' export const LockScreen = createAsyncComponent(() => import('./LockScreen.vue')); export { FullScreen }; + +export { default as RefreshCache } from './RefreshCache.vue'; + +export const ImChat = createAsyncComponent(() => import('./im-chat/index.vue')); diff --git a/jeecgboot-vue3/src/layouts/default/header/components/notify/index.vue b/jeecgboot-vue3/src/layouts/default/header/components/notify/index.vue index 154c921..33a92a6 100644 --- a/jeecgboot-vue3/src/layouts/default/header/components/notify/index.vue +++ b/jeecgboot-vue3/src/layouts/default/header/components/notify/index.vue @@ -25,10 +25,8 @@ import { useDesign } from '/@/hooks/web/useDesign'; import { useGlobSetting } from '/@/hooks/setting'; import { useUserStore } from '/@/store/modules/user'; - import { connectWebSocket, onWebSocket } from '/@/hooks/web/useWebSocket'; + import { connectWebSocket, onWebSocket, buildSystemWebSocketUrl } from '/@/hooks/web/useWebSocket'; import { readAllMsg } from '/@/views/monitor/mynews/mynews.api'; - import { getToken } from '/@/utils/auth'; - import md5 from 'crypto-js/md5'; import { useRouter } from 'vue-router'; import SysMessageModal from '/@/views/system/message/components/SysMessageModal.vue' @@ -148,12 +146,10 @@ // 初始化 WebSocket function initWebSocket() { - let token = getToken(); - //将登录token生成一个短的标识 - let wsClientId = md5(token); - let userId = unref(userStore.getUserInfo).id + "_" + wsClientId; - // WebSocket与普通的请求所用协议有所不同,ws等同于http,wss等同于https - let url = glob.domainUrl?.replace('https://', 'wss://').replace('http://', 'ws://') + '/websocket/' + userId; + const url = buildSystemWebSocketUrl(); + if (!url) { + return; + } connectWebSocket(url); onWebSocket(onWebSocketMessage); } diff --git a/jeecgboot-vue3/src/layouts/default/header/components/useRefreshCache.ts b/jeecgboot-vue3/src/layouts/default/header/components/useRefreshCache.ts new file mode 100644 index 0000000..0aa3c32 --- /dev/null +++ b/jeecgboot-vue3/src/layouts/default/header/components/useRefreshCache.ts @@ -0,0 +1,46 @@ +import { ref } from 'vue'; +import { useI18n } from '/@/hooks/web/useI18n'; +import { useMessage } from '/@/hooks/web/useMessage'; +import { useUserStore } from '/@/store/modules/user'; +import { refreshCache, queryAllDictItems } from '/@/views/system/dict/dict.api'; +import { refreshDragCache } from '/@/api/common/api'; +import { DB_DICT_DATA_KEY } from '/@/enums/cacheEnum'; +import { removeAuthCache, setAuthCache } from '/@/utils/auth'; + +/** 顶部/用户菜单共用的刷新缓存逻辑 */ +export function useRefreshCache() { + const { t } = useI18n(); + const { createMessage } = useMessage(); + const userStore = useUserStore(); + const loading = ref(false); + + async function clearCache() { + if (loading.value) { + return; + } + loading.value = true; + try { + const result = await refreshCache(); + await refreshDragCache(); + if (result.success) { + const res = await queryAllDictItems(); + removeAuthCache(DB_DICT_DATA_KEY); + setAuthCache(DB_DICT_DATA_KEY, res.result); + createMessage.success(t('layout.header.refreshCacheComplete')); + userStore.setAllDictItems(res.result); + } else { + createMessage.error(t('layout.header.refreshCacheFailure')); + } + } catch { + createMessage.error(t('layout.header.refreshCacheFailure')); + } finally { + loading.value = false; + } + } + + return { + loading, + clearCache, + tooltipTitle: t('layout.header.dropdownItemRefreshCache'), + }; +} diff --git a/jeecgboot-vue3/src/layouts/default/header/components/user-dropdown/index.vue b/jeecgboot-vue3/src/layouts/default/header/components/user-dropdown/index.vue index 00ed266..67928e2 100644 --- a/jeecgboot-vue3/src/layouts/default/header/components/user-dropdown/index.vue +++ b/jeecgboot-vue3/src/layouts/default/header/components/user-dropdown/index.vue @@ -44,7 +44,6 @@ import { useI18n } from '/@/hooks/web/useI18n'; import { useDesign } from '/@/hooks/web/useDesign'; import { useModal } from '/@/components/Modal'; - import { useMessage } from '/src/hooks/web/useMessage'; import { useGo } from '/@/hooks/web/usePage'; import headerImg from '/@/assets/images/header.jpg'; import { propTypes } from '/@/utils/propTypes'; @@ -52,15 +51,11 @@ import { createAsyncComponent } from '/@/utils/factory/createAsyncComponent'; - import { refreshCache, queryAllDictItems } from '/@/views/system/dict/dict.api'; - import { DB_DICT_DATA_KEY } from '/src/enums/cacheEnum'; - import { removeAuthCache, setAuthCache } from '/src/utils/auth'; + import { useRefreshCache } from '../useRefreshCache'; import { getFileAccessHttpUrl } from '/@/utils/common/compUtils'; import { getRefPromise } from '/@/utils/index'; - import { refreshDragCache } from "@/api/common/api"; type MenuEvent = 'logout' | 'doc' | 'lock' | 'cache' | 'depart' | 'defaultHomePage' | 'password' | 'account'; - const { createMessage } = useMessage(); export default defineComponent({ name: 'UserDropdown', components: { @@ -84,6 +79,7 @@ const passwordVisible = ref(false); const lockActionVisible = ref(false); const lockActionRef = ref(null); + const { clearCache } = useRefreshCache(); const getUserInfo = computed(() => { const { realname = '', avatar, desc } = userStore.getUserInfo || {}; @@ -119,22 +115,6 @@ openWindow(SITE_URL); } - // 清除缓存 - async function clearCache() { - const result = await refreshCache(); - const dragRes = await refreshDragCache(); - console.log('dragRes', dragRes); - if (result.success) { - const res = await queryAllDictItems(); - removeAuthCache(DB_DICT_DATA_KEY); - setAuthCache(DB_DICT_DATA_KEY, res.result); - createMessage.success(t('layout.header.refreshCacheComplete')); - // 代码逻辑说明: 【issues/7433】vue3 数据字典优化建议 - userStore.setAllDictItems(res.result); - } else { - createMessage.error(t('layout.header.refreshCacheFailure')); - } - } // 切换部门 function updateCurrentDepart() { loginSelectRef.value.show(); diff --git a/jeecgboot-vue3/src/layouts/default/header/index.vue b/jeecgboot-vue3/src/layouts/default/header/index.vue index 453a692..71490f9 100644 --- a/jeecgboot-vue3/src/layouts/default/header/index.vue +++ b/jeecgboot-vue3/src/layouts/default/header/index.vue @@ -29,10 +29,14 @@ + + + + @@ -64,7 +68,7 @@ import { SettingButtonPositionEnum } from '/@/enums/appEnum'; import { AppLocalePicker } from '/@/components/Application'; - import { UserDropDown, LayoutBreadcrumb, FullScreen, Notify, ErrorAction, LockScreen } from './components'; + import { UserDropDown, LayoutBreadcrumb, FullScreen, Notify, ImChat, ErrorAction, LockScreen, RefreshCache } from './components'; import { useAppInject } from '/@/hooks/web/useAppInject'; import { useDesign } from '/@/hooks/web/useDesign'; @@ -86,9 +90,11 @@ LayoutBreadcrumb, LayoutMenu, UserDropDown, + RefreshCache, AppLocalePicker, FullScreen, Notify, + ImChat, AppSearch, ErrorAction, LockScreen, diff --git a/jeecgboot-vue3/src/store/modules/user.ts b/jeecgboot-vue3/src/store/modules/user.ts index 22bc6f2..1d2270a 100644 --- a/jeecgboot-vue3/src/store/modules/user.ts +++ b/jeecgboot-vue3/src/store/modules/user.ts @@ -198,6 +198,8 @@ export const useUserStore = defineStore({ await this.setLoginInfo({ ...data, isLogin: true }); // 代码逻辑说明: 登录成功后缓存拖拽模块的接口前缀 localStorage.setItem(JDragConfigEnum.DRAG_BASE_URL, useGlobSetting().domainUrl); + // 登录后异步预取 IM 聊天数据,减少打开聊天时的等待 + import('/@/views/system/im/imCache').then(({ prefetchImChatData }) => prefetchImChatData()); // 代码逻辑说明: 修复登录成功后,没有正确重定向的问题 let redirect = router.currentRoute.value?.query?.redirect as string; @@ -284,6 +286,9 @@ export const useUserStore = defineStore({ } } + // 退出登录前清除 IM 聊天缓存 + import('/@/views/system/im/imCache').then(({ clearImCache }) => clearImCache()); + // let username:any = this.userInfo && this.userInfo.username; // if(username){ // removeAuthCache(username) diff --git a/jeecgboot-vue3/src/views/system/im/ImChat.vue b/jeecgboot-vue3/src/views/system/im/ImChat.vue new file mode 100644 index 0000000..4831660 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/ImChat.vue @@ -0,0 +1,993 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/system/im/ImChatModal.vue b/jeecgboot-vue3/src/views/system/im/ImChatModal.vue new file mode 100644 index 0000000..82e88b5 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/ImChatModal.vue @@ -0,0 +1,62 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue b/jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue new file mode 100644 index 0000000..fd249f5 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/ImChatSettingsModal.vue @@ -0,0 +1,76 @@ + + + + + diff --git a/jeecgboot-vue3/src/views/system/im/im.api.ts b/jeecgboot-vue3/src/views/system/im/im.api.ts new file mode 100644 index 0000000..5362627 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/im.api.ts @@ -0,0 +1,41 @@ +import { defHttp } from '/@/utils/http/axios'; + +enum Api { + deptMembers = '/sys/im/chat/deptMembers', + open = '/sys/im/chat/open', + messages = '/sys/im/chat/messages', + send = '/sys/im/chat/send', + read = '/sys/im/chat/read', +} + +export const fetchDeptMembers = (keyword?: string) => defHttp.get({ url: Api.deptMembers, params: { keyword } }); + +export const openConversation = (targetUserId: string) => + defHttp.post({ url: Api.open, params: { targetUserId } }, { joinParamsToUrl: true }); + +export interface FetchMessagesParams { + conversationId: string; + pageSize?: number; + /** 起始时间(含),默认首屏传用户配置的默认天数 */ + startTime?: string; + /** 加载更早消息:取该时间之前的记录 */ + beforeTime?: string; +} + +export const fetchMessages = (params: FetchMessagesParams) => + defHttp.get({ + url: Api.messages, + params: { + pageNo: 1, + pageSize: params.pageSize ?? 20, + conversationId: params.conversationId, + startTime: params.startTime, + beforeTime: params.beforeTime, + }, + }); + +export const sendMessage = (data: { conversationId: string; content: string; msgType?: string }) => + defHttp.post({ url: Api.send, data }); + +export const markRead = (conversationId: string) => + defHttp.put({ url: Api.read, params: { conversationId } }, { joinParamsToUrl: true, successMessageMode: 'none' }); diff --git a/jeecgboot-vue3/src/views/system/im/imCache.ts b/jeecgboot-vue3/src/views/system/im/imCache.ts new file mode 100644 index 0000000..b23beb8 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/imCache.ts @@ -0,0 +1,299 @@ +import dayjs from 'dayjs'; +import { createSessionStorage } from '/@/utils/cache'; +import { useUserStoreWithOut } from '/@/store/modules/user'; +import { fetchDeptMembers, fetchMessages } from './im.api'; +import { getImDefaultStartTime } from './imSettings'; +import { syncImUnreadFromMembers } from './useImUnread'; + +export interface ImMemberItem { + id: string; + username: string; + realname?: string; + avatar?: string; + orgCodeTxt?: string; + conversationId?: string; + lastContent?: string; + lastTime?: string; + unreadCount?: number; +} + +export interface ImMessageItem { + id: string; + conversationId: string; + senderId: string; + senderName?: string; + senderAvatar?: string; + content: string; + msgType?: string; + mine?: boolean; + createTime?: string; +} + +export interface ImMessageCacheEntry { + records: ImMessageItem[]; + hasMore: boolean; + loadedAt: number; +} + +interface ImCacheSnapshot { + members: ImMemberItem[]; + membersLoadedAt: number; + messages: Record; +} + +const MEMBERS_STALE_MS = 60_000; +const MESSAGES_STALE_MS = 30_000; +const PREFETCH_CONV_LIMIT = 5; +const MESSAGE_PAGE_SIZE = 20; +const CACHE_KEY = 'im-chat-data'; + +const sessionCache = createSessionStorage({ timeout: 60 * 60 }); + +let memorySnapshot: ImCacheSnapshot | null = null; +let prefetchPromise: Promise | null = null; +let activeConversationId = ''; + +function getCacheScopeKey(): string | null { + const userStore = useUserStoreWithOut(); + const userId = userStore.getUserInfo?.id; + if (!userId) { + return null; + } + const tenantId = userStore.getTenant; + return `${tenantId || '0'}:${userId}`; +} + +function loadFromSession(): ImCacheSnapshot | null { + const scope = getCacheScopeKey(); + if (!scope) { + return null; + } + return sessionCache.get(`${CACHE_KEY}:${scope}`) || null; +} + +function saveToSession(snapshot: ImCacheSnapshot) { + const scope = getCacheScopeKey(); + if (!scope) { + return; + } + sessionCache.set(`${CACHE_KEY}:${scope}`, snapshot); +} + +function ensureMemory(): ImCacheSnapshot { + if (!memorySnapshot) { + memorySnapshot = loadFromSession() || { + members: [], + membersLoadedAt: 0, + messages: {}, + }; + } + return memorySnapshot; +} + +export function setImActiveConversationId(conversationId: string) { + activeConversationId = conversationId || ''; +} + +export function clearImCache() { + const scope = getCacheScopeKey(); + memorySnapshot = null; + prefetchPromise = null; + activeConversationId = ''; + if (scope) { + sessionCache.remove(`${CACHE_KEY}:${scope}`); + } +} + +export function getCachedMembers(): ImMemberItem[] | null { + const snap = ensureMemory(); + return snap.members.length ? snap.members : null; +} + +export function isMembersCacheStale(): boolean { + const snap = ensureMemory(); + if (!snap.membersLoadedAt || !snap.members.length) { + return true; + } + return Date.now() - snap.membersLoadedAt > MEMBERS_STALE_MS; +} + +export function setCachedMembers(members: ImMemberItem[]) { + const snap = ensureMemory(); + snap.members = members; + snap.membersLoadedAt = Date.now(); + saveToSession(snap); +} + +export function getCachedMessages(conversationId: string): ImMessageCacheEntry | null { + return ensureMemory().messages[conversationId] || null; +} + +export function isMessagesCacheStale(conversationId: string): boolean { + const entry = getCachedMessages(conversationId); + if (!entry) { + return true; + } + return Date.now() - entry.loadedAt > MESSAGES_STALE_MS; +} + +export function setCachedMessages( + conversationId: string, + records: ImMessageItem[], + hasMore: boolean, + options?: { allowEmpty?: boolean }, +) { + const existing = getCachedMessages(conversationId); + if (!options?.allowEmpty && records.length === 0 && existing?.records?.length) { + return; + } + const snap = ensureMemory(); + snap.messages[conversationId] = { + records: [...records], + hasMore, + loadedAt: Date.now(), + }; + saveToSession(snap); +} + +export function appendCachedMessage(conversationId: string, msg: ImMessageItem) { + const snap = ensureMemory(); + if (!snap.messages[conversationId]) { + snap.messages[conversationId] = { + records: [], + hasMore: false, + loadedAt: Date.now(), + }; + } + const entry = snap.messages[conversationId]; + if (entry.records.some((item) => item.id === msg.id)) { + return; + } + entry.records.push(msg); + entry.loadedAt = Date.now(); + saveToSession(snap); +} + +export function patchCachedMember( + userId: string, + patch: Partial, + options?: { moveToTop?: boolean; unreadIncrement?: number }, +) { + const snap = ensureMemory(); + const index = snap.members.findIndex((item) => item.id === userId); + if (index < 0) { + return; + } + const current = snap.members[index]; + const updated: ImMemberItem = { + ...current, + ...patch, + }; + if (options?.unreadIncrement) { + updated.unreadCount = (current.unreadCount || 0) + options.unreadIncrement; + } + const list = snap.members.slice(); + list.splice(index, 1); + if (options?.moveToTop !== false) { + list.unshift(updated); + } else { + list.splice(index, 0, updated); + } + snap.members = list; + saveToSession(snap); +} + +async function prefetchConversationMessages(conversationId: string) { + const cached = getCachedMessages(conversationId); + if (cached && !isMessagesCacheStale(conversationId)) { + return; + } + try { + const page = await fetchMessages({ + conversationId, + pageSize: MESSAGE_PAGE_SIZE, + startTime: getImDefaultStartTime(), + }); + const records: ImMessageItem[] = page?.records || []; + if (records.length > 0) { + setCachedMessages(conversationId, records, records.length >= MESSAGE_PAGE_SIZE); + } + } catch { + // 预取失败不影响主流程,保留已有缓存 + } +} + +/** 登录后/进入系统后异步预取 IM 数据(同事列表 + 最近会话首屏消息) */ +export async function prefetchImChatData(force = false): Promise { + if (prefetchPromise && !force) { + return prefetchPromise; + } + prefetchPromise = (async () => { + try { + const members = ((await fetchDeptMembers()) || []) as ImMemberItem[]; + setCachedMembers(members); + syncImUnreadFromMembers(members); + + const candidates = members + .filter((item) => item.conversationId && (item.unreadCount || item.lastTime)) + .sort((a, b) => dayjs(b.lastTime || 0).valueOf() - dayjs(a.lastTime || 0).valueOf()) + .slice(0, PREFETCH_CONV_LIMIT); + + for (let i = 0; i < candidates.length; i += 3) { + const batch = candidates.slice(i, i + 3); + await Promise.all(batch.map((item) => prefetchConversationMessages(item.conversationId!))); + } + } catch { + // 静默失败,进入聊天页时会重新拉取 + } finally { + prefetchPromise = null; + } + })(); + return prefetchPromise; +} + +/** 顶部角标:收到新消息时更新缓存未读数 */ +export function handleImChatSocket(data: Record) { + if (data.cmd !== 'chat') { + return; + } + const conversationId = data.conversationId as string; + const senderId = data.senderId as string; + const isActiveConversation = !!conversationId && conversationId === activeConversationId; + + if (isActiveConversation) { + const userStore = useUserStoreWithOut(); + const currentUserId = userStore.getUserInfo?.id || ''; + appendCachedMessage(conversationId, { + id: data.messageId, + conversationId, + senderId, + senderName: data.senderName, + senderAvatar: data.senderAvatar, + content: data.content, + msgType: data.msgType, + mine: senderId === currentUserId, + createTime: data.createTime, + }); + patchCachedMember( + senderId, + { + conversationId, + lastContent: data.content, + lastTime: data.createTime, + unreadCount: 0, + }, + { moveToTop: true }, + ); + } else { + patchCachedMember( + senderId, + { + conversationId, + lastContent: data.content, + lastTime: data.createTime, + }, + { unreadIncrement: 1 }, + ); + } + syncImUnreadFromMembers(getCachedMembers() || []); +} diff --git a/jeecgboot-vue3/src/views/system/im/imSettings.ts b/jeecgboot-vue3/src/views/system/im/imSettings.ts new file mode 100644 index 0000000..c56fa41 --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/imSettings.ts @@ -0,0 +1,48 @@ +import dayjs from 'dayjs'; +import { useUserStoreWithOut } from '/@/store/modules/user'; + +const STORAGE_KEY_PREFIX = 'im-chat-default-days'; +const DEFAULT_DAYS = 7; + +export const IM_HISTORY_DAYS_MIN = 0.1; +export const IM_HISTORY_DAYS_MAX = 7; + +export function clampImHistoryDays(days: number): number { + const value = Number(days); + if (Number.isNaN(value)) { + return DEFAULT_DAYS; + } + return Math.min(IM_HISTORY_DAYS_MAX, Math.max(IM_HISTORY_DAYS_MIN, Math.round(value * 10) / 10)); +} + +function getStorageKey(): string { + const userStore = useUserStoreWithOut(); + const userId = userStore.getUserInfo?.id || 'anonymous'; + const tenantId = userStore.getTenant || '0'; + return `${STORAGE_KEY_PREFIX}:${tenantId}:${userId}`; +} + +/** 聊天记录默认展示天数(0.1~7) */ +export function getImDefaultHistoryDays(): number { + const raw = localStorage.getItem(getStorageKey()); + if (!raw) { + return DEFAULT_DAYS; + } + return clampImHistoryDays(Number(raw)); +} + +export function setImDefaultHistoryDays(days: number) { + localStorage.setItem(getStorageKey(), String(clampImHistoryDays(days))); +} + +/** 默认展示范围的起始时间 */ +export function getImDefaultStartTime(): string { + return dayjs().subtract(getImDefaultHistoryDays(), 'day').format('YYYY-MM-DD HH:mm:ss'); +} + +export function isWithinDefaultHistoryRange(time?: string): boolean { + if (!time) { + return false; + } + return !dayjs(time).isBefore(dayjs().subtract(getImDefaultHistoryDays(), 'day')); +} diff --git a/jeecgboot-vue3/src/views/system/im/useImUnread.ts b/jeecgboot-vue3/src/views/system/im/useImUnread.ts new file mode 100644 index 0000000..329edda --- /dev/null +++ b/jeecgboot-vue3/src/views/system/im/useImUnread.ts @@ -0,0 +1,39 @@ +import { ref } from 'vue'; +import { fetchDeptMembers } from './im.api'; +import { getCachedMembers, isMembersCacheStale, setCachedMembers } from './imCache'; + +const totalUnread = ref(0); +let refreshing = false; + +export function syncImUnreadFromMembers(members: Array<{ unreadCount?: number }>) { + totalUnread.value = (members || []).reduce((sum, item) => sum + (item.unreadCount || 0), 0); +} + +export async function refreshImUnread(force = false) { + if (refreshing) { + return; + } + if (!force) { + const cached = getCachedMembers(); + if (cached && !isMembersCacheStale()) { + syncImUnreadFromMembers(cached); + return; + } + } + refreshing = true; + try { + const members = await fetchDeptMembers(); + setCachedMembers(members || []); + syncImUnreadFromMembers(members || []); + } finally { + refreshing = false; + } +} + +export function useImUnread() { + return { + totalUnread, + refreshImUnread, + syncImUnreadFromMembers, + }; +}