【同步】BOOT 和 CLOUD 的功能(im、bpm)

This commit is contained in:
YunaiV
2026-07-04 20:14:02 +08:00
parent 58c393164f
commit 0e427886d1
175 changed files with 3048 additions and 2329 deletions
+13 -2
View File
@@ -462,7 +462,7 @@ CREATE TABLE `system_dict_data` (
`update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1061137 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典数据表';
) ENGINE = InnoDB AUTO_INCREMENT = 1061147 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典数据表';
-- ----------------------------
-- Records of system_dict_data
@@ -814,6 +814,16 @@ INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `st
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1512, 6, '委派中', '6', 'bpm_task_status', 0, 'primary', '', '', '1', '2024-03-17 10:06:22', '1', '2024-03-08 22:41:40', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1513, 7, '审批通过中', '7', 'bpm_task_status', 0, 'success', '', '', '1', '2024-03-17 10:06:47', '1', '2024-03-08 22:41:41', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1514, 0, '待审批', '0', 'bpm_task_status', 0, 'info', '', '', '1', '2024-03-17 10:07:11', '1', '2024-03-08 22:41:42', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061137, 0, '评论', '0', 'bpm_comment_type', 0, 'primary', '', '流程评论类型 - 评论', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061138, 1, '审批通过', '1', 'bpm_comment_type', 0, 'success', '', '流程评论类型 - 审批通过', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061139, 2, '不通过', '2', 'bpm_comment_type', 0, 'danger', '', '流程评论类型 - 不通过', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061140, 3, '已取消', '3', 'bpm_comment_type', 0, 'info', '', '流程评论类型 - 已取消', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061141, 4, '退回', '4', 'bpm_comment_type', 0, 'warning', '', '流程评论类型 - 退回', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061142, 5, '委派发起', '5', 'bpm_comment_type', 0, 'primary', '', '流程评论类型 - 委派发起', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061143, 6, '委派完成', '6', 'bpm_comment_type', 0, 'primary', '', '流程评论类型 - 委派完成', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061144, 7, '转派', '7', 'bpm_comment_type', 0, 'warning', '', '流程评论类型 - 转派', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061145, 8, '加签', '8', 'bpm_comment_type', 0, 'primary', '', '流程评论类型 - 加签', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1061146, 9, '减签', '9', 'bpm_comment_type', 0, 'info', '', '流程评论类型 - 减签', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1515, 35, '发起人自选', '35', 'bpm_task_candidate_strategy', 0, '', '', '', '1', '2024-03-22 19:45:16', '1', '2024-03-22 19:45:16', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1516, 1, '执行监听器', 'execution', 'bpm_process_listener_type', 0, 'primary', '', '', '1', '2024-03-23 12:54:03', '1', '2024-03-23 19:14:19', b'0');
INSERT INTO `system_dict_data` (`id`, `sort`, `label`, `value`, `dict_type`, `status`, `color_type`, `css_class`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`) VALUES (1517, 1, '任务监听器', 'task', 'bpm_process_listener_type', 0, 'success', '', '', '1', '2024-03-23 12:54:13', '1', '2024-03-23 19:14:24', b'0');
@@ -1513,7 +1523,7 @@ CREATE TABLE `system_dict_type` (
`deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除',
`deleted_time` datetime NULL DEFAULT NULL COMMENT '删除时间',
PRIMARY KEY (`id`) USING BTREE
) ENGINE = InnoDB AUTO_INCREMENT = 1061099 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典类型表';
) ENGINE = InnoDB AUTO_INCREMENT = 1061100 CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci COMMENT = '字典类型表';
-- ----------------------------
-- Records of system_dict_type
@@ -1726,6 +1736,7 @@ INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creat
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (1061096, 'IM 通话参与角色', 'im_rtc_participant_role', 0, '1=发起人;2=被邀请者;3=主动加入者', 'admin', '2026-05-18 03:36:12', 'admin', '2026-05-18 03:36:12', b'0', NULL);
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (1061097, 'IM 通话参与状态', 'im_rtc_participant_status', 0, '10=邀请中;20=已加入;30=已拒绝;40=未应答;50=已离开', 'admin', '2026-05-18 03:36:12', 'admin', '2026-05-18 03:36:12', b'0', NULL);
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (1061098, 'IM 频道素材内容类型', 'im_channel_material_type', 0, '1=站内富文本 / 2=外链', '1', '2026-05-19 14:09:25', '1', '2026-05-19 14:09:25', b'0', NULL);
INSERT INTO `system_dict_type` (`id`, `name`, `type`, `status`, `remark`, `creator`, `create_time`, `updater`, `update_time`, `deleted`, `deleted_time`) VALUES (1061099, '流程评论类型', 'bpm_comment_type', 0, 'Flowable 评论类型', '1', '2026-07-02 09:20:00', '1', '2026-07-02 09:20:00', b'0', NULL);
COMMIT;
-- ----------------------------
@@ -7,4 +7,6 @@ package cn.iocoder.yudao.module.bpm.enums;
*/
public interface DictTypeConstants {
String COMMENT_TYPE = "bpm_comment_type";
}
@@ -13,6 +13,7 @@ import lombok.Getter;
@AllArgsConstructor
public enum BpmCommentTypeEnum {
COMMENT("0", "评论", "{}"),
APPROVE("1", "审批通过", "审批通过,原因是:{}"),
REJECT("2", "不通过", "审批不通过:原因是:{}"),
CANCEL("3", "已取消", "系统自动取消,原因是:{}"),
@@ -40,7 +41,7 @@ public enum BpmCommentTypeEnum {
private final String comment;
public String formatComment(Object... params) {
return StrUtil.format(comment, params);
return StrUtil.format(comment, params);
}
}
@@ -0,0 +1,81 @@
package cn.iocoder.yudao.module.bpm.controller.admin.comment;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.date.DateUtils;
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.bpm.controller.admin.base.user.UserSimpleBaseVO;
import cn.iocoder.yudao.module.bpm.controller.admin.comment.vo.BpmCommentCreateReqVO;
import cn.iocoder.yudao.module.bpm.controller.admin.comment.vo.BpmCommentRespVO;
import cn.iocoder.yudao.module.bpm.service.comment.BpmCommentService;
import cn.iocoder.yudao.module.bpm.service.task.BpmTaskService;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.flowable.engine.task.Comment;
import org.flowable.task.api.history.HistoricTaskInstance;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
@Tag(name = "管理后台 - 流程评论")
@RestController
@RequestMapping("/bpm/comment")
@Validated
public class BpmCommentController {
@Resource
private BpmCommentService commentService;
@Resource
private BpmTaskService taskService;
@Resource
private AdminUserApi adminUserApi;
@GetMapping("/list-by-process-instance-id")
@Operation(summary = "获得指定流程实例的评论列表")
@Parameter(name = "processInstanceId", description = "流程实例的编号", required = true)
@PreAuthorize("@ss.hasPermission('bpm:task:query')")
public CommonResult<List<BpmCommentRespVO>> getCommentListByProcessInstanceId(
@RequestParam("processInstanceId") String processInstanceId) {
// 获得评论列表
List<Comment> commentList = commentService.getCommentListByProcessInstanceId(processInstanceId);
if (CollUtil.isEmpty(commentList)) {
return success(Collections.emptyList());
}
// 拼接 VO
Map<String, HistoricTaskInstance> taskMap = taskService.getHistoricTaskMap(
convertSet(commentList, Comment::getTaskId));
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
convertSet(commentList, comment -> NumberUtils.parseLong(comment.getUserId())));
return success(convertList(commentList, comment -> BeanUtils.toBean(comment, BpmCommentRespVO.class, commentVO -> {
commentVO.setMessage(comment.getFullMessage())
.setCreateTime(DateUtils.of(comment.getTime()))
.setTask(BeanUtils.toBean(taskMap.get(comment.getTaskId()), BpmCommentRespVO.Task.class))
.setUser(BeanUtils.toBean(userMap.get(NumberUtils.parseLong(comment.getUserId())), UserSimpleBaseVO.class));
})));
}
@PostMapping("/create")
@Operation(summary = "创建流程评论")
@PreAuthorize("@ss.hasPermission('bpm:task:update')")
public CommonResult<Boolean> createComment(@Valid @RequestBody BpmCommentCreateReqVO reqVO) {
commentService.createComment(reqVO);
return success(true);
}
}
@@ -0,0 +1,22 @@
package cn.iocoder.yudao.module.bpm.controller.admin.comment.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.Size;
import lombok.Data;
@Schema(description = "管理后台 - 流程评论创建 Request VO")
@Data
public class BpmCommentCreateReqVO {
@Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
@NotEmpty(message = "任务编号不能为空")
private String taskId;
@Schema(description = "评论内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "请关注报销发票附件")
@NotBlank(message = "评论内容不能为空")
@Size(max = 500, message = "评论内容不能超过 500 个字符")
private String message;
}
@@ -0,0 +1,52 @@
package cn.iocoder.yudao.module.bpm.controller.admin.comment.vo;
import cn.iocoder.yudao.module.bpm.controller.admin.base.user.UserSimpleBaseVO;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 流程评论 Response VO")
@Data
public class BpmCommentRespVO {
@Schema(description = "评论编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private String id;
@Schema(description = "任务编号", example = "2048")
private String taskId;
@Schema(description = "任务")
private Task task;
@Schema(description = "流程实例编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096")
private String processInstanceId;
@Schema(description = "评论类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private String type;
@Schema(description = "评论内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "请关注报销发票附件")
private String message;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "创建人")
private UserSimpleBaseVO user;
@Schema(description = "任务")
@Data
public static class Task {
@Schema(description = "任务编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
private String id;
@Schema(description = "任务名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "经理审批")
private String name;
@Schema(description = "任务定义的标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "Activity_one")
private String taskDefinitionKey;
}
}
@@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.bpm.controller.admin.task.vo.task.*;
import cn.iocoder.yudao.module.bpm.convert.task.BpmTaskConvert;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmFormDO;
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmAttachmentTypeEnum;
import cn.iocoder.yudao.module.bpm.service.definition.BpmFormService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
import cn.iocoder.yudao.module.bpm.service.task.BpmProcessInstanceService;
@@ -24,12 +25,14 @@ import jakarta.validation.Valid;
import org.flowable.bpmn.model.UserTask;
import org.flowable.engine.history.HistoricProcessInstance;
import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.engine.task.Attachment;
import org.flowable.task.api.Task;
import org.flowable.task.api.history.HistoricTaskInstance;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -95,7 +98,9 @@ public class BpmTaskController {
convertSet(processInstanceMap.values(), instance -> Long.valueOf(instance.getStartUserId())));
Map<String, BpmProcessDefinitionInfoDO> processDefinitionInfoMap = processDefinitionService.getProcessDefinitionInfoMap(
convertSet(pageResult.getList(), HistoricTaskInstance::getProcessDefinitionId));
return success(BpmTaskConvert.INSTANCE.buildTaskPage(pageResult, processInstanceMap, userMap, null, processDefinitionInfoMap));
Map<String, List<Attachment>> taskAttachmentMap = getTaskAttachmentMap(pageResult.getList());
return success(BpmTaskConvert.INSTANCE.buildTaskPage(pageResult, processInstanceMap, userMap, null,
processDefinitionInfoMap, taskAttachmentMap));
}
@GetMapping("manager-page")
@@ -118,7 +123,9 @@ public class BpmTaskController {
convertSet(userMap.values(), AdminUserRespDTO::getDeptId));
Map<String, BpmProcessDefinitionInfoDO> processDefinitionInfoMap = processDefinitionService.getProcessDefinitionInfoMap(
convertSet(pageResult.getList(), HistoricTaskInstance::getProcessDefinitionId));
return success(BpmTaskConvert.INSTANCE.buildTaskPage(pageResult, processInstanceMap, userMap, deptMap, processDefinitionInfoMap));
Map<String, List<Attachment>> taskAttachmentMap = getTaskAttachmentMap(pageResult.getList());
return success(BpmTaskConvert.INSTANCE.buildTaskPage(pageResult, processInstanceMap, userMap, deptMap,
processDefinitionInfoMap, taskAttachmentMap));
}
@GetMapping("/list-by-process-instance-id")
@@ -141,8 +148,9 @@ public class BpmTaskController {
// 获得 Form Map
Map<Long, BpmFormDO> formMap = formService.getFormMap(
convertSet(taskList, task -> NumberUtils.parseLong(task.getFormKey())));
Map<String, List<Attachment>> taskAttachmentMap = getTaskAttachmentMap(taskList);
return success(BpmTaskConvert.INSTANCE.buildTaskListByProcessInstanceId(taskList,
formMap, userMap, deptMap));
formMap, userMap, deptMap, taskAttachmentMap));
}
@PutMapping("/approve")
@@ -244,4 +252,22 @@ public class BpmTaskController {
return success(BpmTaskConvert.INSTANCE.buildTaskListByParentTaskId(taskList, userMap, deptMap));
}
/**
* 获得任务附件 Map
*
* @param taskList 历史任务列表
* @return 任务附件 Map,key 为任务编号
*/
private Map<String, List<Attachment>> getTaskAttachmentMap(List<HistoricTaskInstance> taskList) {
// 1.1 获得流程实例的任务 Map
Map<String, List<HistoricTaskInstance>> processTaskMap = convertMultiMap(taskList,
HistoricTaskInstance::getProcessInstanceId);
// 1.2 获得任务附件列表
List<Attachment> attachments = new ArrayList<>();
processTaskMap.forEach((processInstanceId, tasks) -> attachments.addAll(taskService.getAttachments(
processInstanceId, convertSet(tasks, HistoricTaskInstance::getId), BpmAttachmentTypeEnum.TASK_ATTACHMENT)));
// 2. 返回 Map
return convertMultiMap(attachments, Attachment::getTaskId);
}
}
@@ -35,6 +35,12 @@ public class BpmTaskRespVO {
@Schema(description = "审批理由", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private String reason;
@Schema(description = "签名", example = "https://www.iocoder.cn/sign.png")
private String signPicUrl;
@Schema(description = "附件", example = "[https://test.yudao.iocoder.cn/20260609/test.txt]")
private List<String> attachments;
@Schema(description = "任务负责人编号", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "2048")
@JsonIgnore // 不返回,只是方便后续读取,赋值给 ownerUser
private Long owner;
@@ -2,7 +2,6 @@ package cn.iocoder.yudao.module.bpm.convert.task;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.map.MapUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.common.util.date.DateUtils;
@@ -19,6 +18,7 @@ import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import org.flowable.engine.history.HistoricProcessInstance;
import org.flowable.engine.runtime.ProcessInstance;
import org.flowable.engine.task.Attachment;
import org.flowable.task.api.Task;
import org.flowable.task.api.history.HistoricTaskInstance;
import org.flowable.task.service.impl.persistence.entity.TaskEntityImpl;
@@ -65,10 +65,12 @@ public interface BpmTaskConvert {
Map<String, HistoricProcessInstance> processInstanceMap,
Map<Long, AdminUserRespDTO> userMap,
Map<Long, DeptRespDTO> deptMap,
Map<String, BpmProcessDefinitionInfoDO> processDefinitionInfoMap) {
Map<String, BpmProcessDefinitionInfoDO> processDefinitionInfoMap,
Map<String, List<Attachment>> taskAttachmentMap) {
List<BpmTaskRespVO> taskVOList = CollectionUtils.convertList(pageResult.getList(), task -> {
BpmTaskRespVO taskVO = BeanUtils.toBean(task, BpmTaskRespVO.class);
taskVO.setStatus(FlowableUtils.getTaskStatus(task)).setReason(FlowableUtils.getTaskReason(task));
buildTaskEvidence(taskVO, task, taskAttachmentMap);
// 用户信息
AdminUserRespDTO assignUser = userMap.get(NumberUtils.parseLong(task.getAssignee()));
if (assignUser != null) {
@@ -93,7 +95,8 @@ public interface BpmTaskConvert {
default List<BpmTaskRespVO> buildTaskListByProcessInstanceId(List<HistoricTaskInstance> taskList,
Map<Long, BpmFormDO> formMap,
Map<Long, AdminUserRespDTO> userMap,
Map<Long, DeptRespDTO> deptMap) {
Map<Long, DeptRespDTO> deptMap,
Map<String, List<Attachment>> taskAttachmentMap) {
return CollectionUtils.convertList(taskList, task -> {
// 特殊:已取消的任务,不返回
BpmTaskRespVO taskVO = BeanUtils.toBean(task, BpmTaskRespVO.class);
@@ -102,6 +105,7 @@ public interface BpmTaskConvert {
return null;
}
taskVO.setStatus(taskStatus).setReason(FlowableUtils.getTaskReason(task));
buildTaskEvidence(taskVO, task, taskAttachmentMap);
// 表单信息
BpmFormDO form = MapUtil.get(formMap, NumberUtils.parseLong(task.getFormKey()), BpmFormDO.class);
if (form != null) {
@@ -196,6 +200,20 @@ public interface BpmTaskConvert {
}
}
/**
* 构建任务审批证据
*
* @param taskVO 任务 VO
* @param task 历史任务
* @param taskAttachmentMap 任务附件 Map
*/
default void buildTaskEvidence(BpmTaskRespVO taskVO, HistoricTaskInstance task,
Map<String, List<Attachment>> taskAttachmentMap) {
taskVO.setSignPicUrl(FlowableUtils.getTaskSignPicUrl(task));
taskVO.setAttachments(convertList(
taskAttachmentMap != null ? taskAttachmentMap.get(task.getId()) : null, Attachment::getUrl));
}
/**
* 将父任务的属性,拷贝到子任务(加签任务)
* <p>
@@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.bpm.service.comment;
import cn.iocoder.yudao.module.bpm.controller.admin.comment.vo.BpmCommentCreateReqVO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmCommentTypeEnum;
import jakarta.validation.Valid;
import org.flowable.engine.task.Comment;
import java.util.List;
/**
* 流程评论 Service 接口
*
* @author 芋道源码
*/
public interface BpmCommentService {
/**
* 获得指定流程实例的评论列表
*
* @param processInstanceId 流程实例的编号
* @return 评论列表
*/
List<Comment> getCommentListByProcessInstanceId(String processInstanceId);
/**
* 创建流程评论
*
* @param reqVO 评论请求
*/
void createComment(@Valid BpmCommentCreateReqVO reqVO);
/**
* 创建流程评论
*
* @param taskId 任务编号
* @param processInstanceId 流程实例编号
* @param type 评论类型
* @param params 评论模板参数
*/
void createComment(String taskId, String processInstanceId, BpmCommentTypeEnum type, Object... params);
}
@@ -0,0 +1,48 @@
package cn.iocoder.yudao.module.bpm.service.comment;
import cn.iocoder.yudao.module.bpm.controller.admin.comment.vo.BpmCommentCreateReqVO;
import cn.iocoder.yudao.module.bpm.enums.task.BpmCommentTypeEnum;
import cn.iocoder.yudao.module.bpm.service.task.BpmTaskService;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import org.flowable.engine.TaskService;
import org.flowable.engine.task.Comment;
import org.flowable.task.api.Task;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 流程评论 Service 实现类
*
* @author 芋道源码
*/
@Service
public class BpmCommentServiceImpl implements BpmCommentService {
@Resource
private TaskService taskService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private BpmTaskService bpmTaskService;
@Override
public List<Comment> getCommentListByProcessInstanceId(String processInstanceId) {
return taskService.getProcessInstanceComments(processInstanceId);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void createComment(@Valid BpmCommentCreateReqVO reqVO) {
Task task = bpmTaskService.validateTaskExists(reqVO.getTaskId());
createComment(task.getId(), task.getProcessInstanceId(), BpmCommentTypeEnum.COMMENT, reqVO.getMessage());
}
@Override
public void createComment(String taskId, String processInstanceId, BpmCommentTypeEnum type, Object... params) {
taskService.addComment(taskId, processInstanceId, type.getType(), type.formatComment(params));
}
}
@@ -103,6 +103,14 @@ public interface BpmTaskService {
*/
Task validateTask(Long userId, String taskId);
/**
* 校验任务是否存在
*
* @param id 任务编号
* @return 任务
*/
Task validateTaskExists(String id);
/**
* 获取任务
*
@@ -127,6 +135,16 @@ public interface BpmTaskService {
*/
List<HistoricTaskInstance> getHistoricTasks(Collection<String> taskIds);
/**
* 获取历史任务 Map
*
* @param taskIds 任务编号集合
* @return 历史任务 Map
*/
default Map<String, HistoricTaskInstance> getHistoricTaskMap(Collection<String> taskIds) {
return CollectionUtils.convertMap(getHistoricTasks(taskIds), HistoricTaskInstance::getId);
}
/**
* 根据条件查询正在进行中的任务
*
@@ -27,6 +27,7 @@ import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableCon
import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmHttpRequestUtils;
import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.BpmnModelUtils;
import cn.iocoder.yudao.module.bpm.framework.flowable.core.util.FlowableUtils;
import cn.iocoder.yudao.module.bpm.service.comment.BpmCommentService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmFormService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmModelService;
import cn.iocoder.yudao.module.bpm.service.definition.BpmProcessDefinitionService;
@@ -57,6 +58,7 @@ import org.flowable.task.api.history.HistoricTaskInstance;
import org.flowable.task.api.history.HistoricTaskInstanceQuery;
import org.flowable.task.service.impl.persistence.entity.TaskEntity;
import org.flowable.task.service.impl.persistence.entity.TaskEntityImpl;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
@@ -97,6 +99,9 @@ public class BpmTaskServiceImpl implements BpmTaskService {
@Resource
private BpmProcessInstanceCopyService processInstanceCopyService;
@Resource
@Lazy // 延迟加载,避免循环依赖
private BpmCommentService commentService;
@Resource
private BpmModelService modelService;
@Resource
private BpmMessageService messageService;
@@ -310,7 +315,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
@Override
public Task validateTask(Long userId, String taskId) {
Task task = validateTaskExist(taskId);
Task task = validateTaskExists(taskId);
// 为什么判断 assignee 非空的情况下?
// 例如说:在审批人为空时,我们会有“自动审批通过”的策略,此时 userId 为 null,允许通过
if (StrUtil.isNotBlank(task.getAssignee())
@@ -320,7 +325,8 @@ public class BpmTaskServiceImpl implements BpmTaskService {
return task;
}
private Task validateTaskExist(String id) {
@Override
public Task validateTaskExists(String id) {
Task task = getTask(id);
if (task == null) {
throw exception(TASK_NOT_EXISTS);
@@ -340,6 +346,9 @@ public class BpmTaskServiceImpl implements BpmTaskService {
@Override
public List<HistoricTaskInstance> getHistoricTasks(Collection<String> taskIds) {
if (CollUtil.isEmpty(taskIds)) {
return Collections.emptyList();
}
return historyService.createHistoricTaskInstanceQuery().taskIds(taskIds).includeTaskLocalVariables().list();
}
@@ -362,7 +371,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
@Override
public List<UserTask> getUserTaskListByReturn(String id) {
// 1.1 校验当前任务 task 存在
Task task = validateTaskExist(id);
Task task = validateTaskExists(id);
// 1.2 根据流程定义获取流程模型信息
BpmnModel bpmnModel = modelService.getBpmnModelByDefinitionId(task.getProcessDefinitionId());
FlowElement source = BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey());
@@ -602,8 +611,8 @@ public class BpmTaskServiceImpl implements BpmTaskService {
taskService.setVariableLocal(task.getId(), BpmnVariableConstants.TASK_SIGN_PIC_URL, reqVO.getSignPicUrl());
}
// 2.2 添加评论
taskService.addComment(task.getId(), task.getProcessInstanceId(), BpmCommentTypeEnum.APPROVE.getType(),
BpmCommentTypeEnum.APPROVE.formatComment(reqVO.getReason()));
commentService.createComment(task.getId(), task.getProcessInstanceId(), BpmCommentTypeEnum.APPROVE,
reqVO.getReason());
// 2.3 添加附件
if (CollUtil.isNotEmpty(reqVO.getAttachments())) {
reqVO.getAttachments().forEach(attachment -> taskService.createAttachment(BpmAttachmentTypeEnum.TASK_ATTACHMENT.getType(),
@@ -767,7 +776,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
return;
}
// 1.2 只处理加签的父任务
Task parentTask = validateTaskExist(parentTaskId);
Task parentTask = validateTaskExists(parentTaskId);
String scopeType = parentTask.getScopeType();
if (BpmTaskSignTypeEnum.of(scopeType) == null) {
return;
@@ -809,11 +818,11 @@ public class BpmTaskServiceImpl implements BpmTaskService {
*/
private void approveDelegateTask(BpmTaskApproveReqVO reqVO, Task task) {
// 1. 添加审批意见
AdminUserRespDTO currentUser = adminUserApi.getUser(WebFrameworkUtils.getLoginUserId()).getCheckedData();
AdminUserRespDTO ownerUser = adminUserApi.getUser(NumberUtils.parseLong(task.getOwner())).getCheckedData(); // 发起委托的用户
AdminUserRespDTO currentUser = adminUserApi.getUser(WebFrameworkUtils.getLoginUserId());
AdminUserRespDTO ownerUser = adminUserApi.getUser(NumberUtils.parseLong(task.getOwner())); // 发起委托的用户
Assert.notNull(ownerUser, "委派任务找不到原审批人,需要检查数据");
taskService.addComment(reqVO.getId(), task.getProcessInstanceId(), BpmCommentTypeEnum.DELEGATE_END.getType(),
BpmCommentTypeEnum.DELEGATE_END.formatComment(currentUser.getNickname(), ownerUser.getNickname(), reqVO.getReason()));
commentService.createComment(reqVO.getId(), task.getProcessInstanceId(), BpmCommentTypeEnum.DELEGATE_END,
currentUser.getNickname(), ownerUser.getNickname(), reqVO.getReason());
// 2.1 调用 resolveTask 完成任务。
// 底层调用 TaskHelper.changeTaskAssignee(task, task.getOwner()):将 owner 设置为 assignee
@@ -837,8 +846,8 @@ public class BpmTaskServiceImpl implements BpmTaskService {
// 2.1 更新流程任务为不通过
updateTaskStatusAndReason(task.getId(), BpmTaskStatusEnum.REJECT.getStatus(), reqVO.getReason());
// 2.2 添加流程评论
taskService.addComment(task.getId(), task.getProcessInstanceId(), BpmCommentTypeEnum.REJECT.getType(),
BpmCommentTypeEnum.REJECT.formatComment(reqVO.getReason()));
commentService.createComment(task.getId(), task.getProcessInstanceId(), BpmCommentTypeEnum.REJECT,
reqVO.getReason());
// 2.3 添加附件
if (CollUtil.isNotEmpty(reqVO.getAttachments())) {
reqVO.getAttachments().forEach(attachment -> taskService.createAttachment(BpmAttachmentTypeEnum.TASK_ATTACHMENT.getType(),
@@ -852,8 +861,8 @@ public class BpmTaskServiceImpl implements BpmTaskService {
String rootParentId = getTaskRootParentId(task);
updateTaskStatusAndReason(rootParentId, BpmTaskStatusEnum.REJECT.getStatus(),
BpmCommentTypeEnum.REJECT.formatComment("加签任务不通过"));
taskService.addComment(rootParentId, task.getProcessInstanceId(), BpmCommentTypeEnum.REJECT.getType(),
BpmCommentTypeEnum.REJECT.formatComment("加签任务不通过"));
commentService.createComment(rootParentId, task.getProcessInstanceId(), BpmCommentTypeEnum.REJECT,
"加签任务不通过");
}
// 3. 根据不同的 RejectHandler 处理策略
@@ -966,8 +975,8 @@ public class BpmTaskServiceImpl implements BpmTaskService {
// 判断是否分配给自己任务,因为会签任务,一个节点会有多个任务
if (isAssignUserTask(userId, task)) { // 情况一:自己的任务,进行 RETURN 标记
// 2.1.1 添加评论
taskService.addComment(task.getId(), currentTask.getProcessInstanceId(), BpmCommentTypeEnum.RETURN.getType(),
BpmCommentTypeEnum.RETURN.formatComment(reqVO.getReason()));
commentService.createComment(task.getId(), currentTask.getProcessInstanceId(), BpmCommentTypeEnum.RETURN,
reqVO.getReason());
// 2.1.2 更新 task 状态 + 原因
updateTaskStatusAndReason(task.getId(), BpmTaskStatusEnum.RETURN.getStatus(), reqVO.getReason());
} else { // 情况二:别人的任务,进行 CANCEL 标记
@@ -1038,15 +1047,15 @@ public class BpmTaskServiceImpl implements BpmTaskService {
throw exception(TASK_DELEGATE_FAIL_USER_REPEAT);
}
// 1.2 校验目标用户存在
AdminUserRespDTO delegateUser = adminUserApi.getUser(reqVO.getDelegateUserId()).getCheckedData();
AdminUserRespDTO delegateUser = adminUserApi.getUser(reqVO.getDelegateUserId());
if (delegateUser == null) {
throw exception(TASK_DELEGATE_FAIL_USER_NOT_EXISTS);
}
// 2. 添加委托意见
AdminUserRespDTO currentUser = adminUserApi.getUser(userId).getCheckedData();
taskService.addComment(taskId, task.getProcessInstanceId(), BpmCommentTypeEnum.DELEGATE_START.getType(),
BpmCommentTypeEnum.DELEGATE_START.formatComment(currentUser.getNickname(), delegateUser.getNickname(), reqVO.getReason()));
AdminUserRespDTO currentUser = adminUserApi.getUser(userId);
commentService.createComment(taskId, task.getProcessInstanceId(), BpmCommentTypeEnum.DELEGATE_START,
currentUser.getNickname(), delegateUser.getNickname(), reqVO.getReason());
// 3.1 设置任务所有人 (owner) 为原任务的处理人 (assignee)
// 特殊:如果已经被委派(owner 非空),则不需要更新 owner:https://gitee.com/zhijiantianya/yudao-cloud/issues/ICJ153
@@ -1069,15 +1078,15 @@ public class BpmTaskServiceImpl implements BpmTaskService {
throw exception(TASK_TRANSFER_FAIL_USER_REPEAT);
}
// 1.2 校验目标用户存在
AdminUserRespDTO assigneeUser = adminUserApi.getUser(reqVO.getAssigneeUserId()).getCheckedData();
AdminUserRespDTO assigneeUser = adminUserApi.getUser(reqVO.getAssigneeUserId());
if (assigneeUser == null) {
throw exception(TASK_TRANSFER_FAIL_USER_NOT_EXISTS);
}
// 2. 添加委托意见
AdminUserRespDTO currentUser = adminUserApi.getUser(userId).getCheckedData();
taskService.addComment(taskId, task.getProcessInstanceId(), BpmCommentTypeEnum.TRANSFER.getType(),
BpmCommentTypeEnum.TRANSFER.formatComment(currentUser.getNickname(), assigneeUser.getNickname(), reqVO.getReason()));
AdminUserRespDTO currentUser = adminUserApi.getUser(userId);
commentService.createComment(taskId, task.getProcessInstanceId(), BpmCommentTypeEnum.TRANSFER,
currentUser.getNickname(), assigneeUser.getNickname(), reqVO.getReason());
// 3.1 设置任务所有人 (owner) 为原任务的处理人 (assignee)
// 特殊:如果已经被转派(owner 非空),则不需要更新 owner:https://gitee.com/zhijiantianya/yudao-cloud/issues/ICJ153
@@ -1134,7 +1143,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
public void createSignTask(Long userId, BpmTaskSignCreateReqVO reqVO) {
// 1. 获取和校验任务
TaskEntityImpl taskEntity = validateTaskCanCreateSign(userId, reqVO);
List<AdminUserRespDTO> userList = adminUserApi.getUserList(reqVO.getUserIds()).getCheckedData();
List<AdminUserRespDTO> userList = adminUserApi.getUserList(reqVO.getUserIds());
if (CollUtil.isEmpty(userList)) {
throw exception(TASK_SIGN_CREATE_USER_NOT_EXIST);
}
@@ -1161,11 +1170,10 @@ public class BpmTaskServiceImpl implements BpmTaskService {
createSignTaskList(convertList(reqVO.getUserIds(), String::valueOf), taskEntity);
// 4. 记录加签的评论到 task 任务
AdminUserRespDTO currentUser = adminUserApi.getUser(userId).getCheckedData();
String comment = StrUtil.format(BpmCommentTypeEnum.ADD_SIGN.getComment(),
AdminUserRespDTO currentUser = adminUserApi.getUser(userId);
commentService.createComment(reqVO.getId(), taskEntity.getProcessInstanceId(), BpmCommentTypeEnum.ADD_SIGN,
currentUser.getNickname(), BpmTaskSignTypeEnum.nameOfType(reqVO.getType()),
String.join(",", convertList(userList, AdminUserRespDTO::getNickname)), reqVO.getReason());
taskService.addComment(reqVO.getId(), taskEntity.getProcessInstanceId(), BpmCommentTypeEnum.ADD_SIGN.getType(), comment);
}
/**
@@ -1193,7 +1201,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
List<Long> currentAssigneeList = convertListByFlatMap(taskList, task -> // 需要考虑 owner 的情况,因为向后加签时,它暂时没 assignee 而是 owner
Stream.of(NumberUtils.parseLong(task.getAssignee()), NumberUtils.parseLong(task.getOwner())));
if (CollUtil.containsAny(currentAssigneeList, reqVO.getUserIds())) {
List<AdminUserRespDTO> userList = adminUserApi.getUserList(CollUtil.intersection(currentAssigneeList, reqVO.getUserIds())).getCheckedData();
List<AdminUserRespDTO> userList = adminUserApi.getUserList(CollUtil.intersection(currentAssigneeList, reqVO.getUserIds()));
throw exception(TASK_SIGN_CREATE_USER_REPEAT, String.join(",", convertList(userList, AdminUserRespDTO::getNickname)));
}
return taskEntity;
@@ -1255,10 +1263,10 @@ public class BpmTaskServiceImpl implements BpmTaskService {
// 1.2 校验取消人存在
AdminUserRespDTO cancelUser = null;
if (StrUtil.isNotBlank(task.getAssignee())) {
cancelUser = adminUserApi.getUser(NumberUtils.parseLong(task.getAssignee())).getCheckedData();
cancelUser = adminUserApi.getUser(NumberUtils.parseLong(task.getAssignee()));
}
if (cancelUser == null && StrUtil.isNotBlank(task.getOwner())) {
cancelUser = adminUserApi.getUser(NumberUtils.parseLong(task.getOwner())).getCheckedData();
cancelUser = adminUserApi.getUser(NumberUtils.parseLong(task.getOwner()));
}
Assert.notNull(cancelUser, "任务中没有所有者和审批人,数据错误");
@@ -1272,9 +1280,9 @@ public class BpmTaskServiceImpl implements BpmTaskService {
taskService.deleteTasks(convertList(childTaskList, Task::getId));
// 3. 记录日志到父任务中。先记录日志是因为,通过 handleParentTask 方法之后,任务可能被完成了,并且不存在了,会报异常,所以先记录
AdminUserRespDTO user = adminUserApi.getUser(userId).getCheckedData();
taskService.addComment(task.getParentTaskId(), task.getProcessInstanceId(), BpmCommentTypeEnum.SUB_SIGN.getType(),
StrUtil.format(BpmCommentTypeEnum.SUB_SIGN.getComment(), user.getNickname(), cancelUser.getNickname()));
AdminUserRespDTO user = adminUserApi.getUser(userId);
commentService.createComment(task.getParentTaskId(), task.getProcessInstanceId(), BpmCommentTypeEnum.SUB_SIGN,
user.getNickname(), cancelUser.getNickname());
// 4. 处理当前任务的父任务
handleParentTaskIfSign(task.getParentTaskId());
@@ -1331,8 +1339,8 @@ public class BpmTaskServiceImpl implements BpmTaskService {
List<String> withdrawExecutionIds = new ArrayList<>();
for (Task task : runningTasks) {
// 标记撤回任务为取消
taskService.addComment(task.getId(), taskInstance.getProcessInstanceId(), BpmCommentTypeEnum.CANCEL.getType(),
BpmCommentTypeEnum.CANCEL.formatComment("前一节点撤回"));
commentService.createComment(task.getId(), taskInstance.getProcessInstanceId(), BpmCommentTypeEnum.CANCEL,
"前一节点撤回");
updateTaskStatusAndReason(task.getId(), BpmTaskStatusEnum.CANCEL.getStatus(), BpmReasonEnum.CANCEL_BY_WITHDRAW.getReason());
withdrawExecutionIds.add(task.getExecutionId());
}
@@ -1350,7 +1358,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
* @return 任务信息
*/
private Task validateTaskCanSignDelete(String id) {
Task task = validateTaskExist(id);
Task task = validateTaskExists(id);
if (task.getParentTaskId() == null) {
throw exception(TASK_SIGN_DELETE_NO_PARENT);
}
@@ -1468,7 +1476,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
return;
}
updateTaskStatusAndReason(taskId, BpmTaskStatusEnum.CANCEL.getStatus(), BpmReasonEnum.CANCEL_BY_SYSTEM.getReason());
// 补充说明:由于 Task 被删除成 HistoricTask 后,无法通过 taskService.addComment 添加理由,所以无法存储具体的取消理由
// 补充说明:由于 Task 被删除成 HistoricTask 后,无法通过 Flowable Comment 添加理由,所以无法存储具体的取消理由
}
@Override
@@ -1578,9 +1586,9 @@ public class BpmTaskServiceImpl implements BpmTaskService {
// 情况二:转交给部门负责人审批
if (ObjectUtils.equalsAny(assignStartUserHandlerType,
BpmUserTaskAssignStartUserHandlerTypeEnum.TRANSFER_DEPT_LEADER.getType())) {
AdminUserRespDTO startUser = adminUserApi.getUser(Long.valueOf(processInstance.getStartUserId())).getCheckedData();
AdminUserRespDTO startUser = adminUserApi.getUser(Long.valueOf(processInstance.getStartUserId()));
Assert.notNull(startUser, "提交人({})信息为空", processInstance.getStartUserId());
DeptRespDTO dept = startUser.getDeptId() != null ? deptApi.getDept(startUser.getDeptId()).getCheckedData() : null;
DeptRespDTO dept = startUser.getDeptId() != null ? deptApi.getDept(startUser.getDeptId()) : null;
Assert.notNull(dept, "提交人({})部门({})信息为空", processInstance.getStartUserId(), startUser.getDeptId());
// 找不到部门负责人的情况下,自动审批通过
// noinspection DataFlowIssue
@@ -1602,7 +1610,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
}
// 发送消息
AdminUserRespDTO startUser = adminUserApi.getUser(Long.valueOf(processInstance.getStartUserId())).getCheckedData();
AdminUserRespDTO startUser = adminUserApi.getUser(Long.valueOf(processInstance.getStartUserId()));
messageService.sendMessageWhenTaskAssigned(BpmTaskConvert.INSTANCE.convert(processInstance, startUser, task));
});
}
@@ -0,0 +1,51 @@
package cn.iocoder.yudao.module.im.controller.admin.conversation;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.conversation.vo.ImConversationReadRespVO;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - IM 会话读位置")
@RestController
@RequestMapping("/im/conversation-read")
@Validated
public class ImConversationReadController {
@Resource
private ImConversationReadService conversationReadService;
@GetMapping("/pull")
@Operation(summary = "增量拉取当前用户的会话读位置(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImConversationReadRespVO>> pullMyConversationRead(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImConversationReadDO> list = conversationReadService.pullConversationReadList(
getLoginUserId(), lastUpdateTime, lastId, limit);
return success(BeanUtils.toBean(list, ImConversationReadRespVO.class));
}
}
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.im.controller.admin.conversation.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
/**
* IM 会话读位置 Response VO
*
* @author 芋道源码
*/
@Schema(description = "管理后台 - IM 会话读位置 Response VO")
@Data
public class ImConversationReadRespVO {
@Schema(description = "读位置编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Long id;
@Schema(description = "会话类型", example = "1")
private Integer conversationType; // 参见 ImConversationTypeEnum 枚举
@Schema(description = "目标编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "2048")
private Long targetId;
@Schema(description = "最大已读消息编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4096")
private Long messageId;
@Schema(description = "最近更新时间(增量拉取游标用)")
private LocalDateTime updateTime;
}
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.controller.admin.face;
import cn.hutool.core.collection.ListUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.face.vo.pack.ImFacePackUserRespVO;
@@ -39,7 +40,7 @@ public class ImFacePackController {
// 1.1 拉所有启用表情包
List<ImFacePackDO> packs = facePackService.getEnabledFacePackList();
if (packs.isEmpty()) {
return success(List.of());
return success(ListUtil.of());
}
// 1.2 拉这些包下所有启用表情,按 packId 分组
List<ImFacePackItemDO> items = facePackItemService.getEnabledItemListByPackIds(
@@ -49,7 +50,7 @@ public class ImFacePackController {
// 2. 拼装:BeanUtils 把 pack 字段映射 + 自己塞 items
List<ImFacePackUserRespVO> result = convertList(packs, pack -> {
ImFacePackUserRespVO vo = BeanUtils.toBean(pack, ImFacePackUserRespVO.class);
vo.setItems(BeanUtils.toBean(itemsByPackId.getOrDefault(pack.getId(), List.of()), ImFacePackUserRespVO.Item.class));
vo.setItems(BeanUtils.toBean(itemsByPackId.getOrDefault(pack.getId(), ListUtil.of()), ImFacePackUserRespVO.Item.class));
return vo;
});
return success(result);
@@ -17,6 +17,8 @@ import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
@@ -46,11 +48,25 @@ public class ImFriendController {
@GetMapping("/list")
@Operation(summary = "获得当前登录用户的好友列表")
public CommonResult<List<ImFriendRespVO>> getMyFriendList() {
// 含 DISABLE 历史好友:保留给前端展示「已删除好友」的历史对话信息;前端按 status 决定会话级联清理
List<ImFriendDO> friends = friendService.getFriendList(getLoginUserId());
return success(buildFriendRespVOList(friends));
}
@GetMapping("/pull")
@Operation(summary = "增量拉取当前用户的好友关系(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImFriendRespVO>> pullMyFriendList(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImFriendDO> list = friendService.pullFriendList(getLoginUserId(), lastUpdateTime, lastId, limit);
return success(buildFriendRespVOList(list));
}
@GetMapping("/get")
@Operation(summary = "获得好友详情")
@Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048")
@@ -62,8 +78,8 @@ public class ImFriendController {
@DeleteMapping("/delete")
@Operation(summary = "删除好友(单向软删除)")
@Parameters({
@Parameter(description = "好友的用户编号", required = true, example = "2048"),
@Parameter(description = "是否级联清理本端相关数据(如私聊会话)")
@Parameter(name = "friendUserId", description = "好友的用户编号", required = true, example = "2048"),
@Parameter(name = "clear", description = "是否级联清理本端相关数据(如私聊会话)")
})
public CommonResult<Boolean> deleteFriend(
@RequestParam("friendUserId") @NotNull(message = "好友用户编号不能为空") Long friendUserId,
@@ -13,6 +13,7 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
@@ -79,15 +80,32 @@ public class ImFriendRequestController {
@GetMapping("/list")
@Operation(summary = "查询「我相关」的好友申请列表(游标分页:传 maxId 加载更多)")
@Parameters({
@Parameter(name = "maxId", description = "当前列表最旧记录的 id;首页不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImFriendRequestRespVO>> getMyFriendRequestList(
@Parameter(description = "当前列表最旧记录的 id;首页不传")
@RequestParam(value = "maxId", required = false) Long maxId,
@Parameter(description = "单次拉取条数", required = true)
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImFriendRequestDO> list = friendRequestService.getMyFriendRequestList(getLoginUserId(), maxId, limit);
return success(buildList(list));
}
@GetMapping("/pull")
@Operation(summary = "增量拉取「我相关」的好友申请(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImFriendRequestRespVO>> pullMyFriendRequestList(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImFriendRequestDO> list = friendRequestService.pullFriendRequestList(getLoginUserId(), lastUpdateTime, lastId, limit);
return success(buildList(list));
}
@GetMapping("/get")
@Operation(summary = "按 id 单查「我相关」的申请记录(带越权过滤;WebSocket 通知到达后用)")
@Parameter(name = "id", description = "申请记录编号", required = true)
@@ -47,6 +47,9 @@ public class ImFriendRespVO {
@Schema(description = "删除好友时间")
private LocalDateTime deleteTime;
@Schema(description = "最近更新时间(增量拉取游标用)")
private LocalDateTime updateTime;
// ========== 下面是聚合字段,方便前端显示 ==========
@Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道")
@@ -41,6 +41,9 @@ public class ImFriendRequestRespVO {
@Schema(description = "申请创建时间")
private LocalDateTime createTime;
@Schema(description = "最近更新时间(增量拉取游标用)")
private LocalDateTime updateTime;
// ========== 下面是聚合字段,方便前端显示 ==========
@Schema(description = "发起方昵称(实时聚合自 AdminUser)", example = "芋道")
@@ -1,7 +1,9 @@
package cn.iocoder.yudao.module.im.controller.admin.group;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.group.vo.*;
import cn.iocoder.yudao.module.im.controller.admin.group.vo.member.ImGroupMemberInviteReqVO;
@@ -186,20 +188,27 @@ public class ImGroupController {
return Collections.emptyList();
}
// 仅当前用户是有效成员的群才允许回填置顶消息
Set<Long> activeGroupIds = convertSet(
groupMemberService.getActiveGroupMemberListByUserId(loginUserId), ImGroupMemberDO::getGroupId);
Map<Long, ImGroupMemberDO> memberMap = convertMap(
groupMemberService.getGroupMemberListByUserId(loginUserId), ImGroupMemberDO::getGroupId);
Set<Long> activeGroupIds = convertSet(memberMap.values(), ImGroupMemberDO::getGroupId,
member -> CommonStatusEnum.ENABLE.getStatus().equals(member.getStatus()));
Set<Long> allMessageIds = convertSetByFlatMap(groups, group -> activeGroupIds.contains(group.getId())
? CollUtil.emptyIfNull(group.getPinnedMessageIds()).stream() : Stream.empty());
Map<Long, ImGroupMessageDO> messageMap = groupMessageService.getGroupMessageMap(allMessageIds);
// 转换输出
return convertList(groups, group -> {
ImGroupRespVO vo = BeanUtils.toBean(group, ImGroupRespVO.class);
if (!activeGroupIds.contains(group.getId()) || CollUtil.isEmpty(group.getPinnedMessageIds())) {
return vo;
ImGroupRespVO groupVO = BeanUtils.toBean(group, ImGroupRespVO.class);
// 标记登录用户在该群的成员状态,供前端区分当前群与历史退群
boolean joined = activeGroupIds.contains(group.getId());
groupVO.setJoinStatus(joined ? CommonStatusEnum.ENABLE.getStatus() : CommonStatusEnum.DISABLE.getStatus());
MapUtils.findAndThen(memberMap, group.getId(), member ->
groupVO.setGroupRemark(member.getGroupRemark()).setSilent(Boolean.TRUE.equals(member.getSilent())));
if (!joined || CollUtil.isEmpty(group.getPinnedMessageIds())) {
return groupVO;
}
// 按 pin 顺序输出,已被删除的消息(messageMap 没命中)跳过
List<ImGroupMessageDO> pinnedMesages = convertList(group.getPinnedMessageIds(), messageMap::get);
return vo.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class));
return groupVO.setPinnedMessages(BeanUtils.toBean(pinnedMesages, ImGroupMessageRespVO.class));
});
}
@@ -21,6 +21,7 @@ import jakarta.validation.Valid;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.Collections;
import java.util.List;
import java.util.Map;
@@ -88,7 +89,7 @@ public class ImGroupMemberController {
}
@GetMapping("/list")
@Operation(summary = "获得指定群的成员列表")
@Operation(summary = "获得指定群的成员列表(按群全量拉取,作为前端本地成员 cache 的完整基线)")
@Parameter(name = "groupId", description = "群编号", required = true, example = "1024")
public CommonResult<List<ImGroupMemberRespVO>> getGroupMemberList(@RequestParam("groupId") Long groupId) {
// 1.1 查询群成员列表(包含 DISABLE 已退群的成员,不按时间过滤)
@@ -101,16 +102,30 @@ public class ImGroupMemberController {
throw exception(GROUP_MEMBER_NOT_IN_GROUP);
}
// 2.批量聚合 AdminUser 信息(昵称 / 头像)
// 2. 批量聚合昵称 / 头像,并对非本人置空私人字段
return success(buildGroupMemberRespVOList(members));
}
// ========== 私有方法:VO 组装 ==========
/**
* 群成员列表批量转 VO + 关联回填昵称 / 头像,并对非本人置空私人字段
*/
private List<ImGroupMemberRespVO> buildGroupMemberRespVOList(List<ImGroupMemberDO> members) {
if (CollUtil.isEmpty(members)) {
return Collections.emptyList();
}
Long loginUserId = getLoginUserId();
// 批量聚合 AdminUser 信息(昵称 / 头像),避免 N+1
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
convertList(members, ImGroupMemberDO::getUserId));
return success(convertList(members, m -> {
ImGroupMemberRespVO vo = BeanUtils.toBean(m, ImGroupMemberRespVO.class);
MapUtils.findAndThen(userMap, m.getUserId(), user ->
return convertList(members, member -> {
ImGroupMemberRespVO vo = BeanUtils.toBean(member, ImGroupMemberRespVO.class);
MapUtils.findAndThen(userMap, member.getUserId(), user ->
vo.setNickname(user.getNickname()).setAvatar(user.getAvatar()));
hidePrivateFieldsIfNotSelf(vo, m.getUserId(), loginUserId);
hidePrivateFieldsIfNotSelf(vo, member.getUserId(), loginUserId);
return vo;
}));
});
}
/**
@@ -19,9 +19,12 @@ import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import org.springframework.validation.annotation.Validated;
@@ -88,6 +91,22 @@ public class ImGroupRequestController {
return success(buildVOList(list));
}
@GetMapping("/pull")
@Operation(summary = "增量拉取「我管理的群」下的加群申请(重连 / 离线补偿)")
@Parameters({
@Parameter(name = "lastUpdateTime", description = "上次拉取到的最新更新时间(毫秒时间戳);首次拉取不传"),
@Parameter(name = "lastId", description = "上次拉取到的最后一条记录 id;首次拉取不传"),
@Parameter(name = "limit", description = "单次拉取条数", required = true)
})
public CommonResult<List<ImGroupRequestRespVO>> pullMyGroupRequestList(
@RequestParam(value = "lastUpdateTime", required = false) Long lastUpdateTime,
@RequestParam(value = "lastId", required = false) Long lastId,
@RequestParam("limit") @Min(1) @Max(200) Integer limit) {
List<ImGroupRequestDO> list = groupRequestService.pullGroupRequestList(
getLoginUserId(), lastUpdateTime, lastId, limit);
return success(buildVOList(list));
}
@GetMapping("/list-by-group")
@Operation(summary = "查询指定群下的全部加群申请(含已处理);仅群主 / 管理员可查")
@Parameter(name = "groupId", description = "群编号", required = true, example = "1024")
@@ -47,7 +47,16 @@ public class ImGroupRespVO {
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@Schema(description = "当前登录用户在该群的成员状态:在群 / 已退群(历史退群群仍返回,供前端展示离线消息的群名 / 头像)")
private Integer joinStatus; // 参见 CommonStatusEnum 枚举
@Schema(description = "当前登录用户对该群的备注")
private String groupRemark;
@Schema(description = "当前登录用户是否免打扰")
private Boolean silent;
@Schema(description = "群置顶消息列表,按 pin 顺序(最先置顶的在前);非该群有效成员时为空")
private List<ImGroupMessageRespVO> pinnedMessages;
}
}
@@ -42,6 +42,9 @@ public class ImGroupRequestRespVO {
@Schema(description = "申请创建时间")
private LocalDateTime createTime;
@Schema(description = "申请更新时间;增量拉取游标")
private LocalDateTime updateTime;
// ========== 下面是聚合字段,方便前端显示 ==========
@Schema(description = "申请人 / 被邀请人昵称(实时聚合自 AdminUser)", example = "芋道")
@@ -75,7 +75,6 @@ public class ImChannelManagerController {
@GetMapping("/simple-list")
@Operation(summary = "获得启用的频道精简列表;前端表单选择频道时调用")
public CommonResult<List<ImChannelRespVO>> getSimpleChannelList() {
// TODO DONE @AI:getChannelListByStatus 统一命名
List<ImChannelDO> list = channelService.getChannelListByStatus(CommonStatusEnum.ENABLE.getStatus());
return success(BeanUtils.toBean(list, ImChannelRespVO.class));
}
@@ -29,7 +29,7 @@ public class ImChannelMessageRespVO {
private String materialCoverUrl;
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "125")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容;payload JSON 快照")
private String content;
@@ -22,7 +22,7 @@ public class ImGroupMessageManagerPageReqVO extends PageParam {
private Long senderId;
@Schema(description = "消息类型", example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容", example = "你好")
private String content;
@@ -29,7 +29,7 @@ public class ImGroupMessageManagerRespVO {
private String senderNickname;
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容(JSON 格式)", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@@ -43,7 +43,7 @@ public class ImGroupMessageManagerRespVO {
private List<String> atUserNicknames;
@Schema(description = "回执状态", example = "0")
private Integer receiptStatus; // 参见 ImGroupMessageReceiptStatusEnum 枚举类
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime sendTime;
@@ -22,7 +22,7 @@ public class ImPrivateMessageManagerPageReqVO extends PageParam {
private Long receiverId;
@Schema(description = "消息类型", example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容", example = "你好")
private String content;
@@ -28,7 +28,7 @@ public class ImPrivateMessageManagerRespVO {
private String receiverNickname;
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息内容(JSON 格式)", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@@ -36,6 +36,9 @@ public class ImPrivateMessageManagerRespVO {
@Schema(description = "消息状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer status; // 参见 ImMessageStatusEnum 枚举类
@Schema(description = "回执状态", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime sendTime;
@@ -112,7 +112,7 @@ public class ImStatisticsManagerController {
}
@GetMapping("/message-type-distribution")
@Operation(summary = "获得消息类型分布(最近 30 天)")
@Operation(summary = "获得内容类型分布(最近 30 天)")
@PreAuthorize("@ss.hasPermission('im:manager:statistics:query')")
public CommonResult<List<ImStatisticsManagerMessageTypeRespVO>> getMessageTypeDistribution() {
LocalDateTime endTime = LocalDate.now().plusDays(1).atStartOfDay();
@@ -3,12 +3,12 @@ package cn.iocoder.yudao.module.im.controller.admin.manager.statistics.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - IM 数据看板消息类型分布项 Response VO")
@Schema(description = "管理后台 - IM 数据看板内容类型分布项 Response VO")
@Data
public class ImStatisticsManagerMessageTypeRespVO {
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer type; // 参见 ImMessageTypeEnum 枚举类
private Integer type; // 参见 ImContentTypeEnum 枚举类
@Schema(description = "消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "8000")
private Long value;
@@ -5,7 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.controller.admin.message.vo.channel.ImChannelMessagePullRespVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.service.message.ImChannelMessageService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
@@ -40,25 +40,25 @@ public class ImChannelMessageController {
@GetMapping("/pull")
@Operation(summary = "拉取频道消息(离线增量);按 minId 游标分页")
public CommonResult<List<ImChannelMessagePullRespVO>> pull(
public CommonResult<List<ImChannelMessagePullRespVO>> pullChannelMessageList(
@RequestParam(value = "minId", defaultValue = "0") @PositiveOrZero(message = "minId 不能小于 0") Long minId,
@RequestParam(value = "size", defaultValue = "100")
@Min(value = 1, message = "size 必须大于 0")
@Max(value = 200, message = "size 一次最多 200 条") Integer size) {
// 1. 拉取消息列表
Long userId = getLoginUserId();
List<ImChannelMessageDO> list = channelMessageService.getMessageListForPull(userId, minId, size);
List<ImChannelMessageDO> list = channelMessageService.pullChannelMessageList(userId, minId, size);
if (CollUtil.isEmpty(list)) {
return success(Collections.emptyList());
}
// 2. 按 Redis 已读游标补 status;device A 已读后 device B 拉到这条不再算入未读
// 2. 按已读游标补 receiptStatus(已读 DONE / 未读 PENDING)
Map<Long, Long> readMaxByChannel = channelMessageService.getChannelReadMaxMessageIdMap(
userId, convertSet(list, ImChannelMessageDO::getChannelId));
return success(BeanUtils.toBean(list, ImChannelMessagePullRespVO.class, vo -> {
Long readMax = readMaxByChannel.get(vo.getChannelId());
vo.setStatus(readMax != null && readMax >= vo.getId()
? ImMessageStatusEnum.READ.getStatus()
: ImMessageStatusEnum.UNREAD.getStatus());
vo.setReceiptStatus(readMax != null && readMax >= vo.getId()
? ImMessageReceiptStatusEnum.DONE.getStatus()
: ImMessageReceiptStatusEnum.PENDING.getStatus());
}));
}
@@ -61,7 +61,7 @@ public class ImPrivateMessageController {
@GetMapping("/max-read-message-id")
@Operation(summary = "查询对方已读到我发的最大消息 id",
description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,结果用于翻转本地自发消息状态")
description = "用于多端 / 离线场景下的已读位置补齐:进入会话或断线重连后调用,据此把本地自发消息更新为已读")
@Parameter(name = "peerId", description = "对方用户编号", required = true, example = "2")
public CommonResult<Long> getMaxReadMessageId(@RequestParam("peerId") Long peerId) {
return success(privateMessageService.getMaxReadMessageId(getLoginUserId(), peerId));
@@ -24,8 +24,8 @@ public class ImChannelMessagePullRespVO {
@Schema(description = "消息内容;payload JSON 快照", requiredMode = Schema.RequiredMode.REQUIRED)
private String content;
@Schema(description = "当前用户的已读态;按 Redis 游标计算填充", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
private Integer status; // 参见 ImMessageStatusEnum 枚举类
@Schema(description = "当前用户回执 / 已读态;按 Redis 读位置计算(已读 DONE,未读 PENDING)", requiredMode = Schema.RequiredMode.REQUIRED, example = "2")
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime sendTime;
@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.im.controller.admin.message.vo.group;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
@@ -28,7 +28,7 @@ public class ImGroupMessageSendReqVO {
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "消息类型不能为空")
@InEnum(ImMessageTypeEnum.class)
@InEnum(ImContentTypeEnum.class)
private Integer type;
@Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
@@ -47,7 +47,7 @@ public class ImGroupMessageSendReqVO {
@AssertTrue(message = "消息类型不允许")
@JsonIgnore
public boolean isTypeNormal() {
return type == null || ImMessageTypeEnum.validate(type).isNormal();
return type == null || ImContentTypeEnum.validate(type).isNormal();
}
}
@@ -31,7 +31,10 @@ public class ImPrivateMessageRespVO {
private String content;
@Schema(description = "消息状态", example = "0")
private Integer status;
private Integer status; // 参见 ImMessageStatusEnum 枚举类
@Schema(description = "回执状态", example = "0")
private Integer receiptStatus; // 参见 ImMessageReceiptStatusEnum 枚举类
@Schema(description = "发送时间")
private LocalDateTime sendTime;
@@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.im.controller.admin.message.vo.privates;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.AssertTrue;
@@ -26,20 +26,23 @@ public class ImPrivateMessageSendReqVO {
@Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "0")
@NotNull(message = "消息类型不能为空")
@InEnum(ImMessageTypeEnum.class)
@InEnum(ImContentTypeEnum.class)
private Integer type;
@Schema(description = "消息内容,JSON 格式", requiredMode = Schema.RequiredMode.REQUIRED, example = "{\"content\":\"你好\"}")
@NotEmpty(message = "消息内容不能为空")
private String content;
@Schema(description = "是否需要回执", example = "false")
private Boolean receipt;
/**
* 仅允许用户消息(normal)类型
*/
@AssertTrue(message = "消息类型不允许")
@JsonIgnore
public boolean isTypeNormal() {
return type == null || ImMessageTypeEnum.validate(type).isNormal();
return type == null || ImContentTypeEnum.validate(type).isNormal();
}
}
@@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.im.dal.dataobject.conversation;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import java.time.LocalDateTime;
/**
* IM 会话读位置 DO
* <p>
* 只表达「用户在某个会话的最大已读位置」,私聊 / 群聊 / 频道统一落这张表,是读位置的唯一权威。
*
* @author 芋道源码
*/
@TableName("im_conversation_read")
@KeySequence("im_conversation_read_seq")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ImConversationReadDO extends BaseDO {
/**
* 编号
*/
@TableId
private Long id;
/**
* 用户编号
*
* 关联 AdminUserDO 的 id 字段
*/
private Long userId;
/**
* 会话类型
* <p>
* 枚举 {@link ImConversationTypeEnum}
*/
private Integer conversationType;
/**
* 目标编号
* <p>
* 私聊关联 AdminUserDO 的 id;群聊关联 {@link ImGroupDO#getId()};频道关联 {@link ImChannelDO#getId()}
*/
private Long targetId;
/**
* 最大已读消息编号
* <p>
* 关联 {@link ImPrivateMessageDO#getId()}、{@link ImGroupMessageDO#getId()}、{@link ImChannelMessageDO#getId()}
*/
private Long messageId;
/**
* 最近已读时间
*/
private LocalDateTime readTime;
}
@@ -4,7 +4,7 @@ import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelDO;
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -54,7 +54,7 @@ public class ImChannelMessageDO extends BaseDO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@@ -3,9 +3,9 @@ package cn.iocoder.yudao.module.im.dal.dataobject.message;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.framework.mybatis.core.type.LongListTypeHandler;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
@@ -54,7 +54,7 @@ public class ImGroupMessageDO extends BaseDO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@@ -67,8 +67,6 @@ public class ImGroupMessageDO extends BaseDO {
* 消息状态
* <p>
* 枚举 {@link ImMessageStatusEnum}
*
* 为什么没有 READ 状态?与单聊的差异:单聊用 UNREAD/READ 跟踪每条消息状态,群聊用 Redis 存储每个成员的已读位置(游标模型)
*/
private Integer status;
/**
@@ -94,7 +92,7 @@ public class ImGroupMessageDO extends BaseDO {
/**
* 回执状态
* <p>
* 枚举 {@link ImGroupMessageReceiptStatusEnum}
* 枚举 {@link ImMessageReceiptStatusEnum}
*/
private Integer receiptStatus;
@@ -1,8 +1,9 @@
package cn.iocoder.yudao.module.im.dal.dataobject.message;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
@@ -49,7 +50,7 @@ public class ImPrivateMessageDO extends BaseDO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@@ -64,6 +65,12 @@ public class ImPrivateMessageDO extends BaseDO {
* 枚举 {@link ImMessageStatusEnum}
*/
private Integer status;
/**
* 回执状态
* <p>
* 枚举 {@link ImMessageReceiptStatusEnum}
*/
private Integer receiptStatus;
/**
* 发送时间
*/
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import lombok.Data;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
import lombok.experimental.Accessors;
@@ -6,7 +6,7 @@ import lombok.experimental.Accessors;
/**
* 频道素材消息 payload
* <p>
* 对应 {@link cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum#MATERIAL}(type=125)。
* 对应 {@link cn.iocoder.yudao.module.im.enums.ImContentTypeEnum#MATERIAL}(type=125)。
* 客户端按本字段集渲染图文卡片;
* 富文本正文不在本 payload 中传递,点击详情时另调 /im/channel/material/get-content?id= 按需拉取。
*/
@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import lombok.Data;
import java.util.List;
@@ -47,7 +47,7 @@ public class MergeMessage {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import lombok.Data;
import lombok.experimental.Accessors;
@@ -39,7 +39,7 @@ public class QuoteMessage {
/**
* 被引用消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
import lombok.experimental.Accessors;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
@@ -1,4 +1,4 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.message;
package cn.iocoder.yudao.module.im.dal.dataobject.message.content;
import lombok.Data;
@@ -0,0 +1,83 @@
package cn.iocoder.yudao.module.im.dal.mysql.conversation;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import com.baomidou.mybatisplus.core.toolkit.Wrappers;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/**
* IM 会话读位置 Mapper
*
* @author 芋道源码
*/
@Mapper
public interface ImConversationReadMapper extends BaseMapperX<ImConversationReadDO> {
default ImConversationReadDO selectByUserIdAndConversation(Long userId, Integer conversationType, Long conversationId) {
return selectOne(new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getUserId, userId)
.eq(ImConversationReadDO::getConversationType, conversationType)
.eq(ImConversationReadDO::getTargetId, conversationId));
}
default List<ImConversationReadDO> selectListByConversation(Integer conversationType, Long conversationId) {
return selectList(new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getConversationType, conversationType)
.eq(ImConversationReadDO::getTargetId, conversationId));
}
default List<ImConversationReadDO> selectListByUserIdAndConversations(Long userId, Integer conversationType,
Collection<Long> conversationIds) {
return selectList(new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getUserId, userId)
.eq(ImConversationReadDO::getConversationType, conversationType)
.in(ImConversationReadDO::getTargetId, conversationIds));
}
/**
* 增量拉取当前用户的会话读位置(按 update_time + id 正向游标)
*
* @param userId 当前用户编号
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 会话读位置列表
*/
default List<ImConversationReadDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
LambdaQueryWrapperX<ImConversationReadDO> query = new LambdaQueryWrapperX<ImConversationReadDO>()
.eq(ImConversationReadDO::getUserId, userId);
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImConversationReadDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImConversationReadDO::getUpdateTime, lastTime).gt(ImConversationReadDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImConversationReadDO::getUpdateTime).orderByAsc(ImConversationReadDO::getId)
.last("LIMIT " + limit));
}
/**
* 单调递增更新读位置:仅当新位置更大时才更新
* <p>
* 通过 {@code read_message_id < messageId} 的 CAS 条件,保证乱序 / 并发上报时读位置不会回退。
*
* @param id 记录编号
* @param messageId 新的最大已读消息编号
* @param readTime 已读时间
* @return 影响行数;0 表示新位置不大于已有位置,未更新
*/
default int updateReadMessageIdToLarger(Long id, Long messageId, LocalDateTime readTime) {
return update(null, Wrappers.<ImConversationReadDO>lambdaUpdate()
.set(ImConversationReadDO::getMessageId, messageId)
.set(ImConversationReadDO::getReadTime, readTime)
.set(ImConversationReadDO::getUpdateTime, readTime)
.eq(ImConversationReadDO::getId, id)
.lt(ImConversationReadDO::getMessageId, messageId));
}
}
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.friend;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@@ -31,6 +32,28 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
.eq(ImFriendDO::getUserId, userId)
.orderByDesc(ImFriendDO::getId));
}
/**
* 增量拉取当前用户的好友关系(含已删除,按 update_time + id 正向游标)
*
* @param userId 当前用户编号
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 好友关系列表
*/
default List<ImFriendDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
LambdaQueryWrapperX<ImFriendDO> query = new LambdaQueryWrapperX<ImFriendDO>()
.eq(ImFriendDO::getUserId, userId);
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImFriendDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImFriendDO::getUpdateTime, lastTime).gt(ImFriendDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImFriendDO::getUpdateTime).orderByAsc(ImFriendDO::getId)
.last("LIMIT " + limit));
}
default List<ImFriendDO> selectListByUserIdAndStatus(Long userId, Integer status) {
return selectList(new LambdaQueryWrapperX<ImFriendDO>()
.eq(ImFriendDO::getUserId, userId)
@@ -68,12 +91,14 @@ public interface ImFriendMapper extends BaseMapperX<ImFriendDO> {
@SuppressWarnings("UnusedReturnValue")
default int updateReAddFields(Long id, Integer status, LocalDateTime addTime,
LocalDateTime updateTime,
Boolean silent, Boolean pinned, Boolean blocked,
String displayName, Integer addSource) {
return update(null, Wrappers.<ImFriendDO>lambdaUpdate()
.eq(ImFriendDO::getId, id)
.set(ImFriendDO::getStatus, status)
.set(ImFriendDO::getAddTime, addTime)
.set(ImFriendDO::getUpdateTime, updateTime)
.set(ImFriendDO::getSilent, silent)
.set(ImFriendDO::getPinned, pinned)
.set(ImFriendDO::getBlocked, blocked)
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.friend;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@@ -28,6 +29,12 @@ public interface ImFriendRequestMapper extends BaseMapperX<ImFriendRequestDO> {
/**
* 拉取「我相关」的好友申请列表
*
* @param userId 当前用户编号
* @param maxRequestUpdateTime 起始更新时间;首次拉取传 null
* @param maxId 起始记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 好友申请列表
*/
default List<ImFriendRequestDO> selectMyList(Long userId, LocalDateTime maxRequestUpdateTime,
Long maxId, int limit) {
@@ -45,6 +52,28 @@ public interface ImFriendRequestMapper extends BaseMapperX<ImFriendRequestDO> {
return selectList(wrapper);
}
/**
* 增量拉取「我相关」的好友申请(双向 OR,按 update_time + id 正向游标)
*
* @param userId 当前用户编号
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 好友申请列表
*/
default List<ImFriendRequestDO> selectPullListByUserId(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
LambdaQueryWrapperX<ImFriendRequestDO> query = new LambdaQueryWrapperX<>();
query.and(w -> w.eq(ImFriendRequestDO::getFromUserId, userId)
.or().eq(ImFriendRequestDO::getToUserId, userId));
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImFriendRequestDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImFriendRequestDO::getUpdateTime, lastTime).gt(ImFriendRequestDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImFriendRequestDO::getUpdateTime).orderByAsc(ImFriendRequestDO::getId)
.last("LIMIT " + limit));
}
default int updateByIdAndHandleResult(Long id, Integer handleResult, ImFriendRequestDO updateObj) {
return update(updateObj, new LambdaUpdateWrapper<ImFriendRequestDO>()
.eq(ImFriendRequestDO::getId, id).eq(ImFriendRequestDO::getHandleResult, handleResult));
@@ -1,7 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.group;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
@@ -59,20 +58,8 @@ public interface ImGroupMemberMapper extends BaseMapperX<ImGroupMemberDO> {
.eq(ImGroupMemberDO::getStatus, status));
}
/**
* 查询用户已退群的成员记录
* <p>
* 当 {@code minQuitTime} 非空时额外按 {@code quitTime ≥ minQuitTime} 过滤。
*
* @param userId 用户编号
* @param minQuitTime 最早退群时间(含),可空
* @return 已退群成员记录列表
*/
default List<ImGroupMemberDO> selectQuitListByUserId(Long userId, LocalDateTime minQuitTime) {
return selectList(new LambdaQueryWrapperX<ImGroupMemberDO>()
.eq(ImGroupMemberDO::getUserId, userId)
.eq(ImGroupMemberDO::getStatus, CommonStatusEnum.DISABLE.getStatus())
.geIfPresent(ImGroupMemberDO::getQuitTime, minQuitTime));
default List<ImGroupMemberDO> selectListByUserId(Long userId) {
return selectList(ImGroupMemberDO::getUserId, userId);
}
@SuppressWarnings("UnusedReturnValue")
@@ -143,7 +130,8 @@ public interface ImGroupMemberMapper extends BaseMapperX<ImGroupMemberDO> {
.set(ImGroupMemberDO::getAddSource, addSource)
.set(ImGroupMemberDO::getInviterUserId, inviterUserId)
.set(ImGroupMemberDO::getQuitTime, null)
.set(ImGroupMemberDO::getMuteEndTime, null));
.set(ImGroupMemberDO::getMuteEndTime, null)
.set(ImGroupMemberDO::getUpdateTime, joinTime));
}
}
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.dal.mysql.group;
import cn.hutool.core.date.LocalDateTimeUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
@@ -43,6 +44,28 @@ public interface ImGroupRequestMapper extends BaseMapperX<ImGroupRequestDO> {
.orderByDesc(ImGroupRequestDO::getId));
}
/**
* 增量拉取「我管理的群」下的加群申请(含已处理,按 update_time + id 正向游标)
*
* @param groupIds 群编号集合
* @param lastUpdateTime 上次拉取到的更新时间;首次拉取传 null
* @param lastId 上次拉取到的记录编号;首次拉取传 null
* @param limit 拉取数量
* @return 加群申请列表
*/
default List<ImGroupRequestDO> selectPullListByGroupIds(Collection<Long> groupIds, Long lastUpdateTime,
Long lastId, Integer limit) {
LambdaQueryWrapperX<ImGroupRequestDO> query = new LambdaQueryWrapperX<ImGroupRequestDO>()
.in(ImGroupRequestDO::getGroupId, groupIds);
if (lastUpdateTime != null && lastId != null) {
LocalDateTime lastTime = LocalDateTimeUtil.of(lastUpdateTime);
query.and(w -> w.gt(ImGroupRequestDO::getUpdateTime, lastTime)
.or(n -> n.eq(ImGroupRequestDO::getUpdateTime, lastTime).gt(ImGroupRequestDO::getId, lastId)));
}
return selectList(query.orderByAsc(ImGroupRequestDO::getUpdateTime).orderByAsc(ImGroupRequestDO::getId)
.last("LIMIT " + limit));
}
default int updateByIdAndHandleResult(Long id, Integer expectedHandleResult, ImGroupRequestDO updateObj) {
return update(updateObj, new LambdaUpdateWrapper<ImGroupRequestDO>()
.eq(ImGroupRequestDO::getId, id).eq(ImGroupRequestDO::getHandleResult, expectedHandleResult));
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.dal.mysql.message;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessagePageReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import org.apache.ibatis.annotations.Mapper;
@@ -32,7 +33,7 @@ public interface ImChannelMessageMapper extends BaseMapperX<ImChannelMessageDO>
.gt(ImChannelMessageDO::getId, minId)
.and(w -> w.isNull(ImChannelMessageDO::getReceiverUserIds)
.or().eq(ImChannelMessageDO::getReceiverUserIds, "")
.or().apply("FIND_IN_SET({0}, receiver_user_ids)", userId))
.or().apply(MyBatisUtils.findInSet("receiver_user_ids"), userId))
.orderByAsc(ImChannelMessageDO::getId)
.last("LIMIT " + size));
}
@@ -4,13 +4,15 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.group.ImGroupMessageManagerPageReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import org.apache.ibatis.annotations.Mapper;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.List;
/**
@@ -22,48 +24,22 @@ import java.util.List;
public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
/**
* 根据 minId + 时间窗口增量拉取群聊消息(在群成员使用)
* 根据 minId + 时间窗口增量拉取群聊消息
*
* @param groupIds 用户当前仍在群内的群编号列表
* @param userId 当前用户编号
* @param groupIds 候选群编号集合(当前在群 ∪ 窗口内退群),不能为空
* @param minId 最小消息 id(不含)
* @param minSendTime 最早发送时间(不含),限制离线消息时间窗口
* @param size 拉取数量
* @return 消息列表(按 id 升序)
*/
default List<ImGroupMessageDO> selectListByMinId(List<Long> groupIds, Long minId,
default List<ImGroupMessageDO> selectListByMinId(Long userId, Collection<Long> groupIds, Long minId,
LocalDateTime minSendTime, Integer size) {
QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
wrapper.in("group_id", groupIds)
.gt("id", minId)
.gt("send_time", minSendTime)
.orderByAsc("id");
wrapper.limitN(size);
return selectList(wrapper);
}
/**
* 查询"退群前"的离线消息(退群成员使用)
* <p>
* 语义:用户已退出某群,但仍需把 {@code minId} 之后、{@code minSendTime} 之后、不晚于退群时间的消息补齐到本地,便于前端看到完整上下文。
* <p>
* 撤回消息(status=RECALL)保留返回,与在群成员的 {@link #selectListByMinId} 行为一致,由前端按撤回信号渲染「消息已撤回」气泡。
*
* @param groupId 群编号
* @param minId 最小消息 id(不含)
* @param minSendTime 最早发送时间(不含)
* @param quitTime 退群时间(含),仅返回退群当时已存在的消息
* @param size 拉取数量
* @return 消息列表(按 id 升序)
*/
default List<ImGroupMessageDO> selectListByGroupIdAndMinIdAndQuitTimeBefore(Long groupId, Long minId,
LocalDateTime minSendTime,
LocalDateTime quitTime,
Integer size) {
QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
wrapper.eq("group_id", groupId)
.gt("id", minId)
.gt("send_time", minSendTime)
.le("send_time", quitTime)
.apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
.orderByAsc("id");
wrapper.limitN(size);
return selectList(wrapper);
@@ -72,17 +48,17 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
/**
* 查询群聊历史消息(游标拉取)
*
* @param groupId 群编号
* @param maxId 起始消息 id(不含),为空则从最新开始
* @param limit 拉取数量
* @param joinTime 入群时间,仅返回入群之后的消息
* @param userId 当前用户编号
* @param groupId 群编号
* @param maxId 起始消息 id(不含),为空则从最新开始
* @param limit 拉取数量
* @return 消息列表(按 id 倒序)
*/
default List<ImGroupMessageDO> selectHistoryList(Long groupId, Long maxId, Integer limit, LocalDateTime joinTime) {
default List<ImGroupMessageDO> selectHistoryListByUser(Long userId, Long groupId, Long maxId, Integer limit) {
QueryWrapperX<ImGroupMessageDO> wrapper = new QueryWrapperX<>();
wrapper.eq("group_id", groupId)
.lt(maxId != null, "id", maxId)
.ge(joinTime != null, "send_time", joinTime)
.apply(MyBatisUtils.findInSet("receiver_user_ids"), userId)
.orderByDesc("id");
wrapper.limitN(limit);
return selectList(wrapper);
@@ -108,7 +84,7 @@ public interface ImGroupMessageMapper extends BaseMapperX<ImGroupMessageDO> {
default List<ImGroupMessageDO> selectListByGroupIdAndPendingReceipt(Long groupId, Long minId, Long maxId) {
return selectList(new LambdaQueryWrapperX<ImGroupMessageDO>()
.eq(ImGroupMessageDO::getGroupId, groupId)
.eq(ImGroupMessageDO::getReceiptStatus, ImGroupMessageReceiptStatusEnum.PENDING.getStatus())
.eq(ImGroupMessageDO::getReceiptStatus, ImMessageReceiptStatusEnum.PENDING.getStatus())
.gt(minId != null, ImGroupMessageDO::getId, minId)
.le(ImGroupMessageDO::getId, maxId)
.ne(ImGroupMessageDO::getStatus, ImMessageStatusEnum.RECALL.getStatus()));
@@ -67,23 +67,13 @@ public interface ImPrivateMessageMapper extends BaseMapperX<ImPrivateMessageDO>
.eq(ImPrivateMessageDO::getClientMessageId, clientMessageId));
}
default Long selectMaxIdBySenderIdAndReceiverIdAndStatus(Long senderId, Long receiverId, Integer status) {
ImPrivateMessageDO message = selectOne(new LambdaQueryWrapperX<ImPrivateMessageDO>()
.eq(ImPrivateMessageDO::getSenderId, senderId)
.eq(ImPrivateMessageDO::getReceiverId, receiverId)
.eq(ImPrivateMessageDO::getStatus, status)
.orderByDesc(ImPrivateMessageDO::getId)
.last("LIMIT 1"));
return message != null ? message.getId() : null;
}
default int updateBySenderIdAndReceiverIdAndIdLeAndStatus(Long senderId, Long receiverId, Long maxMessageId,
Integer whereStatus, ImPrivateMessageDO updateObj) {
default int updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(Long senderId, Long receiverId, Long maxMessageId,
Integer whereReceiptStatus, ImPrivateMessageDO updateObj) {
return update(updateObj, new LambdaQueryWrapperX<ImPrivateMessageDO>()
.eq(ImPrivateMessageDO::getSenderId, senderId)
.eq(ImPrivateMessageDO::getReceiverId, receiverId)
.le(ImPrivateMessageDO::getId, maxMessageId)
.eq(ImPrivateMessageDO::getStatus, whereStatus));
.eq(ImPrivateMessageDO::getReceiptStatus, whereReceiptStatus));
}
default PageResult<ImPrivateMessageDO> selectPage(ImPrivateMessageManagerPageReqVO reqVO) {
@@ -154,7 +154,7 @@ public interface ImStatisticsManagerMapper {
@Param("endTime") LocalDateTime endTime);
/**
* 区间内消息类型分布(私聊+群聊合并)
* 区间内内容类型分布(私聊+群聊合并)
*
* @return [{type: 0, count: 123}, ...]
*/
@@ -8,20 +8,6 @@ package cn.iocoder.yudao.module.im.dal.redis;
*/
public interface RedisKeyConstants {
/**
* 群消息已读位置
* KEY 格式: im:group:message:read:{groupId}
* VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
*/
String GROUP_MESSAGE_READ = "im:group:message:read:%s";
/**
* 频道消息已读位置
* KEY 格式: im:channel:message:read:{channelId}
* VALUE 数据类型: Hash (field: userId, value: maxReadMessageId)
*/
String CHANNEL_MESSAGE_READ = "im:channel:message:read:%s";
/**
* 好友关系状态缓存(合并「是否好友」+「是否拉黑」两态)
* <p>
@@ -44,7 +30,7 @@ public interface RedisKeyConstants {
* KEY 格式:group_member_ids:{groupId}
* VALUE 数据类型:List<Long>
* <p>
* 说明:只缓存轻量的 userId 列表,适合"群消息推送目标"这类只关心 userId 的场景。
* 说明:只缓存轻量的 userId 列表,适合「群消息推送目标」这类只关心 userId 的场景
*/
String GROUP_MEMBER_IDS = "group_member_ids";
@@ -1,50 +0,0 @@
package cn.iocoder.yudao.module.im.dal.redis.message;
import cn.hutool.core.convert.Convert;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.CHANNEL_MESSAGE_READ;
/**
* IM 频道消息已读位置 Redis DAO
*
* @author 芋道源码
*/
@Repository
public class ImChannelMessageReadRedisDAO {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 更新用户在某频道的最大已读消息编号
*
* @param channelId 频道编号
* @param userId 用户编号
* @param maxMessageId 最大已读消息编号
*/
public void updateReadMaxMessageId(Long channelId, Long userId, Long maxMessageId) {
String key = formatKey(channelId);
stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
}
/**
* 获取用户在某频道的最大已读消息编号
*
* @param channelId 频道编号
* @param userId 用户编号
* @return 最大已读消息编号;不存在则返回 null
*/
public Long getReadMaxMessageId(Long channelId, Long userId) {
String key = formatKey(channelId);
Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
return Convert.toLong(val);
}
private static String formatKey(Long channelId) {
return String.format(CHANNEL_MESSAGE_READ, channelId);
}
}
@@ -1,102 +0,0 @@
package cn.iocoder.yudao.module.im.dal.redis.message;
import cn.hutool.core.convert.Convert;
import jakarta.annotation.Resource;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Repository;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import static cn.iocoder.yudao.module.im.dal.redis.RedisKeyConstants.GROUP_MESSAGE_READ;
/**
* IM 群消息已读位置 Redis DAO
*
* @author 芋道源码
*/
@Repository
public class ImGroupMessageReadRedisDAO {
@Resource
private StringRedisTemplate stringRedisTemplate;
/**
* 更新用户在某群的最大已读消息编号
*
* @param groupId 群编号
* @param userId 用户编号
* @param maxMessageId 最大已读消息编号
*/
public void updateReadMaxMessageId(Long groupId, Long userId, Long maxMessageId) {
String key = formatKey(groupId);
stringRedisTemplate.opsForHash().put(key, userId.toString(), maxMessageId.toString());
}
/**
* 获取用户在某群的最大已读消息编号
*
* @param groupId 群编号
* @param userId 用户编号
* @return 最大已读消息编号,不存在则返回 null
*/
public Long getReadMaxMessageId(Long groupId, Long userId) {
String key = formatKey(groupId);
Object val = stringRedisTemplate.opsForHash().get(key, userId.toString());
return Convert.toLong(val);
}
/**
* 获取某群所有用户的已读位置
*
* @param groupId 群编号
* @return userId → maxReadMessageId 映射
*/
public Map<Long, Long> getReadMaxMessageIdMap(Long groupId) {
String key = formatKey(groupId);
Map<Object, Object> entries = stringRedisTemplate.opsForHash().entries(key);
// 转换为 Long → Long 的 Map
Map<Long, Long> result = new HashMap<>(entries.size());
entries.forEach((k, v) -> result.put(Long.parseLong(k.toString()), Long.parseLong(v.toString())));
return result;
}
/**
* 删除用户在某群的已读位置
*
* @param groupId 群编号
* @param userId 用户编号
*/
public void deleteReadMaxMessageId(Long groupId, Long userId) {
String key = formatKey(groupId);
stringRedisTemplate.opsForHash().delete(key, userId.toString());
}
/**
* 批量删除用户在某群的已读位置
*
* @param groupId 群编号
* @param userIds 用户编号集合
*/
public void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds) {
String key = formatKey(groupId);
Object[] hashKeys = userIds.stream().map(String::valueOf).toArray();
stringRedisTemplate.opsForHash().delete(key, hashKeys);
}
/**
* 删除某群所有用户的已读位置(整个 Hash Key)
*
* @param groupId 群编号
*/
public void deleteReadMaxMessageIdMap(Long groupId) {
String key = formatKey(groupId);
stringRedisTemplate.delete(key);
}
private static String formatKey(Long groupId) {
return String.format(GROUP_MESSAGE_READ, groupId);
}
}
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.enums.message;
package cn.iocoder.yudao.module.im.enums;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ArrayUtil;
import cn.iocoder.yudao.framework.common.core.ArrayValuable;
@@ -10,13 +11,13 @@ import java.util.Arrays;
import java.util.Set;
/**
* IM 消息类型枚举
* IM 内容类型枚举
*
* @author 芋道源码
*/
@Getter
@RequiredArgsConstructor
public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
public enum ImContentTypeEnum implements ArrayValuable<Integer> {
// ========== 用户聊天消息(101-105 直接复用 OpenIM 段位编号) ==========
/**
@@ -127,10 +128,6 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
RTC_CALL_END(1611, "通话结束", true, false),
// ========== 好友通知(1201-1210 直接复用 OpenIM 段位编号) ==========
// TODO @芋艿:FRIEND_REQUEST_* 与 GROUP_REQUEST_* 都是 persistent=false 的 SysMsg,离线 pull 拉不到,
// 目前可能丢失「实时 toast 提醒」体验(业务状态不丢,前端上线 fetch{Friend,Group}RequestList 能补回来);
// 未来思考下怎么优化:候选方案 1)改 persistent=true 入私聊消息流 + 让客户端按 type 自渲染;
// 2)服务端补一个「未读通知拉取」接口给前端冷启动调用。
/**
* 对应 OpenIM:FriendApplicationApprovedNotification 1201
* 对应自己的类:FriendRequestApprovedNotification
@@ -284,9 +281,9 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
/**
* 对应 OpenIM:sdkws.GroupMemberInfoSetTips(GroupMemberInfoSetNotification 1516,窄化到 displayUserName)
* 对应自己的类:GroupMemberNicknameUpdateNotification
* 场景:成员修改自己在群里的昵称,全员广播;前端按 displayUserName 局部更新对应 member
* 场景:成员修改自己在群里的昵称,在线成员同步对应 member
*/
GROUP_MEMBER_NICKNAME_UPDATE(1516, "成员昵称变更", true, false),
GROUP_MEMBER_NICKNAME_UPDATE(1516, "成员昵称变更", false, false),
/**
* 对应 OpenIM:GroupMemberSetToAdminNotification 1517
* 对应自己的类:GroupAdminAddNotification
@@ -338,9 +335,9 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
*/
GROUP_BANNED(1533, "群封禁变更", true, false);
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageTypeEnum::getType).toArray(Integer[]::new);
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImContentTypeEnum::getType).toArray(Integer[]::new);
private static final Set<Integer> FRIEND_NOTIFICATION_TYPES = Set.of(
private static final Set<Integer> FRIEND_NOTIFICATION_TYPES = CollUtil.newHashSet(
FRIEND_REQUEST_APPROVED.type,
FRIEND_REQUEST_REJECTED.type,
FRIEND_REQUEST_RECEIVED.type,
@@ -351,12 +348,12 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
FRIEND_INFO_UPDATED.type,
FRIEND_UPDATE.type);
private static final Set<Integer> GROUP_REQUEST_NOTIFICATION_TYPES = Set.of(
private static final Set<Integer> GROUP_REQUEST_NOTIFICATION_TYPES = CollUtil.newHashSet(
GROUP_REQUEST_RECEIVED.type,
GROUP_REQUEST_APPROVED.type,
GROUP_REQUEST_REJECTED.type);
private static final Set<Integer> RTC_NOTIFICATION_TYPES = Set.of(
private static final Set<Integer> RTC_NOTIFICATION_TYPES = CollUtil.newHashSet(
RTC_CALL.type,
RTC_PARTICIPANT_CONNECTED.type,
RTC_PARTICIPANT_DISCONNECTED.type);
@@ -395,8 +392,8 @@ public enum ImMessageTypeEnum implements ArrayValuable<Integer> {
* @param type 消息类型
* @return 枚举实例
*/
public static ImMessageTypeEnum validate(Integer type) {
ImMessageTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values());
public static ImContentTypeEnum validate(Integer type) {
ImContentTypeEnum result = ArrayUtil.firstMatch(item -> item.type.equals(type), values());
Assert.notNull(result, "未注册的消息类型 type={}", type);
return result;
}
@@ -16,6 +16,7 @@ import java.util.Objects;
@Getter
public enum ImConversationTypeEnum implements ArrayValuable<Integer> {
NONE(0, "无会话"), // 无会话
PRIVATE(1, "私聊"), // 私聊
GROUP(2, "群聊"), // 群聊
CHANNEL(3, "频道"); // 频道 / 公众号
@@ -36,6 +37,10 @@ public enum ImConversationTypeEnum implements ArrayValuable<Integer> {
return ARRAYS;
}
public static boolean isNone(Integer type) {
return Objects.equals(NONE.type, type);
}
public static boolean isPrivate(Integer type) {
return Objects.equals(PRIVATE.type, type);
}
@@ -7,19 +7,19 @@ import lombok.Getter;
import java.util.Arrays;
/**
* IM 群消息回执状态枚举
* IM 消息回执状态枚举
*
* @author 芋道源码
*/
@Getter
@AllArgsConstructor
public enum ImGroupMessageReceiptStatusEnum implements ArrayValuable<Integer> {
public enum ImMessageReceiptStatusEnum implements ArrayValuable<Integer> {
NO_RECEIPT(0, "不需要回执"),
PENDING(1, "待完成"),
DONE(2, "已完成");
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImGroupMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new);
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageReceiptStatusEnum::getStatus).toArray(Integer[]::new);
/**
* 状态
@@ -8,9 +8,6 @@ import java.util.Arrays;
/**
* IM 消息状态枚举
* <p>
* 私聊:SENDING(-1, 仅客户端) / UNREAD(0) / RECALL(2) / READ(3)
* 群聊:SENDING(-1, 仅客户端) / UNREAD(0, 作为正常状态) / RECALL(2)
*
* @author 芋道源码
*/
@@ -19,9 +16,8 @@ import java.util.Arrays;
public enum ImMessageStatusEnum implements ArrayValuable<Integer> {
SENDING(-1, "发送中"), // 仅客户端使用
UNREAD(0, "未读"), // 私聊=未读,群聊=正常(初始状态)
RECALL(2, "已撤回"),
READ(3, "已读"); // 仅私聊使用;群聊已读通过 Redis 已读位置实现
NORMAL(0, "正常"), // 私聊 / 群聊的正常(初始)状态
RECALL(2, "已撤回");
public static final Integer[] ARRAYS = Arrays.stream(values()).map(ImMessageStatusEnum::getStatus).toArray(Integer[]::new);
@@ -24,7 +24,6 @@ import java.util.Objects;
@Getter
public enum ImRtcParticipantStatusEnum implements ArrayValuable<Integer> {
// TODO @芋艿:hand up 要不要也搞下。
INVITING(10, "邀请中"), // 已发出 invite,等被叫响应
JOINED(20, "已加入"), // 已 connect 进 LiveKit 房间
REJECTED(30, "已拒绝"), // 接通前点拒接
@@ -2,11 +2,11 @@ package cn.iocoder.yudao.module.im.mq.consumer.friend;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendInfoUpdatedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendInfoUpdatedNotification;
import cn.iocoder.yudao.module.system.api.message.user.AdminUserProfileUpdateMessage;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -52,8 +52,9 @@ public class AdminUserProfileUpdateConsumer {
try {
FriendInfoUpdatedNotification payload = (FriendInfoUpdatedNotification) new FriendInfoUpdatedNotification()
.setOperatorUserId(userId).setFriendUserId(userId);
websocketService.sendPrivateMessageAsync(friend.getFriendUserId(), ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_INFO_UPDATED.getType(), userId, friend.getFriendUserId(), payload));
websocketService.sendNotificationAsync(friend.getFriendUserId(),
ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_INFO_UPDATED.getType(), payload);
successCount++;
} catch (Exception e) {
log.warn("[onMessage][userId({}) friendUserId({}) 推送失败]",
@@ -0,0 +1,67 @@
package cn.iocoder.yudao.module.im.service.conversation;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import java.util.Collection;
import java.util.List;
import java.util.Map;
/**
* IM 会话读位置 Service 接口
*
* @author 芋道源码
*/
public interface ImConversationReadService {
/**
* 更新用户在某会话的最大已读位置(单调递增,乱序 / 并发上报不会回退)
*
* @param userId 用户编号
* @param conversationType 会话类型
* @param conversationId 会话编号
* @param readMessageId 已读到的最大消息编号
* @return 读位置是否前进;true 时调用方才需要下发已读 / 回执事件
*/
boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId);
/**
* 获取用户在某会话的最大已读位置
*
* @param userId 用户编号
* @param conversationType 会话类型
* @param conversationId 会话编号
* @return 最大已读消息编号;不存在则返回 null
*/
Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId);
/**
* 获取某会话内所有用户的读位置(用于群回执人数聚合)
*
* @param conversationType 会话类型
* @param conversationId 会话编号
* @return userId → 最大已读消息编号
*/
Map<Long, Long> getUserReadMessageIdMap(Integer conversationType, Long conversationId);
/**
* 批量获取某用户在多个会话的读位置(用于频道批量读位置、重连后按活跃会话补偿)
*
* @param userId 用户编号
* @param conversationType 会话类型
* @param conversationIds 会话编号集合
* @return conversationId → 最大已读消息编号
*/
Map<Long, Long> getConversationReadMessageIdMap(Long userId, Integer conversationType, Collection<Long> conversationIds);
/**
* 增量拉取当前用户的会话读位置(重连 / 离线补偿:按 update_time + id 游标)
*
* @param userId 用户编号
* @param lastUpdateTime 上次拉取到的最新更新时间(毫秒时间戳);首次拉取传 null
* @param lastId 上次拉取到的最后一条记录 id;首次拉取传 null
* @param limit 单次拉取条数
* @return 会话读位置列表,按更新时间、id 正序
*/
List<ImConversationReadDO> pullConversationReadList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
}
@@ -0,0 +1,86 @@
package cn.iocoder.yudao.module.im.service.conversation;
import cn.hutool.core.collection.CollUtil;
import cn.iocoder.yudao.module.im.dal.dataobject.conversation.ImConversationReadDO;
import cn.iocoder.yudao.module.im.dal.mysql.conversation.ImConversationReadMapper;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap;
/**
* IM 会话读位置 Service 实现类
*
* @author 芋道源码
*/
@Service
@Slf4j
public class ImConversationReadServiceImpl implements ImConversationReadService {
@Resource
private ImConversationReadMapper conversationReadMapper;
@Override
public boolean updateConversationReadPosition(Long userId, Integer conversationType, Long conversationId, Long readMessageId) {
LocalDateTime now = LocalDateTime.now();
// 1. 不存在则插入;并发下唯一键冲突,降级为回查 + CAS 更新
ImConversationReadDO existing = conversationReadMapper.selectByUserIdAndConversation(
userId, conversationType, conversationId);
if (existing == null) {
try {
conversationReadMapper.insert(new ImConversationReadDO().setUserId(userId)
.setConversationType(conversationType).setTargetId(conversationId)
.setMessageId(readMessageId).setReadTime(now));
return true;
} catch (DuplicateKeyException e) {
log.warn("[updateConversationReadPosition][userId({}) type({}) conversationId({}) 并发插入冲突,回查更新]",
userId, conversationType, conversationId);
existing = conversationReadMapper.selectByUserIdAndConversation(userId, conversationType, conversationId);
}
}
if (existing == null) {
return false;
}
// 2. CAS 单调更新:mapper 内 WHERE message_id < ? 保证乱序 / 并发不回退;影响行数 > 0 即读位置前进
return conversationReadMapper.updateReadMessageIdToLarger(existing.getId(), readMessageId, now) > 0;
}
@Override
public Long getConversationReadMessageId(Long userId, Integer conversationType, Long conversationId) {
ImConversationReadDO read = conversationReadMapper.selectByUserIdAndConversation(
userId, conversationType, conversationId);
return read != null ? read.getMessageId() : null;
}
@Override
public Map<Long, Long> getUserReadMessageIdMap(Integer conversationType, Long conversationId) {
return convertMap(conversationReadMapper.selectListByConversation(conversationType, conversationId),
ImConversationReadDO::getUserId, ImConversationReadDO::getMessageId);
}
@Override
public Map<Long, Long> getConversationReadMessageIdMap(Long userId, Integer conversationType,
Collection<Long> conversationIds) {
if (CollUtil.isEmpty(conversationIds)) {
return Collections.emptyMap();
}
List<ImConversationReadDO> list = conversationReadMapper.selectListByUserIdAndConversations(
userId, conversationType, conversationIds);
return convertMap(list, ImConversationReadDO::getTargetId, ImConversationReadDO::getMessageId);
}
@Override
public List<ImConversationReadDO> pullConversationReadList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
return conversationReadMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
}
}
@@ -50,6 +50,17 @@ public interface ImFriendRequestService {
*/
List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long maxId, Integer limit);
/**
* 增量拉取「我相关」的好友申请(重连 / 离线补偿:双向 OR,按 update_time + id 游标)
*
* @param userId 用户编号
* @param lastUpdateTime 上次拉取到的最新更新时间(毫秒时间戳);首次拉取传 null
* @param lastId 上次拉取到的最后一条记录 id;首次拉取传 null
* @param limit 单次拉取条数
* @return 申请记录列表,按更新时间、id 正序
*/
List<ImFriendRequestDO> pullFriendRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
/**
* 按 id 单查申请记录;通用读接口,调用方自行做越权过滤
*/
@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.im.service.friend;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.extra.spring.SpringUtil;
@@ -13,13 +14,13 @@ import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendRequestMapper;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendRequestHandleResultEnum;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.FriendRequestRejectedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendRequestNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.FriendRequestRejectedNotification;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -104,8 +105,8 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
if (fromUser != null) {
payload.setFromNickname(fromUser.getNickname()).setFromAvatar(fromUser.getAvatar());
}
websocketService.sendPrivateMessageAsync(toUserId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), fromUserId, toUserId, payload));
websocketService.sendNotificationAsync(toUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_REQUEST_RECEIVED.getType(), payload);
// 4. 全局自动通过开关:注册 afterCommit 回调,事务提交后再走同意流程
// 回调内 try/catch 兜底 —— afterCommit 异常会被 Spring 静默吞掉,否则同意失败时申请方永远等不到 APPROVED
@@ -171,8 +172,6 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
public void agreeFriendRequest(Long userId, Long requestId) {
// 1.1 校验申请存在、未处理、操作人是接收方
ImFriendRequestDO request = validateRequestForHandle(userId, requestId);
// 1.2 复验双方用户有效
adminUserApi.validateUserList(List.of(request.getFromUserId(), request.getToUserId())).checkError();
// 2. 乐观锁更新申请处理结果
ImFriendRequestDO updateObj = new ImFriendRequestDO()
@@ -191,8 +190,8 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
FriendRequestApprovedNotification payload = (FriendRequestApprovedNotification)
new FriendRequestApprovedNotification().setRequestId(request.getId())
.setOperatorUserId(userId).setFriendUserId(userId);
websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_REQUEST_APPROVED.getType(), userId, request.getFromUserId(), payload));
websocketService.sendNotificationAsync(request.getFromUserId(), ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_REQUEST_APPROVED.getType(), payload);
}
@Override
@@ -216,15 +215,15 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
new FriendRequestRejectedNotification().setRequestId(request.getId())
.setHandleContent(handleContent)
.setOperatorUserId(userId).setFriendUserId(userId);
websocketService.sendPrivateMessageAsync(request.getFromUserId(), ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_REQUEST_REJECTED.getType(), userId, request.getFromUserId(), payload));
websocketService.sendNotificationAsync(request.getFromUserId(), ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_REQUEST_REJECTED.getType(), payload);
}
@Override
public List<ImFriendRequestDO> getMyFriendRequestList(Long userId, Long maxId, Integer limit) {
ImFriendRequestDO maxRequest = maxId != null ? friendRequestMapper.selectById(maxId) : null;
if (maxId != null && maxRequest == null) {
return List.of();
return ListUtil.of();
}
return friendRequestMapper.selectMyList(userId,
maxRequest != null ? maxRequest.getUpdateTime() : null,
@@ -232,6 +231,11 @@ public class ImFriendRequestServiceImpl implements ImFriendRequestService {
limit);
}
@Override
public List<ImFriendRequestDO> pullFriendRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
return friendRequestMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
}
@Override
public ImFriendRequestDO getFriendRequest(Long id) {
return friendRequestMapper.selectById(id);
@@ -42,6 +42,11 @@ public interface ImFriendService {
*/
List<ImFriendDO> getFriendList(Long userId);
/**
* 增量拉取当前用户的好友关系(重连 / 离线补偿:含已删除,按 update_time + id 游标)
*/
List<ImFriendDO> pullFriendList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
/**
* 获得当前用户的有效好友列表(仅 ENABLE 状态)
*/
@@ -11,12 +11,12 @@ import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendDO;
import cn.iocoder.yudao.module.im.dal.dataobject.friend.ImFriendRequestDO;
import cn.iocoder.yudao.module.im.dal.mysql.friend.ImFriendMapper;
import cn.iocoder.yudao.module.im.enums.friend.ImFriendStateEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.*;
import cn.iocoder.yudao.module.im.service.websocket.notification.friend.*;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cache.annotation.CacheEvict;
@@ -98,6 +98,11 @@ public class ImFriendServiceImpl implements ImFriendService {
return friendMapper.selectListByUserId(userId);
}
@Override
public List<ImFriendDO> pullFriendList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
return friendMapper.selectPullListByUserId(userId, lastUpdateTime, lastId, limit);
}
@Override
public List<ImFriendDO> getEnableFriendList(Long userId) {
return friendMapper.selectListByUserIdAndStatus(userId, CommonStatusEnum.ENABLE.getStatus());
@@ -155,8 +160,8 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendUpdateNotification payload = (FriendUpdateNotification) new FriendUpdateNotification()
.setDisplayName(reqVO.getDisplayName()).setSilent(reqVO.getSilent()).setPinned(reqVO.getPinned())
.setOperatorUserId(userId).setFriendUserId(reqVO.getFriendUserId());
websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_UPDATE.getType(), userId, userId, payload));
websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_UPDATE.getType(), payload);
}
@Override
@@ -178,7 +183,7 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification()
.setOperatorUserId(fromUserId).setFriendUserId(toUserId);
privateMessageService.sendPrivateMessage(fromUserId, new ImPrivateMessageSendDTO()
.setReceiverId(toUserId).setType(ImMessageTypeEnum.FRIEND_ADD.getType()).setContent(payload));
.setReceiverId(toUserId).setType(ImContentTypeEnum.FRIEND_ADD.getType()).setContent(payload));
}
@Override
@@ -197,7 +202,7 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendAddNotification payload = (FriendAddNotification) new FriendAddNotification()
.setOperatorUserId(friendUserId).setFriendUserId(friendUserId);
privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO()
.setReceiverId(friendUserId).setType(ImMessageTypeEnum.FRIEND_ADD.getType())
.setReceiverId(friendUserId).setType(ImContentTypeEnum.FRIEND_ADD.getType())
.setContent(payload).setPersistent(false));
}
@@ -219,7 +224,7 @@ public class ImFriendServiceImpl implements ImFriendService {
FriendDeleteNotification payload = ((FriendDeleteNotification) new FriendDeleteNotification()
.setOperatorUserId(userId).setFriendUserId(friendUserId)).setClear(clear);
privateMessageService.sendPrivateMessage(userId, new ImPrivateMessageSendDTO()
.setReceiverId(friendUserId).setType(ImMessageTypeEnum.FRIEND_DELETE.getType())
.setReceiverId(friendUserId).setType(ImContentTypeEnum.FRIEND_DELETE.getType())
.setContent(payload).setPersistent(false));
}
@@ -245,8 +250,8 @@ public class ImFriendServiceImpl implements ImFriendService {
// 3. 推 FRIEND_BLOCK 给 A 多端
FriendBlockNotification payload = (FriendBlockNotification) new FriendBlockNotification()
.setOperatorUserId(userId).setFriendUserId(friendUserId);
websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_BLOCK.getType(), userId, userId, payload));
websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_BLOCK.getType(), payload);
}
@Override
@@ -271,8 +276,8 @@ public class ImFriendServiceImpl implements ImFriendService {
// 3. 推 FRIEND_UNBLOCK 给 A 多端
FriendUnblockNotification payload = (FriendUnblockNotification) new FriendUnblockNotification()
.setOperatorUserId(userId).setFriendUserId(friendUserId);
websocketService.sendPrivateMessageAsync(userId, ImPrivateMessageDTO.ofFriendNotification(
ImMessageTypeEnum.FRIEND_UNBLOCK.getType(), userId, userId, payload));
websocketService.sendNotificationAsync(userId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.FRIEND_UNBLOCK.getType(), payload);
}
/**
@@ -294,7 +299,8 @@ public class ImFriendServiceImpl implements ImFriendService {
}
// 情况二:复用 DISABLE 旧记录 → 恢复 ENABLE + 重置 silent / pinned / blocked,对齐"重新加好友"语义
if (exists != null) {
friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), LocalDateTime.now(),
LocalDateTime now = LocalDateTime.now();
friendMapper.updateReAddFields(exists.getId(), CommonStatusEnum.ENABLE.getStatus(), now, now,
false, false, false, displayName, addSource);
return;
}
@@ -87,13 +87,12 @@ public interface ImGroupMemberService {
List<ImGroupMemberDO> getActiveGroupMemberListByUserId(Long userId);
/**
* 查询用户已退群的群成员记录(DISABLE 状态)
* 查询用户曾经加入的所有群成员记录(含已退群)
*
* @param userId 用户编号,必传
* @param minQuitTime 最早退群时间(含),可空
* @return 已退群成员记录列表
* @param userId 用户编号
* @return 群成员记录列表
*/
List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime);
List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId);
/**
* 添加群成员(入群),角色默认 MEMBER
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.im.service.group;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.ListUtil;
import cn.hutool.core.util.ObjUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -76,9 +77,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
@Override
public List<ImGroupMemberDO> getGroupMemberListByOwnerAndAdmin(Long groupId) {
// TODO DONE @AI:把条件往下传;这样减少加载数据量!
return groupMemberMapper.selectListByGroupIdAndStatusAndRoles(groupId, CommonStatusEnum.ENABLE.getStatus(),
List.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole()));
ListUtil.of(ImGroupMemberRoleEnum.OWNER.getRole(), ImGroupMemberRoleEnum.ADMIN.getRole()));
}
/**
@@ -105,8 +105,8 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
}
@Override
public List<ImGroupMemberDO> getQuitGroupMemberListByUserId(Long userId, LocalDateTime minQuitTime) {
return groupMemberMapper.selectQuitListByUserId(userId, minQuitTime);
public List<ImGroupMemberDO> getGroupMemberListByUserId(Long userId) {
return groupMemberMapper.selectListByUserId(userId);
}
@Override
@@ -258,7 +258,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
.setId(member.getId());
groupMemberMapper.updateById(updateObj);
// 3.1 displayUserName 是公开字段,单独走 GROUP_MEMBER_NICKNAME_UPDATE 广播给全员;空串视为「清空昵称」也要广播;与旧值相同跳过
// 3.1 displayUserName 是公开字段,单独走 GROUP_MEMBER_NICKNAME_UPDATE 在线同步给全员;空串视为「清空昵称」也要同步;与旧值相同跳过
if (updateReqVO.getDisplayUserName() != null
&& ObjUtil.notEqual(updateReqVO.getDisplayUserName(), member.getDisplayUserName())) {
groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberNicknameUpdate(
@@ -270,7 +270,7 @@ public class ImGroupMemberServiceImpl implements ImGroupMemberService {
boolean groupRemarkChanged = updateReqVO.getGroupRemark() != null
&& ObjUtil.notEqual(updateReqVO.getGroupRemark(), member.getGroupRemark());
if (silentChanged || groupRemarkChanged) {
groupMessageService.sendGroupMessage(userId, List.of(userId), ImGroupMessageSendDTO.ofGroupMemberSettingUpdate(
groupMessageService.sendGroupMessage(userId, ListUtil.of(userId), ImGroupMessageSendDTO.ofGroupMemberSettingUpdate(
groupId, userId, updateReqVO.getSilent(), updateReqVO.getGroupRemark()));
}
}
@@ -63,6 +63,19 @@ public interface ImGroupRequestService {
*/
List<ImGroupRequestDO> getUnhandledRequestListByOwnerOrAdmin(Long userId);
/**
* 增量拉取「我管理的群」下的加群申请(重连 / 离线补偿:含已处理,按 update_time + id 游标)
* <p>
* 作用域与 {@link #getUnhandledRequestListByOwnerOrAdmin} 一致:按 ImGroupMember.role 取我作为 OWNER / ADMIN 的群
*
* @param userId 当前用户编号
* @param lastUpdateTime 游标:上次拉取的最后一条更新时间戳(毫秒)
* @param lastId 游标:上次拉取的最后一条申请编号
* @param limit 单次拉取条数
* @return 申请记录列表
*/
List<ImGroupRequestDO> pullGroupRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit);
/**
* 拉取指定群下的全部加群申请(含已处理);仅群主 / 管理员可查
* <p>
@@ -13,15 +13,15 @@ import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupRequestMapper;
import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupRequestHandleResultEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.BaseGroupNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestReceivedNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.GroupRequestRejectedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.BaseGroupNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.GroupRequestApprovedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.GroupRequestReceivedNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.GroupRequestRejectedNotification;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -97,12 +97,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
// 3. 情况二:群开启了审批,创建或复用一条主动申请记录
ImGroupRequestDO request = createOrResetApplyRequest(groupId, userId, reqVO);
// 4. 1503 私聊定向推群主 + 全部管理员(多端同步);payload 携带申请方昵称 / 头像
// 4. 1503 定向推群主 + 全部管理员(多端同步);payload 携带申请方昵称 / 头像
AdminUserRespDTO applyUser = adminUserApi.getUser(userId).getCheckedData();
GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser);
for (Long receiverUserId : getGroupMemberListByOwnerAndAdminUserIds(group)) {
websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification(
ImMessageTypeEnum.GROUP_REQUEST_RECEIVED.getType(), userId, receiverUserId, payload));
websocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.GROUP_REQUEST_RECEIVED.getType(), payload);
}
return request;
}
@@ -139,12 +139,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
groupMemberService.addGroupMember(request.getGroupId(), request.getUserId(),
ImGroupMemberRoleEnum.NORMAL.getRole(), request.getAddSource(), request.getInviterUserId());
// 5.1 1505 私聊推送给申请人 + 群主 + 全部管理员(每端单推)
// 5.1 1505 定向推送给申请人 + 群主 + 全部管理员(每端单推)
GroupRequestApprovedNotification payload = (GroupRequestApprovedNotification) new GroupRequestApprovedNotification()
.setRequestId(request.getId()).setGroupId(request.getGroupId()).setUserId(request.getUserId())
.setOperatorUserId(userId);
broadcastToOwnerAdminsAndApplicant(request.getGroupId(), request.getUserId(), payload,
ImMessageTypeEnum.GROUP_REQUEST_APPROVED.getType(), userId);
ImContentTypeEnum.GROUP_REQUEST_APPROVED.getType(), userId);
// 5.2 群事件:主动申请 → 1510 自由进群;被邀请 → 1509 成员加入
if (request.getInviterUserId() == null) {
groupMessageService.sendGroupMessage(userId,
@@ -174,12 +174,12 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
throw exception(GROUP_REQUEST_HANDLED);
}
// 3. 1506 私聊推送给申请人 + 群主 + 全部管理员
// 3. 1506 定向推送给申请人 + 群主 + 全部管理员
GroupRequestRejectedNotification payload = (GroupRequestRejectedNotification) new GroupRequestRejectedNotification()
.setRequestId(request.getId()).setGroupId(request.getGroupId()).setUserId(request.getUserId())
.setHandleContent(handleContent).setOperatorUserId(userId);
broadcastToOwnerAdminsAndApplicant(request.getGroupId(), request.getUserId(), payload,
ImMessageTypeEnum.GROUP_REQUEST_REJECTED.getType(), userId);
ImContentTypeEnum.GROUP_REQUEST_REJECTED.getType(), userId);
}
@Override
@@ -201,8 +201,8 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
AdminUserRespDTO applyUser = userMap.get(request.getUserId());
GroupRequestReceivedNotification payload = buildRequestNotification(group, request, applyUser);
for (Long receiverUserId : ownerAndAdmins) {
websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification(
ImMessageTypeEnum.GROUP_REQUEST_RECEIVED.getType(), inviterUserId, receiverUserId, payload));
websocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.GROUP_REQUEST_RECEIVED.getType(), payload);
}
}
}
@@ -212,8 +212,7 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
// 1. 找出当前用户作为 OWNER / ADMIN 的所有群
List<ImGroupMemberDO> myMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
Set<Long> ownerOrAdminGroupIds = convertSet(myMembers,
ImGroupMemberDO::getGroupId,
m -> ImGroupMemberRoleEnum.isOwnerOrAdmin(m.getRole()));
ImGroupMemberDO::getGroupId, member -> ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()));
if (CollUtil.isEmpty(ownerOrAdminGroupIds)) {
return Collections.emptyList();
}
@@ -222,6 +221,19 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
ownerOrAdminGroupIds, ImGroupRequestHandleResultEnum.UNHANDLED.getResult());
}
@Override
public List<ImGroupRequestDO> pullGroupRequestList(Long userId, Long lastUpdateTime, Long lastId, Integer limit) {
// 1. 找出当前用户作为 OWNER / ADMIN 的所有群
List<ImGroupMemberDO> myMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
Set<Long> ownerOrAdminGroupIds = convertSet(myMembers,
ImGroupMemberDO::getGroupId, member -> ImGroupMemberRoleEnum.isOwnerOrAdmin(member.getRole()));
if (CollUtil.isEmpty(ownerOrAdminGroupIds)) {
return Collections.emptyList();
}
// 2. 按游标增量拉取这些群下的申请
return groupRequestMapper.selectPullListByGroupIds(ownerOrAdminGroupIds, lastUpdateTime, lastId, limit);
}
@Override
public List<ImGroupRequestDO> getGroupRequestListByGroupId(Long userId, Long groupId) {
// 1. 校验群存在 + 当前用户是群主 / 管理员
@@ -383,7 +395,7 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
}
/**
* 1505 / 1506 受众:申请人 + 群主 + 全部管理员;每端单独推一帧,前端按 receiver 是否申请人区分文案
* 1505 / 1506 受众:申请人 + 群主 + 全部管理员
*/
private void broadcastToOwnerAdminsAndApplicant(Long groupId, Long applicantUserId, BaseGroupNotification payload,
Integer messageType, Long operatorUserId) {
@@ -394,8 +406,8 @@ public class ImGroupRequestServiceImpl implements ImGroupRequestService {
Set<Long> receivers = new LinkedHashSet<>(getGroupMemberListByOwnerAndAdminUserIds(group));
receivers.add(applicantUserId);
for (Long receiverUserId : receivers) {
websocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofGroupNotification(
messageType, operatorUserId, receiverUserId, payload));
websocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
messageType, payload);
}
}
@@ -28,10 +28,10 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.group.ImGroupMapper;
import cn.iocoder.yudao.module.im.enums.group.ImGroupAddSourceEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.message.ImGroupMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
@@ -149,7 +149,9 @@ public class ImGroupServiceImpl implements ImGroupService {
boolean nameChanged = StrUtil.isNotEmpty(updateReqVO.getName());
boolean noticeChanged = updateReqVO.getNotice() != null;
boolean avatarChanged = StrUtil.isNotEmpty(updateReqVO.getAvatar());
if (nameChanged || noticeChanged || avatarChanged) {
boolean joinApprovalChanged = updateReqVO.getJoinApproval() != null
&& ObjUtil.notEqual(group.getJoinApproval(), updateReqVO.getJoinApproval());
if (nameChanged || noticeChanged || avatarChanged || joinApprovalChanged) {
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(groupId);
if (nameChanged) {
groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupNameUpdate(
@@ -161,7 +163,11 @@ public class ImGroupServiceImpl implements ImGroupService {
}
if (avatarChanged) {
groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupInfoUpdate(
groupId, userId, group.getAvatar(), updateReqVO.getAvatar()));
groupId, userId, group.getAvatar(), updateReqVO.getAvatar(), null, null));
}
if (joinApprovalChanged) {
groupMessageService.sendGroupMessage(userId, memberUserIds, ImGroupMessageSendDTO.ofGroupInfoUpdate(
groupId, userId, null, null, group.getJoinApproval(), updateReqVO.getJoinApproval()));
}
}
@@ -193,8 +199,6 @@ public class ImGroupServiceImpl implements ImGroupService {
.setStatus(CommonStatusEnum.DISABLE.getStatus()).setDissolvedTime(LocalDateTime.now()));
// 2.2 移除全部群成员
groupMemberService.removeGroupMembersByGroupId(id);
// 2.3 清理已读缓存
groupMessageService.deleteReadMaxMessageIdMap(id);
}
// ==================== 群成员的写操作 ====================
@@ -267,10 +271,8 @@ public class ImGroupServiceImpl implements ImGroupService {
// 2. 先发广播,后移成员(见类 javadoc)
groupMessageService.sendGroupMessage(userId, ImGroupMessageSendDTO.ofGroupMemberQuit(groupId, userId));
// 3.1 移除群成员
// 3. 移除群成员
groupMemberService.removeGroupMember(groupId, userId);
// 3.2 清理已读缓存
groupMessageService.deleteReadMaxMessageId(groupId, userId);
}
@Override
@@ -307,10 +309,8 @@ public class ImGroupServiceImpl implements ImGroupService {
groupMessageService.sendGroupMessage(userId,
ImGroupMessageSendDTO.ofGroupMemberKick(groupId, userId, validTargetUserIds));
// 3.1 批量移除群成员
// 3. 批量移除群成员;不清理读位置(保留退群前历史已读,供离线补偿)
groupMemberService.removeGroupMembers(groupId, validTargetUserIds);
// 3.2 批量清理已读缓存
groupMessageService.deleteReadMaxMessageIds(groupId, validTargetUserIds);
}
@Override
@@ -413,12 +413,12 @@ public class ImGroupServiceImpl implements ImGroupService {
ImGroupMemberDO newOwner = groupMemberService.validateMemberInGroup(groupId, newOwnerUserId);
// 2.1 更新成员角色
int newOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, Set.of(newOwner.getUserId()),
int newOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, CollUtil.newHashSet(newOwner.getUserId()),
ImGroupMemberRoleEnum.OWNER.getRole());
if (newOwnerAffected != 1) {
throw exception(GROUP_MEMBER_NOT_IN_GROUP);
}
int oldOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, Set.of(userId),
int oldOwnerAffected = groupMemberService.updateGroupMemberRole(groupId, CollUtil.newHashSet(userId),
ImGroupMemberRoleEnum.NORMAL.getRole());
if (oldOwnerAffected != 1) {
throw exception(GROUP_MEMBER_NOT_IN_GROUP);
@@ -442,7 +442,7 @@ public class ImGroupServiceImpl implements ImGroupService {
if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
throw exception(MESSAGE_NOT_IN_GROUP);
}
if (!ImMessageTypeEnum.validate(message.getType()).isNormal()
if (!ImContentTypeEnum.validate(message.getType()).isNormal()
|| ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
throw exception(MESSAGE_NOT_IN_GROUP);
}
@@ -601,11 +601,8 @@ public class ImGroupServiceImpl implements ImGroupService {
@Override
public List<ImGroupDO> getMyGroupList(Long userId) {
// 1.1 查用户所在的、仍有效的群成员记录(仅 ENABLE 状态)
List<ImGroupMemberDO> members = groupMemberService.getActiveGroupMemberListByUserId(userId);
// 1.2 再查最近 N 天(与群消息离线拉取窗口一致)内退群的成员记录(退群前可能有离线消息需要展示,一并返回作为前端缓存)
LocalDateTime minQuitTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays());
members.addAll(groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime));
// 1. 查用户曾经加入的所有群(含退群,前端按需过滤);退群前的离线消息也要能展示
List<ImGroupMemberDO> members = groupMemberService.getGroupMemberListByUserId(userId);
if (CollUtil.isEmpty(members)) {
return Collections.emptyList();
}
@@ -27,7 +27,7 @@ public interface ImChannelMessageService {
* @param size 返回条数
* @return 频道消息列表;按 id 升序
*/
List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size);
List<ImChannelMessageDO> pullChannelMessageList(Long userId, Long minId, Integer size);
/**
* 上报频道消息已读位置;同步推 READ 事件给自己多端
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.im.service.message;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
@@ -11,12 +12,14 @@ import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.Im
import cn.iocoder.yudao.module.im.dal.dataobject.channel.ImChannelMaterialDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImChannelMessageMapper;
import cn.iocoder.yudao.module.im.dal.redis.message.ImChannelMessageReadRedisDAO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.service.channel.ImChannelMaterialService;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.MaterialMessage;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImChannelMessageNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReadNotification;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.MaterialMessage;
import jakarta.annotation.Resource;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
@@ -25,7 +28,6 @@ import org.springframework.validation.annotation.Validated;
import java.time.LocalDateTime;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -50,39 +52,41 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
private ImWebSocketService webSocketService;
@Resource
private ImChannelMessageReadRedisDAO channelMessageReadRedisDAO;
private ImConversationReadService conversationReadService;
// ==================== 用户端 ====================
@Override
public List<ImChannelMessageDO> getMessageListForPull(Long userId, Long minId, Integer size) {
public List<ImChannelMessageDO> pullChannelMessageList(Long userId, Long minId, Integer size) {
return channelMessageMapper.selectListByUserAndMinId(userId, minId, size);
}
@Override
public Map<Long, Long> getChannelReadMaxMessageIdMap(Long userId, Collection<Long> channelIds) {
Map<Long, Long> result = new HashMap<>(channelIds.size());
for (Long channelId : channelIds) {
Long max = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
if (max != null) {
result.put(channelId, max);
}
}
return result;
return conversationReadService.getConversationReadMessageIdMap(
userId, ImConversationTypeEnum.CHANNEL.getType(), channelIds);
}
@Override
public void readChannelMessages(Long userId, Long channelId, Long messageId) {
Assert.notNull(channelId, "频道编号不能为空");
Assert.notNull(messageId, "已读消息编号不能为空");
// 1. 已读位置未前进,直接返回
Long prevMaxMessageId = channelMessageReadRedisDAO.getReadMaxMessageId(channelId, userId);
if (prevMaxMessageId != null && prevMaxMessageId >= messageId) {
// 1.1 校验消息真实存在且属于该频道,避免未来 / 伪造 messageId 污染读位置
ImChannelMessageDO message = channelMessageMapper.selectById(messageId);
if (message == null || ObjUtil.notEqual(message.getChannelId(), channelId)) {
return;
}
// 1.2 定向消息校验对当前用户可见(receiver_user_ids 为空表示全员可见)
if (CollUtil.isNotEmpty(message.getReceiverUserIds()) && !message.getReceiverUserIds().contains(userId)) {
return;
}
// 2. 更新 Redis 频道已读位置
channelMessageReadRedisDAO.updateReadMaxMessageId(channelId, userId, messageId);
// 2. 更新频道已读位置;读位置未前进则不推
boolean advanced = conversationReadService.updateConversationReadPosition(
userId, ImConversationTypeEnum.CHANNEL.getType(), channelId, messageId);
if (!advanced) {
return;
}
// 3. 异步推 READ 事件给自己多端同步
getSelf().readChannelMessageEvent(userId, channelId, messageId);
@@ -93,7 +97,8 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
*/
@Async
public void readChannelMessageEvent(Long userId, Long channelId, Long readId) {
webSocketService.sendChannelMessageAsync(userId, ImChannelMessageDTO.ofRead(channelId, readId));
webSocketService.sendNotificationAsync(userId, ImConversationTypeEnum.CHANNEL.getType(),
ImContentTypeEnum.READ.getType(), ImMessageReadNotification.ofChannel(channelId, readId));
}
private ImChannelMessageServiceImpl getSelf() {
@@ -113,15 +118,16 @@ public class ImChannelMessageServiceImpl implements ImChannelMessageService {
String payloadJson = JsonUtils.toJsonString(payload);
// 2.2 落库 1 行 message;reqVO 同名字段(materialId / receiverUserIds)自动拷贝,剩余字段补 set
ImChannelMessageDO message = BeanUtils.toBean(reqVO, ImChannelMessageDO.class).setChannelId(material.getChannelId())
.setType(ImMessageTypeEnum.MATERIAL.getType()).setContent(payloadJson).setSendTime(LocalDateTime.now());
.setType(ImContentTypeEnum.MATERIAL.getType()).setContent(payloadJson).setSendTime(LocalDateTime.now());
channelMessageMapper.insert(message);
// 3. 异步推 WebSocket:指定用户走点对点;全员(receiverUserIds 为空)走广播
ImChannelMessageDTO dto = ImChannelMessageDTO.ofSend(message);
ImChannelMessageNotification dto = ImChannelMessageNotification.ofSend(message);
if (CollUtil.isNotEmpty(reqVO.getReceiverUserIds())) {
webSocketService.sendChannelMessageAsync(reqVO.getReceiverUserIds(), dto);
webSocketService.sendNotificationAsync(reqVO.getReceiverUserIds(), ImConversationTypeEnum.CHANNEL.getType(),
dto.getType(), dto);
} else {
webSocketService.broadcastChannelMessageAsync(dto);
webSocketService.broadcastNotificationAsync(ImConversationTypeEnum.CHANNEL.getType(), dto.getType(), dto);
}
return message.getId();
}
@@ -98,35 +98,6 @@ public interface ImGroupMessageService {
*/
List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO);
/**
* 清理用户在某群的已读位置缓存
* <p>
* 用于成员退群场景
*
* @param groupId 群编号
* @param userId 用户编号
*/
void deleteReadMaxMessageId(Long groupId, Long userId);
/**
* 批量清理用户在某群的已读位置缓存
* <p>
* 用于批量踢出场景
*
* @param groupId 群编号
* @param userIds 用户编号集合
*/
void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds);
/**
* 清理某群所有用户的已读位置缓存
* <p>
* 用于群解散场景
*
* @param groupId 群编号
*/
void deleteReadMaxMessageIdMap(Long groupId);
// ==================== 管理后台 ====================
/**
@@ -6,7 +6,6 @@ import cn.hutool.core.util.BooleanUtil;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
@@ -17,23 +16,27 @@ import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupDO;
import cn.iocoder.yudao.module.im.dal.dataobject.group.ImGroupMemberDO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImGroupMessageMapper;
import cn.iocoder.yudao.module.im.dal.redis.message.ImGroupMessageReadRedisDAO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.group.ImGroupMemberRoleEnum;
import cn.iocoder.yudao.module.im.enums.message.ImGroupMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.group.ImGroupMemberService;
import cn.iocoder.yudao.module.im.service.group.ImGroupService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImGroupMessageNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReadNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReceiptNotification;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.QuoteMessage;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
import cn.iocoder.yudao.module.im.util.ImMessageUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -56,17 +59,8 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
@Slf4j
public class ImGroupMessageServiceImpl implements ImGroupMessageService {
/**
* 仅用于规避群消息 pull 的"假空页":
* 首批消息可能因入群时间或定向接收过滤后变成空列表,但后续更大的 id 仍然存在可见消息。
* 因此仅在过滤结果为空时,按本轮消息最大 id 向后再试几次。
*/
private static final int PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES = 3;
@Resource
private ImGroupMessageMapper groupMessageMapper;
@Resource
private ImGroupMessageReadRedisDAO groupMessageReadRedisDAO;
@Resource
private ImGroupService groupService;
@@ -74,6 +68,8 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
private ImGroupMemberService groupMemberService;
@Resource
private ImSensitiveWordService sensitiveWordService;
@Resource
private ImConversationReadService conversationReadService;
@Resource
private ImWebSocketService imWebSocketService;
@@ -99,34 +95,44 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
// 1.4 禁言校验
validateMuteStatus(group, senderMember);
// 1.5 文本消息敏感词过滤
if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
if (ImContentTypeEnum.TEXT.getType().equals(reqVO.getType())) {
sensitiveWordService.validateText(reqVO.getContent());
}
// 2.1 引用 quote 消息规范化
reqVO.setContent(normalizeQuoteContent(reqVO, senderMember));
// 2.2 构建并保存消息
// 2.1 固化发送当时可见成员快照:用户发送均为全员广播;getReceiverUserIds 兜底纳入发送者,钉死发送者必可见
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(reqVO.getGroupId());
Set<Long> receiverUserIds = getReceiverUserIds(null, senderId, memberUserIds);
// 2.2 引用 quote 消息规范化(含可见性子集校验,防止定向消息内容被广播引用泄漏)
reqVO.setContent(normalizeQuoteContent(reqVO, senderMember, receiverUserIds));
// 2.3 构建并保存消息;唯一键冲突时回查已存在消息返回
ImGroupMessageDO message = BeanUtils.toBean(reqVO, ImGroupMessageDO.class, m -> m
.setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
.setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now())
.setReceiverUserIds(new ArrayList<>(receiverUserIds))
.setReceiptStatus(resolveReceiptStatus(reqVO.getReceipt())));
groupMessageMapper.insert(message);
try {
groupMessageMapper.insert(message);
} catch (DuplicateKeyException e) {
log.warn("[sendGroupMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]",
senderId, reqVO.getClientMessageId());
return groupMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId());
}
// 3. WebSocket 异步推送(群内可见成员 + 发送方多端同步)
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(message.getGroupId());
Set<Long> targetUserIds = getVisibleUserIds(message.getReceiverUserIds(), senderId, memberUserIds);
imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
// 3. WebSocket 异步推送(快照内可见成员 + 发送方多端同步)
ImGroupMessageNotification notification = ImGroupMessageNotification.ofSend(message);
imWebSocketService.sendNotificationAsync(receiverUserIds, ImConversationTypeEnum.GROUP.getType(),
notification.getType(), notification);
return message;
}
@Override
public ImGroupMessageDO sendGroupMessage(Long senderId, ImGroupMessageSendDTO dto) {
List<Long> memberUserIds = groupMemberService.getActiveGroupMemberUserIdsByGroupId(dto.getGroupId());
Set<Long> targetUserIds = getVisibleUserIds(dto.getReceiverUserIds(), senderId, memberUserIds);
return sendGroupMessage(senderId, targetUserIds, dto);
Set<Long> receiverUserIds = getReceiverUserIds(dto.getReceiverUserIds(), senderId, memberUserIds);
return sendGroupMessage(senderId, receiverUserIds, dto);
}
@Override
public ImGroupMessageDO sendGroupMessage(Long senderId, Collection<Long> targetUserIds, ImGroupMessageSendDTO dto) {
public ImGroupMessageDO sendGroupMessage(Long senderId, Collection<Long> receiverUserIds, ImGroupMessageSendDTO dto) {
// 1.1 content 序列化:null / String 透传,POJO 走 JSON
Object payload = dto.getContent();
String contentString = payload == null || payload instanceof String
@@ -136,16 +142,18 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
ImGroupMessageDO message = new ImGroupMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
.setSenderId(senderId).setGroupId(dto.getGroupId())
.setType(dto.getType()).setContent(contentString)
.setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now())
.setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(dto.getReceiverUserIds())
.setStatus(ImMessageStatusEnum.NORMAL.getStatus()).setSendTime(LocalDateTime.now())
.setAtUserIds(dto.getAtUserIds()).setReceiverUserIds(new ArrayList<>(receiverUserIds))
.setReceiptStatus(resolveReceiptStatus(dto.getReceipt()));
// 1.3 按 type.persistent 决定是否入库
if (ImMessageTypeEnum.validate(dto.getType()).isPersistent()) {
if (ImContentTypeEnum.validate(dto.getType()).isPersistent()) {
groupMessageMapper.insert(message);
}
// 2. WebSocket 异步推送
imWebSocketService.sendGroupMessageAsync(targetUserIds, ImGroupMessageDTO.ofSend(message));
ImGroupMessageNotification notification = ImGroupMessageNotification.ofSend(message);
imWebSocketService.sendNotificationAsync(receiverUserIds, ImConversationTypeEnum.GROUP.getType(),
notification.getType(), notification);
return message;
}
@@ -179,7 +187,7 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
// 3. 发送撤回事件
return sendGroupMessage(userId, new ImGroupMessageSendDTO().setGroupId(message.getGroupId())
.setType(ImMessageTypeEnum.RECALL.getType())
.setType(ImContentTypeEnum.RECALL.getType())
.setReceiverUserIds(message.getReceiverUserIds())
.setContent(new RecallMessage().setMessageId(messageId)));
}
@@ -194,155 +202,53 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
// 0. 拉取时间窗;超过窗口的老消息不再通过离线通道推送
LocalDateTime minSendTime = LocalDateTime.now().minusDays(imProperties.getMessage().getGroupPullMaxDays());
// 1.1 主查询:仅用"当前仍在群"的成员记录驱动;若首批消息过滤后为空,则允许内部重试
List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByUserId(userId);
Map<Long, ImGroupMemberDO> memberMap = convertMap(activeMembers, ImGroupMemberDO::getGroupId);
List<ImGroupMessageDO> messages = new ArrayList<>();
if (CollUtil.isNotEmpty(activeMembers)) {
List<Long> groupIds = convertList(activeMembers, ImGroupMemberDO::getGroupId);
messages.addAll(pullActiveGroupMessageList(userId, groupIds, minId, size, minSendTime, memberMap));
// 1. 候选群 = 用户曾经加入的所有群(含退群);可见性与时间窗都交给 SQL,退群群最多贡献 0 条
Set<Long> groupIds = convertSet(groupMemberService.getGroupMemberListByUserId(userId),
ImGroupMemberDO::getGroupId);
if (CollUtil.isEmpty(groupIds)) {
return Collections.emptyList();
}
// 1.2 补齐"退群前"的消息:
// - 继续基于用户原始 minId 单次查询,不能被主查询内部探测游标带着前进;
// - 若 minId > 0 且能查到对应消息,则进一步把下限抬到该消息的 sendTime,避免把客户端已拥有的老消息再次推送。
messages.addAll(pullQuitGroupMessageList(userId, minId, size, minSendTime, memberMap));
// 2. 合并后统一过滤,得到当前用户可见的结果
List<ImGroupMessageDO> result = filterGroupMessageList(messages, memberMap, userId, size);
// 2. 按 receiver_user_ids 快照过滤可见性,结果按 id 升序
List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(userId, groupIds, minId, minSendTime, size);
// 3. 按当前用户补齐:消息已读态(相对 Redis 已读游标)、本人发送的回执消息的已读人数
appendMessageStatusAndReceipt(userId, result);
log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, result.size());
return result;
}
/**
* 拉取当前仍在群的主路径消息。
*
* 仅当首批消息过滤后仍无可见消息时,才按消息最大 id 继续向后探测,
* 直到命中可见消息或确认该来源已耗尽。
*/
private List<ImGroupMessageDO> pullActiveGroupMessageList(Long userId, List<Long> groupIds, Long minId,
Integer size, LocalDateTime minSendTime,
Map<Long, ImGroupMemberDO> memberMap) {
// 1. 主查询内部探测游标:仅用于向后探测,不代表客户端真实已送达边界
Long activeMinId = minId;
for (int retryCount = 0; retryCount <= PULL_GROUP_MESSAGE_EMPTY_RETRY_TIMES; retryCount++) {
// 2. 查询本轮消息;若已无更多消息,则当前主路径直接结束
List<ImGroupMessageDO> messages = groupMessageMapper.selectListByMinId(groupIds, activeMinId,
minSendTime, size);
if (CollUtil.isEmpty(messages)) {
return Collections.emptyList();
}
boolean hasVisibleMessage = CollUtil.anyMatch(messages,
message -> isMessageVisible(message, memberMap.get(message.getGroupId()), userId));
boolean sourceExhausted = messages.size() < size;
// 3. 本轮已命中可见消息,或主查询来源已耗尽,直接返回这一轮消息
if (hasVisibleMessage || sourceExhausted) {
return messages;
}
// 4. 按本轮消息最大 id 推进内部游标,跳过这段不可见区间;若游标未前进则直接停止
Long maxMessageId = getMaxValue(messages, ImGroupMessageDO::getId);
if (maxMessageId == null || maxMessageId <= activeMinId) {
return Collections.emptyList();
}
activeMinId = maxMessageId;
}
return Collections.emptyList();
}
/**
* 拉取已离开群路径的消息(退群前消息)
*/
private List<ImGroupMessageDO> pullQuitGroupMessageList(Long userId, Long minId, Integer size,
LocalDateTime minSendTime,
Map<Long, ImGroupMemberDO> memberMap) {
// 1. 退群补齐始终基于用户原始 minId 计算时间边界,避免被主查询内部重试游标误伤
LocalDateTime minQuitTime = minSendTime;
if (minId != null && minId > 0) {
ImGroupMessageDO minMessage = groupMessageMapper.selectById(minId);
if (minMessage != null && minMessage.getSendTime() != null
&& minMessage.getSendTime().isAfter(minSendTime)) {
minQuitTime = minMessage.getSendTime();
}
}
// 2. 查询用户离开的群记录;若原始 minId 对应消息仍在窗口内,则用它的发送时间抬升退群筛选下限
List<ImGroupMessageDO> messages = new ArrayList<>();
List<ImGroupMemberDO> quitMembers = groupMemberService.getQuitGroupMemberListByUserId(userId, minQuitTime);
for (ImGroupMemberDO quitMember : quitMembers) {
// 3. 按原始 minId + 退群时间补齐该群退群前消息,并把成员记录写回 memberMap 供统一可见性过滤使用
List<ImGroupMessageDO> quitGroupMessages = groupMessageMapper.selectListByGroupIdAndMinIdAndQuitTimeBefore(
quitMember.getGroupId(), minId, minSendTime, quitMember.getQuitTime(), size);
if (CollUtil.isEmpty(quitGroupMessages)) {
continue;
}
messages.addAll(quitGroupMessages);
memberMap.put(quitMember.getGroupId(), quitMember);
}
// 3. 补齐本人发送的回执消息的已读人数
appendMessageReceipt(userId, messages);
log.info("[pullGroupMessageList][userId({}) minId({}) size({}) result({})]", userId, minId, size, messages.size());
return messages;
}
/**
* 过滤一批原始群消息,得到当前用户可见的返回结果
* 补全本人发送消息的回执已读人数(readCount)
* <p>
* 仅对本人发送、且需要回执(非 NO_RECEIPT)的消息,按 receiver_user_ids 快照 ∩ 当前有效成员 - 发送者 算已读人数
*/
private List<ImGroupMessageDO> filterGroupMessageList(List<ImGroupMessageDO> messages,
Map<Long, ImGroupMemberDO> memberMap,
Long userId, Integer size) {
// 按可见性过滤(入群前不可见、定向消息排除),按 id 升序后仅取本页 size 条,
// 避免「在群 + 退群前」多路合并时一次响应跨度过大、游标直接跳到全局最大 id 而漏拉中间消息
return messages.stream()
.filter(msg -> isMessageVisible(msg, memberMap.get(msg.getGroupId()), userId))
.sorted(Comparator.comparing(ImGroupMessageDO::getId))
.limit(size)
.toList();
}
/**
* 补全消息已读态和回执已读人数
*
* 1. 消息已读态(status):根据 Redis 已读游标判断 READ / UNREAD
* 2. 回执已读人数(readCount):仅对本人发送的回执消息,计算可见成员中的已读人数
*/
@SuppressWarnings({"StatementWithEmptyBody", "DataFlowIssue"})
private void appendMessageStatusAndReceipt(Long userId, List<ImGroupMessageDO> messages) {
@SuppressWarnings("DataFlowIssue")
private void appendMessageReceipt(Long userId, List<ImGroupMessageDO> messages) {
if (CollUtil.isEmpty(messages)) {
return;
}
// 群已读关闭:不查 Redis 已读游标,status 保持 DB 原值(含撤回),readCount 不补齐
// 群已读关闭:不补 readCount
if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
return;
}
Map<Long, Long> readMaxMessageIdsByGroup = new HashMap<>(); // 群 → 已读位置
Map<Long, Map<Long, Long>> readPositionsByGroup = new HashMap<>(); // 群 → (用户 → 已读位置)
Map<Long, List<ImGroupMemberDO>> membersByGroup = new HashMap<>(); // 群 → 全部成员列表
for (ImGroupMessageDO message : messages) {
// 消息已读态(status):撤回 > 已读 > 未读
Long groupId = message.getGroupId();
long readMaxMessageId = readMaxMessageIdsByGroup.computeIfAbsent(groupId, gid -> {
Long readMaxMsgId = groupMessageReadRedisDAO.getReadMaxMessageId(gid, userId);
return readMaxMsgId != null ? readMaxMsgId : -1L;
});
if (ImMessageStatusEnum.RECALL.getStatus().equals(message.getStatus())) {
// 保持撤回态
} else if (readMaxMessageId >= message.getId()) {
message.setStatus(ImMessageStatusEnum.READ.getStatus());
} else {
message.setStatus(ImMessageStatusEnum.UNREAD.getStatus());
}
// 回执消息的已读人数(readCount):仅补齐本人发送的,其他消息不处理(回执消息才关心已读人数,且只对发送者可见)
// 仅补本人发送、且需要回执(非 NO_RECEIPT)的消息
if (ObjUtil.notEqual(message.getSenderId(), userId)
|| ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
|| ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus().equals(message.getReceiptStatus())) {
continue;
}
Long groupId = message.getGroupId();
Map<Long, Long> positions = readPositionsByGroup.computeIfAbsent(groupId,
gid -> groupMessageReadRedisDAO.getReadMaxMessageIdMap(gid));
List<ImGroupMemberDO> allMembers = membersByGroup.computeIfAbsent(groupId,
groupMemberService::getGroupMemberListByGroupId);
Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers);
visibleUserIds.remove(message.getSenderId());
int readCount = getSumValue(visibleUserIds,
gid -> conversationReadService.getUserReadMessageIdMap(ImConversationTypeEnum.GROUP.getType(), gid));
// 应读分母取当前有效成员(剔除已退群),与异步回执刷新、前端已读弹层口径一致
List<ImGroupMemberDO> activeMembers = membersByGroup.computeIfAbsent(groupId,
groupMemberService::getActiveGroupMemberListByGroupId);
Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
receiverUserIds.remove(message.getSenderId());
int readCount = getSumValue(receiverUserIds,
uid -> positions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
Integer::sum, 0);
message.setReadCount(readCount);
@@ -356,25 +262,24 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
throw exception(MESSAGE_GROUP_READ_DISABLED);
}
Assert.notNull(messageId, "已读消息编号不能为空");
// 1.1 校验用户在群中(权限校验)
ImGroupMemberDO member = groupMemberService.validateMemberInGroup(groupId, userId);
// 1.2 校验消息属于当前群,且对当前用户可见
// 1. 校验消息属于当前群,且对当前用户可见(按快照)
ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
if (message == null
|| ObjUtil.notEqual(message.getGroupId(), groupId)
|| !isMessageVisible(message, member, userId)) {
|| !isMessageReceived(message, userId)) {
throw exception(MESSAGE_NOT_IN_GROUP);
}
// 2. 已读位置未前进,直接返回
Long prevMaxMessageId = groupMessageReadRedisDAO.getReadMaxMessageId(groupId, userId);
if (prevMaxMessageId != null && prevMaxMessageId >= messageId) {
// 2. 取旧读位置(用于回执刷新区间),再更新群已读位置;读位置未前进则不推
Long prevMaxMessageId = conversationReadService.getConversationReadMessageId(
userId, ImConversationTypeEnum.GROUP.getType(), groupId);
boolean advanced = conversationReadService.updateConversationReadPosition(
userId, ImConversationTypeEnum.GROUP.getType(), groupId, messageId);
if (!advanced) {
return;
}
// 3. 更新 Redis 群已读位置
groupMessageReadRedisDAO.updateReadMaxMessageId(groupId, userId, messageId);
// 4. 异步发送 READ 事件 + 刷新范围内的群回执
// 3. 异步发送 READ 事件 + 刷新范围内的群回执
getSelf().readGroupMessageEvent(userId, groupId, prevMaxMessageId, messageId);
}
@@ -385,13 +290,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
throw exception(MESSAGE_GROUP_READ_DISABLED);
}
// 1.1 校验用户在群中(权限校验)
ImGroupMemberDO operator = groupMemberService.validateMemberInGroup(groupId, userId);
groupMemberService.validateMemberInGroup(groupId, userId);
// 1.2 获取消息;并校验消息归属于该群、对调用者可见、调用者是发送方
ImGroupMessageDO message = groupMessageMapper.selectById(messageId);
if (message == null || ObjUtil.notEqual(message.getGroupId(), groupId)) {
return Collections.emptyList();
}
if (!isMessageVisible(message, operator, userId)) {
if (!isMessageReceived(message, userId)) {
return Collections.emptyList();
}
// 1.3 仅消息发送方关心已读人数;非发送方查询直接返回空
@@ -399,18 +304,19 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
return Collections.emptyList();
}
// 2. 获取所有成员和已读位置
List<ImGroupMemberDO> allMembers = groupMemberService.getGroupMemberListByGroupId(groupId);
Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
// 2. 获取当前有效成员和已读位置(剔除已退群,与回执分母口径一致)
List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
Map<Long, Long> allPositions = conversationReadService.getUserReadMessageIdMap(
ImConversationTypeEnum.GROUP.getType(), groupId);
// 3. 计算该消息的可见成员集合(排除发送者自己)
Set<Long> visibleUserIds = getVisibleUserIds(message, allMembers);
visibleUserIds.remove(message.getSenderId());
Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
receiverUserIds.remove(message.getSenderId());
// 4. 只返回在可见范围内且已读位置 >= messageId 的用户
List<Long> readUserIds = new ArrayList<>();
allPositions.forEach((uid, readMaxMessageId) -> {
if (visibleUserIds.contains(uid) && readMaxMessageId >= messageId) {
if (receiverUserIds.contains(uid) && readMaxMessageId >= messageId) {
readUserIds.add(uid);
}
});
@@ -419,15 +325,14 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
@Override
public List<ImGroupMessageDO> getGroupMessageList(Long userId, ImGroupMessageListReqVO reqVO) {
// 1. 校验用户在群中
ImGroupMemberDO member = groupMemberService.validateMemberInGroup(reqVO.getGroupId(), userId);
// 1. 校验用户曾经在群(当前在群或已退群),与 pull 退群窗口口径一致;内容仍由 receiver_user_ids 快照过滤
if (groupMemberService.getGroupMember(reqVO.getGroupId(), userId) == null) {
throw exception(GROUP_MEMBER_NOT_IN_GROUP);
}
// 2. 查询历史消息(仅入群之后)
List<ImGroupMessageDO> messages = groupMessageMapper.selectHistoryList(
reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit(), member.getJoinTime());
// 3. 过滤定向消息:仅保留当前用户可见的(receiverUserIds 为空 / 含当前用户 / 本人发送)
return filterList(messages, message -> isMessageVisible(message, member, userId));
// 2. 查询历史消息:SQL 已按 receiver_user_ids 快照过滤当前用户可见
return groupMessageMapper.selectHistoryListByUser(
userId, reqVO.getGroupId(), reqVO.getMaxId(), reqVO.getLimit());
}
// ========== 异步 WebSocket 推送 ==========
@@ -444,8 +349,9 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
@SuppressWarnings("DataFlowIssue")
public void readGroupMessageEvent(Long userId, Long groupId, Long prevMaxMessageId, Long newMaxMessageId) {
// 1. 发送 READ 事件给自己的其他终端(多端同步)
imWebSocketService.sendGroupMessageAsync(userId,
ImGroupMessageDTO.ofRead(userId, groupId, newMaxMessageId));
imWebSocketService.sendNotificationAsync(userId, ImConversationTypeEnum.GROUP.getType(),
ImContentTypeEnum.READ.getType(),
ImMessageReadNotification.ofGroup(userId, groupId, newMaxMessageId));
// 2. 刷新 (prevMaxMessageId, newMaxMessageId] 区间内的待回执消息
List<ImGroupMessageDO> pendingMessages = groupMessageMapper.selectListByGroupIdAndPendingReceipt(
@@ -454,28 +360,30 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
return;
}
List<ImGroupMemberDO> activeMembers = groupMemberService.getActiveGroupMemberListByGroupId(groupId);
Map<Long, Long> allPositions = groupMessageReadRedisDAO.getReadMaxMessageIdMap(groupId);
Map<Long, Long> allPositions = conversationReadService.getUserReadMessageIdMap(
ImConversationTypeEnum.GROUP.getType(), groupId);
for (ImGroupMessageDO message : pendingMessages) {
// 2.1.1 统计可见成员中的已读人数
Set<Long> visibleUserIds = getVisibleUserIds(message, activeMembers);
visibleUserIds.remove(message.getSenderId()); // 发送者自己不算已读
if (CollUtil.isEmpty(visibleUserIds)) {
// 2.1.1 统计可见成员中的已读人数(应读分母 = 快照 ∩ 当前有效成员 - sender)
Set<Long> receiverUserIds = getReceiverUserIds(message, activeMembers);
receiverUserIds.remove(message.getSenderId()); // 发送者自己不算已读
if (CollUtil.isEmpty(receiverUserIds)) {
continue;
}
int readCount = getSumValue(visibleUserIds,
int readCount = getSumValue(receiverUserIds,
uid -> allPositions.getOrDefault(uid, -1L) >= message.getId() ? 1 : null,
Integer::sum, 0);
// 2.1.2 全部已读 → 标记回执完成
Integer newReceiptStatus = ImGroupMessageReceiptStatusEnum.PENDING.getStatus();
if (readCount >= visibleUserIds.size()) {
newReceiptStatus = ImGroupMessageReceiptStatusEnum.DONE.getStatus();
Integer newReceiptStatus = ImMessageReceiptStatusEnum.PENDING.getStatus();
if (readCount >= receiverUserIds.size()) {
newReceiptStatus = ImMessageReceiptStatusEnum.DONE.getStatus();
groupMessageMapper.updateById(new ImGroupMessageDO().setId(message.getId())
.setReceiptStatus(newReceiptStatus));
}
// 2.2 发送 RECEIPT 事件给消息发送方(只有 ta 关心已读进度)
imWebSocketService.sendGroupMessageAsync(message.getSenderId(),
ImGroupMessageDTO.ofReceipt(message.getId(), groupId, readCount, newReceiptStatus));
imWebSocketService.sendNotificationAsync(message.getSenderId(), ImConversationTypeEnum.GROUP.getType(),
ImContentTypeEnum.RECEIPT.getType(),
ImMessageReceiptNotification.ofGroup(message.getId(), groupId, readCount, newReceiptStatus));
}
}
@@ -486,9 +394,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
*
* @param reqVO 发送请求
* @param senderMember 发送人成员
* @param newAudience 新消息可见成员集合(发送当时快照)
* @return 规范化后的 content
*/
private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember) {
private String normalizeQuoteContent(ImGroupMessageSendReqVO reqVO, ImGroupMemberDO senderMember,
Set<Long> newAudience) {
// 解析客户端 content 里的 quote.messageId
Long quoteMessageId = ImMessageUtils.parseQuoteMessageId(reqVO.getContent());
@@ -507,12 +417,13 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
if (ObjUtil.notEqual(original.getGroupId(), reqVO.getGroupId())) {
throw exception(MESSAGE_QUOTE_INVALID);
}
// 拒绝定向消息(仅发送人可见的内容若被全员广播 quote.content,会泄漏给原本看不到的成员)
if (CollUtil.isNotEmpty(original.getReceiverUserIds())) {
// 校验对发送人可见(按快照)
if (!isMessageReceived(original, senderMember.getUserId())) {
throw exception(MESSAGE_QUOTE_INVALID);
}
// 校验对发送人可见(入群时间 / 退群时间)
if (!isMessageVisible(original, senderMember, senderMember.getUserId())) {
// 防泄漏:新消息可见集合必须是被引用消息可见集合的子集,否则禁止引用;
// 否则只对部分成员可见的定向消息内容,会随广播引用泄漏给原本看不到的成员
if (!new HashSet<>(original.getReceiverUserIds()).containsAll(newAudience)) {
throw exception(MESSAGE_QUOTE_INVALID);
}
// 构建 quote 对象并注入 content
@@ -536,87 +447,48 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
}
/**
* 判断一条群消息对某个群成员是否可见
*
* @param msg 消息
* @param member 群成员
* @param userId 当前用户编号(用于定向消息过滤)
* @return 是否可见
*/
private boolean isMessageVisible(ImGroupMessageDO msg, ImGroupMemberDO member, Long userId) {
if (member == null) {
return false;
}
// 1. 入群时间晚于消息发送时间 → 不可见
if (member.getJoinTime() != null && msg.getSendTime().isBefore(member.getJoinTime())) {
return false;
}
// 2. 已退群且退群时间早于消息发送时间 → 不可见
if (CommonStatusEnum.DISABLE.getStatus().equals(member.getStatus())
&& member.getQuitTime() != null && msg.getSendTime().isAfter(member.getQuitTime())) {
return false;
}
// 3.1 无定向接收列表 → 全员可见
if (CollUtil.isEmpty(msg.getReceiverUserIds())) {
return true;
}
// 3.2 当前用户在定向列表中,或本人即发送者 → 可见
return msg.getReceiverUserIds().contains(userId)
|| ObjUtil.equal(msg.getSenderId(), userId);
}
/**
* 计算一条群消息的可见成员集合(含发送者)
*/
private Set<Long> getVisibleUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
return convertSet(members, ImGroupMemberDO::getUserId,
member -> isMessageVisible(message, member, member.getUserId()));
}
/**
* 基于群成员 userId 列表,过滤出一条新消息的可见成员集合(含发送者)。
* 判断一条群消息是否被某个用户接收到
* <p>
* 仅适用于「新消息」推送场景({@code sendTime = now}),不涉及 joinTime / quitTime 判定,
* 只应用 {@code receiverUserIds} 定向过滤;语义与
* {@link #isMessageVisible(ImGroupMessageDO, ImGroupMemberDO, Long)} 的第 3 步保持一致。
* 接收范围以发送当时固化的 receiver_user_ids 快照为准;发送者也在快照内。
*
* @param message 消息
* @param userId 当前用户编号
* @return 是否接收到
*/
private Set<Long> getVisibleUserIds(List<Long> receiverUserIds, Long senderId, Collection<Long> memberUserIds) {
if (CollUtil.isEmpty(memberUserIds)) {
@SuppressWarnings("BooleanMethodIsAlwaysInverted")
private boolean isMessageReceived(ImGroupMessageDO message, Long userId) {
return CollUtil.contains(message.getReceiverUserIds(), userId);
}
/**
* 计算一条群消息的可见成员集合(快照与传入成员的交集,含发送者)
*/
private Set<Long> getReceiverUserIds(ImGroupMessageDO message, List<ImGroupMemberDO> members) {
if (CollUtil.isEmpty(message.getReceiverUserIds())) {
return new HashSet<>();
}
// 无定向接收列表 → 全员可见
if (CollUtil.isEmpty(receiverUserIds)) {
return new HashSet<>(memberUserIds);
}
// 有定向接收列表 → 仅定向用户可见;发送者自己也能看到自己的消息(多端同步)
Set<Long> allowed = new HashSet<>(receiverUserIds);
Set<Long> snapshot = new HashSet<>(message.getReceiverUserIds());
return convertSet(members, ImGroupMemberDO::getUserId, member -> snapshot.contains(member.getUserId()));
}
/**
* 基于群成员 userId 列表,计算一条新消息的可见成员快照(含发送者)。
* <p>
* 仅适用于「新消息」发送场景:无定向接收列表则全员可见,否则取定向用户与当前成员的交集;
* 发送者始终纳入快照,保证 receiver_user_ids 非空且发送方可见。
*/
private Set<Long> getReceiverUserIds(List<Long> directedUserIds, Long senderId, Collection<Long> memberUserIds) {
// 无定向接收列表 → 全员可见;否则取定向用户与当前成员的交集
Set<Long> result = CollUtil.isEmpty(directedUserIds)
? new HashSet<>(memberUserIds)
: new HashSet<>(CollUtil.intersection(memberUserIds, directedUserIds));
// 发送者始终可见(多端同步),即便成员缓存暂未包含
if (senderId != null) {
allowed.add(senderId);
}
Set<Long> result = new HashSet<>();
for (Long userId : memberUserIds) {
if (allowed.contains(userId)) {
result.add(userId);
}
result.add(senderId);
}
return result;
}
@Override
public void deleteReadMaxMessageId(Long groupId, Long userId) {
groupMessageReadRedisDAO.deleteReadMaxMessageId(groupId, userId);
}
@Override
public void deleteReadMaxMessageIds(Long groupId, Collection<Long> userIds) {
groupMessageReadRedisDAO.deleteReadMaxMessageIds(groupId, userIds);
}
@Override
public void deleteReadMaxMessageIdMap(Long groupId) {
groupMessageReadRedisDAO.deleteReadMaxMessageIdMap(groupId);
}
// ==================== 管理后台 ====================
@Override
@@ -651,11 +523,11 @@ public class ImGroupMessageServiceImpl implements ImGroupMessageService {
*/
private Integer resolveReceiptStatus(Boolean receipt) {
if (BooleanUtil.isFalse(imProperties.getMessage().isGroupReadEnabled())) {
return ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
return BooleanUtil.isTrue(receipt)
? ImGroupMessageReceiptStatusEnum.PENDING.getStatus()
: ImGroupMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
? ImMessageReceiptStatusEnum.PENDING.getStatus()
: ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
/**
@@ -59,8 +59,8 @@ public interface ImPrivateMessageService {
/**
* 标记私聊消息已读
* <p>
* 语义:将「对方发给当前用户、id <= messageId 的未读消息」一次性翻转为已读,
* 与群聊 readGroupMessages 对称,避免"select-then-update"两步式带来的竞态。
* 语义:把「对方发给当前用户、id <= messageId 的待回执消息」一次性更新为已完成(DONE)并前进读位置,
* 与群聊 readGroupMessages 对称,避免「select-then-update」两步式带来的竞态。
*
* @param userId 当前用户编号
* @param receiverId 接收方用户编号(对方)
@@ -72,7 +72,7 @@ public interface ImPrivateMessageService {
* 查询对方已读到我发的最大消息 id
* <p>
* 用于多端 / 离线场景下的已读位置补齐:客户端进入会话或断线重连后,
* 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 翻转本地自发消息为已读,弥补离线期间错过的 RECEIPT 推送事件。
* 调用此接口拿到对方的 maxReadId,再按 id <= maxReadId 更新本地自发消息的回执状态,弥补离线期间错过的 RECEIPT 推送事件。
*
* @param userId 当前用户编号
* @param peerId 对方用户编号
@@ -12,19 +12,25 @@ import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivate
import cn.iocoder.yudao.module.im.controller.admin.message.vo.privates.ImPrivateMessageSendReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.dal.mysql.message.ImPrivateMessageMapper;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageReceiptStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageStatusEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.framework.config.ImProperties;
import cn.iocoder.yudao.module.im.service.conversation.ImConversationReadService;
import cn.iocoder.yudao.module.im.service.friend.ImFriendService;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.sensitiveword.ImSensitiveWordService;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.QuoteMessage;
import cn.iocoder.yudao.module.im.service.websocket.dto.message.RecallMessage;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReadNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImMessageReceiptNotification;
import cn.iocoder.yudao.module.im.service.websocket.notification.message.ImPrivateMessageNotification;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.QuoteMessage;
import cn.iocoder.yudao.module.im.dal.dataobject.message.content.RecallMessage;
import cn.iocoder.yudao.module.im.util.ImMessageUtils;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
@@ -52,6 +58,8 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
private ImFriendService friendService;
@Resource
private ImSensitiveWordService sensitiveWordService;
@Resource
private ImConversationReadService conversationReadService;
@Resource
private ImWebSocketService imWebSocketService;
@@ -74,21 +82,32 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
// 1.3 好友校验
friendService.validateFriend(senderId, reqVO.getReceiverId());
// 1.4 文本消息敏感词过滤
if (ImMessageTypeEnum.TEXT.getType().equals(reqVO.getType())) {
if (ImContentTypeEnum.TEXT.getType().equals(reqVO.getType())) {
sensitiveWordService.validateText(reqVO.getContent());
}
// 2.1 引用 quote 消息规范化
reqVO.setContent(normalizeQuoteContent(reqVO, senderId));
// 2.2 构建并保存消息
// 2.2 构建并保存消息;唯一键冲突时回查已存在消息返回
// 用户私聊消息默认需要回执(receipt 不传按 true);系统通知走 DTO 通道,默认不回执
Boolean receipt = reqVO.getReceipt() != null ? reqVO.getReceipt() : Boolean.TRUE;
ImPrivateMessageDO message = BeanUtils.toBean(reqVO, ImPrivateMessageDO.class, m -> m
.setSenderId(senderId).setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now()));
privateMessageMapper.insert(message);
.setSenderId(senderId).setStatus(ImMessageStatusEnum.NORMAL.getStatus())
.setReceiptStatus(resolveReceiptStatus(receipt)).setSendTime(LocalDateTime.now()));
try {
privateMessageMapper.insert(message);
} catch (DuplicateKeyException e) {
log.warn("[sendPrivateMessage][senderId({}) clientMessageId({}) 并发插入冲突,回查返回]",
senderId, reqVO.getClientMessageId());
return privateMessageMapper.selectBySenderIdAndClientMessageId(senderId, reqVO.getClientMessageId());
}
// 3. WebSocket 异步推送:接收方 + 发送方多端同步
ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
imWebSocketService.sendPrivateMessageAsync(message.getReceiverId(), websocketMessage);
imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage);
ImPrivateMessageNotification notification = ImPrivateMessageNotification.ofSend(message);
imWebSocketService.sendNotificationAsync(message.getReceiverId(), ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
imWebSocketService.sendNotificationAsync(senderId, ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
return message;
}
@@ -103,24 +122,39 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
ImPrivateMessageDO message = new ImPrivateMessageDO().setClientMessageId(IdUtil.fastSimpleUUID())
.setSenderId(senderId).setReceiverId(dto.getReceiverId())
.setType(dto.getType()).setContent(contentString)
.setStatus(ImMessageStatusEnum.UNREAD.getStatus()).setSendTime(LocalDateTime.now());
.setStatus(ImMessageStatusEnum.NORMAL.getStatus())
.setReceiptStatus(resolveReceiptStatus(dto.getReceipt())).setSendTime(LocalDateTime.now());
// 1.3 决定是否持久化:dto.persistent 优先;为 null 时按 type 默认
boolean persistent = dto.getPersistent() != null
? dto.getPersistent()
: ImMessageTypeEnum.validate(dto.getType()).isPersistent();
: ImContentTypeEnum.validate(dto.getType()).isPersistent();
if (persistent) {
privateMessageMapper.insert(message);
}
// 2. WebSocket 异步推送:双向(默认);单边语义(persistent=false)下仅推 sender 多端,对方不感知
ImPrivateMessageDTO websocketMessage = ImPrivateMessageDTO.ofSend(message);
ImPrivateMessageNotification notification = ImPrivateMessageNotification.ofSend(message);
if (persistent) {
imWebSocketService.sendPrivateMessageAsync(dto.getReceiverId(), websocketMessage);
imWebSocketService.sendNotificationAsync(dto.getReceiverId(), ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
}
imWebSocketService.sendPrivateMessageAsync(senderId, websocketMessage);
imWebSocketService.sendNotificationAsync(senderId, ImConversationTypeEnum.PRIVATE.getType(),
notification.getType(), notification);
return message;
}
/**
* 计算私聊消息回执 status:私聊已读关闭时强制 NO_RECEIPT,忽略发送方传入的 receipt(receipt 为 null 等价 false)
*/
private Integer resolveReceiptStatus(Boolean receipt) {
if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) {
return ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
return BooleanUtil.isTrue(receipt)
? ImMessageReceiptStatusEnum.PENDING.getStatus()
: ImMessageReceiptStatusEnum.NO_RECEIPT.getStatus();
}
@Override
@Transactional(rollbackFor = Exception.class)
public ImPrivateMessageDO recallPrivateMessage(Long userId, Long messageId) {
@@ -149,7 +183,7 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
// 3. 发送撤回事件
return sendPrivateMessage(userId, new ImPrivateMessageSendDTO().setReceiverId(message.getReceiverId())
.setType(ImMessageTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId)));
.setType(ImContentTypeEnum.RECALL.getType()).setContent(new RecallMessage().setMessageId(messageId)));
}
/**
@@ -211,20 +245,26 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
throw exception(MESSAGE_PRIVATE_READ_DISABLED);
}
Assert.notNull(messageId, "已读消息编号不能为空");
// 2. 把 (receiverId → userId) 这条会话上、id <= messageId 的未读消息一步更新为已读
// 仅 UNREAD 行被命中,避免覆盖已撤回/已读的状态;select-then-update 合成单条 SQL 后也消除了竞态窗口
int updated = privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndStatus(
receiverId, userId, messageId, ImMessageStatusEnum.UNREAD.getStatus(),
new ImPrivateMessageDO().setStatus(ImMessageStatusEnum.READ.getStatus()));
if (updated == 0) {
// 2. 回执置 DONE:把 (receiverId → userId) 上 id <= messageId、待回执(PENDING) 的消息标记为已完成
// status 不再表达已读(保持 NORMAL);只翻 PENDING 行,避免覆盖 NO_RECEIPT / 已 DONE
privateMessageMapper.updateBySenderIdAndReceiverIdAndIdLeAndReceiptStatus(
receiverId, userId, messageId, ImMessageReceiptStatusEnum.PENDING.getStatus(),
new ImPrivateMessageDO().setReceiptStatus(ImMessageReceiptStatusEnum.DONE.getStatus()));
// 3. 同步写 im_conversation_read(读位置唯一权威,单调递增);读位置前进才下发事件
boolean advanced = conversationReadService.updateConversationReadPosition(
userId, ImConversationTypeEnum.PRIVATE.getType(), receiverId, messageId);
if (!advanced) {
return;
}
// 3. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
imWebSocketService.sendPrivateMessageAsync(userId,
ImPrivateMessageDTO.ofRead(userId, receiverId, messageId));
imWebSocketService.sendPrivateMessageAsync(receiverId,
ImPrivateMessageDTO.ofReceipt(userId, receiverId, messageId));
// 4. 异步发送 READ + RECEIPT 事件(已读位置以前端上报为准,与多端 / 对方 UI 显示一致)
imWebSocketService.sendNotificationAsync(userId, ImConversationTypeEnum.PRIVATE.getType(),
ImContentTypeEnum.READ.getType(), ImMessageReadNotification.ofPrivate(userId, receiverId, messageId));
imWebSocketService.sendNotificationAsync(receiverId, ImConversationTypeEnum.PRIVATE.getType(),
ImContentTypeEnum.RECEIPT.getType(),
ImMessageReceiptNotification.ofPrivate(userId, receiverId, messageId));
}
@Override
@@ -232,8 +272,9 @@ public class ImPrivateMessageServiceImpl implements ImPrivateMessageService {
if (BooleanUtil.isFalse(imProperties.getMessage().isPrivateReadEnabled())) {
throw exception(MESSAGE_PRIVATE_READ_DISABLED);
}
return privateMessageMapper.selectMaxIdBySenderIdAndReceiverIdAndStatus(
userId, peerId, ImMessageStatusEnum.READ.getStatus());
// 对端 peer 在「与 userId 的会话」里的读位置 = peer 把 userId 发的消息读到哪
return conversationReadService.getConversationReadMessageId(
peerId, ImConversationTypeEnum.PRIVATE.getType(), userId);
}
@Override
@@ -1,8 +1,8 @@
package cn.iocoder.yudao.module.im.service.message.dto;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.*;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.notification.group.*;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@@ -26,7 +26,7 @@ public class ImGroupMessageSendDTO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
@NotNull(message = "消息类型不能为空")
private Integer type;
@@ -53,30 +53,34 @@ public class ImGroupMessageSendDTO {
*/
private Boolean receipt;
// ========== 群广播事件静态工厂(对应 ImMessageTypeEnum 群事件) ==========
// ========== 群广播事件静态工厂(对应 ImContentTypeEnum 群事件) ==========
public static ImGroupMessageSendDTO ofGroupCreate(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
GroupCreateNotification notification = new GroupCreateNotification();
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_CREATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_CREATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupInfoUpdate(Long groupId, Long operatorUserId, String oldAvatar, String newAvatar) {
public static ImGroupMessageSendDTO ofGroupInfoUpdate(Long groupId, Long operatorUserId,
String oldAvatar, String newAvatar,
Boolean oldJoinApproval, Boolean newJoinApproval) {
GroupInfoUpdateNotification notification = new GroupInfoUpdateNotification();
notification.setOperatorUserId(operatorUserId);
notification.setOldAvatar(oldAvatar);
notification.setNewAvatar(newAvatar);
notification.setOldJoinApproval(oldJoinApproval);
notification.setNewJoinApproval(newJoinApproval);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_INFO_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_INFO_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberQuit(Long groupId, Long operatorUserId) {
GroupMemberQuitNotification notification = new GroupMemberQuitNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_QUIT.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_QUIT.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupOwnerTransfer(Long groupId, Long operatorUserId, Long newOwnerUserId) {
@@ -84,7 +88,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setNewOwnerUserId(newOwnerUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_OWNER_TRANSFER.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_OWNER_TRANSFER.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberKick(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@@ -92,7 +96,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_KICK.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_KICK.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberInvite(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@@ -100,7 +104,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_INVITE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_INVITE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberEnter(Long groupId, Long entrantUserId, Integer addSource) {
@@ -109,14 +113,14 @@ public class ImGroupMessageSendDTO {
notification.setEntrantUserId(entrantUserId);
notification.setAddSource(addSource);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_ENTER.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_ENTER.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupDissolve(Long groupId, Long operatorUserId) {
GroupDissolveNotification notification = new GroupDissolveNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_DISSOLVE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_DISSOLVE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberNicknameUpdate(Long groupId, Long operatorUserId, String displayUserName) {
@@ -124,7 +128,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setDisplayUserName(displayUserName);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_NICKNAME_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_NICKNAME_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupAdminAdd(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@@ -132,7 +136,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_ADMIN_ADD.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_ADMIN_ADD.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupAdminRemove(Long groupId, Long operatorUserId, Collection<Long> memberUserIds) {
@@ -140,7 +144,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMemberUserIds(new ArrayList<>(memberUserIds));
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_ADMIN_REMOVE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_ADMIN_REMOVE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupNoticeUpdate(Long groupId, Long operatorUserId, String oldNotice, String newNotice) {
@@ -148,7 +152,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setOldNotice(oldNotice).setNewNotice(newNotice);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_NOTICE_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_NOTICE_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupNameUpdate(Long groupId, Long operatorUserId, String oldName, String newName) {
@@ -156,7 +160,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setOldName(oldName).setNewName(newName);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_NAME_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_NAME_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberSettingUpdate(Long groupId, Long operatorUserId, Boolean silent, String groupRemark) {
@@ -164,7 +168,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setSilent(silent).setGroupRemark(groupRemark);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_SETTING_UPDATE.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMessagePin(Long groupId, Long operatorUserId, ImGroupMessageDO message) {
@@ -176,7 +180,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMessageId(message.getId()).setMessage(pinnedMessage);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MESSAGE_PIN.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMessageUnpin(Long groupId, Long operatorUserId, Long messageId) {
@@ -184,7 +188,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMessageId(messageId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MESSAGE_UNPIN.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MESSAGE_UNPIN.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberMuted(Long groupId, Long operatorUserId,
@@ -195,7 +199,7 @@ public class ImGroupMessageSendDTO {
notification.setMutedUserId(mutedUserId);
notification.setMuteEndTime(muteEndTime);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMemberCancelMuted(Long groupId, Long operatorUserId, Long mutedUserId) {
@@ -203,21 +207,21 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setMutedUserId(mutedUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MEMBER_CANCEL_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MEMBER_CANCEL_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupMuted(Long groupId, Long operatorUserId) {
GroupMutedNotification notification = new GroupMutedNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupCancelMuted(Long groupId, Long operatorUserId) {
GroupCancelMutedNotification notification = new GroupCancelMutedNotification();
notification.setOperatorUserId(operatorUserId);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_CANCEL_MUTED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_CANCEL_MUTED.getType()).setContent(notification);
}
public static ImGroupMessageSendDTO ofGroupBanned(Long groupId, Long operatorUserId, boolean banned) {
@@ -225,7 +229,7 @@ public class ImGroupMessageSendDTO {
notification.setOperatorUserId(operatorUserId);
notification.setBanned(banned);
return new ImGroupMessageSendDTO().setGroupId(groupId)
.setType(ImMessageTypeEnum.GROUP_BANNED.getType()).setContent(notification);
.setType(ImContentTypeEnum.GROUP_BANNED.getType()).setContent(notification);
}
}
@@ -1,6 +1,6 @@
package cn.iocoder.yudao.module.im.service.message.dto;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import lombok.Data;
/**
@@ -18,7 +18,7 @@ public class ImPrivateMessageSendDTO {
/**
* 消息类型
* <p>
* 枚举 {@link ImMessageTypeEnum}
* 枚举 {@link ImContentTypeEnum}
*/
private Integer type;
/**
@@ -30,10 +30,14 @@ public class ImPrivateMessageSendDTO {
/**
* 是否持久化 + 推送给接收方(单边语义开关)
* <p>
* null:默认按 {@link ImMessageTypeEnum#isPersistent()} 决定是否入库 + 双向 WS 推送(保持原行为)<br>
* null:默认按 {@link ImContentTypeEnum#isPersistent()} 决定是否入库 + 双向 WS 推送(保持原行为)<br>
* false:覆盖为单边——不入库、仅推 sender 多端,对方不感知(如「你已删除 XXX」这类仅自己可见的 TIP)<br>
* true:覆盖为双向 + 入库
*/
private Boolean persistent;
/**
* 是否需要回执;null 等价 false(系统消息默认不需要回执)
*/
private Boolean receipt;
}
@@ -15,7 +15,7 @@ import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcCallMapper;
import cn.iocoder.yudao.module.im.dal.mysql.rtc.ImRtcParticipantMapper;
import cn.iocoder.yudao.module.im.dal.redis.rtc.ImRtcCallLockRedisDAO;
import cn.iocoder.yudao.module.im.enums.ImConversationTypeEnum;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.enums.ImContentTypeEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallEndReasonEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcCallStatusEnum;
import cn.iocoder.yudao.module.im.enums.rtc.ImRtcParticipantRoleEnum;
@@ -30,8 +30,7 @@ import cn.iocoder.yudao.module.im.service.message.ImPrivateMessageService;
import cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO;
import cn.iocoder.yudao.module.im.service.message.dto.ImPrivateMessageSendDTO;
import cn.iocoder.yudao.module.im.service.websocket.ImWebSocketService;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.rtc.*;
import cn.iocoder.yudao.module.im.service.websocket.notification.rtc.*;
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
import jakarta.annotation.Resource;
@@ -57,7 +56,7 @@ import static cn.iocoder.yudao.module.im.enums.ErrorCodeConstants.*;
* 并发幂等:同好友对 / 同群活跃唯一性走 {@link ImRtcCallLockRedisDAO} 分布式锁 + 锁内 SELECT 兜底;webhook 兜底走条件 UPDATE;
* <p>
* 推送通道分流:
* 1601 RTC_CALL(INVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendPrivateMessageAsync} 仅推参与方;
* 1601 RTC_CALL(INVITING / JOINED / REJECTED / NO_ANSWER / LEFT 子类型)→ {@link ImWebSocketService#sendNotificationAsync} 仅推参与方;
* 1602 / 1603 PARTICIPANT_CONNECTED / DISCONNECTED → {@link ImWebSocketService} 推参与方 + 群通话场景广播全群;
* 1610 RTC_CALL_START + 1611 RTC_CALL_END → {@link ImPrivateMessageService} / {@link ImGroupMessageService} 入消息流当聊天 tip
* (START 仅群通话;两者分别在 invite / cancel(leave) 事务里 INSERT,自增 id 自然保证顺序)
@@ -707,7 +706,6 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
}
// 3.1 群通话:推 RTC_CALL(NO_ANSWER) 让前端 banner 移除该人 + 级联关房判定
// TODO DONE @AI:拆分独立 NO_ANSWER 信令,不再复用 REJECT
if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
pushCallNoAnswerNotification(call, userId, userMap.get(userId));
endSessionIfTerminal(call, userId);
@@ -1003,8 +1001,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
String token = signToken(inviteeId, resolveDisplayName(invitee, inviteeId), call.getRoom());
ImRtcCallNotification payload = ImRtcCallNotification.ofInvite(
call, inviter, imProperties.getRtc().getLivekitUrl(), token, inviteeIds);
webSocketService.sendPrivateMessageAsync(inviteeId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), call.getInviterUserId(), inviteeId, payload));
webSocketService.sendNotificationAsync(inviteeId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.RTC_CALL.getType(), payload);
}
/**
@@ -1019,8 +1017,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
AdminUserRespDTO operator = operatorUserId != null ? adminUserApi.getUser(operatorUserId).getCheckedData() : null;
ImRtcCallNotification payload = ImRtcCallNotification.ofReject(call, operatorUserId, operator);
for (Long receiverUserId : getCallAudienceUserIdList(call)) {
webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload));
webSocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.RTC_CALL.getType(), payload);
}
}
@@ -1036,8 +1034,8 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
private void pushCallNoAnswerNotification(ImRtcCallDO call, Long operatorUserId, AdminUserRespDTO operator) {
ImRtcCallNotification payload = ImRtcCallNotification.ofNoAnswer(call, operatorUserId, operator);
for (Long receiverUserId : getCallAudienceUserIdList(call)) {
webSocketService.sendPrivateMessageAsync(receiverUserId, ImPrivateMessageDTO.ofRtcNotification(
ImMessageTypeEnum.RTC_CALL.getType(), operatorUserId, receiverUserId, payload));
webSocketService.sendNotificationAsync(receiverUserId, ImConversationTypeEnum.NONE.getType(),
ImContentTypeEnum.RTC_CALL.getType(), payload);
}
}
@@ -1048,7 +1046,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
* @param userId 加入的参与者用户编号
*/
private void pushParticipantConnectedNotification(ImRtcCallDO call, Long userId) {
pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_CONNECTED.getType(), userId,
pushParticipantNotification(call, ImContentTypeEnum.RTC_PARTICIPANT_CONNECTED.getType(), userId,
ImRtcParticipantConnectedNotification.of(call, userId));
}
@@ -1059,7 +1057,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
* @param userId 离开的参与者用户编号
*/
private void pushParticipantDisconnectedNotification(ImRtcCallDO call, Long userId) {
pushParticipantNotification(call, ImMessageTypeEnum.RTC_PARTICIPANT_DISCONNECTED.getType(), userId,
pushParticipantNotification(call, ImContentTypeEnum.RTC_PARTICIPANT_DISCONNECTED.getType(), userId,
ImRtcParticipantDisconnectedNotification.of(call, userId));
}
@@ -1076,8 +1074,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
if (CollUtil.isEmpty(receivers)) {
return;
}
ImPrivateMessageDTO dto = ImPrivateMessageDTO.ofRtcNotification(type, actorUserId, null, payload);
webSocketService.sendPrivateMessageAsync(receivers, dto);
webSocketService.sendNotificationAsync(receivers, ImConversationTypeEnum.NONE.getType(), type, payload);
}
/**
@@ -1092,7 +1089,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
private void pushCallStartNotification(ImRtcCallDO call, AdminUserRespDTO inviter, Set<Long> invitees) {
ImRtcCallStartNotification payload = ImRtcCallStartNotification.of(call, inviter);
Long peerUserId = ImConversationTypeEnum.isGroup(call.getConversationType()) ? null : CollUtil.getFirst(invitees);
pushCallChatMessage(call, ImMessageTypeEnum.RTC_CALL_START, payload, peerUserId);
pushCallChatMessage(call, ImContentTypeEnum.RTC_CALL_START, payload, peerUserId);
}
/**
@@ -1116,7 +1113,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
p -> ObjUtil.notEqual(p.getUserId(), call.getInviterUserId()));
peerUserId = peer != null ? peer.getUserId() : null;
}
pushCallChatMessage(call, ImMessageTypeEnum.RTC_CALL_END, payload, peerUserId);
pushCallChatMessage(call, ImContentTypeEnum.RTC_CALL_END, payload, peerUserId);
}
/**
@@ -1129,7 +1126,7 @@ public class ImRtcCallServiceImpl implements ImRtcCallService {
* @param payload 推送 payload
* @param peerUserId 私聊对端用户编号;群聊忽略,私聊缺失时回退为 senderId
*/
private void pushCallChatMessage(ImRtcCallDO call, ImMessageTypeEnum type, Object payload, Long peerUserId) {
private void pushCallChatMessage(ImRtcCallDO call, ImContentTypeEnum type, Object payload, Long peerUserId) {
Long senderId = call.getInviterUserId();
if (ImConversationTypeEnum.isGroup(call.getConversationType())) {
ImGroupMessageSendDTO dto = new ImGroupMessageSendDTO().setGroupId(call.getGroupId())
@@ -80,7 +80,7 @@ public interface ImStatisticsManagerService {
Map<LocalDateTime, Long> getGroupMessageDailyCountMap(LocalDateTime beginTime, LocalDateTime endTime);
/**
* 获取区间内消息类型分布 Map(key 为消息类型)
* 获取区间内内容类型分布 Map(key 为消息类型)
*/
Map<Integer, Long> getMessageTypeCountMap(LocalDateTime beginTime, LocalDateTime endTime);
@@ -1,82 +1,46 @@
package cn.iocoder.yudao.module.im.service.websocket;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import java.util.Collection;
import java.util.Collections;
/**
* IM WebSocket 推送 Service 接口
* <p>
* 统一封装 WebSocket 消息推送,事务内调用时提交后异步执行。
* 统一封装 WebSocket 通知推送,事务内调用时提交后异步执行。
*
* @author 芋道源码
*/
public interface ImWebSocketService {
/**
* 异步推送私聊消息给指定用户
* 异步推送 WebSocket 通知给指定用户
*
* @param userId 目标用户编号
* @param dto 私聊消息 DTO
* @param userId 目标用户编号
* @param conversationType 会话类型,参见 ImConversationTypeEnum 枚举类
* @param contentType 内容类型,参见 ImContentTypeEnum 枚举类
* @param payload 通知 payload
*/
default void sendPrivateMessageAsync(Long userId, ImPrivateMessageDTO dto) {
sendPrivateMessageAsync(Collections.singleton(userId), dto);
default void sendNotificationAsync(Long userId, Integer conversationType, Integer contentType, Object payload) {
sendNotificationAsync(Collections.singleton(userId), conversationType, contentType, payload);
}
/**
* 异步批量推送私聊消息给多个用户;用于同一份 DTO 扇出到多个收件人
* <p>
* 相比逐个发送,仅注册一次 afterCommit 回调和异步任务。
* 异步批量推送 WebSocket 通知给多个用户
*
* @param userIds 目标用户编号列表
* @param dto 私聊消息 DTO
* @param userIds 目标用户编号列表
* @param conversationType 会话类型,参见 ImConversationTypeEnum 枚举类
* @param contentType 内容类型,参见 ImContentTypeEnum 枚举类
* @param payload 通知 payload
*/
void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto);
void sendNotificationAsync(Collection<Long> userIds, Integer conversationType, Integer contentType, Object payload);
/**
* 异步推送群聊消息给指定用户
* 异步广播 WebSocket 通知给当前所有在线管理端用户
*
* @param userId 目标用户编号
* @param dto 群聊消息 DTO
* @param conversationType 会话类型,参见 ImConversationTypeEnum 枚举类
* @param contentType 内容类型,参见 ImContentTypeEnum 枚举类
* @param payload 通知 payload
*/
default void sendGroupMessageAsync(Long userId, ImGroupMessageDTO dto) {
sendGroupMessageAsync(Collections.singleton(userId), dto);
}
/**
* 异步批量推送群聊消息给多个用户
*
* @param userIds 目标用户编号列表
* @param dto 群聊消息 DTO
*/
void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto);
/**
* 异步推送频道消息给指定用户
*
* @param userId 目标用户编号
* @param dto 频道消息 DTO
*/
default void sendChannelMessageAsync(Long userId, ImChannelMessageDTO dto) {
sendChannelMessageAsync(Collections.singleton(userId), dto);
}
/**
* 异步批量推送频道消息给多个用户
*
* @param userIds 目标用户编号列表
* @param dto 频道消息 DTO
*/
void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto);
/**
* 异步广播频道消息给当前所有在线管理端用户;用于全员推送
*
* @param dto 频道消息 DTO
*/
void broadcastChannelMessageAsync(ImChannelMessageDTO dto);
void broadcastNotificationAsync(Integer conversationType, Integer contentType, Object payload);
}
@@ -2,9 +2,7 @@ package cn.iocoder.yudao.module.im.service.websocket;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImChannelMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImGroupMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.dto.ImPrivateMessageDTO;
import cn.iocoder.yudao.module.im.service.websocket.notification.ImNotificationWebSocketDTO;
import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
@@ -36,83 +34,51 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
private WebSocketSenderApi webSocketSenderApi;
@Override
public void sendPrivateMessageAsync(Collection<Long> userIds, ImPrivateMessageDTO dto) {
// 说明:通过 executeAfterTransaction 保证事务提交后再推送,避免客户端收到消息后查询数据库时事务尚未提交
// 通过 getSelf() 获取 Spring 代理对象调用 @Async 方法,确保异步 AOP 生效(直接 this 调用会绕过代理)
executeAfterTransaction(() -> getSelf().doSendPrivateMessage(userIds, dto));
public void sendNotificationAsync(Collection<Long> userIds, Integer conversationType, Integer contentType,
Object payload) {
ImNotificationWebSocketDTO notification = buildNotification(conversationType, contentType, payload);
executeAfterTransaction(() -> getSelf().doSendNotification(userIds, notification));
}
@Override
public void sendGroupMessageAsync(Collection<Long> userIds, ImGroupMessageDTO dto) {
executeAfterTransaction(() -> getSelf().doSendGroupMessage(userIds, dto));
public void broadcastNotificationAsync(Integer conversationType, Integer contentType, Object payload) {
ImNotificationWebSocketDTO notification = buildNotification(conversationType, contentType, payload);
executeAfterTransaction(() -> getSelf().doBroadcastNotification(notification));
}
@Override
public void sendChannelMessageAsync(Collection<Long> userIds, ImChannelMessageDTO dto) {
executeAfterTransaction(() -> getSelf().doSendChannelMessage(userIds, dto));
}
@Override
public void broadcastChannelMessageAsync(ImChannelMessageDTO dto) {
executeAfterTransaction(() -> getSelf().doBroadcastChannelMessage(dto));
private static ImNotificationWebSocketDTO buildNotification(Integer conversationType, Integer contentType,
Object payload) {
return new ImNotificationWebSocketDTO()
.setConversationType(conversationType)
.setContentType(contentType)
.setPayload(payload);
}
/**
* 异步发送私聊 WebSocket 消息;多收件人共享同一 dto,避免按收件人重复注册 afterCommit 回调
* 异步发送 WebSocket 通知
*/
@Async
public void doSendPrivateMessage(Collection<Long> userIds, ImPrivateMessageDTO dto) {
public void doSendNotification(Collection<Long> userIds, ImNotificationWebSocketDTO notification) {
for (Long userId : getDistinctUserIds(userIds)) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
ImPrivateMessageDTO.TYPE, dto);
ImNotificationWebSocketDTO.TYPE, notification);
} catch (Exception e) {
log.error("[doSendPrivateMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
log.error("[doSendNotification][userId({}) notification({}) 发送失败]", userId, notification, e);
}
}
}
/**
* 异步发送群聊 WebSocket 消息
* 异步广播 WebSocket 通知
*/
@Async
public void doSendGroupMessage(Collection<Long> userIds, ImGroupMessageDTO dto) {
for (Long userId : getDistinctUserIds(userIds)) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
ImGroupMessageDTO.TYPE, dto);
} catch (Exception e) {
log.error("[doSendGroupMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
}
}
}
/**
* 异步发送频道 WebSocket 消息
*/
@Async
public void doSendChannelMessage(Collection<Long> userIds, ImChannelMessageDTO dto) {
for (Long userId : getDistinctUserIds(userIds)) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), userId,
ImChannelMessageDTO.TYPE, dto);
} catch (Exception e) {
log.error("[doSendChannelMessage][userId({}) dto({}) 发送失败]", userId, dto, e);
}
}
}
/**
* 异步广播频道 WebSocket 消息给当前所有在线管理端用户;
* 依赖 infra WebSocketSenderApi 按 UserType 广播能力,离线用户由客户端上线 pull 兜底
*/
@Async
public void doBroadcastChannelMessage(ImChannelMessageDTO dto) {
public void doBroadcastNotification(ImNotificationWebSocketDTO notification) {
try {
webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(),
ImChannelMessageDTO.TYPE, dto);
ImNotificationWebSocketDTO.TYPE, notification);
} catch (Exception e) {
log.error("[doBroadcastChannelMessage][dto({}) 广播失败]", dto, e);
log.error("[doBroadcastNotification][notification({}) 广播失败]", notification, e);
}
}
@@ -121,19 +87,15 @@ public class ImWebSocketServiceImpl implements ImWebSocketService {
}
/**
* 事务感知的任务调度:
* - 有事务:注册 afterCommit 回调,事务提交后再执行,防止客户端拿到消息去查库时数据还没落盘
* - 无事务:直接执行(如非 @Transactional 方法中的调用)
* 事务感知的任务调度
*
* @param task 待执行的推送任务(内部通过 getSelf() 走 @Async 异步执行)
* @param task 待执行的推送任务
*/
private void executeAfterTransaction(Runnable task) {
// 情况一:没有事务,直接执行
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
task.run();
return;
}
// 情况二:有事务,注册 afterCommit 事件,在事务提交后执行
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
@@ -1,70 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* IM 频道消息 WebSocket 推送 DTO
* <p>
* 单向:服务端运营推送 → C 端用户;C 端不能向频道发消息。
* 字段分层:顶层是消息元数据 + 检索维度,content 是 MaterialMessage payload 的 JSON 串。
*
* @author 芋道源码
*/
@Data
@Accessors(chain = true)
public class ImChannelMessageDTO {
public static final String TYPE = "im-channel-message";
/**
* 消息编号
*/
private Long id;
/**
* 频道编号
*/
private Long channelId;
/**
* 关联素材编号
*/
private Long materialId;
/**
* 消息类型
*/
private Integer type;
/**
* 消息内容;payload JSON 串
*/
private String content;
/**
* 发送时间
*/
private LocalDateTime sendTime;
/**
* 由频道消息 DO 构建推送 DTO
*/
public static ImChannelMessageDTO ofSend(ImChannelMessageDO message) {
return BeanUtils.toBean(message, ImChannelMessageDTO.class);
}
/**
* 构建已读同步 DTO;多端同步:通知自己的其他终端「我已经读了某频道」
*
* @param channelId 频道编号
* @param readId 已读位置(最大已读消息编号)
* @return 频道 DTO
*/
public static ImChannelMessageDTO ofRead(Long channelId, Long readId) {
return new ImChannelMessageDTO()
.setId(readId).setType(ImMessageTypeEnum.READ.getType())
.setChannelId(channelId);
}
}
@@ -1,123 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImGroupMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* IM 群聊消息 WebSocket 统一推送 DTO
* <p>
* 发送、已读、回执都通过 {@link #type} 字段区分,
* 并统一复用 {@link #id} 字段表达目标消息或已读位置。
*
* @author 芋道源码
*/
@Data
public class ImGroupMessageDTO {
public static final String TYPE = "im-group-message";
/**
* 消息编号
* <p>
* 普通消息:当前消息 id
* READ:无(群已读不需要)
* RECEIPT:需要回执的目标消息 id
*/
private Long id;
/**
* 客户端消息编号
*/
private String clientMessageId;
/**
* 发送人编号
*/
private Long senderId;
/**
* 群编号
*/
private Long groupId;
/**
* 消息类型
*/
private Integer type;
/**
* 消息内容
*/
private String content;
/**
* 消息状态
*/
private Integer status;
/**
* 发送时间
*/
private LocalDateTime sendTime;
/**
* @ 目标用户编号列表
*/
private List<Long> atUserIds;
/**
* 定向接收用户编号列表
*/
private List<Long> receiverUserIds;
/**
* 群回执状态
*/
private Integer receiptStatus;
/**
* 群回执已读人数
*/
private Integer readCount;
/**
* 已读位置
*/
private Long readId;
// ========== 静态工厂方法 ==========
/**
* 构建发送消息 DTO
*
* @param message 群聊消息 DO
* @return 群聊 DTO
*/
public static ImGroupMessageDTO ofSend(ImGroupMessageDO message) {
return BeanUtils.toBean(message, ImGroupMessageDTO.class).setReceiverUserIds(null);
}
/**
* 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个群")
*
* @param senderId 当前用户编号
* @param groupId 群编号
* @param readId 已读位置(最大已读消息编号)
* @return 群聊 DTO
*/
public static ImGroupMessageDTO ofRead(Long senderId, Long groupId, Long readId) {
return new ImGroupMessageDTO()
.setId(readId).setReadId(readId).setType(ImMessageTypeEnum.READ.getType())
.setSenderId(senderId).setGroupId(groupId);
}
/**
* 构建群回执 DTO(广播回执状态和已读人数)
*
* @param messageId 消息编号
* @param groupId 群编号
* @param readCount 已读人数
* @param receiptStatus 回执状态
* @return 群聊 DTO
*/
public static ImGroupMessageDTO ofReceipt(Long messageId, Long groupId,
Integer readCount, Integer receiptStatus) {
return new ImGroupMessageDTO()
.setId(messageId).setType(ImMessageTypeEnum.RECEIPT.getType())
.setGroupId(groupId).setReadCount(readCount).setReceiptStatus(receiptStatus);
}
}
@@ -1,176 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto;
import cn.hutool.core.lang.Assert;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImPrivateMessageDO;
import cn.iocoder.yudao.module.im.enums.message.ImMessageTypeEnum;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.friend.BaseFriendNotification;
import cn.iocoder.yudao.module.im.service.websocket.dto.notification.group.BaseGroupNotification;
import lombok.Data;
import lombok.experimental.Accessors;
import java.time.LocalDateTime;
/**
* IM 私聊消息 WebSocket 统一推送 DTO
* <p>
* 发送、已读、回执、撤回都通过 {@link #type} 字段区分,
* 并统一复用 {@link #id} 字段表达目标消息或已读位置。
*
* @author 芋道源码
*/
@Data
@Accessors(chain = true)
public class ImPrivateMessageDTO {
public static final String TYPE = "im-private-message";
/**
* 消息编号
* <p>
* 普通消息:当前消息 id
* READ:已读位置(我已读到这条消息)
* RECEIPT:已读位置(对方已读到这条消息)
* RECALL:被撤回的原消息 id
*/
private Long id;
/**
* 客户端消息编号
*/
private String clientMessageId;
/**
* 发送人编号
*/
private Long senderId;
/**
* 接收人编号
*/
private Long receiverId;
/**
* 消息类型
*/
private Integer type;
/**
* 消息内容
*/
private String content;
/**
* 消息状态
*/
private Integer status;
/**
* 发送时间
*/
private LocalDateTime sendTime;
// ========== 静态工厂方法 ==========
/**
* 构建发送消息 DTO
*
* @param message 私聊消息 DO
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofSend(ImPrivateMessageDO message) {
return BeanUtils.toBean(message, ImPrivateMessageDTO.class);
}
/**
* 构建已读同步 DTO(多端同步:通知自己的其他终端"我已经读了某个会话")
*
* @param senderId 当前用户编号
* @param receiverId 对方用户编号
* @param readId 已读位置(最大已读消息编号)
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofRead(Long senderId, Long receiverId, Long readId) {
return new ImPrivateMessageDTO()
.setId(readId).setType(ImMessageTypeEnum.READ.getType())
.setSenderId(senderId).setReceiverId(receiverId);
}
/**
* 构建已读回执 DTO(通知对方"我已读了你的消息")
*
* @param senderId 已读方的用户编号
* @param receiverId 对方用户编号
* @param readId 已读位置(最大已读消息编号)
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofReceipt(Long senderId, Long receiverId, Long readId) {
return new ImPrivateMessageDTO()
.setId(readId).setType(ImMessageTypeEnum.RECEIPT.getType())
.setSenderId(senderId).setReceiverId(receiverId);
}
// ==================== 好友变更相关 ====================
/**
* 构建好友通知推送 DTO(统一入口)
*
* @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 FRIEND_* 段
* @param operatorUserId 操作人用户编号;同时作为帧的 senderId 用于定位接收端 friendUserId
* @param receiverUserId 推送目标用户编号
* @param payload 好友通知 payload(继承 {@link BaseFriendNotification})
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofFriendNotification(Integer type, Long operatorUserId,
Long receiverUserId, BaseFriendNotification payload) {
validateNotification(type, payload, ImMessageTypeEnum.isFriendNotification(type));
return new ImPrivateMessageDTO().setType(type)
.setSenderId(operatorUserId).setReceiverId(receiverUserId)
.setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
}
// ==================== 群定向私聊通知 ====================
// TODO DONE @AI:群申请定向通知继续走私聊通道
/**
* 构建群通知推送 DTO(走私聊通道定向推送,不入群消息流)
* <p>
* 用于 GROUP_REQUEST_RECEIVED / GROUP_REQUEST_APPROVED / GROUP_REQUEST_REJECTED 段位;
* 与 {@link cn.iocoder.yudao.module.im.service.message.dto.ImGroupMessageSendDTO} 走 sendGroupMessage 群广播路径不同
*
* @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 GROUP_REQUEST_* 段
* @param operatorUserId 操作人用户编号;同时作为帧的 senderId
* @param receiverUserId 推送目标用户编号
* @param payload 群事件 payload(继承 {@link BaseGroupNotification})
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofGroupNotification(Integer type, Long operatorUserId,
Long receiverUserId, BaseGroupNotification payload) {
validateNotification(type, payload, ImMessageTypeEnum.isGroupRequestNotification(type));
return new ImPrivateMessageDTO().setType(type)
.setSenderId(operatorUserId).setReceiverId(receiverUserId)
.setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
}
// ==================== 实时通话信令 ====================
/**
* 构建通话信令推送 DTO(走私聊通道仅推参与方,不入消息流)
* <p>
* 用于 RTC_CALL(INVITE / REJECT 等)/ RTC_PARTICIPANT_CONNECTED / RTC_PARTICIPANT_DISCONNECTED
*
* @param type 消息类型;取自 {@link ImMessageTypeEnum} 中的 RTC_* 段
* @param senderId 发送人编号;INVITE 时为发起人,REJECT 时为拒绝者,参与者事件时为加入 / 离开者
* @param receiverUserId 推送目标用户编号
* @param payload 通话事件 payload(任意 RTC 通知 DTO)
* @return 私聊 DTO
*/
public static ImPrivateMessageDTO ofRtcNotification(Integer type, Long senderId,
Long receiverUserId, Object payload) {
validateNotification(type, payload, ImMessageTypeEnum.isRtcNotification(type));
return new ImPrivateMessageDTO().setType(type)
.setSenderId(senderId).setReceiverId(receiverUserId)
.setContent(JsonUtils.toJsonString(payload)).setSendTime(LocalDateTime.now());
}
private static void validateNotification(Integer type, Object payload, boolean supported) {
Assert.notNull(type, "消息类型不能为空");
Assert.notNull(payload, "消息内容不能为空");
Assert.isTrue(supported, "消息类型不匹配 type={}", type);
}
}
@@ -1,20 +0,0 @@
package cn.iocoder.yudao.module.im.service.websocket.dto.notification.group;
import lombok.Data;
/**
* 群信息变更事件通知(当前承载头像变更,NAME / NOTICE 走独立事件)
*/
@Data
public class GroupInfoUpdateNotification extends BaseGroupNotification {
/**
* 旧群头像
*/
private String oldAvatar;
/**
* 新群头像
*/
private String newAvatar;
}

Some files were not shown because too many files have changed in this diff Show More