mirror of
https://gitee.com/zhijiantianya/yudao-cloud.git
synced 2026-10-06 07:22:58 +08:00
【同步】BOOT 和 CLOUD 的功能(im、bpm)
This commit is contained in:
@@ -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;
|
||||
|
||||
-- ----------------------------
|
||||
|
||||
+2
@@ -7,4 +7,6 @@ package cn.iocoder.yudao.module.bpm.enums;
|
||||
*/
|
||||
public interface DictTypeConstants {
|
||||
|
||||
String COMMENT_TYPE = "bpm_comment_type";
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+81
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+22
@@ -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;
|
||||
|
||||
}
|
||||
+52
@@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
+29
-3
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+6
@@ -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;
|
||||
|
||||
+21
-3
@@ -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>
|
||||
|
||||
+42
@@ -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);
|
||||
|
||||
}
|
||||
+48
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据条件查询正在进行中的任务
|
||||
*
|
||||
|
||||
+49
-41
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
+51
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+32
@@ -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;
|
||||
|
||||
}
|
||||
+3
-2
@@ -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);
|
||||
|
||||
+19
-3
@@ -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,
|
||||
|
||||
+20
-2
@@ -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)
|
||||
|
||||
+3
@@ -47,6 +47,9 @@ public class ImFriendRespVO {
|
||||
@Schema(description = "删除好友时间")
|
||||
private LocalDateTime deleteTime;
|
||||
|
||||
@Schema(description = "最近更新时间(增量拉取游标用)")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ========== 下面是聚合字段,方便前端显示 ==========
|
||||
|
||||
@Schema(description = "好友昵称(实时聚合自 AdminUser)", example = "芋道")
|
||||
|
||||
+3
@@ -41,6 +41,9 @@ public class ImFriendRequestRespVO {
|
||||
@Schema(description = "申请创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "最近更新时间(增量拉取游标用)")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ========== 下面是聚合字段,方便前端显示 ==========
|
||||
|
||||
@Schema(description = "发起方昵称(实时聚合自 AdminUser)", example = "芋道")
|
||||
|
||||
+15
-6
@@ -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));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+22
-7
@@ -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
@@ -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")
|
||||
|
||||
+10
-1
@@ -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;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+3
@@ -42,6 +42,9 @@ public class ImGroupRequestRespVO {
|
||||
@Schema(description = "申请创建时间")
|
||||
private LocalDateTime createTime;
|
||||
|
||||
@Schema(description = "申请更新时间;增量拉取游标")
|
||||
private LocalDateTime updateTime;
|
||||
|
||||
// ========== 下面是聚合字段,方便前端显示 ==========
|
||||
|
||||
@Schema(description = "申请人 / 被邀请人昵称(实时聚合自 AdminUser)", example = "芋道")
|
||||
|
||||
-1
@@ -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));
|
||||
}
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
|
||||
+1
-1
@@ -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();
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+7
-7
@@ -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());
|
||||
}));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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));
|
||||
|
||||
+2
-2
@@ -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;
|
||||
|
||||
+3
-3
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+4
-1
@@ -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;
|
||||
|
||||
+6
-3
@@ -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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+68
@@ -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;
|
||||
|
||||
}
|
||||
+2
-2
@@ -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;
|
||||
/**
|
||||
|
||||
+4
-6
@@ -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;
|
||||
|
||||
|
||||
+9
-2
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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;
|
||||
|
||||
+2
-2
@@ -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= 按需拉取。
|
||||
*/
|
||||
+3
-3
@@ -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;
|
||||
/**
|
||||
+3
-3
@@ -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
-1
@@ -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
-1
@@ -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
-1
@@ -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;
|
||||
|
||||
+83
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+25
@@ -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)
|
||||
|
||||
+29
@@ -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));
|
||||
|
||||
+4
-16
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+23
@@ -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));
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
|
||||
+15
-39
@@ -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()));
|
||||
|
||||
+3
-13
@@ -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) {
|
||||
|
||||
+1
-1
@@ -154,7 +154,7 @@ public interface ImStatisticsManagerMapper {
|
||||
@Param("endTime") LocalDateTime endTime);
|
||||
|
||||
/**
|
||||
* 区间内消息类型分布(私聊+群聊合并)
|
||||
* 区间内内容类型分布(私聊+群聊合并)
|
||||
*
|
||||
* @return [{type: 0, count: 123}, ...]
|
||||
*/
|
||||
|
||||
+1
-15
@@ -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";
|
||||
|
||||
|
||||
-50
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
-102
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+12
-15
@@ -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;
|
||||
}
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
+3
-3
@@ -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);
|
||||
|
||||
/**
|
||||
* 状态
|
||||
+2
-6
@@ -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);
|
||||
|
||||
|
||||
-1
@@ -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, "已拒绝"), // 接通前点拒接
|
||||
|
||||
+6
-5
@@ -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({}) 推送失败]",
|
||||
|
||||
+67
@@ -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);
|
||||
|
||||
}
|
||||
+86
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+11
@@ -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 单查申请记录;通用读接口,调用方自行做越权过滤
|
||||
*/
|
||||
|
||||
+18
-14
@@ -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);
|
||||
|
||||
+5
@@ -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 状态)
|
||||
*/
|
||||
|
||||
+19
-13
@@ -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;
|
||||
}
|
||||
|
||||
+4
-5
@@ -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
|
||||
|
||||
+6
-6
@@ -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()));
|
||||
}
|
||||
}
|
||||
|
||||
+13
@@ -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>
|
||||
|
||||
+32
-20
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-20
@@ -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();
|
||||
}
|
||||
|
||||
+1
-1
@@ -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 事件给自己多端
|
||||
|
||||
+31
-25
@@ -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();
|
||||
}
|
||||
|
||||
-29
@@ -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);
|
||||
|
||||
// ==================== 管理后台 ====================
|
||||
|
||||
/**
|
||||
|
||||
+153
-281
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+3
-3
@@ -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 对方用户编号
|
||||
|
||||
+71
-30
@@ -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
|
||||
|
||||
+30
-26
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+7
-3
@@ -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
-18
@@ -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())
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
|
||||
+19
-55
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+24
-62
@@ -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
|
||||
|
||||
-70
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
-123
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
-176
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
-20
@@ -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
Reference in New Issue
Block a user