This commit is contained in:
oneao committed 2025-12-29 17:31:10 +08:00
1 parent 00b21b6b44
commit 6fd3bf5138
41 files changed
+1183 -98

No files matched your search

@@ -287,14 +287,14 @@ async function loadFriends() {
friends.value = friends.value.concat(records)
}
// 设置当前聊天对象
if (initialFriendId.value) {
const friend = friends.value.find(f => f.b_frienduser_id === initialFriendId.value)
if (friend) {
openPrivateChat(friend)
// 设置当前聊天对象
if (initialFriendId.value) {
const friend = friends.value.find(f => f.b_frienduser_id === initialFriendId.value)
if (friend) {
openPrivateChat(friend)
}
initialFriendId.value = ''
}
initialFriendId.value = ''
}
friendsPage.value.total = total
}
@@ -65,13 +65,13 @@ public class NettyServer {
pipeline.addLast(new CorsHandler(CorsConfig.withAnyOrigin().build()));
pipeline.addLast(new WebSocketServerProtocolHandler(nettyConfig.getPath(), null, true, 65536));
// 4. 空闲检测(60 秒没收到客户端任何消息就断)
pipeline.addLast("idle-handler",
new IdleStateHandler(
60,
0,
0,
TimeUnit.SECONDS
));
// pipeline.addLast("idle-handler",
// new IdleStateHandler(
// 60,
// 0,
// 0,
// TimeUnit.SECONDS
// ));
pipeline.addLast(serverHandler);
pipeline.addLast(authHandler);
pipeline.addLast(dispatchHandler);
@@ -0,0 +1,55 @@
package cn.link56.im.controller;
import cn.link56.common.BasePageVO;
import cn.link56.common.Result;
import cn.link56.common.context.UserContext;
import cn.link56.im.model.dto.CreateFriendDTO;
import cn.link56.im.model.dto.QueryFriendDTO;
import cn.link56.im.model.dto.QueryGroupDTO;
import cn.link56.im.model.vo.QueryFriendVO;
import cn.link56.im.model.vo.QueryGroupVO;
import cn.link56.im.service.ImFriendService;
import cn.link56.im.service.ImGroupService;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import java.util.List;
import java.util.Objects;
@RestController
@RequestMapping("/im")
public class ImController {
@Resource
private ImFriendService imFriendService;
@Resource
private ImGroupService imGroupService;
@PostMapping("/friend/create")
public Result<Void> createFriend(@RequestBody @Validated CreateFriendDTO dto){
Long friendId = dto.getFriendId();
Long userId = UserContext.getUserId();
if (!Objects.equals(friendId, userId)){
imFriendService.createFriend(userId,friendId);
}
return Result.success();
}
@PostMapping("/friend/query")
public Result<BasePageVO<QueryFriendVO>> queryFriends(@RequestBody QueryFriendDTO dto){
BasePageVO<QueryFriendVO> basePageVO = imFriendService.queryFriend(dto);
return Result.success(basePageVO);
}
@PostMapping("/group/query")
public Result<BasePageVO<QueryGroupVO>> queryFriends(@RequestBody QueryGroupDTO dto){
BasePageVO<QueryGroupVO> basePageVO = imGroupService.queryGroup(dto);
return Result.success(basePageVO);
}
}
@@ -9,9 +9,11 @@ public enum MessageTypeEnums {
LOGIN(2),
LOGOUT(3),
ONLINE(4),
OFFLINE(5),
PRIVATE(6),
GROUP(7);
GROUP_ONLINE(5),
OFFLINE(6),
GROUP_OFFLINE(7),
PRIVATE(8),
GROUP(9);
private final int code;
@@ -1,12 +1,9 @@
package cn.link56.im.handler;
import cn.link56.auth.token.model.TokenPayload;
import cn.link56.auth.token.service.AuthTokenService;
import cn.link56.im.enums.MessageTypeEnums;
import cn.link56.im.message.MessageRequest;
import cn.link56.im.model.dto.OnlineDTO;
import cn.link56.im.storage.SessionManager;
import com.alibaba.druid.util.StringUtils;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.ChannelInboundHandlerAdapter;
@@ -33,13 +30,14 @@ public class AuthHandler extends ChannelInboundHandlerAdapter {
MessageRequest req = (MessageRequest) msg;
Integer type = req.getType();
if (type == MessageTypeEnums.PING.getCode() || type == MessageTypeEnums.ONLINE.getCode()){
if (type == MessageTypeEnums.PING.getCode() || type == MessageTypeEnums.LOGIN.getCode()){
ctx.fireChannelRead(req);
return;
}
// 如果该通道未验证
if (!SessionManager.isAuthenticated(ctx.channel())) {
// 不断开连接
ctx.close();
return;
}
@@ -5,33 +5,39 @@ import cn.link56.auth.token.service.AuthTokenService;
import cn.link56.im.enums.MessageTypeEnums;
import cn.link56.im.message.MessageRequest;
import cn.link56.im.message.MessageResponse;
import cn.link56.im.model.dto.OnlineDTO;
import cn.link56.im.model.dto.LoginDTO;
import cn.link56.im.service.ImFriendService;
import cn.link56.im.service.ImGroupUserService;
import cn.link56.im.storage.SessionManager;
import cn.link56.im.utils.MessageDataConverter;
import cn.link56.im.utils.MessageWriter;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandlerContext;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
import java.util.List;
@Component
public class MessageHandler {
@Resource
private AuthTokenService authTokenService;
@Resource
private ImFriendService imFriendService;
@Resource
private ImGroupUserService imGroupUserService;
// 处理消息
public void handleMessage(Channel channel, MessageRequest request) {
System.out.println(request);
switch (MessageTypeEnums.valueOf(request.getType())) {
case PING:
handlePing(channel, request);
break;
case ONLINE:
handleOnline(channel, request);
case LOGIN:
handleLogin(channel,request);
break;
case OFFLINE:
handleOffline(channel, request);
case LOGOUT:
handleLogout(channel,request);
break;
case PRIVATE:
handlePrivate(channel, request);
@@ -49,9 +55,9 @@ public class MessageHandler {
MessageWriter.writeToChannel(channel, MessageResponse.success(MessageTypeEnums.PONG));
}
// 上线
private void handleOnline(Channel channel, MessageRequest request) {
OnlineDTO dto = MessageDataConverter.convert(request.getData(), OnlineDTO.class);
private void handleLogin(Channel channel, MessageRequest request) {
System.out.println("===LOGIN===");
LoginDTO dto = MessageDataConverter.convert(request.getData(), LoginDTO.class);
TokenPayload tokenPayload = authTokenService.parsePayload(dto.getToken());
if (tokenPayload == null) {
@@ -65,11 +71,41 @@ public class MessageHandler {
// 添加 Session 并自动标记已认证
SessionManager.addSession(companyId, userId, channel);
MessageWriter.writeToChannel(channel, MessageResponse.success(MessageTypeEnums.ONLINE));
MessageWriter.writeToChannel(channel, MessageResponse.success(MessageTypeEnums.LOGIN));
handleOnline(channel,userId);
}
private void handleLogout(Channel channel, MessageRequest request) {
System.out.println("===LOGOUT===");
Long userId = SessionManager.getUserId(channel);
if (ObjectUtils.isNotEmpty(userId)){
handleOffline(channel , userId);
SessionManager.removeChannel(channel);
}
}
// 上线:通知好友
private void handleOnline(Channel channel, Long from) {
// 通知给好友
List<Long> onlineFriends = imFriendService.getOnlineFriends(from);
MessageWriter.broadcastToUsers(onlineFriends,MessageResponse.success(MessageTypeEnums.ONLINE).setFrom(from));
// 通知群组
List<Long> onlineGroupUsers = imGroupUserService.getOnlineGroupUsers(from);
MessageWriter.broadcastToUsers(onlineGroupUsers,MessageResponse.success(MessageTypeEnums.GROUP_ONLINE).setFrom(from));
}
// 下线逻辑
private void handleOffline(Channel channel, MessageRequest request) {
private void handleOffline(Channel channel, Long from) {
// 通知给好友
List<Long> onlineFriends = imFriendService.getOnlineFriends(from);
MessageWriter.broadcastToUsers(onlineFriends,MessageResponse.success(MessageTypeEnums.OFFLINE).setFrom(from));
// 通知群组
List<Long> onlineGroupUsers = imGroupUserService.getOnlineGroupUsers(from);
MessageWriter.broadcastToUsers(onlineGroupUsers,MessageResponse.success(MessageTypeEnums.GROUP_OFFLINE).setFrom(from));
}
// 私聊逻辑
@@ -1,18 +1,27 @@
package cn.link56.im.handler;
import cn.link56.im.enums.MessageTypeEnums;
import cn.link56.im.message.MessageRequest;
import cn.link56.im.storage.Session;
import cn.link56.im.storage.SessionManager;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.netty.channel.Channel;
import io.netty.channel.ChannelHandler;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.websocketx.TextWebSocketFrame;
import io.netty.handler.timeout.IdleStateEvent;
import org.apache.commons.lang3.ObjectUtils;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
@Component
@ChannelHandler.Sharable
public class ServerHandler extends SimpleChannelInboundHandler<TextWebSocketFrame> {
@Resource
private MessageHandler messageHandler;
private static final ObjectMapper MAPPER = new ObjectMapper();
@Override
@@ -22,14 +31,32 @@ public class ServerHandler extends SimpleChannelInboundHandler<TextWebSocketFram
@Override
public void handlerRemoved(ChannelHandlerContext ctx) {
System.out.println("客户端断开: " + ctx.channel().id().asShortText());
Channel channel = ctx.channel();
System.out.println("客户端断开: " + channel.id().asShortText());
SessionManager.removeChannel(ctx.channel());
boolean authenticated = SessionManager.isAuthenticated(channel);
if (authenticated){
Long userId = SessionManager.getUserId(channel);
if (ObjectUtils.isEmpty(userId)){
SessionManager.removeChannel(channel);
return;
}
// 通知
MessageRequest request = new MessageRequest();
request.setType(MessageTypeEnums.LOGOUT.getCode());
request.setFrom(userId);
messageHandler.handleMessage(channel,request);
} else {
SessionManager.removeChannel(channel);
}
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, TextWebSocketFrame frame) throws Exception {
MessageRequest request = MAPPER.readValue(frame.text(), MessageRequest.class);
MessageRequest request = MAPPER.readValue(frame.text(), MessageRequest.class);
ctx.fireChannelRead(request);
}
@@ -37,8 +64,17 @@ public class ServerHandler extends SimpleChannelInboundHandler<TextWebSocketFram
@Override
public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception {
if (evt instanceof IdleStateEvent) {
System.out.println("连接超时,关闭: " + ctx.channel().id().asShortText());
ctx.close();
IdleStateEvent idleEvent = (IdleStateEvent) evt; // 强制类型转换
switch (idleEvent.state()) {
case READER_IDLE:
System.out.println("读空闲超时,关闭: " + ctx.channel().id().asShortText());
ctx.close();
break;
case WRITER_IDLE:
case ALL_IDLE:
// 可选处理
break;
}
} else {
super.userEventTriggered(ctx, evt);
}
@@ -50,4 +86,5 @@ public class ServerHandler extends SimpleChannelInboundHandler<TextWebSocketFram
cause.printStackTrace();
ctx.close();
}
}
@@ -0,0 +1,23 @@
package cn.link56.im.mapper;
import cn.link56.im.model.po.ImFriend;
import cn.link56.im.model.vo.QueryFriendVO;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import java.util.List;
@Mapper
public interface ImFriendMapper extends BaseMapper<ImFriend> {
List<QueryFriendVO> selectFriend(
@Param("offset") int offset,
@Param("limit") int limit,
@Param("userId") Long userId,
@Param("name") String name,
@Param("id") Long id
);
Long selectFriendTotal(@Param("userId") Long userId);
}
@@ -0,0 +1,9 @@
package cn.link56.im.mapper;
import cn.link56.im.model.po.ImGroup;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ImGroupMapper extends BaseMapper<ImGroup> {
}
@@ -0,0 +1,9 @@
package cn.link56.im.mapper;
import cn.link56.im.model.po.ImGroupMessage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ImGroupMessageMapper extends BaseMapper<ImGroupMessage> {
}
@@ -0,0 +1,9 @@
package cn.link56.im.mapper;
import cn.link56.im.model.po.ImGroupUser;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ImGroupUserMapper extends BaseMapper<ImGroupUser> {
}
@@ -0,0 +1,9 @@
package cn.link56.im.mapper;
import cn.link56.im.model.po.ImPrivateMessage;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface ImPrivateMessageMapper extends BaseMapper<ImPrivateMessage> {
}
@@ -9,42 +9,79 @@ import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class MessageResponse {
private Integer type;
private Long from;
private Object data;
private Long timestamp;
private Integer code;
private String message;
public static MessageResponse success() {
return buildResponse(null, MessageResponseStatusConstants.SUCCESS, null, null);
// ---------------- 链式 Setter ----------------
public MessageResponse setType(MessageTypeEnums type) {
this.type = type.getCode();
return this;
}
public static MessageResponse success(MessageTypeEnums messageTypeEnums) {
return buildResponse(messageTypeEnums.getCode(), MessageResponseStatusConstants.SUCCESS, null, null);
public MessageResponse setFrom(Long from) {
this.from = from;
return this;
}
public static MessageResponse error() {
return buildResponse(null, MessageResponseStatusConstants.ERROR, null, "系统异常");
public MessageResponse setData(Object data) {
this.data = data;
return this;
}
public MessageResponse setTimestamp(Long timestamp) {
this.timestamp = timestamp;
return this;
}
public MessageResponse setCode(Integer code) {
this.code = code;
return this;
}
public MessageResponse setMessage(String message) {
this.message = message;
return this;
}
// ---------------- 构建方法 ----------------
public static MessageResponse success(MessageTypeEnums type) {
return new MessageResponse()
.setCode(MessageResponseStatusConstants.SUCCESS)
.setType(type)
.setTimestamp(TimeUtils.getCurrentTimestampMillis());
}
public static MessageResponse success(MessageTypeEnums type, Long from) {
return new MessageResponse()
.setCode(MessageResponseStatusConstants.SUCCESS)
.setType(type)
.setFrom(from)
.setTimestamp(TimeUtils.getCurrentTimestampMillis());
}
public static MessageResponse error(String msg) {
return buildResponse(null, MessageResponseStatusConstants.ERROR, null, msg);
return new MessageResponse()
.setCode(MessageResponseStatusConstants.ERROR)
.setMessage(msg)
.setTimestamp(TimeUtils.getCurrentTimestampMillis());
}
private static MessageResponse buildResponse(Integer type, Integer code, Object data, String message) {
MessageResponse response = new MessageResponse();
response.setType(type);
response.setCode(code);
response.setData(data);
response.setMessage(message);
response.setTimestamp(TimeUtils.getCurrentTimestampMillis());
return response;
public static MessageResponse error(String msg, MessageTypeEnums type, Long from) {
return new MessageResponse()
.setCode(MessageResponseStatusConstants.ERROR)
.setMessage(msg)
.setType(type)
.setFrom(from)
.setTimestamp(TimeUtils.getCurrentTimestampMillis());
}
}
@@ -0,0 +1,15 @@
package cn.link56.im.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import javax.validation.constraints.NotNull;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class CreateFriendDTO {
@NotNull(message = "好友ID不能为空")
private Long friendId;
}
@@ -7,6 +7,6 @@ import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class OnlineDTO {
public class LoginDTO {
private String token;
}
@@ -0,0 +1,17 @@
package cn.link56.im.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class QueryFriendDTO {
private Integer currentPage;
private Integer pageSize;
private String name;
private Long id;
}
@@ -0,0 +1,17 @@
package cn.link56.im.model.dto;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class QueryGroupDTO {
private Integer currentPage;
private Integer pageSize;
private String name;
private Long id;
}
@@ -0,0 +1,46 @@
package cn.link56.im.model.po;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.experimental.Accessors;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("b_im_friend")
@Accessors(chain = true)
public class ImFriend {
@TableId(value = "b_id")
private Long id;
@TableField("b_user_id")
private Long userId;
@TableField("b_friend_id")
private Long friendId;
@TableField("b_unread_count")
private Integer unreadCount;
@TableField("b_last_message_id")
private Long lastMessageId;
@TableField(
value = "b_create_time",
fill = FieldFill.INSERT
)
private Date createTime;
@TableField(
value = "b_update_time",
fill = FieldFill.INSERT_UPDATE
)
private Date updateTime;
}
@@ -0,0 +1,56 @@
package cn.link56.im.model.po;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("b_im_group")
public class ImGroup {
@TableId(value = "b_id")
private Long id;
@TableField("b_name")
private String name;
@TableField("b_description")
private String description;
@TableField("b_last_message_id")
private Long lastMessageId;
@TableField("b_avatar")
private String avatar;
@TableField(
value = "b_create_by",
fill = FieldFill.INSERT
)
private Long createBy;
@TableField(
value = "b_create_time",
fill = FieldFill.INSERT
)
private Date createTime;
@TableField(
value = "b_update_by",
fill = FieldFill.INSERT_UPDATE
)
private Long updateBy;
@TableField(
value = "b_update_time",
fill = FieldFill.INSERT_UPDATE
)
private Date updateTime;
}
@@ -0,0 +1,32 @@
package cn.link56.im.model.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("b_im_group_message")
public class ImGroupMessage {
@TableId(value = "b_id")
private Long id;
@TableField("b_sender_id")
private Long senderId;
@TableField("b_group_id")
private Long groupId;
@TableField("b_content")
private String content;
@TableField("b_type")
private Integer type;
@TableField("b_send_time")
private Long sendTime;
}
@@ -0,0 +1,30 @@
package cn.link56.im.model.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("b_im_group_user")
public class ImGroupUser {
@TableField("b_group_id")
private Long groupId;
@TableField("b_user_id")
private Long userId;
@TableField("b_join_time")
private Date joinTime;
@TableField("b_last_active_time")
private Date lastActiveTime;
@TableField("b_unread_count")
private Integer unreadCount;
}
@@ -0,0 +1,32 @@
package cn.link56.im.model.po;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName("b_im_private_message")
public class ImPrivateMessage {
@TableId(value = "b_id")
private Long id;
@TableField("b_sender_id")
private Long senderId;
@TableField("b_receiver_id")
private Long receiverId;
@TableField("b_content")
private String content;
@TableField("b_type")
private Integer type;
@TableField("b_send_time")
private Long sendTime;
}
@@ -0,0 +1,27 @@
package cn.link56.im.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.Date;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class QueryFriendVO {
private Long id;
private Long friendId;
private String friendName;
private String friendAvatar;
private Integer unreadCount;
private Long lastMessageId;
private Long laseMessageTime;
private Integer lastMessageType;
private String lastMessageContent;
private Boolean isOnline;
}
@@ -0,0 +1,21 @@
package cn.link56.im.model.vo;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@AllArgsConstructor
@NoArgsConstructor
public class QueryGroupVO {
private Long id;
private String name;
private String avatar;
private Integer unreadCount;
private Long lastMessageId;
private Long laseMessageTime;
private Integer lastMessageType;
private String lastMessageContent;
}
@@ -0,0 +1,90 @@
package cn.link56.im.service;
import cn.link56.common.BasePageVO;
import cn.link56.common.context.UserContext;
import cn.link56.common.utils.snowflake.idgen.IdGenerator;
import cn.link56.im.mapper.ImFriendMapper;
import cn.link56.im.model.dto.QueryFriendDTO;
import cn.link56.im.model.po.ImFriend;
import cn.link56.im.model.vo.QueryFriendVO;
import cn.link56.im.storage.SessionManager;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.apache.ibatis.annotations.Mapper;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ImFriendService extends ServiceImpl<ImFriendMapper, ImFriend> {
@Resource
private ImFriendMapper imFriendMapper;
public void createFriend(Long userId,Long friendId){
// 先检查是否存在
LambdaQueryWrapper<ImFriend> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ImFriend::getUserId,userId)
.eq(ImFriend::getFriendId,friendId);
Long selectCount = imFriendMapper.selectCount(queryWrapper);
if (selectCount > 0){
return;
}
// 不存在则插入
ImFriend imFriend = new ImFriend();
imFriend.setId(IdGenerator.nextId())
.setUserId(userId)
.setFriendId(friendId)
.setUnreadCount(0);
imFriendMapper.insert(imFriend);
}
public BasePageVO<QueryFriendVO> queryFriend(QueryFriendDTO dto){
Long userId = UserContext.getUserId();
String name = dto.getName();
Long id = dto.getId();
int offset = (dto.getCurrentPage() - 1) * dto.getPageSize() + 1;
int limit = dto.getCurrentPage() * dto.getPageSize();
// 分页查询
List<QueryFriendVO> queryFriendVOS = imFriendMapper.selectFriend(offset, limit,userId, name, id);
queryFriendVOS.forEach(item -> {
Long friendId = item.getFriendId();
item.setIsOnline(SessionManager.isOnline(friendId));
});
// 查询总数
Long total = imFriendMapper.selectFriendTotal(userId);
BasePageVO<QueryFriendVO> basePageVO = new BasePageVO<>();
basePageVO.setRecords(queryFriendVOS);
basePageVO.setTotal(total);
return basePageVO;
}
// 获取在线好友ID列表
public List<Long> getOnlineFriends(Long userId) {
LambdaQueryWrapper<ImFriend> queryWrapper = new LambdaQueryWrapper<>();
queryWrapper.eq(ImFriend::getUserId, userId)
.select(ImFriend::getFriendId);
List<ImFriend> friends = imFriendMapper.selectList(queryWrapper);
return friends.stream()
.map(ImFriend::getFriendId)
.filter(SessionManager::isOnline)
.collect(Collectors.toList());
}
}
@@ -0,0 +1,11 @@
package cn.link56.im.service;
import cn.link56.im.mapper.ImGroupMessageMapper;
import cn.link56.im.model.po.ImGroupMessage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@Service
public class ImGroupMessageService extends ServiceImpl<ImGroupMessageMapper, ImGroupMessage> {
}
@@ -0,0 +1,28 @@
package cn.link56.im.service;
import cn.link56.common.BasePageVO;
import cn.link56.im.mapper.ImGroupMapper;
import cn.link56.im.model.dto.QueryGroupDTO;
import cn.link56.im.model.po.ImGroup;
import cn.link56.im.model.vo.QueryGroupVO;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
@Service
public class ImGroupService extends ServiceImpl<ImGroupMapper, ImGroup>{
@Resource
private ImGroupMapper imGroupMapper;
public BasePageVO<QueryGroupVO> queryGroup(QueryGroupDTO dto) {
String name = dto.getName();
Long id = dto.getId();
int offset = (dto.getCurrentPage() - 1) * dto.getPageSize() + 1;
int limit = dto.getCurrentPage() * dto.getPageSize();
return new BasePageVO<>();
}
}
@@ -0,0 +1,47 @@
package cn.link56.im.service;
import cn.link56.im.mapper.ImGroupUserMapper;
import cn.link56.im.model.po.ImGroupUser;
import cn.link56.im.storage.SessionManager;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
import javax.annotation.Resource;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
@Service
public class ImGroupUserService extends ServiceImpl<ImGroupUserMapper, ImGroupUser> {
@Resource
private ImGroupUserMapper imGroupUserMapper;
// 获取群组在线用户列表
public List<Long> getOnlineGroupUsers(Long userId) {
// 1️⃣ 查询用户所在的群组ID列表
LambdaQueryWrapper<ImGroupUser> groupWrapper = new LambdaQueryWrapper<>();
groupWrapper.eq(ImGroupUser::getUserId, userId)
.select(ImGroupUser::getGroupId);
List<ImGroupUser> userGroups = imGroupUserMapper.selectList(groupWrapper);
List<Long> groupIds = userGroups.stream()
.map(ImGroupUser::getGroupId)
.collect(Collectors.toList());
if (groupIds.isEmpty()) return Collections.emptyList();
// 2️⃣ 查询这些群组的成员ID
LambdaQueryWrapper<ImGroupUser> memberWrapper = new LambdaQueryWrapper<>();
memberWrapper.in(ImGroupUser::getGroupId, groupIds)
.select(ImGroupUser::getUserId);
List<ImGroupUser> groupMembers = imGroupUserMapper.selectList(memberWrapper);
// 3️⃣ 过滤在线用户
return groupMembers.stream()
.map(ImGroupUser::getUserId)
.filter(SessionManager::isOnline) // 内存判断在线
.distinct() // 去重
.collect(Collectors.toList());
}
}
@@ -0,0 +1,10 @@
package cn.link56.im.service;
import cn.link56.im.mapper.ImPrivateMessageMapper;
import cn.link56.im.model.po.ImPrivateMessage;
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
import org.springframework.stereotype.Service;
@Service
public class ImPrivateMessageService extends ServiceImpl<ImPrivateMessageMapper, ImPrivateMessage> {
}
@@ -6,7 +6,6 @@ import io.netty.util.AttributeKey;
import java.util.concurrent.ConcurrentHashMap;
public class SessionManager {
/** userId -> Session */
private static final ConcurrentHashMap<Long, Session> sessions = new ConcurrentHashMap<>();
@@ -42,8 +42,6 @@ public class MessageWriter {
String payload = MAPPER.writeValueAsString(message);
// 发送给客户端
channel.writeAndFlush(new TextWebSocketFrame(payload));
System.out.println("===发送消息===");
System.out.println(payload);
} catch (Exception e) {
System.out.println("===发送消息失败===");
e.printStackTrace();
@@ -0,0 +1,42 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.link56.im.mapper.ImFriendMapper">
<select id="selectFriend" resultType="cn.link56.im.model.vo.QueryFriendVO">
SELECT *
FROM (
SELECT
f.b_id AS id,
f.b_friend_id AS friendId,
u.b_name AS friendName,
u.b_avatar AS friendAvatar,
f.b_unread_count AS unreadCount,
m.b_id AS lastMessageId,
m.b_send_time AS lastMessageTime,
m.b_type AS lastMessageType,
m.b_content AS lastMessageContent,
ROW_NUMBER() OVER (
ORDER BY
<if test="id != null and id != 0">
CASE WHEN f.b_id = #{id} THEN 0 ELSE 1 END,
</if>
m.b_send_time DESC
) AS rn
FROM b_im_friend f
LEFT JOIN b_company_user u ON f.b_friend_id = u.b_id
LEFT JOIN b_im_private_message m ON f.b_last_message_id = m.b_id
WHERE f.b_user_id = #{userId}
) AS t
WHERE t.rn BETWEEN #{offset} AND #{limit}
</select>
<select id="selectFriendTotal" resultType="java.lang.Long">
SELECT COUNT(*)
FROM b_im_friend f
WHERE f.b_user_id = #{userId}
</select>
</mapper>
@@ -0,0 +1,43 @@
<?xml version="1.0" encoding="UTF-8" ?>
<!DOCTYPE mapper
PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
"http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.link56.im.mapper.ImGroupMapper">
<select id="selectGroup" resultType="cn.link56.im.model.vo.QueryGroupVO">
SELECT *
FROM (
SELECT
g.b_id AS id,
g.b_name AS name,
g.b_avatar AS avatar,
gu.b_unread_count AS unreadCount,
m.b_id AS lastMessageId,
m.b_send_time AS lastMessageTime,
m.b_type AS lastMessageType,
m.b_content AS lastMessageContent,
ROW_NUMBER() OVER (
ORDER BY
<if test="id != null and id != 0">
CASE WHEN g.b_id = #{id} THEN 0 ELSE 1 END,
</if>
m.b_send_time DESC
) AS rn
FROM b_im_group g
INNER JOIN b_im_group_user gu ON g.b_id = gu.b_group_id
LEFT JOIN b_im_group_message m ON gu.b_last_message_id = m.b_id
WHERE gu.b_user_id = #{userId}
) AS t
WHERE t.rn BETWEEN #{offset} AND #{limit}
</select>
<select id="selectGroupTotal" resultType="java.lang.Long">
SELECT COUNT(DISTINCT g.b_id)
FROM b_im_group g
INNER JOIN b_im_group_user gu ON g.b_id = gu.b_group_id
WHERE gu.b_user_id = #{userId}
</select>
</mapper>
+2 -7
View File
@@ -4,25 +4,20 @@ import { onBeforeUnmount, onMounted } from 'vue'
import { initBaseApi } from '@/request/api/base'
import { useCommonStore } from '@/store/common'
import { getPostTagApi } from './request/api/post'
import { useUserStore } from './store/user'
import { useWebSocket } from './store/websocket'
const websocket = useWebSocket()
const commonOptions = useCommonStore()
const userStore = useUserStore()
onMounted(() => {
websocket.connect()
if (userStore.isLogined) {
// websocket.setAuthToken(userStore.token)
websocket.setAuthToken("123")
}
handleInitData()
})
onBeforeUnmount(() => {
websocket.logout()
websocket.close()
})
@@ -0,0 +1,10 @@
import type { CreateFriendParams, ImFriend, QueryFriendParams } from '@/types/im'
import { httpPostJson } from '@/request/utils'
export function createFriendApi(params: CreateFriendParams) {
return httpPostJson<void>(`/im/friend/create`, params)
}
export function queryFriendApi(params: QueryFriendParams) {
return httpPostJson<BasePageResult<ImFriend>>('/im/friend/query', params)
}
@@ -1,6 +1,7 @@
import type { MessageRequest, MessageResponse } from '@/types/im'
import { readonly, ref } from 'vue'
import { MessageType } from '@/types/im'
import { useUserStore } from './user'
interface UseWebSocketOptions {
url: string
@@ -14,6 +15,7 @@ interface UseWebSocketOptions {
let singleton: ReturnType<typeof createWebSocket> | null = null
function createWebSocket(options?: Partial<UseWebSocketOptions>) {
const userStore = useUserStore()
/* ================== 配置 ================== */
const config: Required<UseWebSocketOptions> = {
url:
@@ -33,7 +35,7 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
const connected = ref(false)
const authenticated = ref(false)
let cachedToken: string | null = null
let cachedToken: string | null = userStore.token
let heartbeatTimer: number | null = null
let pongTimeoutTimer: number | null = null
@@ -75,6 +77,10 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
ws.onmessage = (evt) => {
const msg: MessageResponse = JSON.parse(evt.data)
console.log('===收到消息===')
console.log(msg)
handleMessage(msg)
}
@@ -178,7 +184,7 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
const msg: MessageRequest = {
type,
from: 0,
from: userStore.user.id,
to: to ?? null,
timestamp: Date.now(),
data: data ?? null,
@@ -196,15 +202,9 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
}
/* ================== 登录相关 ================== */
function setAuthToken(token: string | null) {
cachedToken = token
if (token && connected.value) {
login(token)
}
}
function login(token: string) {
console.log('触发login:', authenticated.value)
if (!connected.value || authenticated.value)
return
cachedToken = token
@@ -214,7 +214,6 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
function logout() {
cachedToken = null
authenticated.value = false
sendRaw(MessageType.LOGOUT)
}
/* ================== 消息处理(核心修改点) ================== */
@@ -264,7 +263,6 @@ function createWebSocket(options?: Partial<UseWebSocketOptions>) {
return {
connect,
close,
setAuthToken,
login,
logout,
@@ -33,6 +33,16 @@ export interface Company {
email: string
}
export interface CompanyDetailUser {
id: number
companyId: number
phone: string
email: string
role: number
position: string
name: string
}
export interface CompanyDetail {
id: number
name: string
@@ -44,15 +54,7 @@ export interface CompanyDetail {
email: string
description: string
establishmentDate: string
users: {
id: number
companyId: number
phone: string
email: string
role: number
position: string
name: string
}[]
users: CompanyDetailUser[]
}
export interface UpdateCompanyParams {
@@ -1,3 +1,4 @@
// WebSocket 相关
export enum MessageType {
PING = 0,
PONG = 1,
@@ -6,10 +7,12 @@ export enum MessageType {
LOGOUT = 3,
ONLINE = 4,
OFFLINE = 5,
GROUP_ONLINE = 5,
OFFLINE = 6,
GROUP_OFFLINE = 7,
PRIVATE = 6,
GROUP = 7,
PRIVATE = 8,
GROUP = 9,
}
export interface MessageRequest {
@@ -29,3 +32,33 @@ export interface MessageResponse {
code: number
message: string
}
// http 相关
export interface CreateFriendParams {
friendId: number
}
export interface QueryFriendParams {
currentPage: number
pageSize: number
name: string
id: number | null
}
export interface ImFriend {
id: number
friendId: number
friendName: string
friendAvatar: string
unreadCount: number
lastMessageId: number
lastMessageTime: number
lastMessageType: number
lastMessageContent: string
isOnline: boolean
}
@@ -113,3 +113,54 @@ export function formatDate(
return date.format(format)
}
/**
* 格式化聊天时间
* @param timestamp - 毫秒级时间戳
* @param mode - 显示模式:'list' 为列表,'chat' 为聊天记录
* @returns 格式化后的时间字符串
*/
export function formatImTime(timestamp: number | null | undefined, mode: 'list' | 'chat' = 'list'): string {
if (!timestamp)
return ''
const now = new Date()
const date = new Date(timestamp)
const hour = String(date.getHours()).padStart(2, '0')
const minute = String(date.getMinutes()).padStart(2, '0')
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
// 去除时分秒的日期
const nowMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate())
const dateMidnight = new Date(date.getFullYear(), date.getMonth(), date.getDate())
const diffDays = Math.floor((nowMidnight.getTime() - dateMidnight.getTime()) / (1000 * 3600 * 24))
// 1️⃣ 当天
if (diffDays === 0)
return `${hour}:${minute}`
// 2️⃣ 昨天
if (diffDays === 1)
return `昨天 ${hour}:${minute}`
// 3️⃣ 一周内(周一到周日)
const weekStart = new Date(nowMidnight.getTime() - ((now.getDay() || 7) - 1) * 24 * 3600 * 1000)
if (dateMidnight >= weekStart) {
return mode === 'chat'
? `${weekDays[date.getDay()]} ${hour}:${minute}`
: weekDays[date.getDay()]
}
// 4️⃣ 本年或跨年(聊天记录模式:统一显示 YYYY/MM/DD HH:mm)
const ymd = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
if (mode === 'chat')
return `${ymd} ${hour}:${minute}`
// 列表模式区分本年/跨年
if (date.getFullYear() === now.getFullYear())
return `${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`
return ymd
}
@@ -1,6 +1,6 @@
<script lang="ts" setup>
import type { AnchorMenuItem } from '@/components/anchor-menu/type'
import type { CompanyDetail } from '@/types/company'
import type { CompanyDetail, CompanyDetailUser } from '@/types/company'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
@@ -8,8 +8,12 @@ import AnchorMenu from '@/components/anchor-menu/index.vue'
import WangEditor from '@/components/wang-editor/index.vue'
import { getCompanyDetailApi } from '@/request/api/company'
import { createFriendApi } from '@/request/api/im'
import { useUserStore } from '@/store/user'
import { formatDate } from '@/utils/time'
const userStore = useUserStore()
const route = useRoute()
const router = useRouter()
@@ -78,6 +82,28 @@ onMounted(() => {
function handleUpdateEditorDirectory(menus: AnchorMenuItem[]) {
anchorMenu.value[3].children.push(...menus)
}
function handleToIm(user: CompanyDetailUser) {
if (userStore.user.id === user.id) {
ElMessage.warning('不能与自己发起聊天')
return
}
createFriendApi({
friendId: user.id,
}).then((res) => {
if (res.isSuccess) {
router.push('/im')
}
else {
ElMessage.error(res.message || '发起聊天失败,请稍后再试')
}
}).catch((err) => {
console.log(err)
ElMessage.error('发起聊天失败,请稍后再试')
})
}
</script>
<template>
@@ -145,8 +171,8 @@ function handleUpdateEditorDirectory(menus: AnchorMenuItem[]) {
<el-table-column prop="phone" label="手机号" width="180" />
<el-table-column prop="email" label="邮箱号" width="180" />
<el-table-column fixed="right" label="操作" min-width="120">
<template #default>
<el-button type="primary" size="small" text bg>
<template #default="scope">
<el-button v-if="scope.row.id !== userStore.user.id" type="primary" size="small" text bg @click="handleToIm(scope.row)">
发起聊天
</el-button>
</template>
@@ -1,9 +1,108 @@
<script lang="ts" setup>
import type { ImFriend } from '@/types/im'
import { Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { queryFriendApi } from '@/request/api/im'
import { useWebSocket } from '@/store/websocket'
import { MessageType } from '@/types/im'
import { formatImTime } from '@/utils/time'
const route = useRoute()
const websocket = useWebSocket()
// 当前聊天对象
const activeChatTab = ref<'private' | 'group'>('private')
const initialFriendId = ref<number | null>(
route.params.friendId ? Number(route.params.friendId) : null,
)
// 好友列表
const friends = ref<ImFriend[]>([])
const friendsPage = ref({
page: 1,
pageSize: 10,
total: 0,
loading: false,
})
// 初始化
onMounted(() => {
loadFriends()
websocket.onMessage((msg) => {
const { type, from } = msg
const updateOnlineStatus = (isOnline: boolean) => {
const friend = friends.value.find(item => item.friendId === from)
if (friend)
friend.isOnline = isOnline
}
if (type === MessageType.ONLINE) {
updateOnlineStatus(true)
}
else if (type === MessageType.OFFLINE) {
updateOnlineStatus(false)
}
})
})
// 加载好友列表
async function loadFriends() {
if (friendsPage.value.loading)
return
friendsPage.value.loading = true
try {
const res = await queryFriendApi({
currentPage: friendsPage.value.page,
pageSize: friendsPage.value.pageSize,
name: '',
id: initialFriendId.value,
})
if (res.isSuccess) {
const { records, total } = res.data
if (friendsPage.value.page === 1) {
friends.value = records
}
else {
// 合并并去重
const merged = friends.value.concat(records)
const map = new Map<number, typeof merged[0]>()
for (const f of merged) {
map.set(f.id, f)
}
friends.value = Array.from(map.values())
}
// 设置当前聊天对象
if (initialFriendId.value) {
const friend = friends.value.find(f => f.id === initialFriendId.value)
if (friend) {
// openPrivateChat(friend)
}
initialFriendId.value = null
}
friendsPage.value.total = total
}
else {
ElMessage.error(res.message || '加载好友列表失败,请稍后再试')
}
}
catch (err) {
console.log(err)
ElMessage.error('加载好友列表失败,请稍后再试')
}
finally {
friendsPage.value.loading = false
}
}
</script>
<template>
@@ -40,15 +139,88 @@ const websocket = useWebSocket()
<div class="im__list-search">
<el-input placeholder="请根据名称搜索" :prefix-icon="Search" />
</div>
<el-tabs v-model="activeChatTab" class="im__list-tabs">
<el-tab-pane label="单聊" name="private" />
<el-tab-pane label="群聊" name="group" />
</el-tabs>
<div class="im__list-item">
<div class="im__list-body">
<div
v-for="item in friends" :key="item.id" class="im__chat-item"
>
<el-badge
is-dot
:color="item.isOnline ? '#24e68a' : '#bfbfbf'"
:badge-style="{
width: '12px',
height: '12px',
}"
:offset="[-4, 34]"
>
<SafeImage class="im__chat-item-avatar" :url="item.friendAvatar" :size="40" round />
</el-badge>
<div class="im__chat-item-info">
<div class="im__chat-item-main">
<div class="im__chat-item-name">
{{ item.friendName }}
</div>
<div class="im__chat-item-last-time">
{{ formatImTime(item.lastMessageTime, 'list') }}
</div>
</div>
<div class="im__chat-item-message">
<div class="im__chat-item-last-message">
{{ item.lastMessageContent }}
</div>
<el-badge :value="item.unreadCount || 0" :show-zero="false" :max="99" class="im__chat-item-unread-count" />
</div>
</div>
</div>
</div>
</div>
<!-- <div class="im__list-item">
<div class="im__list-header">
<SvgIcon icon="tabler:message-circle" :size="16" />
<span class="im__list-header-title">
单聊
</span>
</div>
</div>
<div class="im__list-body">
<div
v-for="item in friends" :key="item.id" class="im__chat-item"
>
<el-badge
is-dot
:color="item.isOnline ? '#24e68a' : '#bfbfbf'"
:badge-style="{
width: '12px',
height: '12px',
}"
:offset="[-4, 34]"
>
<SafeImage class="im__chat-item-avatar" :url="item.friendAvatar" :size="40" round />
</el-badge>
<div class="im__chat-item-info">
<div class="im__chat-item-main">
<div class="im__chat-item-name">
{{ item.friendName }}
</div>
<div class="im__chat-item-last-time">
{{ formatImTime(item.lastMessageTime, 'list') }}
</div>
</div>
<div class="im__chat-item-message">
<div class="im__chat-item-last-message">
{{ item.lastMessageContent }}
</div>
<el-badge :value="item.unreadCount || 0" :show-zero="false" :max="99" class="im__chat-item-unread-count" />
</div>
</div>
</div>
</div>
</div> -->
</div>
</div>
</div>
@@ -62,6 +234,7 @@ const websocket = useWebSocket()
align-items: center;
width: 100%;
background-color: #f5f6fa;
margin-top: 60px;
& *::-webkit-scrollbar {
width: 6px;
@@ -186,6 +359,18 @@ const websocket = useWebSocket()
border-bottom: 1px solid #e6e9ef;
}
.im__list-tabs {
:deep(.el-tabs__header) {
margin: 0;
.el-tabs__nav {
width: 100%;
.el-tabs__item {
width: 100%;
}
}
}
}
.im__list-item {
display: flex;
flex-direction: column;
@@ -244,7 +429,7 @@ const websocket = useWebSocket()
}
.im__chat-item-name {
font-size: 14px;
font-size: 16px;
font-weight: 500;
}
.im__chat-item-last-time,