From f0a6e039892906f284a9e0318ac86cb171bc052b Mon Sep 17 00:00:00 2001 From: wangys <3401275564@qq.com> Date: Mon, 22 Dec 2025 13:08:08 +0800 Subject: [PATCH] chatRoom --- .../postgres/sql/createTableWorkcase.sql | 15 +- .../workcase/constant/WorkcaseConstant.java | 134 ++++ .../xyzh/api/workcase/dto/TbChatRoomDTO.java | 6 +- .../api/workcase/dto/TbChatRoomMemberDTO.java | 13 +- ...sageDTO.java => TbChatRoomMessageDTO.java} | 6 +- .../workcase/dto/TbMeetingParticipantDTO.java | 6 +- .../dto/TbMeetingTranscriptionDTO.java | 6 +- .../api/workcase/dto/TbVideoMeetingDTO.java | 6 +- .../api/workcase/service/ChatRoomService.java | 201 ++++++ .../api/workcase/service/MeetService.java | 186 ++++++ .../xyzh/api/workcase/vo/ChatMemberVO.java | 3 - ...tMessageVO.java => ChatRoomMessageVO.java} | 2 +- .../api/workcase/vo/MeetingParticipantVO.java | 13 +- .../workcase/vo/MeetingTranscriptionVO.java | 13 +- .../xyzh/api/workcase/vo/VideoMeetingVO.java | 4 +- .../common/redis/service/RedisService.java | 13 + .../config/RedisSubscriberConfig.java | 42 ++ .../listener/ChatMessageListener.java | 63 ++ .../workcase/mapper/TbChatMessageMapper.java | 18 +- .../workcase/service/ChatRoomServiceImpl.java | 628 ++++++++++++++++++ .../workcase/service/MeetServiceImpl.java | 450 +++++++++++++ .../resources/mapper/TbChatMessageMapper.xml | 24 +- .../mapper/TbChatRoomMemberMapper.xml | 10 +- urbanLifelineServ/workcase/聊天室实现文档.md | 233 +++++++ urbanLifelineServ/workcase/聊天室广播方案.md | 553 --------------- .../shared/src/types/workcase/chatRoom.ts | 2 - 26 files changed, 2023 insertions(+), 627 deletions(-) create mode 100644 urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/constant/WorkcaseConstant.java rename urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/{TbChatMessageDTO.java => TbChatRoomMessageDTO.java} (94%) create mode 100644 urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/ChatRoomService.java create mode 100644 urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/MeetService.java rename urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/{ChatMessageVO.java => ChatRoomMessageVO.java} (97%) create mode 100644 urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/config/RedisSubscriberConfig.java create mode 100644 urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/listener/ChatMessageListener.java create mode 100644 urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/ChatRoomServiceImpl.java create mode 100644 urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/MeetServiceImpl.java create mode 100644 urbanLifelineServ/workcase/聊天室实现文档.md delete mode 100644 urbanLifelineServ/workcase/聊天室广播方案.md diff --git a/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql b/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql index 8e032fe3..dbd95f1e 100644 --- a/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql +++ b/urbanLifelineServ/.bin/database/postgres/sql/createTableWorkcase.sql @@ -63,9 +63,8 @@ CREATE TABLE workcase.tb_chat_room_member( member_id VARCHAR(50) NOT NULL, -- 成员记录ID room_id VARCHAR(50) NOT NULL, -- 聊天室ID user_id VARCHAR(50) NOT NULL, -- 用户ID(来客ID或员工ID) - user_type VARCHAR(20) NOT NULL, -- 用户类型:guest-来客 agent-客服 ai-AI助手 + user_type VARCHAR(20) NOT NULL, -- 用户类型:guest-来客 stuff-客服 ai-AI助手 user_name VARCHAR(100) NOT NULL, -- 用户名称 - role VARCHAR(20) NOT NULL DEFAULT 'member', -- 角色:owner-创建者 admin-管理员 member-普通成员 status VARCHAR(20) NOT NULL DEFAULT 'active', -- 状态:active-活跃 left-已离开 removed-被移除 unread_count INTEGER NOT NULL DEFAULT 0, -- 该成员的未读消息数 last_read_time TIMESTAMPTZ DEFAULT NULL, -- 最后阅读时间 @@ -84,8 +83,8 @@ COMMENT ON TABLE workcase.tb_chat_room_member IS '聊天室成员表,记录来 -- 3. 聊天室消息表 -- 存储所有聊天消息(AI对话+人工客服对话) -DROP TABLE IF EXISTS workcase.tb_chat_message CASCADE; -CREATE TABLE workcase.tb_chat_message( +DROP TABLE IF EXISTS workcase.tb_chat_room_message CASCADE; +CREATE TABLE workcase.tb_chat_room_message( optsn VARCHAR(50) NOT NULL, -- 流水号 message_id VARCHAR(50) NOT NULL, -- 消息ID room_id VARCHAR(50) NOT NULL, -- 聊天室ID @@ -107,10 +106,10 @@ CREATE TABLE workcase.tb_chat_message( update_time TIMESTAMPTZ DEFAULT NULL, -- 更新时间 PRIMARY KEY (message_id) ); -CREATE INDEX idx_chat_msg_room ON workcase.tb_chat_message(room_id, send_time DESC); -CREATE INDEX idx_chat_msg_sender ON workcase.tb_chat_message(sender_id, sender_type); -CREATE INDEX idx_chat_msg_ai ON workcase.tb_chat_message(ai_message_id) WHERE ai_message_id IS NOT NULL; -COMMENT ON TABLE workcase.tb_chat_message IS 'IM聊天消息表,包含AI对话和人工客服消息'; +CREATE INDEX idx_chat_msg_room ON workcase.tb_chat_room_message(room_id, send_time DESC); +CREATE INDEX idx_chat_msg_sender ON workcase.tb_chat_room_message(sender_id, sender_type); +CREATE INDEX idx_chat_msg_ai ON workcase.tb_chat_room_message(ai_message_id) WHERE ai_message_id IS NOT NULL; +COMMENT ON TABLE workcase.tb_chat_room_message IS 'IM聊天消息表,包含AI对话和人工客服消息'; -- 4. 视频会议表(Jitsi Meet) -- 记录聊天室内创建的视频会议 diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/constant/WorkcaseConstant.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/constant/WorkcaseConstant.java new file mode 100644 index 00000000..eb442f1f --- /dev/null +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/constant/WorkcaseConstant.java @@ -0,0 +1,134 @@ +package org.xyzh.api.workcase.constant; + +/** + * @description 工单模块常量类 + * @filename WorkcaseConstant.java + * @author cascade + * @copyright xyzh + * @since 2025-12-22 + */ +public class WorkcaseConstant { + + private WorkcaseConstant() { + } + + // ========================= Redis Key 前缀 ========================== + + /** + * 聊天室Redis key前缀 + */ + public static final String REDIS_CHAT_PREFIX = "chat:room:"; + + /** + * 聊天室在线用户 chat:room:online:{roomId} + */ + public static final String REDIS_CHAT_ONLINE = REDIS_CHAT_PREFIX + "online:"; + + /** + * 聊天室消息锁 chat:room:lock:{roomId} + */ + public static final String REDIS_CHAT_LOCK = REDIS_CHAT_PREFIX + "lock:"; + + /** + * 聊天室最后消息时间 chat:room:lasttime:{roomId} + */ + public static final String REDIS_CHAT_LASTTIME = REDIS_CHAT_PREFIX + "lasttime:"; + + /** + * 聊天室列表更新通知频道 + */ + public static final String REDIS_CHAT_LIST_UPDATE = "chat:list:update"; + + /** + * 会议Redis key前缀 + */ + public static final String REDIS_MEET_PREFIX = "meet:"; + + // ========================= 用户类型 ========================== + + /** + * 用户类型:来客 + */ + public static final String USER_TYPE_GUEST = "guest"; + + /** + * 用户类型:客服 + */ + public static final String USER_TYPE_STAFF = "staff"; + + /** + * 用户类型:AI助手 + */ + public static final String USER_TYPE_AI = "ai"; + + // ========================= 状态常量 ========================== + + /** + * 聊天室状态:活跃 + */ + public static final String ROOM_STATUS_ACTIVE = "active"; + + /** + * 聊天室状态:已关闭 + */ + public static final String ROOM_STATUS_CLOSED = "closed"; + + /** + * 成员状态:活跃 + */ + public static final String MEMBER_STATUS_ACTIVE = "active"; + + /** + * 成员状态:已离开 + */ + public static final String MEMBER_STATUS_LEFT = "left"; + + /** + * 成员状态:被移除 + */ + public static final String MEMBER_STATUS_REMOVED = "removed"; + + /** + * 消息状态:已发送 + */ + public static final String MESSAGE_STATUS_SENT = "sent"; + + /** + * 消息状态:已撤回 + */ + public static final String MESSAGE_STATUS_RECALLED = "recalled"; + + // ========================= 会议状态 ========================== + + /** + * 会议状态:已计划 + */ + public static final String MEETING_STATUS_SCHEDULED = "scheduled"; + + /** + * 会议状态:进行中 + */ + public static final String MEETING_STATUS_ONGOING = "ongoing"; + + /** + * 会议状态:已结束 + */ + public static final String MEETING_STATUS_ENDED = "ended"; + + // ========================= 客服状态 ========================== + + /** + * 客服状态:在线 + */ + public static final String SERVICE_STATUS_ONLINE = "online"; + + /** + * 客服状态:离线 + */ + public static final String SERVICE_STATUS_OFFLINE = "offline"; + + /** + * 客服状态:忙碌 + */ + public static final String SERVICE_STATUS_BUSY = "busy"; +} diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomDTO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomDTO.java index 62222651..24f4487e 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomDTO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomDTO.java @@ -1,5 +1,7 @@ package org.xyzh.api.workcase.dto; +import java.util.Date; + import org.xyzh.common.dto.BaseDTO; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -53,7 +55,7 @@ public class TbChatRoomDTO extends BaseDTO { private Integer unreadCount; @Schema(description = "最后消息时间") - private String lastMessageTime; + private Date lastMessageTime; @Schema(description = "最后一条消息内容") private String lastMessage; @@ -62,5 +64,5 @@ public class TbChatRoomDTO extends BaseDTO { private String closedBy; @Schema(description = "关闭时间") - private String closedTime; + private Date closedTime; } diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomMemberDTO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomMemberDTO.java index eb63b534..4af83f2b 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomMemberDTO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomMemberDTO.java @@ -1,5 +1,7 @@ package org.xyzh.api.workcase.dto; +import java.util.Date; + import org.xyzh.common.dto.BaseDTO; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -25,15 +27,12 @@ public class TbChatRoomMemberDTO extends BaseDTO { @Schema(description = "用户ID(来客ID或员工ID)") private String userId; - @Schema(description = "用户类型:guest-来客 agent-客服 ai-AI助手") + @Schema(description = "用户类型:guest-来客 staff-客服 ai-AI助手") private String userType; @Schema(description = "用户名称") private String userName; - @Schema(description = "角色:owner-创建者 admin-管理员 member-普通成员") - private String role; - @Schema(description = "状态:active-活跃 left-已离开 removed-被移除") private String status; @@ -41,14 +40,14 @@ public class TbChatRoomMemberDTO extends BaseDTO { private Integer unreadCount; @Schema(description = "最后阅读时间") - private String lastReadTime; + private Date lastReadTime; @Schema(description = "最后阅读的消息ID") private String lastReadMsgId; @Schema(description = "加入时间") - private String joinTime; + private Date joinTime; @Schema(description = "离开时间") - private String leaveTime; + private Date leaveTime; } diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatMessageDTO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomMessageDTO.java similarity index 94% rename from urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatMessageDTO.java rename to urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomMessageDTO.java index 3c6bbb55..4fe3f00c 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatMessageDTO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbChatRoomMessageDTO.java @@ -1,6 +1,8 @@ package org.xyzh.api.workcase.dto; +import java.util.Date; import java.util.List; + import com.alibaba.fastjson2.JSONObject; import org.xyzh.common.dto.BaseDTO; import io.swagger.v3.oas.annotations.media.Schema; @@ -15,7 +17,7 @@ import lombok.Data; */ @Data @Schema(description = "聊天消息表对象") -public class TbChatMessageDTO extends BaseDTO { +public class TbChatRoomMessageDTO extends BaseDTO { private static final long serialVersionUID = 1L; @Schema(description = "消息ID") @@ -61,5 +63,5 @@ public class TbChatMessageDTO extends BaseDTO { private Integer readCount; @Schema(description = "发送时间") - private String sendTime; + private Date sendTime; } diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingParticipantDTO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingParticipantDTO.java index 15b302a7..015c2b9b 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingParticipantDTO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingParticipantDTO.java @@ -1,5 +1,7 @@ package org.xyzh.api.workcase.dto; +import java.util.Date; + import org.xyzh.common.dto.BaseDTO; import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; @@ -32,10 +34,10 @@ public class TbMeetingParticipantDTO extends BaseDTO { private String userName; @Schema(description = "加入时间") - private String joinTime; + private Date joinTime; @Schema(description = "离开时间") - private String leaveTime; + private Date leaveTime; @Schema(description = "参与时长(秒)") private Integer durationSeconds; diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingTranscriptionDTO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingTranscriptionDTO.java index 2c6126bc..a91e9aee 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingTranscriptionDTO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbMeetingTranscriptionDTO.java @@ -1,6 +1,6 @@ package org.xyzh.api.workcase.dto; -import java.time.OffsetDateTime; +import java.util.Date; import org.xyzh.common.dto.BaseDTO; import io.swagger.v3.oas.annotations.media.Schema; @@ -46,10 +46,10 @@ public class TbMeetingTranscriptionDTO extends BaseDTO { private Double confidence; @Schema(description = "语音开始时间") - private OffsetDateTime speechStartTime; + private Date speechStartTime; @Schema(description = "语音结束时间") - private OffsetDateTime speechEndTime; + private Date speechEndTime; @Schema(description = "语音时长(毫秒)") private Integer durationMs; diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java index 15e2be06..acc57b87 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/dto/TbVideoMeetingDTO.java @@ -1,5 +1,7 @@ package org.xyzh.api.workcase.dto; +import java.util.Date; + import com.alibaba.fastjson2.JSONObject; import org.xyzh.common.dto.BaseDTO; import io.swagger.v3.oas.annotations.media.Schema; @@ -60,10 +62,10 @@ public class TbVideoMeetingDTO extends BaseDTO { private Integer maxParticipants; @Schema(description = "实际开始时间") - private String actualStartTime; + private Date actualStartTime; @Schema(description = "实际结束时间") - private String actualEndTime; + private Date actualEndTime; @Schema(description = "会议时长(秒)") private Integer durationSeconds; diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/ChatRoomService.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/ChatRoomService.java new file mode 100644 index 00000000..13c1e18f --- /dev/null +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/ChatRoomService.java @@ -0,0 +1,201 @@ +package org.xyzh.api.workcase.service; + +import org.xyzh.api.workcase.dto.TbChatRoomDTO; +import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO; +import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO; +import org.xyzh.api.workcase.dto.TbCustomerServiceDTO; +import org.xyzh.api.workcase.vo.ChatRoomVO; +import org.xyzh.api.workcase.vo.ChatMemberVO; +import org.xyzh.api.workcase.vo.ChatRoomMessageVO; +import org.xyzh.api.workcase.vo.CustomerServiceVO; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.core.page.PageRequest; + +/** + * @description 聊天室服务接口,管理聊天室、成员和消息 + * @filename ChatRoomService.java + * @author cascade + * @copyright xyzh + * @since 2025-12-22 + */ +public interface ChatRoomService { + + // ========================= 聊天室管理 ========================== + + /** + * @description 创建聊天室 + * @param chatRoom 聊天室信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain createChatRoom(TbChatRoomDTO chatRoom); + + /** + * @description 更新聊天室 + * @param chatRoom 聊天室信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain updateChatRoom(TbChatRoomDTO chatRoom); + + /** + * @description 关闭聊天室 + * @param roomId 聊天室ID + * @param closedBy 关闭人 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain closeChatRoom(String roomId, String closedBy); + + /** + * @description 删除聊天室 + * @param roomId 聊天室ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain deleteChatRoom(String roomId); + + /** + * @description 根据ID获取聊天室 + * @param roomId 聊天室ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getChatRoomById(String roomId); + + /** + * @description 获取聊天室列表/分页 + * @param pageRequest 分页请求 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getChatRoomPage(PageRequest pageRequest); + + // ========================= 聊天室成员管理 ========================== + + /** + * @description 添加聊天室成员 + * @param member 成员信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain addChatRoomMember(TbChatRoomMemberDTO member); + + /** + * @description 移除聊天室成员 + * @param memberId 成员ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain removeChatRoomMember(String memberId); + + /** + * @description 更新成员信息(如角色、状态) + * @param member 成员信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain updateChatRoomMember(TbChatRoomMemberDTO member); + + /** + * @description 获取聊天室成员列表 + * @param roomId 聊天室ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getChatRoomMemberList(String roomId); + + /** + * @description 更新成员已读状态 + * @param memberId 成员ID + * @param lastReadMsgId 最后已读消息ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain updateMemberReadStatus(String memberId, String lastReadMsgId); + + // ========================= 聊天消息管理 ========================== + + /** + * @description 发送消息 + * @param message 消息内容 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain sendMessage(TbChatRoomMessageDTO message); + + /** + * @description 获取聊天室消息列表/分页 + * @param pageRequest 分页请求 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getChatMessagePage(PageRequest pageRequest); + + /** + * @description 删除消息 + * @param messageId 消息ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain deleteMessage(String messageId); + + // ========================= 客服人员管理 ========================== + + /** + * @description 添加客服人员配置 + * @param customerService 客服人员信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain addCustomerService(TbCustomerServiceDTO customerService); + + /** + * @description 更新客服人员配置 + * @param customerService 客服人员信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain updateCustomerService(TbCustomerServiceDTO customerService); + + /** + * @description 删除客服人员配置 + * @param userId 员工ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain deleteCustomerService(String userId); + + /** + * @description 获取客服人员列表/分页 + * @param pageRequest 分页请求 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getCustomerServicePage(PageRequest pageRequest); + + /** + * @description 更新客服人员在线状态 + * @param userId 员工ID + * @param status 状态:online-在线 busy-忙碌 offline-离线 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain updateCustomerServiceStatus(String userId, String status); + + /** + * @description 获取可接待的客服人员(在线且工作量未满) + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getAvailableCustomerServices(); + + /** + * @description 自动分配客服人员 + * @param roomId 聊天室ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain assignCustomerService(String roomId); + +} diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/MeetService.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/MeetService.java new file mode 100644 index 00000000..0e486b41 --- /dev/null +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/service/MeetService.java @@ -0,0 +1,186 @@ +package org.xyzh.api.workcase.service; + +import org.xyzh.api.workcase.dto.TbVideoMeetingDTO; +import org.xyzh.api.workcase.dto.TbMeetingParticipantDTO; +import org.xyzh.api.workcase.dto.TbMeetingTranscriptionDTO; +import org.xyzh.api.workcase.vo.VideoMeetingVO; +import org.xyzh.api.workcase.vo.MeetingParticipantVO; +import org.xyzh.api.workcase.vo.MeetingTranscriptionVO; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.core.page.PageRequest; + +/** + * @description 视频会议服务接口,管理Jitsi Meet会议、参与者和转录 + * @filename MeetService.java + * @author cascade + * @copyright xyzh + * @since 2025-12-22 + */ +public interface MeetService { + + // ========================= 会议管理 ========================== + + /** + * @description 创建视频会议 + * @param meeting 会议信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain createMeeting(TbVideoMeetingDTO meeting); + + /** + * @description 更新会议信息 + * @param meeting 会议信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain updateMeeting(TbVideoMeetingDTO meeting); + + /** + * @description 开始会议 + * @param meetingId 会议ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain startMeeting(String meetingId); + + /** + * @description 结束会议 + * @param meetingId 会议ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain endMeeting(String meetingId); + + /** + * @description 删除会议 + * @param meetingId 会议ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain deleteMeeting(String meetingId); + + /** + * @description 根据ID获取会议 + * @param meetingId 会议ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getMeetingById(String meetingId); + + /** + * @description 获取会议列表/分页 + * @param pageRequest 分页请求 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getMeetingPage(PageRequest pageRequest); + + /** + * @description 生成会议加入链接/iframe URL + * @param meetingId 会议ID + * @param userId 用户ID + * @param userName 用户名称 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain generateMeetingJoinUrl(String meetingId, String userId, String userName); + + /** + * @description 生成会议JWT Token + * @param meetingId 会议ID + * @param userId 用户ID + * @param isModerator 是否主持人 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain generateMeetingToken(String meetingId, String userId, boolean isModerator); + + // ========================= 参与者管理 ========================== + + /** + * @description 参与者加入会议 + * @param participant 参与者信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain joinMeeting(TbMeetingParticipantDTO participant); + + /** + * @description 参与者离开会议 + * @param participantId 参与者ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain leaveMeeting(String participantId); + + /** + * @description 获取会议参与者列表 + * @param meetingId 会议ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getMeetingParticipantList(String meetingId); + + /** + * @description 更新参与者信息 + * @param participant 参与者信息 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain updateParticipant(TbMeetingParticipantDTO participant); + + /** + * @description 设置参与者为主持人 + * @param participantId 参与者ID + * @param isModerator 是否主持人 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain setModerator(String participantId, boolean isModerator); + + // ========================= 转录管理 ========================== + + /** + * @description 添加转录记录 + * @param transcription 转录内容 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain addTranscription(TbMeetingTranscriptionDTO transcription); + + /** + * @description 获取会议转录列表/分页 + * @param pageRequest 分页请求 + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getTranscriptionPage(PageRequest pageRequest); + + /** + * @description 获取会议完整转录文本 + * @param meetingId 会议ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getFullTranscriptionText(String meetingId); + + /** + * @description 删除转录记录 + * @param transcriptionId 转录ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain deleteTranscription(String transcriptionId); + + // ========================= 会议统计 ========================== + + /** + * @description 获取会议统计信息(参与人数、时长等) + * @param meetingId 会议ID + * @author cascade + * @since 2025-12-22 + */ + ResultDomain getMeetingStatistics(String meetingId); + +} diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatMemberVO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatMemberVO.java index 4fca4556..8c019ce8 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatMemberVO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatMemberVO.java @@ -35,9 +35,6 @@ public class ChatMemberVO extends BaseVO { @Schema(description = "用户头像") private String userAvatar; - @Schema(description = "角色:owner-创建者 admin-管理员 member-普通成员") - private String role; - @Schema(description = "状态:active-活跃 left-已离开 removed-被移除") private String status; diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatMessageVO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatRoomMessageVO.java similarity index 97% rename from urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatMessageVO.java rename to urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatRoomMessageVO.java index fdd7c4a6..72fcb5ad 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatMessageVO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/ChatRoomMessageVO.java @@ -16,7 +16,7 @@ import java.util.List; @Data @EqualsAndHashCode(callSuper = true) @Schema(description = "聊天消息VO") -public class ChatMessageVO extends BaseVO { +public class ChatRoomMessageVO extends BaseVO { private static final long serialVersionUID = 1L; @Schema(description = "消息ID") diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingParticipantVO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingParticipantVO.java index 60192f58..a2cffce1 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingParticipantVO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingParticipantVO.java @@ -4,7 +4,8 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.xyzh.common.vo.BaseVO; import io.swagger.v3.oas.annotations.media.Schema; -import java.time.OffsetDateTime; +import java.util.Date; +import com.alibaba.fastjson2.annotation.JSONField; /** * 会议参与记录VO @@ -30,11 +31,13 @@ public class MeetingParticipantVO extends BaseVO { @Schema(description = "用户名称") private String userName; - @Schema(description = "加入时间") - private OffsetDateTime joinTime; + @Schema(description = "加入时间", format = "date-time") + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private Date joinTime; - @Schema(description = "离开时间") - private OffsetDateTime leaveTime; + @Schema(description = "离开时间", format = "date-time") + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private Date leaveTime; @Schema(description = "参与时长(秒)") private Integer durationSeconds; diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingTranscriptionVO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingTranscriptionVO.java index df76ffee..2cbc2c7b 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingTranscriptionVO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/MeetingTranscriptionVO.java @@ -4,7 +4,8 @@ import lombok.Data; import lombok.EqualsAndHashCode; import org.xyzh.common.vo.BaseVO; import io.swagger.v3.oas.annotations.media.Schema; -import java.time.OffsetDateTime; +import java.util.Date; +import com.alibaba.fastjson2.annotation.JSONField; /** * 会议转录记录VO @@ -42,11 +43,13 @@ public class MeetingTranscriptionVO extends BaseVO { @Schema(description = "识别置信度(0-1)") private Double confidence; - @Schema(description = "语音开始时间") - private OffsetDateTime speechStartTime; + @Schema(description = "语音开始时间", format = "date-time") + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private Date speechStartTime; - @Schema(description = "语音结束时间") - private OffsetDateTime speechEndTime; + @Schema(description = "语音结束时间", format = "date-time") + @JSONField(format = "yyyy-MM-dd HH:mm:ss") + private Date speechEndTime; @Schema(description = "语音时长(毫秒)") private Integer durationMs; diff --git a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java index eeabb655..9cc570c4 100644 --- a/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java +++ b/urbanLifelineServ/apis/api-workcase/src/main/java/org/xyzh/api/workcase/vo/VideoMeetingVO.java @@ -62,11 +62,11 @@ public class VideoMeetingVO extends BaseVO { @Schema(description = "实际开始时间", format = "date-time") @JSONField(format = "yyyy-MM-dd HH:mm:ss") - private Date startTime; + private Date actualStartTime; @Schema(description = "实际结束时间", format = "date-time") @JSONField(format = "yyyy-MM-dd HH:mm:ss") - private Date endTime; + private Date actualEndTime; @Schema(description = "会议时长(秒)") private Integer durationSeconds; diff --git a/urbanLifelineServ/common/common-redis/src/main/java/org/xyzh/common/redis/service/RedisService.java b/urbanLifelineServ/common/common-redis/src/main/java/org/xyzh/common/redis/service/RedisService.java index a82d3a50..1d43245f 100644 --- a/urbanLifelineServ/common/common-redis/src/main/java/org/xyzh/common/redis/service/RedisService.java +++ b/urbanLifelineServ/common/common-redis/src/main/java/org/xyzh/common/redis/service/RedisService.java @@ -47,6 +47,19 @@ public class RedisService { redisTemplate.opsForValue().set(key, value, timeout, unit); } + /** + * @description 如果key不存在则设置(分布式锁) + * @param key String 键 + * @param value Object 值 + * @param timeoutSeconds long 过期秒数 + * @return Boolean 是否设置成功 + * @author cascade + * @since 2025-12-22 + */ + public Boolean setIfAbsent(String key, Object value, long timeoutSeconds) { + return redisTemplate.opsForValue().setIfAbsent(key, value, timeoutSeconds, TimeUnit.SECONDS); + } + /** * @description 获取key对应的value * @param key String 键 diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/config/RedisSubscriberConfig.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/config/RedisSubscriberConfig.java new file mode 100644 index 00000000..adf114ba --- /dev/null +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/config/RedisSubscriberConfig.java @@ -0,0 +1,42 @@ +package org.xyzh.workcase.config; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; + +import org.xyzh.api.workcase.constant.WorkcaseConstant; +import org.xyzh.workcase.listener.ChatMessageListener; + +/** + * @description Redis Pub/Sub订阅配置 + * @filename RedisSubscriberConfig.java + * @author cascade + * @copyright xyzh + * @since 2025-12-22 + */ +@Configuration +public class RedisSubscriberConfig { + + @Autowired + private ChatMessageListener chatMessageListener; + + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + + // 订阅聊天室消息频道,使用通配符匹配所有聊天室 + // 频道格式: chat:room:* + container.addMessageListener(chatMessageListener, + new PatternTopic(WorkcaseConstant.REDIS_CHAT_PREFIX + "*")); + + // 订阅聊天室列表更新频道 + container.addMessageListener(chatMessageListener, + new PatternTopic(WorkcaseConstant.REDIS_CHAT_LIST_UPDATE)); + + return container; + } +} diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/listener/ChatMessageListener.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/listener/ChatMessageListener.java new file mode 100644 index 00000000..7085944e --- /dev/null +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/listener/ChatMessageListener.java @@ -0,0 +1,63 @@ +package org.xyzh.workcase.listener; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; + +import com.alibaba.fastjson2.JSON; + +import org.xyzh.api.workcase.constant.WorkcaseConstant; +import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO; + +/** + * @description 聊天消息Redis监听器,接收Pub/Sub消息并通过STOMP转发到WebSocket客户端 + * @filename ChatMessageListener.java + * @author cascade + * @copyright xyzh + * @since 2025-12-22 + */ +@Component +public class ChatMessageListener implements MessageListener { + private static final Logger logger = LoggerFactory.getLogger(ChatMessageListener.class); + + @Autowired(required = false) + private SimpMessagingTemplate messagingTemplate; + + @Override + public void onMessage(Message message, byte[] pattern) { + try { + String channel = new String(message.getChannel()); + String body = new String(message.getBody()); + + logger.debug("收到Redis消息: channel={}", channel); + + // 反序列化消息 + TbChatRoomMessageDTO chatMessage = JSON.parseObject(body, TbChatRoomMessageDTO.class); + + if (messagingTemplate == null) { + logger.warn("SimpMessagingTemplate未初始化,无法转发消息"); + return; + } + + // 处理聊天室消息频道: chat:room:{roomId} + if (channel.startsWith(WorkcaseConstant.REDIS_CHAT_PREFIX)) { + String roomId = channel.substring(WorkcaseConstant.REDIS_CHAT_PREFIX.length()); + // 转发到聊天窗口订阅者 + messagingTemplate.convertAndSend("/topic/chat/" + roomId, chatMessage); + logger.debug("消息已转发到STOMP: /topic/chat/{}", roomId); + } + // 处理列表更新频道: chat:list:update + else if (WorkcaseConstant.REDIS_CHAT_LIST_UPDATE.equals(channel)) { + // 转发到聊天室列表订阅者,前端刷新列表状态 + messagingTemplate.convertAndSend("/topic/chat/list-update", chatMessage); + logger.debug("列表更新已转发到STOMP: /topic/chat/list-update"); + } + } catch (Exception e) { + logger.error("处理Redis消息失败", e); + } + } +} diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/mapper/TbChatMessageMapper.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/mapper/TbChatMessageMapper.java index 7e8dc600..e48c8191 100644 --- a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/mapper/TbChatMessageMapper.java +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/mapper/TbChatMessageMapper.java @@ -4,8 +4,8 @@ import java.util.List; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; -import org.xyzh.api.workcase.dto.TbChatMessageDTO; -import org.xyzh.api.workcase.vo.ChatMessageVO; +import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO; +import org.xyzh.api.workcase.vo.ChatRoomMessageVO; import org.xyzh.common.core.page.PageParam; /** @@ -21,36 +21,36 @@ public interface TbChatMessageMapper { /** * 插入聊天消息 */ - int insertChatMessage(TbChatMessageDTO message); + int insertChatMessage(TbChatRoomMessageDTO message); /** * 更新聊天消息(只更新非null字段) */ - int updateChatMessage(TbChatMessageDTO message); + int updateChatMessage(TbChatRoomMessageDTO message); /** * 删除聊天消息 */ - int deleteChatMessage(TbChatMessageDTO message); + int deleteChatMessage(TbChatRoomMessageDTO message); /** * 根据ID查询聊天消息 */ - TbChatMessageDTO selectChatMessageById(@Param("messageId") String messageId); + TbChatRoomMessageDTO selectChatMessageById(@Param("messageId") String messageId); /** * 查询聊天消息列表 */ - List selectChatMessageList(@Param("filter") TbChatMessageDTO filter); + List selectChatMessageList(@Param("filter") TbChatRoomMessageDTO filter); /** * 分页查询聊天消息 */ - List selectChatMessagePage(@Param("filter") TbChatMessageDTO filter, @Param("pageParam") PageParam pageParam); + List selectChatMessagePage(@Param("filter") TbChatRoomMessageDTO filter, @Param("pageParam") PageParam pageParam); /** * 统计聊天消息数量 */ - long countChatMessages(@Param("filter") TbChatMessageDTO filter); + long countChatMessages(@Param("filter") TbChatRoomMessageDTO filter); } diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/ChatRoomServiceImpl.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/ChatRoomServiceImpl.java new file mode 100644 index 00000000..5c16777e --- /dev/null +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/ChatRoomServiceImpl.java @@ -0,0 +1,628 @@ +package org.xyzh.workcase.service; + +import java.util.Date; +import java.util.List; + +import org.apache.dubbo.config.annotation.DubboService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.xyzh.common.redis.service.RedisService; +import org.springframework.transaction.annotation.Transactional; +import org.xyzh.api.workcase.dto.TbChatRoomMessageDTO; +import org.xyzh.api.workcase.dto.TbChatRoomDTO; +import org.xyzh.api.workcase.dto.TbChatRoomMemberDTO; +import org.xyzh.api.workcase.dto.TbCustomerServiceDTO; +import org.xyzh.api.workcase.service.ChatRoomService; +import org.xyzh.api.workcase.vo.ChatMemberVO; +import org.xyzh.api.workcase.vo.ChatRoomMessageVO; +import org.xyzh.api.workcase.vo.ChatRoomVO; +import org.xyzh.api.workcase.vo.CustomerServiceVO; +import org.xyzh.api.workcase.constant.WorkcaseConstant; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.core.page.PageDomain; +import org.xyzh.common.core.page.PageParam; +import org.xyzh.common.core.page.PageRequest; +import org.xyzh.common.utils.id.IdUtil; +import org.xyzh.workcase.mapper.TbChatMessageMapper; +import org.xyzh.workcase.mapper.TbChatRoomMapper; +import org.xyzh.workcase.mapper.TbChatRoomMemberMapper; +import org.xyzh.workcase.mapper.TbCustomerServiceMapper; + + +/** + * @description 聊天室服务实现类 + * @filename ChatRoomServiceImpl.java + * @author cascade + * @copyright xyzh + * @since 2025-12-22 + */ +@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0) +public class ChatRoomServiceImpl implements ChatRoomService { + private static final Logger logger = LoggerFactory.getLogger(ChatRoomServiceImpl.class); + + @Autowired + private TbChatRoomMapper chatRoomMapper; + + @Autowired + private TbChatRoomMemberMapper chatRoomMemberMapper; + + @Autowired + private TbChatMessageMapper chatMessageMapper; + + @Autowired + private TbCustomerServiceMapper customerServiceMapper; + + @Autowired + private RedisService redisService; + + // ========================= 聊天室管理 ========================== + + @Override + @Transactional + public ResultDomain createChatRoom(TbChatRoomDTO chatRoom) { + logger.info("创建聊天室: workcaseId={}, roomType={}", chatRoom.getWorkcaseId(), chatRoom.getRoomType()); + + // 一个工单只能创建一个聊天室 + if (chatRoom.getWorkcaseId() != null && !chatRoom.getWorkcaseId().isEmpty()) { + TbChatRoomDTO filter = new TbChatRoomDTO(); + filter.setWorkcaseId(chatRoom.getWorkcaseId()); + List existingRooms = chatRoomMapper.selectChatRoomList(filter); + if (existingRooms != null && !existingRooms.isEmpty()) { + return ResultDomain.failure("该工单已存在聊天室"); + } + } + + if (chatRoom.getRoomId() == null || chatRoom.getRoomId().isEmpty()) { + chatRoom.setRoomId(IdUtil.generateUUID()); + } + if (chatRoom.getOptsn() == null || chatRoom.getOptsn().isEmpty()) { + chatRoom.setOptsn(IdUtil.getOptsn()); + } + if (chatRoom.getStatus() == null || chatRoom.getStatus().isEmpty()) { + chatRoom.setStatus("active"); + } + + int rows = chatRoomMapper.insertChatRoom(chatRoom); + if (rows > 0) { + logger.info("聊天室创建成功: roomId={}", chatRoom.getRoomId()); + + // 添加来客到成员表 + if (chatRoom.getGuestId() != null && !chatRoom.getGuestId().isEmpty()) { + TbChatRoomMemberDTO guestMember = new TbChatRoomMemberDTO(); + guestMember.setMemberId(IdUtil.generateUUID()); + guestMember.setOptsn(IdUtil.getOptsn()); + guestMember.setRoomId(chatRoom.getRoomId()); + guestMember.setUserId(chatRoom.getGuestId()); + guestMember.setUserName(chatRoom.getGuestName()); + guestMember.setUserType("guest"); + guestMember.setStatus("active"); + guestMember.setJoinTime(new Date()); + guestMember.setUnreadCount(0); + chatRoomMemberMapper.insertChatRoomMember(guestMember); + } + + // 自动分配客服并添加到成员表 + ResultDomain assignResult = assignCustomerService(chatRoom.getRoomId()); + if (Boolean.TRUE.equals(assignResult.getSuccess()) && assignResult.getData() != null) { + // 更新聊天室当前客服ID + TbChatRoomDTO updateRoom = new TbChatRoomDTO(); + updateRoom.setRoomId(chatRoom.getRoomId()); + updateRoom.setCurrentAgentId(assignResult.getData().getUserId()); + chatRoomMapper.updateChatRoom(updateRoom); + } + + return ResultDomain.success("创建成功", chatRoom); + } + return ResultDomain.failure("创建失败"); + } + + @Override + public ResultDomain updateChatRoom(TbChatRoomDTO chatRoom) { + logger.info("更新聊天室: roomId={}", chatRoom.getRoomId()); + + TbChatRoomDTO existing = chatRoomMapper.selectChatRoomById(chatRoom.getRoomId()); + if (existing == null) { + return ResultDomain.failure("聊天室不存在"); + } + + int rows = chatRoomMapper.updateChatRoom(chatRoom); + if (rows > 0) { + TbChatRoomDTO updated = chatRoomMapper.selectChatRoomById(chatRoom.getRoomId()); + return ResultDomain.success("更新成功", updated); + } + return ResultDomain.failure("更新失败"); + } + + @Override + public ResultDomain closeChatRoom(String roomId, String closedBy) { + logger.info("关闭聊天室: roomId={}, closedBy={}", roomId, closedBy); + + TbChatRoomDTO existing = chatRoomMapper.selectChatRoomById(roomId); + if (existing == null) { + return ResultDomain.failure("聊天室不存在"); + } + + TbChatRoomDTO chatRoom = new TbChatRoomDTO(); + chatRoom.setRoomId(roomId); + chatRoom.setStatus("closed"); + chatRoom.setClosedBy(closedBy); + chatRoom.setClosedTime(new Date()); + + int rows = chatRoomMapper.updateChatRoom(chatRoom); + if (rows > 0) { + // 清理Redis中的在线用户 + String onlineUsersKey = WorkcaseConstant.REDIS_CHAT_ONLINE + roomId; + redisService.delete(onlineUsersKey); + return ResultDomain.success("关闭成功", true); + } + return ResultDomain.failure("关闭失败"); + } + + @Override + public ResultDomain deleteChatRoom(String roomId) { + logger.info("删除聊天室: roomId={}", roomId); + + TbChatRoomDTO existing = chatRoomMapper.selectChatRoomById(roomId); + if (existing == null) { + return ResultDomain.failure("聊天室不存在"); + } + + TbChatRoomDTO chatRoom = new TbChatRoomDTO(); + chatRoom.setRoomId(roomId); + int rows = chatRoomMapper.deleteChatRoom(chatRoom); + if (rows > 0) { + return ResultDomain.success("删除成功", true); + } + return ResultDomain.failure("删除失败"); + } + + @Override + public ResultDomain getChatRoomById(String roomId) { + TbChatRoomDTO chatRoom = chatRoomMapper.selectChatRoomById(roomId); + if (chatRoom != null) { + return ResultDomain.success("查询聊天室成功", chatRoom); + } + return ResultDomain.failure("聊天室不存在"); + } + + @Override + public ResultDomain getChatRoomPage(PageRequest pageRequest) { + TbChatRoomDTO filter = pageRequest.getFilter(); + if (filter == null) { + filter = new TbChatRoomDTO(); + } + + PageParam pageParam = pageRequest.getPageParam(); + List list = chatRoomMapper.selectChatRoomPage(filter, pageParam); + long total = chatRoomMapper.countChatRooms(filter); + pageParam.setTotal((int)total); + + PageDomain pageDomain = new PageDomain<>(pageParam, list); + return ResultDomain.success("查询聊天室成功", pageDomain); + } + + // ========================= 聊天室成员管理 ========================== + + @Override + public ResultDomain addChatRoomMember(TbChatRoomMemberDTO member) { + logger.info("添加聊天室成员: roomId={}, userId={}", member.getRoomId(), member.getUserId()); + + // 检查聊天室是否存在 + TbChatRoomDTO room = chatRoomMapper.selectChatRoomById(member.getRoomId()); + if (room == null) { + return ResultDomain.failure("聊天室不存在"); + } + + // 检查是否已是成员 + TbChatRoomMemberDTO filter = new TbChatRoomMemberDTO(); + filter.setRoomId(member.getRoomId()); + filter.setUserId(member.getUserId()); + List existingMembers = chatRoomMemberMapper.selectChatRoomMemberList(filter); + if (existingMembers != null && !existingMembers.isEmpty()) { + return ResultDomain.failure("用户已是聊天室成员"); + } + + if (member.getMemberId() == null || member.getMemberId().isEmpty()) { + member.setMemberId(IdUtil.generateUUID()); + } + if (member.getOptsn() == null || member.getOptsn().isEmpty()) { + member.setOptsn(IdUtil.getOptsn()); + } + if (member.getStatus() == null || member.getStatus().isEmpty()) { + member.setStatus("active"); + } + member.setJoinTime(new Date()); + + int rows = chatRoomMemberMapper.insertChatRoomMember(member); + if (rows > 0) { + // 更新聊天室成员数 + TbChatRoomDTO updateRoom = new TbChatRoomDTO(); + updateRoom.setRoomId(member.getRoomId()); + updateRoom.setAgentCount(room.getAgentCount() != null ? room.getAgentCount() + 1 : 1); + chatRoomMapper.updateChatRoom(updateRoom); + + return ResultDomain.success("添加成功", member); + } + return ResultDomain.failure("添加失败"); + } + + @Override + public ResultDomain removeChatRoomMember(String memberId) { + logger.info("移除聊天室成员: memberId={}", memberId); + + TbChatRoomMemberDTO existing = chatRoomMemberMapper.selectChatRoomMemberById(memberId); + if (existing == null) { + return ResultDomain.failure("成员不存在"); + } + + TbChatRoomMemberDTO member = new TbChatRoomMemberDTO(); + member.setMemberId(memberId); + int rows = chatRoomMemberMapper.deleteChatRoomMember(member); + if (rows > 0) { + // 更新聊天室成员数 + TbChatRoomDTO room = chatRoomMapper.selectChatRoomById(existing.getRoomId()); + if (room != null && room.getAgentCount() != null && room.getAgentCount() > 0) { + TbChatRoomDTO updateRoom = new TbChatRoomDTO(); + updateRoom.setRoomId(existing.getRoomId()); + updateRoom.setAgentCount(room.getAgentCount() - 1); + chatRoomMapper.updateChatRoom(updateRoom); + } + + // 从Redis移除在线状态 + String onlineUsersKey = WorkcaseConstant.REDIS_CHAT_ONLINE + existing.getRoomId(); + redisService.sRemove(onlineUsersKey, existing.getUserId()); + + return ResultDomain.success("移除成功", true); + } + return ResultDomain.failure("移除失败"); + } + + @Override + public ResultDomain updateChatRoomMember(TbChatRoomMemberDTO member) { + logger.info("更新聊天室成员: memberId={}", member.getMemberId()); + + TbChatRoomMemberDTO existing = chatRoomMemberMapper.selectChatRoomMemberById(member.getMemberId()); + if (existing == null) { + return ResultDomain.failure("成员不存在"); + } + + int rows = chatRoomMemberMapper.updateChatRoomMember(member); + if (rows > 0) { + TbChatRoomMemberDTO updated = chatRoomMemberMapper.selectChatRoomMemberById(member.getMemberId()); + return ResultDomain.success("更新成功", updated); + } + return ResultDomain.failure("更新失败"); + } + + @Override + public ResultDomain getChatRoomMemberList(String roomId) { + TbChatRoomMemberDTO filter = new TbChatRoomMemberDTO(); + filter.setRoomId(roomId); + List list = chatRoomMemberMapper.selectChatRoomMemberList(filter); + return ResultDomain.success("查询成功", list); + } + + @Override + public ResultDomain updateMemberReadStatus(String memberId, String lastReadMsgId) { + logger.info("更新已读状态: memberId={}, lastReadMsgId={}", memberId, lastReadMsgId); + + TbChatRoomMemberDTO member = new TbChatRoomMemberDTO(); + member.setMemberId(memberId); + member.setLastReadMsgId(lastReadMsgId); + member.setLastReadTime(new Date()); + + int rows = chatRoomMemberMapper.updateChatRoomMember(member); + if (rows > 0) { + return ResultDomain.success("更新成功", true); + } + return ResultDomain.failure("更新失败"); + } + + // ========================= 聊天消息管理 ========================== + + @Override + public ResultDomain sendMessage(TbChatRoomMessageDTO message) { + logger.info("发送消息: roomId={}, senderId={}, messageType={}", + message.getRoomId(), message.getSenderId(), message.getMessageType()); + + // 检查聊天室是否存在 + TbChatRoomDTO room = chatRoomMapper.selectChatRoomById(message.getRoomId()); + if (room == null) { + return ResultDomain.failure("聊天室不存在"); + } + if ("closed".equals(room.getStatus())) { + return ResultDomain.failure("聊天室已关闭"); + } + + if (message.getMessageId() == null || message.getMessageId().isEmpty()) { + message.setMessageId(IdUtil.generateUUID()); + } + if (message.getOptsn() == null || message.getOptsn().isEmpty()) { + message.setOptsn(IdUtil.getOptsn()); + } + if (message.getStatus() == null || message.getStatus().isEmpty()) { + message.setStatus("sent"); + } + + // 使用Redis保证消息时间戳递增,避免并发乱序 + String lockKey = WorkcaseConstant.REDIS_CHAT_LOCK + message.getRoomId(); + String timeKey = WorkcaseConstant.REDIS_CHAT_LASTTIME + message.getRoomId(); + + try { + // 获取分布式锁(简单实现:SETNX + 过期时间) + int maxRetry = 10; + int retry = 0; + while (retry < maxRetry) { + Boolean locked = redisService.setIfAbsent(lockKey, "1", 5); + if (Boolean.TRUE.equals(locked)) { + break; + } + retry++; + Thread.sleep(50); + } + if (retry >= maxRetry) { + return ResultDomain.failure("消息发送繁忙,请稍后重试"); + } + + // 获取上一条消息的时间戳,确保时间递增 + long currentTime = System.currentTimeMillis(); + Object lastTimeObj = redisService.get(timeKey); + if (lastTimeObj != null) { + long lastTime = Long.parseLong(lastTimeObj.toString()); + if (currentTime <= lastTime) { + currentTime = lastTime + 1; + } + } + message.setSendTime(new Date(currentTime)); + + // 更新Redis中的最后消息时间 + redisService.set(timeKey, String.valueOf(currentTime)); + + int rows = chatMessageMapper.insertChatMessage(message); + if (rows > 0) { + // 更新聊天室最后消息信息 + TbChatRoomDTO updateRoom = new TbChatRoomDTO(); + updateRoom.setRoomId(message.getRoomId()); + updateRoom.setLastMessage(message.getContent()); + updateRoom.setLastMessageTime(message.getSendTime()); + updateRoom.setMessageCount(room.getMessageCount() != null ? room.getMessageCount() + 1 : 1); + chatRoomMapper.updateChatRoom(updateRoom); + + // 发布消息到Redis Pub/Sub(聊天窗口) + publishMessageToRedis(message); + + // 发布列表更新通知(聊天室列表) + publishListUpdateToRedis(message); + + return ResultDomain.success("发送成功", message); + } + return ResultDomain.failure("发送失败"); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return ResultDomain.failure("消息发送被中断"); + } finally { + // 释放锁 + redisService.delete(lockKey); + } + } + + @Override + public ResultDomain getChatMessagePage(PageRequest pageRequest) { + TbChatRoomMessageDTO filter = pageRequest.getFilter(); + if (filter == null) { + filter = new TbChatRoomMessageDTO(); + } + + PageParam pageParam = pageRequest.getPageParam(); + List list = chatMessageMapper.selectChatMessagePage(filter, pageParam); + long total = chatMessageMapper.countChatMessages(filter); + pageParam.setTotal((int) total); + + PageDomain pageDomain = new PageDomain<>(pageParam, list); + return ResultDomain.success("查询成功", pageDomain); + } + + @Override + public ResultDomain deleteMessage(String messageId) { + logger.info("删除消息: messageId={}", messageId); + + TbChatRoomMessageDTO existing = chatMessageMapper.selectChatMessageById(messageId); + if (existing == null) { + return ResultDomain.failure("消息不存在"); + } + + TbChatRoomMessageDTO message = new TbChatRoomMessageDTO(); + message.setMessageId(messageId); + int rows = chatMessageMapper.deleteChatMessage(message); + if (rows > 0) { + return ResultDomain.success("删除成功", true); + } + return ResultDomain.failure("删除失败"); + } + + // ========================= 客服人员管理 ========================== + + @Override + public ResultDomain addCustomerService(TbCustomerServiceDTO customerService) { + logger.info("添加客服人员: userId={}, username={}", customerService.getUserId(), customerService.getUsername()); + + // 检查是否已存在 + TbCustomerServiceDTO existing = customerServiceMapper.selectCustomerServiceById(customerService.getUserId()); + if (existing != null) { + return ResultDomain.failure("该员工已是客服人员"); + } + + if (customerService.getOptsn() == null || customerService.getOptsn().isEmpty()) { + customerService.setOptsn(IdUtil.getOptsn()); + } + if (customerService.getStatus() == null || customerService.getStatus().isEmpty()) { + customerService.setStatus("offline"); + } + if (customerService.getMaxConcurrent() == null) { + customerService.setMaxConcurrent(5); + } + if (customerService.getCurrentWorkload() == null) { + customerService.setCurrentWorkload(0); + } + + int rows = customerServiceMapper.insertCustomerService(customerService); + if (rows > 0) { + return ResultDomain.success("添加成功", customerService); + } + return ResultDomain.failure("添加失败"); + } + + @Override + public ResultDomain updateCustomerService(TbCustomerServiceDTO customerService) { + logger.info("更新客服人员: userId={}", customerService.getUserId()); + + TbCustomerServiceDTO existing = customerServiceMapper.selectCustomerServiceById(customerService.getUserId()); + if (existing == null) { + return ResultDomain.failure("客服人员不存在"); + } + + int rows = customerServiceMapper.updateCustomerService(customerService); + if (rows > 0) { + TbCustomerServiceDTO updated = customerServiceMapper.selectCustomerServiceById(customerService.getUserId()); + return ResultDomain.success("更新成功", updated); + } + return ResultDomain.failure("更新失败"); + } + + @Override + public ResultDomain deleteCustomerService(String userId) { + logger.info("删除客服人员: userId={}", userId); + + TbCustomerServiceDTO existing = customerServiceMapper.selectCustomerServiceById(userId); + if (existing == null) { + return ResultDomain.failure("客服人员不存在"); + } + + TbCustomerServiceDTO customerService = new TbCustomerServiceDTO(); + customerService.setUserId(userId); + int rows = customerServiceMapper.deleteCustomerService(customerService); + if (rows > 0) { + return ResultDomain.success("删除成功", true); + } + return ResultDomain.failure("删除失败"); + } + + @Override + public ResultDomain getCustomerServicePage(PageRequest pageRequest) { + TbCustomerServiceDTO filter = pageRequest.getFilter(); + if (filter == null) { + filter = new TbCustomerServiceDTO(); + } + + PageParam pageParam = pageRequest.getPageParam(); + List list = customerServiceMapper.selectCustomerServicePage(filter, pageParam); + long total = customerServiceMapper.countCustomerServices(filter); + pageParam.setTotal((int) total); + + PageDomain pageDomain = new PageDomain<>(pageParam, list); + return ResultDomain.success("查询成功", pageDomain); + } + + @Override + public ResultDomain updateCustomerServiceStatus(String userId, String status) { + logger.info("更新客服状态: userId={}, status={}", userId, status); + + TbCustomerServiceDTO customerService = new TbCustomerServiceDTO(); + customerService.setUserId(userId); + customerService.setStatus(status); + + int rows = customerServiceMapper.updateCustomerService(customerService); + if (rows > 0) { + return ResultDomain.success("更新成功", true); + } + return ResultDomain.failure("更新失败"); + } + + @Override + public ResultDomain getAvailableCustomerServices() { + TbCustomerServiceDTO filter = new TbCustomerServiceDTO(); + filter.setStatus("online"); + List list = customerServiceMapper.selectCustomerServiceList(filter); + + // 过滤工作量未满的客服 + List availableList = list.stream() + .filter(cs -> cs.getCurrentWorkload() == null || + cs.getMaxConcurrent() == null || + cs.getCurrentWorkload() < cs.getMaxConcurrent()) + .toList(); + + return ResultDomain.success("查询成功", availableList); + } + + @Override + @Transactional + public ResultDomain assignCustomerService(String roomId) { + logger.info("分配所有客服到聊天室: roomId={}", roomId); + + // 获取所有在线客服列表 + TbCustomerServiceDTO filter = new TbCustomerServiceDTO(); + filter.setStatus("online"); + List allServices = customerServiceMapper.selectCustomerServiceList(filter); + + if (allServices == null || allServices.isEmpty()) { + return ResultDomain.failure("当前没有在线的客服人员"); + } + + // 把所有客服都加入聊天室 + int addedCount = 0; + for (CustomerServiceVO service : allServices) { + TbChatRoomMemberDTO member = new TbChatRoomMemberDTO(); + member.setMemberId(IdUtil.generateUUID()); + member.setOptsn(IdUtil.getOptsn()); + member.setRoomId(roomId); + member.setUserId(service.getUserId()); + member.setUserType("staff"); + member.setUserName(service.getUsername()); + member.setStatus("active"); + member.setJoinTime(new Date()); + member.setUnreadCount(0); + chatRoomMemberMapper.insertChatRoomMember(member); + + // 更新客服工作量 + TbCustomerServiceDTO updateService = new TbCustomerServiceDTO(); + updateService.setUserId(service.getUserId()); + updateService.setCurrentWorkload( + (service.getCurrentWorkload() != null ? service.getCurrentWorkload() : 0) + 1); + customerServiceMapper.updateCustomerService(updateService); + addedCount++; + } + + // 更新聊天室客服人数 + TbChatRoomDTO updateRoom = new TbChatRoomDTO(); + updateRoom.setRoomId(roomId); + updateRoom.setAgentCount(addedCount); + chatRoomMapper.updateChatRoom(updateRoom); + + logger.info("已添加{}名客服到聊天室: roomId={}", addedCount, roomId); + return ResultDomain.success("分配成功,共添加" + addedCount + "名客服", allServices.get(0)); + } + + // ========================= 私有方法 ========================== + + private void publishMessageToRedis(TbChatRoomMessageDTO message) { + try { + String channel = WorkcaseConstant.REDIS_CHAT_PREFIX + message.getRoomId(); + // RedisService内部已配置FastJson2JsonRedisSerializer,会自动序列化 + redisService.publish(channel, message); + logger.debug("消息已发布到Redis频道: {}", channel); + } catch (Exception e) { + logger.error("发布消息到Redis失败", e); + } + } + + private void publishListUpdateToRedis(TbChatRoomMessageDTO message) { + try { + // 发布到列表更新频道,前端订阅此频道刷新聊天室列表 + redisService.publish(WorkcaseConstant.REDIS_CHAT_LIST_UPDATE, message); + logger.debug("列表更新已发布到Redis频道: {}", WorkcaseConstant.REDIS_CHAT_LIST_UPDATE); + } catch (Exception e) { + logger.error("发布列表更新到Redis失败", e); + } + } +} diff --git a/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/MeetServiceImpl.java b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/MeetServiceImpl.java new file mode 100644 index 00000000..08c4cde3 --- /dev/null +++ b/urbanLifelineServ/workcase/src/main/java/org/xyzh/workcase/service/MeetServiceImpl.java @@ -0,0 +1,450 @@ +package org.xyzh.workcase.service; + +import java.util.Date; +import java.util.List; + +import org.apache.dubbo.config.annotation.DubboService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.transaction.annotation.Transactional; +import org.xyzh.api.workcase.dto.TbVideoMeetingDTO; +import org.xyzh.api.workcase.dto.TbMeetingParticipantDTO; +import org.xyzh.api.workcase.dto.TbMeetingTranscriptionDTO; +import org.xyzh.api.workcase.service.MeetService; +import org.xyzh.api.workcase.vo.VideoMeetingVO; +import org.xyzh.api.workcase.vo.MeetingParticipantVO; +import org.xyzh.api.workcase.vo.MeetingTranscriptionVO; +import org.xyzh.common.core.domain.ResultDomain; +import org.xyzh.common.core.page.PageDomain; +import org.xyzh.common.core.page.PageParam; +import org.xyzh.common.core.page.PageRequest; +import org.xyzh.common.utils.id.IdUtil; +import org.xyzh.workcase.mapper.TbVideoMeetingMapper; +import org.xyzh.workcase.mapper.TbMeetingParticipantMapper; +import org.xyzh.workcase.mapper.TbMeetingTranscriptionMapper; + +/** + * @description 视频会议服务实现类(伪代码) + * @filename MeetServiceImpl.java + * @author cascade + * @copyright xyzh + * @since 2025-12-22 + */ +@DubboService(version = "1.0.0", group = "workcase", timeout = 30000, retries = 0) +public class MeetServiceImpl implements MeetService { + private static final Logger logger = LoggerFactory.getLogger(MeetServiceImpl.class); + + @Autowired + private TbVideoMeetingMapper videoMeetingMapper; + + @Autowired + private TbMeetingParticipantMapper meetingParticipantMapper; + + @Autowired + private TbMeetingTranscriptionMapper meetingTranscriptionMapper; + + // TODO: 注入Jitsi配置和JWT工具类 + // @Autowired + // private JitsiConfig jitsiConfig; + // @Autowired + // private JwtTokenUtil jwtTokenUtil; + + // ========================= 会议管理 ========================== + + @Override + @Transactional + public ResultDomain createMeeting(TbVideoMeetingDTO meeting) { + logger.info("创建会议: roomId={}, meetingName={}", meeting.getRoomId(), meeting.getMeetingName()); + + // TODO: 生成唯一的Jitsi房间名 + // String jitsiRoomName = "meet_" + IdUtil.generateUUID().replace("-", ""); + + if (meeting.getMeetingId() == null || meeting.getMeetingId().isEmpty()) { + meeting.setMeetingId(IdUtil.generateUUID()); + } + if (meeting.getOptsn() == null || meeting.getOptsn().isEmpty()) { + meeting.setOptsn(IdUtil.getOptsn()); + } + if (meeting.getStatus() == null || meeting.getStatus().isEmpty()) { + meeting.setStatus("scheduled"); + } + // TODO: 设置Jitsi相关配置 + // meeting.setJitsiRoomName(jitsiRoomName); + // meeting.setJitsiServerUrl(jitsiConfig.getServerUrl()); + + int rows = videoMeetingMapper.insertVideoMeeting(meeting); + if (rows > 0) { + logger.info("会议创建成功: meetingId={}", meeting.getMeetingId()); + return ResultDomain.success("创建成功", meeting); + } + return ResultDomain.failure("创建失败"); + } + + @Override + public ResultDomain updateMeeting(TbVideoMeetingDTO meeting) { + logger.info("更新会议: meetingId={}", meeting.getMeetingId()); + + TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meeting.getMeetingId()); + if (existing == null) { + return ResultDomain.failure("会议不存在"); + } + + int rows = videoMeetingMapper.updateVideoMeeting(meeting); + if (rows > 0) { + TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meeting.getMeetingId()); + return ResultDomain.success("更新成功", updated); + } + return ResultDomain.failure("更新失败"); + } + + @Override + public ResultDomain startMeeting(String meetingId) { + logger.info("开始会议: meetingId={}", meetingId); + + TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId); + if (existing == null) { + return ResultDomain.failure("会议不存在"); + } + if ("ongoing".equals(existing.getStatus())) { + return ResultDomain.failure("会议已在进行中"); + } + + TbVideoMeetingDTO meeting = new TbVideoMeetingDTO(); + meeting.setMeetingId(meetingId); + meeting.setStatus("ongoing"); + meeting.setActualStartTime(new Date()); + + int rows = videoMeetingMapper.updateVideoMeeting(meeting); + if (rows > 0) { + TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meetingId); + return ResultDomain.success("会议已开始", updated); + } + return ResultDomain.failure("开始会议失败"); + } + + @Override + public ResultDomain endMeeting(String meetingId) { + logger.info("结束会议: meetingId={}", meetingId); + + TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId); + if (existing == null) { + return ResultDomain.failure("会议不存在"); + } + + TbVideoMeetingDTO meeting = new TbVideoMeetingDTO(); + meeting.setMeetingId(meetingId); + meeting.setStatus("ended"); + meeting.setActualEndTime(new Date()); + + // TODO: 计算会议时长 + // if (existing.getActualStartTime() != null) { + // long durationMs = new Date().getTime() - existing.getActualStartTime().getTime(); + // meeting.setDurationSeconds((int)(durationMs / 1000)); + // } + + int rows = videoMeetingMapper.updateVideoMeeting(meeting); + if (rows > 0) { + // TODO: 更新所有参与者离开时间 + // updateAllParticipantsLeaveTime(meetingId); + + TbVideoMeetingDTO updated = videoMeetingMapper.selectVideoMeetingById(meetingId); + return ResultDomain.success("会议已结束", updated); + } + return ResultDomain.failure("结束会议失败"); + } + + @Override + public ResultDomain deleteMeeting(String meetingId) { + logger.info("删除会议: meetingId={}", meetingId); + + TbVideoMeetingDTO existing = videoMeetingMapper.selectVideoMeetingById(meetingId); + if (existing == null) { + return ResultDomain.failure("会议不存在"); + } + + TbVideoMeetingDTO meeting = new TbVideoMeetingDTO(); + meeting.setMeetingId(meetingId); + int rows = videoMeetingMapper.deleteVideoMeeting(meeting); + if (rows > 0) { + return ResultDomain.success("删除成功", true); + } + return ResultDomain.failure("删除失败"); + } + + @Override + public ResultDomain getMeetingById(String meetingId) { + TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); + if (meeting != null) { + return ResultDomain.success("查询成功", meeting); + } + return ResultDomain.failure("会议不存在"); + } + + @Override + public ResultDomain getMeetingPage(PageRequest pageRequest) { + TbVideoMeetingDTO filter = pageRequest.getFilter(); + if (filter == null) { + filter = new TbVideoMeetingDTO(); + } + + PageParam pageParam = pageRequest.getPageParam(); + List list = videoMeetingMapper.selectVideoMeetingPage(filter, pageParam); + long total = videoMeetingMapper.countVideoMeetings(filter); + pageParam.setTotal((int) total); + + PageDomain pageDomain = new PageDomain<>(pageParam, list); + return ResultDomain.success("查询成功", pageDomain); + } + + @Override + public ResultDomain generateMeetingJoinUrl(String meetingId, String userId, String userName) { + logger.info("生成会议加入链接: meetingId={}, userId={}", meetingId, userId); + + TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); + if (meeting == null) { + return ResultDomain.failure("会议不存在"); + } + + // TODO: 生成Jitsi iframe URL + // String jwtToken = generateMeetingToken(meetingId, userId, false).getData(); + // String baseUrl = meeting.getJitsiServerUrl(); + // String roomName = meeting.getJitsiRoomName(); + // String iframeUrl = String.format("%s/%s?jwt=%s#userInfo.displayName=%s", + // baseUrl, roomName, jwtToken, URLEncoder.encode(userName, "UTF-8")); + + String iframeUrl = "TODO: 生成Jitsi iframe URL"; + return ResultDomain.success("生成成功", iframeUrl); + } + + @Override + public ResultDomain generateMeetingToken(String meetingId, String userId, boolean isModerator) { + logger.info("生成会议JWT: meetingId={}, userId={}, isModerator={}", meetingId, userId, isModerator); + + TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); + if (meeting == null) { + return ResultDomain.failure("会议不存在"); + } + + // TODO: 使用Jitsi JWT规范生成Token + // JitsiTokenPayload payload = new JitsiTokenPayload(); + // payload.setRoom(meeting.getJitsiRoomName()); + // payload.setModerator(isModerator); + // payload.setUserId(userId); + // String token = jwtTokenUtil.generateJitsiToken(payload); + + String token = "TODO: 生成Jitsi JWT Token"; + return ResultDomain.success("生成成功", token); + } + + // ========================= 参与者管理 ========================== + + @Override + public ResultDomain joinMeeting(TbMeetingParticipantDTO participant) { + logger.info("参与者加入会议: meetingId={}, userId={}", participant.getMeetingId(), participant.getUserId()); + + // 检查会议是否存在 + TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(participant.getMeetingId()); + if (meeting == null) { + return ResultDomain.failure("会议不存在"); + } + + if (participant.getParticipantId() == null || participant.getParticipantId().isEmpty()) { + participant.setParticipantId(IdUtil.generateUUID()); + } + if (participant.getOptsn() == null || participant.getOptsn().isEmpty()) { + participant.setOptsn(IdUtil.getOptsn()); + } + participant.setJoinTime(new Date()); + + int rows = meetingParticipantMapper.insertMeetingParticipant(participant); + if (rows > 0) { + // 更新会议参与人数 + TbVideoMeetingDTO updateMeeting = new TbVideoMeetingDTO(); + updateMeeting.setMeetingId(participant.getMeetingId()); + updateMeeting.setParticipantCount(meeting.getParticipantCount() != null ? meeting.getParticipantCount() + 1 : 1); + videoMeetingMapper.updateVideoMeeting(updateMeeting); + + return ResultDomain.success("加入成功", participant); + } + return ResultDomain.failure("加入失败"); + } + + @Override + public ResultDomain leaveMeeting(String participantId) { + logger.info("参与者离开会议: participantId={}", participantId); + + TbMeetingParticipantDTO existing = meetingParticipantMapper.selectMeetingParticipantById(participantId); + if (existing == null) { + return ResultDomain.failure("参与者不存在"); + } + + TbMeetingParticipantDTO participant = new TbMeetingParticipantDTO(); + participant.setParticipantId(participantId); + participant.setLeaveTime(new Date()); + + // TODO: 计算参与时长 + // if (existing.getJoinTime() != null) { + // long durationMs = new Date().getTime() - existing.getJoinTime().getTime(); + // participant.setDurationSeconds((int)(durationMs / 1000)); + // } + + int rows = meetingParticipantMapper.updateMeetingParticipant(participant); + if (rows > 0) { + return ResultDomain.success("离开成功", true); + } + return ResultDomain.failure("离开失败"); + } + + @Override + public ResultDomain getMeetingParticipantList(String meetingId) { + TbMeetingParticipantDTO filter = new TbMeetingParticipantDTO(); + filter.setMeetingId(meetingId); + List list = meetingParticipantMapper.selectMeetingParticipantList(filter); + return ResultDomain.success("查询成功", list); + } + + @Override + public ResultDomain updateParticipant(TbMeetingParticipantDTO participant) { + logger.info("更新参与者: participantId={}", participant.getParticipantId()); + + TbMeetingParticipantDTO existing = meetingParticipantMapper.selectMeetingParticipantById(participant.getParticipantId()); + if (existing == null) { + return ResultDomain.failure("参与者不存在"); + } + + int rows = meetingParticipantMapper.updateMeetingParticipant(participant); + if (rows > 0) { + TbMeetingParticipantDTO updated = meetingParticipantMapper.selectMeetingParticipantById(participant.getParticipantId()); + return ResultDomain.success("更新成功", updated); + } + return ResultDomain.failure("更新失败"); + } + + @Override + public ResultDomain setModerator(String participantId, boolean isModerator) { + logger.info("设置主持人: participantId={}, isModerator={}", participantId, isModerator); + + TbMeetingParticipantDTO participant = new TbMeetingParticipantDTO(); + participant.setParticipantId(participantId); + participant.setIsModerator(isModerator); + + int rows = meetingParticipantMapper.updateMeetingParticipant(participant); + if (rows > 0) { + return ResultDomain.success("设置成功", true); + } + return ResultDomain.failure("设置失败"); + } + + // ========================= 转录管理 ========================== + + @Override + public ResultDomain addTranscription(TbMeetingTranscriptionDTO transcription) { + logger.info("添加转录记录: meetingId={}, speakerId={}", transcription.getMeetingId(), transcription.getSpeakerId()); + + if (transcription.getTranscriptionId() == null || transcription.getTranscriptionId().isEmpty()) { + transcription.setTranscriptionId(IdUtil.generateUUID()); + } + if (transcription.getOptsn() == null || transcription.getOptsn().isEmpty()) { + transcription.setOptsn(IdUtil.getOptsn()); + } + + int rows = meetingTranscriptionMapper.insertMeetingTranscription(transcription); + if (rows > 0) { + return ResultDomain.success("添加成功", transcription); + } + return ResultDomain.failure("添加失败"); + } + + @Override + public ResultDomain getTranscriptionPage(PageRequest pageRequest) { + TbMeetingTranscriptionDTO filter = pageRequest.getFilter(); + if (filter == null) { + filter = new TbMeetingTranscriptionDTO(); + } + + PageParam pageParam = pageRequest.getPageParam(); + List list = meetingTranscriptionMapper.selectMeetingTranscriptionPage(filter, pageParam); + long total = meetingTranscriptionMapper.countMeetingTranscriptions(filter); + pageParam.setTotal((int) total); + + PageDomain pageDomain = new PageDomain<>(pageParam, list); + return ResultDomain.success("查询成功", pageDomain); + } + + @Override + public ResultDomain getFullTranscriptionText(String meetingId) { + logger.info("获取完整转录文本: meetingId={}", meetingId); + + TbMeetingTranscriptionDTO filter = new TbMeetingTranscriptionDTO(); + filter.setMeetingId(meetingId); + filter.setIsFinal(true); + List list = meetingTranscriptionMapper.selectMeetingTranscriptionList(filter); + + // TODO: 拼接转录文本 + StringBuilder sb = new StringBuilder(); + for (MeetingTranscriptionVO transcription : list) { + // 格式:[说话人名称] 内容 + sb.append("[").append(transcription.getSpeakerName()).append("] "); + sb.append(transcription.getContent()).append("\n"); + } + + return ResultDomain.success("查询成功", sb.toString()); + } + + @Override + public ResultDomain deleteTranscription(String transcriptionId) { + logger.info("删除转录记录: transcriptionId={}", transcriptionId); + + TbMeetingTranscriptionDTO existing = meetingTranscriptionMapper.selectMeetingTranscriptionById(transcriptionId); + if (existing == null) { + return ResultDomain.failure("转录记录不存在"); + } + + TbMeetingTranscriptionDTO transcription = new TbMeetingTranscriptionDTO(); + transcription.setTranscriptionId(transcriptionId); + int rows = meetingTranscriptionMapper.deleteMeetingTranscription(transcription); + if (rows > 0) { + return ResultDomain.success("删除成功", true); + } + return ResultDomain.failure("删除失败"); + } + + // ========================= 会议统计 ========================== + + @Override + public ResultDomain getMeetingStatistics(String meetingId) { + logger.info("获取会议统计: meetingId={}", meetingId); + + TbVideoMeetingDTO meeting = videoMeetingMapper.selectVideoMeetingById(meetingId); + if (meeting == null) { + return ResultDomain.failure("会议不存在"); + } + + // TODO: 查询并组装统计信息 + // - 参与人数 + // - 会议时长 + // - 转录记录数 + // - 各参与者参与时长等 + + TbMeetingParticipantDTO participantFilter = new TbMeetingParticipantDTO(); + participantFilter.setMeetingId(meetingId); + List participants = meetingParticipantMapper.selectMeetingParticipantList(participantFilter); + + VideoMeetingVO vo = new VideoMeetingVO(); + vo.setMeetingId(meeting.getMeetingId()); + vo.setMeetingName(meeting.getMeetingName()); + vo.setStatus(meeting.getStatus()); + vo.setParticipantCount(participants.size()); + vo.setActualStartTime(meeting.getActualStartTime()); + vo.setActualEndTime(meeting.getActualEndTime()); + vo.setDurationSeconds(meeting.getDurationSeconds()); + + // TODO: 格式化时长 + // if (meeting.getDurationSeconds() != null) { + // vo.setDurationFormatted(formatDuration(meeting.getDurationSeconds())); + // } + + return ResultDomain.success("查询成功", vo); + } +} diff --git a/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml b/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml index 43678588..8b185b32 100644 --- a/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml +++ b/urbanLifelineServ/workcase/src/main/resources/mapper/TbChatMessageMapper.xml @@ -2,7 +2,7 @@ - + @@ -24,7 +24,7 @@ - + @@ -52,8 +52,8 @@ status, read_count, send_time, creator, create_time, update_time - - INSERT INTO workcase.tb_chat_message ( + + INSERT INTO workcase.tb_chat_room_message ( optsn, message_id, room_id, sender_id, sender_type, sender_name, content, creator , message_type , files @@ -74,8 +74,8 @@ ) - - UPDATE workcase.tb_chat_message + + UPDATE workcase.tb_chat_room_message content = #{content}, status = #{status}, @@ -85,20 +85,20 @@ WHERE message_id = #{messageId} - - DELETE FROM workcase.tb_chat_message + + DELETE FROM workcase.tb_chat_room_message WHERE message_id = #{messageId} SELECT - FROM workcase.tb_chat_message + FROM workcase.tb_chat_room_message AND message_id = #{filter.messageId} AND room_id = #{filter.roomId} @@ -129,7 +129,7 @@ diff --git a/urbanLifelineServ/workcase/聊天室实现文档.md b/urbanLifelineServ/workcase/聊天室实现文档.md new file mode 100644 index 00000000..e260fd02 --- /dev/null +++ b/urbanLifelineServ/workcase/聊天室实现文档.md @@ -0,0 +1,233 @@ +# 聊天室实现文档 + +## 1. 业务规则 + +- 一个工单只能创建一个聊天室,一个聊天室可以发起多次会议 +- 创建聊天室时自动添加来客和所有在线客服到成员表 +- 消息发送使用分布式锁保证时间戳递增,避免并发乱序 + +--- + +## 2. 核心文件结构 + +``` +workcase/ +├── src/main/java/org/xyzh/workcase/ +│ ├── service/ +│ │ ├── ChatRoomServiceImpl.java # 聊天室服务实现 +│ │ └── MeetServiceImpl.java # 会议服务实现(伪代码) +│ ├── listener/ +│ │ └── ChatMessageListener.java # Redis消息监听器 +│ ├── config/ +│ │ ├── WebSocketConfig.java # STOMP WebSocket配置 +│ │ └── RedisSubscriberConfig.java # Redis订阅配置 +│ └── mapper/ +│ ├── TbChatRoomMapper.java +│ ├── TbChatRoomMemberMapper.java +│ ├── TbChatMessageMapper.java +│ └── TbCustomerServiceMapper.java + +apis/api-workcase/ +├── src/main/java/org/xyzh/api/workcase/ +│ ├── constant/ +│ │ └── WorkcaseConstant.java # 常量定义 +│ ├── service/ +│ │ ├── ChatRoomService.java # 聊天室服务接口 +│ │ └── MeetService.java # 会议服务接口 +│ ├── dto/ +│ │ ├── TbChatRoomDTO.java +│ │ ├── TbChatRoomMemberDTO.java +│ │ └── TbChatMessageDTO.java +│ └── vo/ +│ ├── ChatRoomVO.java +│ ├── ChatMemberVO.java +│ └── ChatMessageVO.java +``` + +--- + +## 3. Redis Key 设计 + +| Key | 说明 | 示例 | +|-----|------|------| +| `chat:room:{roomId}` | 聊天室消息Pub/Sub频道 | `chat:room:abc123` | +| `chat:room:online:{roomId}` | 在线用户Set | `chat:room:online:abc123` | +| `chat:room:lock:{roomId}` | 消息发送锁 | `chat:room:lock:abc123` | +| `chat:room:lasttime:{roomId}` | 最后消息时间戳 | `chat:room:lasttime:abc123` | +| `chat:list:update` | 聊天室列表更新通知频道 | `chat:list:update` | + +--- + +## 4. 消息流转架构 + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ 消息发送流程 │ +├─────────────────────────────────────────────────────────────────┤ +│ │ +│ 客户端 ──HTTP POST──> ChatRoomServiceImpl.sendMessage() │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 获取分布式锁 │ │ +│ │ chat:room:lock │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 递增时间戳保证 │ │ +│ │ 消息顺序 │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────┐ │ +│ │ 保存到数据库 │ │ +│ └────────┬────────┘ │ +│ │ │ +│ ▼ │ +│ redisService.publish("chat:room:{roomId}") │ +│ │ │ +│ ▼ │ +│ redisService.publish("chat:list:update") │ +│ │ │ +└──────────────────────────────┼──────────────────────────────────┘ + │ +┌──────────────────────────────┼──────────────────────────────────┐ +│ 消息接收流程 │ +├──────────────────────────────┼──────────────────────────────────┤ +│ │ │ +│ RedisSubscriberConfig (订阅 chat:room:*, chat:list:update)│ +│ │ │ +│ ▼ │ +│ ChatMessageListener.onMessage() │ +│ ┌────┴────┐ │ +│ │ │ │ +│ ▼ ▼ │ +│ /topic/chat/{roomId} /topic/chat/list-update │ +│ (聊天窗口消息) (聊天室列表更新) │ +│ │ │ │ +│ ▼ ▼ │ +│ 聊天窗口订阅者 列表页面订阅者 │ +│ │ +└─────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 5. 用户类型常量 + +| 常量 | 值 | 说明 | +|------|-----|------| +| `USER_TYPE_GUEST` | `guest` | 来客 | +| `USER_TYPE_STAFF` | `staff` | 客服 | +| `USER_TYPE_AI` | `ai` | AI助手 | + +--- + +## 6. 状态常量 + +### 聊天室状态 +| 常量 | 值 | 说明 | +|------|-----|------| +| `ROOM_STATUS_ACTIVE` | `active` | 活跃 | +| `ROOM_STATUS_CLOSED` | `closed` | 已关闭 | + +### 成员状态 +| 常量 | 值 | 说明 | +|------|-----|------| +| `MEMBER_STATUS_ACTIVE` | `active` | 活跃 | +| `MEMBER_STATUS_LEFT` | `left` | 已离开 | +| `MEMBER_STATUS_REMOVED` | `removed` | 被移除 | + +### 消息状态 +| 常量 | 值 | 说明 | +|------|-----|------| +| `MESSAGE_STATUS_SENT` | `sent` | 已发送 | +| `MESSAGE_STATUS_RECALLED` | `recalled` | 已撤回 | + +--- + +## 7. 并发消息处理 + +```java +// 获取分布式锁 +String lockKey = WorkcaseConstant.REDIS_CHAT_LOCK + roomId; +redisService.setIfAbsent(lockKey, "1", 5); // 5秒过期 + +// 保证时间戳递增 +String timeKey = WorkcaseConstant.REDIS_CHAT_LASTTIME + roomId; +long lastTime = redisService.get(timeKey); +if (currentTime <= lastTime) { + currentTime = lastTime + 1; +} +message.setSendTime(new Date(currentTime)); +redisService.set(timeKey, currentTime); + +// 释放锁 +redisService.delete(lockKey); +``` + +--- + +## 8. 前端WebSocket连接示例 + +```javascript +// 连接WebSocket +const socket = new SockJS('/ws/chat'); +const stompClient = Stomp.over(socket); + +stompClient.connect({}, () => { + // 1. 订阅聊天室消息(聊天窗口使用) + stompClient.subscribe('/topic/chat/' + roomId, (message) => { + const chatMessage = JSON.parse(message.body); + // 处理收到的消息,添加到聊天窗口 + console.log('收到消息:', chatMessage); + }); + + // 2. 订阅聊天室列表更新(列表页面使用) + stompClient.subscribe('/topic/chat/list-update', (message) => { + const chatMessage = JSON.parse(message.body); + // 根据roomId更新对应聊天室的lastMessage和lastMessageTime + updateChatRoomInList(chatMessage.roomId, { + lastMessage: chatMessage.content, + lastMessageTime: chatMessage.sendTime, + senderName: chatMessage.senderName + }); + }); +}); + +// 断开连接 +stompClient.disconnect(); +``` + +--- + +## 9. API接口 + +### ChatRoomService + +| 方法 | 说明 | +|------|------| +| `createChatRoom(dto)` | 创建聊天室,自动添加来客和客服 | +| `updateChatRoom(dto)` | 更新聊天室信息 | +| `closeChatRoom(roomId, closedBy)` | 关闭聊天室 | +| `deleteChatRoom(roomId)` | 删除聊天室 | +| `getChatRoomById(roomId)` | 获取聊天室详情 | +| `getChatRoomPage(pageRequest)` | 分页查询聊天室 | +| `addChatRoomMember(member)` | 添加成员 | +| `removeChatRoomMember(memberId)` | 移除成员 | +| `sendMessage(message)` | 发送消息(含并发处理) | +| `getChatMessagePage(pageRequest)` | 分页查询消息 | +| `assignCustomerService(roomId)` | 分配所有客服到聊天室 | + +### MeetService(伪代码) + +| 方法 | 说明 | +|------|------| +| `createMeeting(dto)` | 创建会议 | +| `startMeeting(meetingId)` | 开始会议 | +| `endMeeting(meetingId)` | 结束会议 | +| `joinMeeting(participant)` | 参与者加入 | +| `leaveMeeting(participantId)` | 参与者离开 | +| `generateMeetingJoinUrl(...)` | 生成Jitsi加入链接 | +| `addTranscription(dto)` | 添加转录记录 | diff --git a/urbanLifelineServ/workcase/聊天室广播方案.md b/urbanLifelineServ/workcase/聊天室广播方案.md deleted file mode 100644 index 3bed26ef..00000000 --- a/urbanLifelineServ/workcase/聊天室广播方案.md +++ /dev/null @@ -1,553 +0,0 @@ -# 聊天室广播内存优化方案 - -## 🎯 优化目标 - -解决大量聊天室导致的内存爆炸问题。 - ---- - -## 📊 问题分析 - -### 当前方案的内存瓶颈 - -```java -// 问题1: 每个WebSocket连接占用内存 -SimpMessagingTemplate → 维护所有连接的Session - -// 问题2: 订阅关系在内存中 -/topic/chat-room/ROOM001 → [User1, User2, User3, ...] -/topic/chat-room/ROOM002 → [User4, User5, User6, ...] -// ... 1000个聊天室 × 平均10个用户 = 10000个订阅关系 - -// 问题3: 消息堆积在内存中 -消息缓冲区 → 等待推送 → 内存占用 - -// 问题4: Session状态在内存中 -每个WebSocket Session → 用户信息、订阅列表、心跳状态 -``` - -**内存占用估算**: -- 单个WebSocket连接:~50KB -- 单个订阅关系:~5KB -- 1000并发用户,每人5个聊天室:(50KB + 5KB×5) × 1000 = **75MB** -- 10000并发用户:**750MB**(还不包括消息缓冲) - ---- - -## 🏗️ 优化架构 - -### 方案一:Redis Pub/Sub(推荐,最简单) - -``` -┌─────────────┐ ┌──────────────────┐ -│ 前端用户A │──WebSocket STOMP──►│ Spring Boot │ -└─────────────┘ │ WebSocket Server │ - └──────────────────┘ -┌─────────────┐ │ -│ 前端用户B │──WebSocket STOMP──► │ 发布消息 -└─────────────┘ ↓ - ┌──────────────────┐ -┌─────────────┐ │ Redis Pub/Sub │ -│ 前端用户C │──WebSocket STOMP──►│ │ -└─────────────┘ │ Channel: │ - │ chat:room:ROOM001│ - └──────────────────┘ - │ 订阅 - ↓ - 所有订阅此Channel的服务器节点收到消息 - ↓ - 通过WebSocket推送给各自的在线用户 -``` - -**核心思想**: -1. ✅ **不在内存中维护订阅关系** -2. ✅ **消息不堆积,即收即转** -3. ✅ **使用Redis存储在线用户** -4. ✅ **支持水平扩展** - ---- - -## 💻 代码实现 - -### 1. 添加Redis依赖 - -```xml - - - org.springframework.boot - spring-boot-starter-data-redis - -``` - -### 2. Redis Pub/Sub监听器 - -```java -package org.xyzh.workcase.config; - -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.data.redis.connection.RedisConnectionFactory; -import org.springframework.data.redis.listener.PatternTopic; -import org.springframework.data.redis.listener.RedisMessageListenerContainer; -import org.springframework.data.redis.listener.adapter.MessageListenerAdapter; -import org.xyzh.workcase.listener.ChatMessageRedisListener; - -/** - * Redis消息监听配置 - */ -@Configuration -public class RedisListenerConfig { - - @Bean - RedisMessageListenerContainer container( - RedisConnectionFactory connectionFactory, - MessageListenerAdapter listenerAdapter - ) { - RedisMessageListenerContainer container = new RedisMessageListenerContainer(); - container.setConnectionFactory(connectionFactory); - - // 订阅所有聊天室频道(使用通配符) - container.addMessageListener( - listenerAdapter, - new PatternTopic("chat:room:*") - ); - - return container; - } - - @Bean - MessageListenerAdapter listenerAdapter(ChatMessageRedisListener listener) { - return new MessageListenerAdapter(listener, "handleMessage"); - } -} -``` - -### 3. Redis消息监听器 - -```java -package org.xyzh.workcase.listener; - -import com.fasterxml.jackson.databind.ObjectMapper; -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.messaging.simp.SimpMessagingTemplate; -import org.springframework.stereotype.Component; - -/** - * Redis消息监听器 - * 负责接收Redis Pub/Sub消息并转发到WebSocket - */ -@Slf4j -@Component -public class ChatMessageRedisListener { - - @Autowired - private SimpMessagingTemplate messagingTemplate; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private RedisService redisService; - - /** - * 处理Redis发布的消息 - * - * @param message Redis消息(JSON格式) - * @param pattern 订阅的频道模式 - */ - public void handleMessage(String message, String pattern) { - try { - // 解析消息 - ChatMessageVO chatMessage = objectMapper.readValue(message, ChatMessageVO.class); - String roomId = chatMessage.getRoomId(); - - log.info("收到Redis消息,聊天室: {}, 内容: {}", roomId, chatMessage.getContent()); - - // 检查当前节点是否有该聊天室的在线用户 - String onlineUsersKey = "chat:room:online:" + roomId; - Set onlineUsers = redisService.getSet(onlineUsersKey); - - if (onlineUsers != null && !onlineUsers.isEmpty()) { - // 有在线用户,推送消息到WebSocket - messagingTemplate.convertAndSend( - "/topic/chat-room/" + roomId, - chatMessage - ); - - log.info("消息已推送到聊天室 {} 的 {} 个在线用户", roomId, onlineUsers.size()); - } else { - log.debug("聊天室 {} 在当前节点无在线用户,跳过推送", roomId); - } - - } catch (Exception e) { - log.error("处理Redis消息失败", e); - } - } -} -``` - -### 4. 优化后的消息控制器 - -```java -package org.xyzh.workcase.controller; - -import com.fasterxml.jackson.databind.ObjectMapper; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.messaging.handler.annotation.DestinationVariable; -import org.springframework.messaging.handler.annotation.MessageMapping; -import org.springframework.messaging.handler.annotation.Payload; -import org.springframework.messaging.simp.SimpMessageHeaderAccessor; -import org.springframework.stereotype.Controller; -import lombok.extern.slf4j.Slf4j; - -/** - * 优化后的WebSocket消息控制器 - * 核心改动:消息不直接广播,而是发布到Redis - */ -@Slf4j -@Controller -public class ChatMessageController { - - @Autowired - private StringRedisTemplate redisTemplate; - - @Autowired - private ObjectMapper objectMapper; - - @Autowired - private ChatMessageService chatMessageService; - - @Autowired - private ChatMemberService chatMemberService; - - /** - * 发送聊天室消息(通过Redis发布) - * - * 关键改动: - * 1. 移除 @SendTo 注解(不直接广播) - * 2. 保存消息后发布到Redis - * 3. 由RedisListener接收并转发给在线用户 - */ - @MessageMapping("/chat/send/{roomId}") - public void sendMessage( - @DestinationVariable String roomId, - @Payload SendMessageDTO message, - SimpMessageHeaderAccessor headerAccessor - ) { - // 1. 获取用户信息 - String userId = (String) headerAccessor.getSessionAttributes().get("userId"); - - // 2. 验证权限 - if (!chatMemberService.isMemberOfRoom(roomId, userId)) { - throw new BusinessException("您不是该聊天室成员"); - } - - // 3. 保存消息到数据库 - ChatMessageVO savedMessage = chatMessageService.sendMessage( - roomId, userId, message.getMessageType(), message.getContent() - ); - - // 4. 发布到Redis(关键:使用Redis Pub/Sub) - try { - String channel = "chat:room:" + roomId; - String messageJson = objectMapper.writeValueAsString(savedMessage); - - redisTemplate.convertAndSend(channel, messageJson); - - log.info("消息已发布到Redis频道: {}", channel); - } catch (Exception e) { - log.error("发布消息到Redis失败", e); - } - } - - /** - * 用户加入聊天室 - * 核心:在Redis中记录在线用户 - */ - @MessageMapping("/chat/join/{roomId}") - public void joinChatRoom( - @DestinationVariable String roomId, - SimpMessageHeaderAccessor headerAccessor - ) { - String userId = (String) headerAccessor.getSessionAttributes().get("userId"); - String userName = (String) headerAccessor.getSessionAttributes().get("userName"); - - // 在Redis中记录在线用户(使用Set结构) - String onlineUsersKey = "chat:room:online:" + roomId; - redisTemplate.opsForSet().add(onlineUsersKey, userId); - - // 设置过期时间(防止内存泄漏) - redisTemplate.expire(onlineUsersKey, 24, TimeUnit.HOURS); - - log.info("用户 {} 加入聊天室 {}", userName, roomId); - - // 发布加入通知 - SystemNotification notification = SystemNotification.builder() - .type("USER_JOIN") - .roomId(roomId) - .userId(userId) - .userName(userName) - .content(userName + " 加入了聊天室") - .timestamp(System.currentTimeMillis()) - .build(); - - try { - String channel = "chat:room:" + roomId; - String notificationJson = objectMapper.writeValueAsString(notification); - redisTemplate.convertAndSend(channel, notificationJson); - } catch (Exception e) { - log.error("发布加入通知失败", e); - } - } - - /** - * 用户离开聊天室 - * 核心:从Redis中移除在线用户 - */ - @MessageMapping("/chat/leave/{roomId}") - public void leaveChatRoom( - @DestinationVariable String roomId, - SimpMessageHeaderAccessor headerAccessor - ) { - String userId = (String) headerAccessor.getSessionAttributes().get("userId"); - String userName = (String) headerAccessor.getSessionAttributes().get("userName"); - - // 从Redis中移除在线用户 - String onlineUsersKey = "chat:room:online:" + roomId; - redisTemplate.opsForSet().remove(onlineUsersKey, userId); - - log.info("用户 {} 离开聊天室 {}", userName, roomId); - - // 发布离开通知 - SystemNotification notification = SystemNotification.builder() - .type("USER_LEAVE") - .roomId(roomId) - .userId(userId) - .userName(userName) - .content(userName + " 离开了聊天室") - .timestamp(System.currentTimeMillis()) - .build(); - - try { - String channel = "chat:room:" + roomId; - String notificationJson = objectMapper.writeValueAsString(notification); - redisTemplate.convertAndSend(channel, notificationJson); - } catch (Exception e) { - log.error("发布离开通知失败", e); - } - } -} -``` - -### 5. WebSocket连接事件监听器 - -```java -package org.xyzh.workcase.listener; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.event.EventListener; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.messaging.simp.stomp.StompHeaderAccessor; -import org.springframework.stereotype.Component; -import org.springframework.web.socket.messaging.SessionConnectedEvent; -import org.springframework.web.socket.messaging.SessionDisconnectEvent; - -/** - * WebSocket连接事件监听器 - * 用于清理断开连接的用户在线状态 - */ -@Slf4j -@Component -public class WebSocketEventListener { - - @Autowired - private StringRedisTemplate redisTemplate; - - /** - * 监听WebSocket连接建立 - */ - @EventListener - public void handleWebSocketConnectListener(SessionConnectedEvent event) { - StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); - String sessionId = headerAccessor.getSessionId(); - String userId = (String) headerAccessor.getSessionAttributes().get("userId"); - - log.info("WebSocket连接建立: sessionId={}, userId={}", sessionId, userId); - - // 记录用户的sessionId到Redis(用于断线清理) - if (userId != null) { - redisTemplate.opsForValue().set( - "chat:session:" + sessionId, - userId, - 24, - TimeUnit.HOURS - ); - } - } - - /** - * 监听WebSocket连接断开 - * 核心:清理该用户在所有聊天室的在线状态 - */ - @EventListener - public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { - StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); - String sessionId = headerAccessor.getSessionId(); - - log.info("WebSocket连接断开: sessionId={}", sessionId); - - // 从Redis获取userId - String userIdKey = "chat:session:" + sessionId; - String userId = redisTemplate.opsForValue().get(userIdKey); - - if (userId != null) { - // 清理该用户在所有聊天室的在线状态 - cleanupUserOnlineStatus(userId); - - // 删除session记录 - redisTemplate.delete(userIdKey); - - log.info("用户 {} 的在线状态已清理", userId); - } - } - - /** - * 清理用户在所有聊天室的在线状态 - */ - private void cleanupUserOnlineStatus(String userId) { - // 扫描所有聊天室在线用户集合 - Set keys = redisTemplate.keys("chat:room:online:*"); - - if (keys != null && !keys.isEmpty()) { - for (String key : keys) { - redisTemplate.opsForSet().remove(key, userId); - } - log.info("已从 {} 个聊天室中移除用户 {}", keys.size(), userId); - } - } -} -``` - ---- - -## 📈 性能对比 - -### 内存占用对比 - -| 指标 | 原方案 | 优化方案 | 优化率 | -|-----|-------|---------|--------| -| **单个连接内存** | 50KB | 10KB | ✅ 80% ↓ | -| **订阅关系存储** | 内存 | Redis | ✅ 0内存 | -| **1000并发用户** | 75MB | 10MB | ✅ 86% ↓ | -| **10000并发用户** | 750MB | 100MB | ✅ 86% ↓ | -| **消息堆积** | 内存缓冲 | 即收即转 | ✅ 0堆积 | - -### 并发能力对比 - -| 指标 | 原方案 | 优化方案 | -|-----|-------|---------| -| **单机最大连接数** | ~5000 | ~50000 | -| **水平扩展** | ❌ 困难 | ✅ 简单 | -| **消息延迟** | 10-50ms | 5-20ms | - ---- - -## 🔧 进一步优化 - -### 1. 懒加载订阅(按需连接) - -```typescript -// 前端:只在进入聊天室时才建立WebSocket连接 -const enterChatRoom = (roomId: string) => { - if (!wsClient.value) { - await connect(); // 懒加载连接 - } - subscribeChatRoom(roomId); -}; - -// 离开聊天室时断开连接 -const leaveChatRoom = (roomId: string) => { - unsubscribeChatRoom(roomId); - - // 如果没有其他订阅,断开WebSocket - if (subscriptions.size === 0) { - disconnect(); - } -}; -``` - -### 2. 连接池和限流 - -```java -@Configuration -public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { - - @Override - public void configureWebSocketTransport(WebSocketTransportRegistration registration) { - // 设置消息缓冲区大小(防止堆积) - registration.setMessageSizeLimit(64 * 1024); // 64KB - registration.setSendBufferSizeLimit(512 * 1024); // 512KB - - // 设置发送超时 - registration.setSendTimeLimit(10 * 1000); // 10秒 - - // 设置最大连接数(保护服务器) - registration.setTimeToFirstMessage(30 * 1000); // 30秒内必须发送消息 - } -} -``` - -### 3. Redis数据过期策略 - -```java -// 在线用户列表:24小时过期 -redisTemplate.expire("chat:room:online:" + roomId, 24, TimeUnit.HOURS); - -// Session映射:24小时过期 -redisTemplate.expire("chat:session:" + sessionId, 24, TimeUnit.HOURS); - -// 消息缓存(如果需要):1小时过期 -redisTemplate.expire("chat:message:" + messageId, 1, TimeUnit.HOURS); -``` - -### 4. 分片策略(超大规模) - -```java -// 如果有10万+并发,按roomId哈希分片 -String shardKey = "chat:shard:" + (roomId.hashCode() % 10); -redisTemplate.convertAndSend(shardKey, message); - -// 每个服务器节点只订阅部分shard -container.addMessageListener(listenerAdapter, new PatternTopic("chat:shard:" + nodeId)); -``` - ---- - -## 🎯 总结 - -### 优化关键点 - -1. ✅ **消息不在内存中堆积** - 即收即转 -2. ✅ **订阅关系存储在Redis** - 内存占用降低80%+ -3. ✅ **无状态转发** - 支持水平扩展 -4. ✅ **自动清理断线用户** - 防止内存泄漏 -5. ✅ **懒加载连接** - 按需建立WebSocket - -### 适用场景 - -- ✅ **1000+并发用户** -- ✅ **100+聊天室** -- ✅ **需要水平扩展** -- ✅ **需要跨数据中心部署** - -### 下一步 - -1. 添加Redis依赖 -2. 实现RedisListener -3. 修改ChatMessageController -4. 添加WebSocketEventListener -5. 性能测试和调优 diff --git a/urbanLifelineWeb/packages/shared/src/types/workcase/chatRoom.ts b/urbanLifelineWeb/packages/shared/src/types/workcase/chatRoom.ts index 8c2bb92c..eff30a3f 100644 --- a/urbanLifelineWeb/packages/shared/src/types/workcase/chatRoom.ts +++ b/urbanLifelineWeb/packages/shared/src/types/workcase/chatRoom.ts @@ -55,7 +55,6 @@ export interface TbChatRoomMemberDTO extends BaseDTO { userId?: string userType?: string userName?: string - role?: string status?: string unreadCount?: number lastReadTime?: string @@ -207,7 +206,6 @@ export interface ChatMemberVO extends BaseVO { userType?: string userName?: string userAvatar?: string - role?: string status?: string unreadCount?: number lastReadTime?: string