diff --git a/pom.xml b/pom.xml index 5be11566f..be95db7b0 100644 --- a/pom.xml +++ b/pom.xml @@ -16,12 +16,12 @@ yudao-module-system yudao-module-infra - + yudao-module-bpm - + yudao-module-crm diff --git a/sql/mysql/mall-promotion-kefu.sql b/sql/mysql/mall-promotion-kefu.sql new file mode 100644 index 000000000..83742e4a8 --- /dev/null +++ b/sql/mysql/mall-promotion-kefu.sql @@ -0,0 +1,39 @@ +DROP TABLE IF EXISTS `promotion_kefu_conversation`; +CREATE TABLE `promotion_kefu_conversation` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `user_id` bigint NOT NULL COMMENT '会话所属用户', + `last_message_time` datetime NOT NULL COMMENT '最后聊天时间', + `last_message_content` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '最后聊天内容', + `last_message_content_type` int NOT NULL COMMENT '最后发送的消息类型', + `admin_pinned` bit(1) NOT NULL DEFAULT b'0' COMMENT '管理端置顶', + `user_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '用户是否可见', + `admin_deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '管理员是否可见', + `admin_unread_message_count` int NOT NULL COMMENT '管理员未读消息数', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='客服会话'; + +DROP TABLE IF EXISTS `promotion_kefu_message`; +CREATE TABLE `promotion_kefu_message` ( + `id` bigint NOT NULL AUTO_INCREMENT COMMENT '编号', + `conversation_id` bigint NOT NULL COMMENT '会话编号', + `sender_id` bigint NOT NULL COMMENT '发送人编号', + `sender_type` int NOT NULL COMMENT '发送人类型', + `receiver_id` bigint DEFAULT NULL COMMENT '接收人编号', + `receiver_type` int DEFAULT NULL COMMENT '接收人类型', + `content_type` int NOT NULL COMMENT '消息类型', + `content` varchar(2048) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息', + `read_status` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否已读', + `creator` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '创建者', + `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updater` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT '' COMMENT '更新者', + `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + `deleted` bit(1) NOT NULL DEFAULT b'0' COMMENT '是否删除', + `tenant_id` bigint NOT NULL DEFAULT 0 COMMENT '租户编号', + PRIMARY KEY (`id`) USING BTREE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci ROW_FORMAT=DYNAMIC COMMENT='客服消息'; \ No newline at end of file diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/performance/CrmStatisticsPerformanceReqVO.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/performance/CrmStatisticsPerformanceReqVO.java index de76acbb3..bfb5c840f 100644 --- a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/performance/CrmStatisticsPerformanceReqVO.java +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/controller/admin/statistics/vo/performance/CrmStatisticsPerformanceReqVO.java @@ -33,7 +33,6 @@ public class CrmStatisticsPerformanceReqVO { @Schema(description = "负责人用户 id 集合", requiredMode = Schema.RequiredMode.NOT_REQUIRED, example = "2") private List userIds; - // TODO @scholar:应该传递的是 int year;年份 @Schema(description = "时间范围", requiredMode = Schema.RequiredMode.REQUIRED) @DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND) @NotEmpty(message = "时间范围不能为空") diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsPerformanceServiceImpl.java b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsPerformanceServiceImpl.java index b24728ddc..1e7e3bbb2 100644 --- a/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsPerformanceServiceImpl.java +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/java/cn/iocoder/yudao/module/crm/service/statistics/CrmStatisticsPerformanceServiceImpl.java @@ -1,8 +1,6 @@ package cn.iocoder.yudao.module.crm.service.statistics; import cn.hutool.core.collection.CollUtil; -import cn.hutool.core.collection.ListUtil; -import cn.hutool.core.date.LocalDateTimeUtil; import cn.hutool.core.util.ObjUtil; import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.performance.CrmStatisticsPerformanceReqVO; import cn.iocoder.yudao.module.crm.controller.admin.statistics.vo.performance.CrmStatisticsPerformanceRespVO; @@ -11,16 +9,20 @@ import cn.iocoder.yudao.module.system.api.dept.DeptApi; import cn.iocoder.yudao.module.system.api.dept.dto.DeptRespDTO; import cn.iocoder.yudao.module.system.api.user.AdminUserApi; import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import jakarta.annotation.Resource; - import java.math.BigDecimal; -import java.util.*; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; import java.util.function.Function; import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMap; /** * CRM 员工业绩分析 Service 实现类 @@ -41,10 +43,6 @@ public class CrmStatisticsPerformanceServiceImpl implements CrmStatisticsPerform @Override public List getContractCountPerformance(CrmStatisticsPerformanceReqVO performanceReqVO) { - // TODO @scholar:可以把下面这个注释,你理解后,重新整理下,写到 getPerformance 里; - // 比如说,2024 年的合同数据,是不是 2022-12 到 2024-12-31,每个月的统计呢? - // 理解之后,我们可以数据 group by 年-月,20222-12 到 2024-12-31 的,然后内存在聚合出 CrmStatisticsPerformanceRespVO 这样 - // 这样,我们就可以减少数据库的计算量,提升性能;同时 SQL 也会很简单,开发者理解起来也简单哈; return getPerformance(performanceReqVO, performanceMapper::selectContractCountPerformance); } @@ -58,99 +56,45 @@ public class CrmStatisticsPerformanceServiceImpl implements CrmStatisticsPerform return getPerformance(performanceReqVO, performanceMapper::selectReceivablePricePerformance); } - // TODO @scholar:代码注释,应该有 3 个变量哈; /** * 获得员工业绩数据 * + * 1. 获得今年 + 去年的数据 + * 2. 遍历今年的月份,逐个拼接去年的月份数据 + * * @param performanceReqVO 参数 * @param performanceFunction 员工业绩统计方法 * @return 员工业绩数据 */ - // TODO @scholar:下面一行的变量,超过一行了,阅读不美观;可以考虑每一行一个变量; - private List getPerformance(CrmStatisticsPerformanceReqVO performanceReqVO, Function> performanceFunction) { - - // TODO @scholar:没使用到的变量,建议删除; - List performanceRespVOList; + private List getPerformance(CrmStatisticsPerformanceReqVO performanceReqVO, + Function> performanceFunction) { // 1. 获得用户编号数组 - final List userIds = getUserIds(performanceReqVO); + List userIds = getUserIds(performanceReqVO); if (CollUtil.isEmpty(userIds)) { return Collections.emptyList(); } performanceReqVO.setUserIds(userIds); - // TODO @scholar:1. 和 2. 之间,可以考虑换一行;保证每一块逻辑的间隔; + // 2. 获得业绩数据 - // TODO @scholar:复数变量,建议使用 s 或者 list 结果;这里用 performanceList 好列; - List performance = performanceFunction.apply(performanceReqVO); + int year = performanceReqVO.getTimes()[0].getYear(); // 获取查询的年份 + performanceReqVO.getTimes()[0] = performanceReqVO.getTimes()[0].minusYears(1); + List performanceList = performanceFunction.apply(performanceReqVO); + Map performanceMap = convertMap(performanceList, CrmStatisticsPerformanceRespVO::getTime, + CrmStatisticsPerformanceRespVO::getCurrentMonthCount); - // 获取查询的年份 - // TODO @scholar:逻辑可以简化一下; - // TODO 1)把 performance 转换成 map;key 是 time,value 是 count - // TODO 2)当前年,遍历 1-12 月份,去 map 拿到 count;接着月份 -1,去 map 拿 count;再年份 -1,拿 count - String currentYear = LocalDateTimeUtil.format(performanceReqVO.getTimes()[0],"yyyy"); - - // 构造查询当年和前一年,每年12个月的年月组合 - List allMonths = new ArrayList<>(); - for (int year = Integer.parseInt(currentYear)-1; year <= Integer.parseInt(currentYear); year++) { - for (int month = 1; month <= 12; month++) { - allMonths.add(String.format("%d%02d", year, month)); - } + // 3. 组装数据返回 + List result = new ArrayList<>(); + for (int month = 1; month <= 12; month++) { + String currentMonth = String.format("%d%02d", year, month); + String lastMonth = month == 1 ? String.format("%d%02d", year - 1, 12) : String.format("%d%02d", year, month - 1); + String lastYear = String.format("%d%02d", year - 1, month); + result.add(new CrmStatisticsPerformanceRespVO().setTime(currentMonth) + .setCurrentMonthCount(performanceMap.getOrDefault(currentMonth, BigDecimal.ZERO)) + .setLastMonthCount(performanceMap.getOrDefault(lastMonth, BigDecimal.ZERO)) + .setLastYearCount(performanceMap.getOrDefault(lastYear, BigDecimal.ZERO))); } - - List computedList = new ArrayList<>(); - List respVOList = new ArrayList<>(); - - // 生成computedList基础数据 - // 构造完整的2*12个月的数据,如果某月数据缺失,需要补上0,一年12个月不能有缺失 - for (String month : allMonths) { - CrmStatisticsPerformanceRespVO foundData = performance.stream() - .filter(data -> data.getTime().equals(month)) - .findFirst() - .orElse(null); - - if (foundData != null) { - computedList.add(foundData); - } else { - CrmStatisticsPerformanceRespVO missingData = new CrmStatisticsPerformanceRespVO(); - missingData.setTime(month); - missingData.setCurrentMonthCount(BigDecimal.ZERO); - missingData.setLastMonthCount(BigDecimal.ZERO); - missingData.setLastYearCount(BigDecimal.ZERO); - computedList.add(missingData); - } - } - //根据查询年份和前一年的数据,计算查询年份的同比环比数据 - for (CrmStatisticsPerformanceRespVO currentData : computedList) { - String currentMonth = currentData.getTime(); - - // 根据当年和前一年的月销售数据,计算currentYear的完整数据 - if (currentMonth.startsWith(currentYear)) { - // 计算 LastMonthCount - int currentIndex = computedList.indexOf(currentData); - if (currentIndex > 0) { - CrmStatisticsPerformanceRespVO lastMonthData = computedList.get(currentIndex - 1); - currentData.setLastMonthCount(lastMonthData.getCurrentMonthCount()); - } else { - currentData.setLastMonthCount(BigDecimal.ZERO); // 第一个月的 LastMonthCount 设为0 - } - - // 计算 LastYearCount - String lastYearMonth = String.valueOf(Integer.parseInt(currentMonth) - 100); - CrmStatisticsPerformanceRespVO lastYearData = computedList.stream() - .filter(data -> data.getTime().equals(lastYearMonth)) - .findFirst() - .orElse(null); - - if (lastYearData != null) { - currentData.setLastYearCount(lastYearData.getCurrentMonthCount()); - } else { - currentData.setLastYearCount(BigDecimal.ZERO); // 如果去年同月数据不存在,设为0 - } - respVOList.add(currentData);//给前端只需要返回查询当年的数据,不需要前一年数据 - } - } - return respVOList; + return result; } /** @@ -162,7 +106,7 @@ public class CrmStatisticsPerformanceServiceImpl implements CrmStatisticsPerform private List getUserIds(CrmStatisticsPerformanceReqVO reqVO) { // 情况一:选中某个用户 if (ObjUtil.isNotNull(reqVO.getUserId())) { - return ListUtil.of(reqVO.getUserId()); + return List.of(reqVO.getUserId()); } // 情况二:选中某个部门 // 2.1 获得部门列表 diff --git a/yudao-module-crm/yudao-module-crm-biz/src/main/resources/mapper/statistics/CrmStatisticsPerformanceMapper.xml b/yudao-module-crm/yudao-module-crm-biz/src/main/resources/mapper/statistics/CrmStatisticsPerformanceMapper.xml index 79ff45471..81962cdae 100644 --- a/yudao-module-crm/yudao-module-crm-biz/src/main/resources/mapper/statistics/CrmStatisticsPerformanceMapper.xml +++ b/yudao-module-crm/yudao-module-crm-biz/src/main/resources/mapper/statistics/CrmStatisticsPerformanceMapper.xml @@ -9,51 +9,47 @@ COUNT(1) AS currentMonthCount FROM crm_contract WHERE deleted = 0 - - AND audit_status = 20 + AND audit_status = ${@cn.iocoder.yudao.module.crm.enums.common.CrmAuditStatusEnum@APPROVE.status} AND owner_user_id in #{userId} - - AND (DATE_FORMAT(order_date, '%Y') = DATE_FORMAT(#{times[0],javaType=java.time.LocalDateTime}, '%Y') - or DATE_FORMAT(order_date, '%Y') = DATE_FORMAT(#{times[0],javaType=java.time.LocalDateTime}, '%Y') - 1) + AND order_date between #{times[0],javaType=java.time.LocalDateTime} and + #{times[1],javaType=java.time.LocalDateTime} GROUP BY time - - diff --git a/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/ErrorCodeConstants.java b/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/ErrorCodeConstants.java index 3b19d616a..8cebd6e13 100644 --- a/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/ErrorCodeConstants.java +++ b/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/ErrorCodeConstants.java @@ -125,4 +125,10 @@ public interface ErrorCodeConstants { ErrorCode DIY_PAGE_NOT_EXISTS = new ErrorCode(1_013_018_000, "装修页面不存在"); ErrorCode DIY_PAGE_NAME_USED = new ErrorCode(1_013_018_001, "装修页面名称({})已经被使用"); + // ========== 客服会话 1-013-019-000 ========== + ErrorCode KEFU_CONVERSATION_NOT_EXISTS = new ErrorCode(1_013_019_000, "客服会话不存在"); + + // ========== 客服消息 1-013-020-000 ========== + ErrorCode KEFU_MESSAGE_NOT_EXISTS = new ErrorCode(1_013_020_000, "客服消息不存在"); + } diff --git a/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/WebSocketMessageTypeConstants.java b/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/WebSocketMessageTypeConstants.java new file mode 100644 index 000000000..4b1c34837 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/WebSocketMessageTypeConstants.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.promotion.enums; + +/** + * Promotion 的 WebSocket 消息类型枚举类 + * + * @author HUIHUI + */ +public interface WebSocketMessageTypeConstants { + + // ======================= mall 客服 ======================= + + String KEFU_MESSAGE_TYPE = "kefu_message_type"; // 客服消息类型 + String KEFU_MESSAGE_ADMIN_READ = "kefu_message_read_status_change"; // 客服消息管理员已读 + +} diff --git a/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/kehu/KeFuMessageContentTypeEnum.java b/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/kehu/KeFuMessageContentTypeEnum.java new file mode 100644 index 000000000..51ee59332 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-api/src/main/java/cn/iocoder/yudao/module/promotion/enums/kehu/KeFuMessageContentTypeEnum.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.promotion.enums.kehu; + +import cn.iocoder.yudao.framework.common.core.IntArrayValuable; +import lombok.AllArgsConstructor; +import lombok.Getter; + +import java.util.Arrays; + +/** + * 客服消息的类型枚举 + * + * @author HUIHUI + */ +@AllArgsConstructor +@Getter +public enum KeFuMessageContentTypeEnum implements IntArrayValuable { + + TEXT(1, "文本消息"), + IMAGE(2, "图片消息"), + VOICE(3, "语音消息"), + VIDEO(4, "视频消息"), + SYSTEM(5, "系统消息"), + + // ========== 商城特殊消息 ========== + PRODUCT(10, "商品消息"), + ORDER(11, "订单消息"); + + private static final int[] ARRAYS = Arrays.stream(values()).mapToInt(KeFuMessageContentTypeEnum::getType).toArray(); + + /** + * 类型 + */ + private final Integer type; + + /** + * 名称 + */ + private final String name; + + @Override + public int[] array() { + return ARRAYS; + } + +} diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/KeFuConversationController.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/KeFuConversationController.java new file mode 100644 index 000000000..8d286a36a --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/KeFuConversationController.java @@ -0,0 +1,69 @@ +package cn.iocoder.yudao.module.promotion.controller.admin.kefu; + +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.conversation.KeFuConversationRespVO; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.conversation.KeFuConversationUpdatePinnedReqVO; +import cn.iocoder.yudao.module.promotion.service.kefu.KeFuConversationService; +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.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +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.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; + +@Tag(name = "管理后台 - 客服会话") +@RestController +@RequestMapping("/promotion/kefu-conversation") +@Validated +public class KeFuConversationController { + + @Resource + private KeFuConversationService conversationService; + @Resource + private MemberUserApi memberUserApi; + + @PutMapping("/update-conversation-pinned") + @Operation(summary = "置顶/取消置顶客服会话") + @PreAuthorize("@ss.hasPermission('promotion:kefu-conversation:update')") + public CommonResult updateConversationPinned(@Valid @RequestBody KeFuConversationUpdatePinnedReqVO updateReqVO) { + conversationService.updateConversationPinnedByAdmin(updateReqVO); + return success(true); + } + + @DeleteMapping("/delete") + @Operation(summary = "删除客服会话") + @Parameter(name = "id", description = "编号", required = true) + @PreAuthorize("@ss.hasPermission('promotion:kefu-conversation:delete')") + public CommonResult deleteConversation(@RequestParam("id") Long id) { + conversationService.deleteKefuConversation(id); + return success(true); + } + + @GetMapping("/list") + @Operation(summary = "获得客服会话列表") + @PreAuthorize("@ss.hasPermission('promotion:kefu-conversation:query')") + public CommonResult> getConversationList() { + // 查询会话列表 + List respList = BeanUtils.toBean(conversationService.getKefuConversationList(), + KeFuConversationRespVO.class); + + // 拼接数据 + Map userMap = memberUserApi.getUserMap(convertSet(respList, KeFuConversationRespVO::getUserId)); + respList.forEach(item-> findAndThen(userMap, item.getUserId(), + memberUser-> item.setUserAvatar(memberUser.getAvatar()).setUserNickname(memberUser.getNickname()))); + return success(respList); + } + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/KeFuMessageController.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/KeFuMessageController.java new file mode 100644 index 000000000..72ca7b066 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/KeFuMessageController.java @@ -0,0 +1,75 @@ +package cn.iocoder.yudao.module.promotion.controller.admin.kefu; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessageRespVO; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessageSendReqVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuMessageDO; +import cn.iocoder.yudao.module.promotion.service.kefu.KeFuMessageService; +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.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList; +import static cn.iocoder.yudao.framework.common.util.collection.MapUtils.findAndThen; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "管理后台 - 客服消息") +@RestController +@RequestMapping("/promotion/kefu-message") +@Validated +public class KeFuMessageController { + + @Resource + private KeFuMessageService messageService; + @Resource + private AdminUserApi adminUserApi; + + @PostMapping("/send") + @Operation(summary = "发送客服消息") + @PreAuthorize("@ss.hasPermission('promotion:kefu-message:send')") + public CommonResult sendKeFuMessage(@Valid @RequestBody KeFuMessageSendReqVO sendReqVO) { + sendReqVO.setSenderId(getLoginUserId()).setSenderType(UserTypeEnum.ADMIN.getValue()); // 设置用户编号和类型 + return success(messageService.sendKefuMessage(sendReqVO)); + } + + @PutMapping("/update-read-status") + @Operation(summary = "更新客服消息已读状态") + @Parameter(name = "conversationId", description = "会话编号", required = true) + @PreAuthorize("@ss.hasPermission('promotion:kefu-message:update')") + public CommonResult updateKeFuMessageReadStatus(@RequestParam("conversationId") Long conversationId) { + messageService.updateKeFuMessageReadStatus(conversationId, getLoginUserId(), UserTypeEnum.ADMIN.getValue()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得客服消息分页") + @PreAuthorize("@ss.hasPermission('promotion:kefu-message:query')") + public CommonResult> getKeFuMessagePage(@Valid KeFuMessagePageReqVO pageReqVO) { + // 获得数据 + PageResult pageResult = messageService.getKeFuMessagePage(pageReqVO); + + // 拼接数据 + PageResult result = BeanUtils.toBean(pageResult, KeFuMessageRespVO.class); + Map userMap = adminUserApi.getUserMap(convertSet(filterList(result.getList(), + item -> UserTypeEnum.ADMIN.getValue().equals(item.getSenderType())), KeFuMessageRespVO::getSenderId)); + result.getList().forEach(item-> findAndThen(userMap, item.getSenderId(), + user -> item.setSenderAvatar(user.getAvatar()))); + return success(result); + } + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/conversation/KeFuConversationRespVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/conversation/KeFuConversationRespVO.java new file mode 100644 index 000000000..98cd5acd8 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/conversation/KeFuConversationRespVO.java @@ -0,0 +1,46 @@ +package cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.conversation; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "管理后台 - 客服会话 Response VO") +@Data +public class KeFuConversationRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24988") + private Long id; + + @Schema(description = "会话所属用户", requiredMode = Schema.RequiredMode.REQUIRED, example = "8300") + private Long userId; + @Schema(description = "会话所属用户头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://yudao.com/images/avatar.jpg") + private String userAvatar; + @Schema(description = "会话所属用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋道") + private String userNickname; + + @Schema(description = "最后聊天时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime lastMessageTime; + + @Schema(description = "最后聊天内容", requiredMode = Schema.RequiredMode.REQUIRED, example = "嗨,您好啊") + private String lastMessageContent; + + @Schema(description = "最后发送的消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer lastMessageContentType; + + @Schema(description = "管理端置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + private Boolean adminPinned; + + @Schema(description = "用户是否可见", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean userDeleted; + + @Schema(description = "管理员是否可见", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean adminDeleted; + + @Schema(description = "管理员未读消息数", requiredMode = Schema.RequiredMode.REQUIRED, example = "6") + private Integer adminUnreadMessageCount; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/conversation/KeFuConversationUpdatePinnedReqVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/conversation/KeFuConversationUpdatePinnedReqVO.java new file mode 100644 index 000000000..9b108891a --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/conversation/KeFuConversationUpdatePinnedReqVO.java @@ -0,0 +1,19 @@ +package cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.conversation; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 客服会话置顶 Request VO") +@Data +public class KeFuConversationUpdatePinnedReqVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "23202") + @NotNull(message = "会话编号不能为空") + private Long id; + + @Schema(description = "管理端置顶", requiredMode = Schema.RequiredMode.REQUIRED, example = "false") + @NotNull(message = "管理端置顶不能为空") + private Boolean adminPinned; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessagePageReqVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessagePageReqVO.java new file mode 100644 index 000000000..f60c997be --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessagePageReqVO.java @@ -0,0 +1,15 @@ +package cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message; + +import lombok.*; +import io.swagger.v3.oas.annotations.media.Schema; +import cn.iocoder.yudao.framework.common.pojo.PageParam; + +@Schema(description = "管理后台 - 客服消息分页 Request VO") +@Data +@ToString(callSuper = true) +public class KeFuMessagePageReqVO extends PageParam { + + @Schema(description = "会话编号", example = "12580") + private Long conversationId; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessageRespVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessageRespVO.java new file mode 100644 index 000000000..248160dd9 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessageRespVO.java @@ -0,0 +1,45 @@ +package cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; +import com.alibaba.excel.annotation.*; + +@Schema(description = "管理后台 - 客服消息 Response VO") +@Data +public class KeFuMessageRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "23202") + private Long id; + + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12580") + private Long conversationId; + + @Schema(description = "发送人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24571") + private Long senderId; + @Schema(description = "发送人头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://yudao.com/images/avatar.jpg") + private String senderAvatar; + + @Schema(description = "发送人类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer senderType; + + @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "29124") + private Long receiverId; + + @Schema(description = "接收人类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer receiverType; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer contentType; + + @Schema(description = "消息", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "是否已读", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Boolean readStatus; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessageSendReqVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessageSendReqVO.java new file mode 100644 index 000000000..fb4e1a26d --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/kefu/vo/message/KeFuMessageSendReqVO.java @@ -0,0 +1,31 @@ +package cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "管理后台 - 发送客服消息 Request VO") +@Data +public class KeFuMessageSendReqVO { + + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12580") + @NotNull(message = "会话编号不能为空") + private Long conversationId; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "消息类型不能为空") + private Integer contentType; + + @Schema(description = "消息", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "消息不能为空") + private String content; + + // ========== 后端设置的参数,前端无需传递 ========== + + @Schema(description = "发送人编号", example = "24571", hidden = true) + private Long senderId; + @Schema(description = "发送人类型", example = "1", hidden = true) + private Integer senderType; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/seckill/SeckillConfigController.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/seckill/SeckillConfigController.java index 21b9a1400..093003bac 100644 --- a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/seckill/SeckillConfigController.java +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/admin/seckill/SeckillConfigController.java @@ -78,9 +78,9 @@ public class SeckillConfigController { return success(SeckillConfigConvert.INSTANCE.convertList(list)); } - @GetMapping("/list-all-simple") + @GetMapping("/simple-list") @Operation(summary = "获得所有开启状态的秒杀时段精简列表", description = "主要用于前端的下拉选项") - public CommonResult> getListAllSimple() { + public CommonResult> getSeckillConfigSimpleList() { List list = seckillConfigService.getSeckillConfigListByStatus( CommonStatusEnum.ENABLE.getStatus()); return success(SeckillConfigConvert.INSTANCE.convertList1(list)); diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/AppKeFuMessageController.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/AppKeFuMessageController.java new file mode 100644 index 000000000..2c99c75cb --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/AppKeFuMessageController.java @@ -0,0 +1,58 @@ +package cn.iocoder.yudao.module.promotion.controller.app.kefu; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.CommonResult; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.framework.security.core.annotations.PreAuthenticated; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessageRespVO; +import cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message.AppKeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message.AppKeFuMessageSendReqVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuMessageDO; +import cn.iocoder.yudao.module.promotion.service.kefu.KeFuMessageService; +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.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; + +import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; +import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; + +@Tag(name = "用户 APP - 客服消息") +@RestController +@RequestMapping("/promotion/kefu-message") +@Validated +public class AppKeFuMessageController { + + @Resource + private KeFuMessageService kefuMessageService; + + @PostMapping("/send") + @Operation(summary = "发送客服消息") + @PreAuthenticated + public CommonResult sendKefuMessage(@Valid @RequestBody AppKeFuMessageSendReqVO sendReqVO) { + sendReqVO.setSenderId(getLoginUserId()).setSenderType(UserTypeEnum.MEMBER.getValue()); // 设置用户编号和类型 + return success(kefuMessageService.sendKefuMessage(sendReqVO)); + } + + @PutMapping("/update-read-status") + @Operation(summary = "更新客服消息已读状态") + @Parameter(name = "conversationId", description = "会话编号", required = true) + @PreAuthenticated + public CommonResult updateKefuMessageReadStatus(@RequestParam("conversationId") Long conversationId) { + kefuMessageService.updateKeFuMessageReadStatus(conversationId, getLoginUserId(), UserTypeEnum.MEMBER.getValue()); + return success(true); + } + + @GetMapping("/page") + @Operation(summary = "获得客服消息分页") + @PreAuthenticated + public CommonResult> getKefuMessagePage(@Valid AppKeFuMessagePageReqVO pageReqVO) { + PageResult pageResult = kefuMessageService.getKeFuMessagePage(pageReqVO, getLoginUserId()); + return success(BeanUtils.toBean(pageResult, KeFuMessageRespVO.class)); + } + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessagePageReqVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessagePageReqVO.java new file mode 100644 index 000000000..a354a5858 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessagePageReqVO.java @@ -0,0 +1,17 @@ +package cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message; + +import cn.iocoder.yudao.framework.common.pojo.PageParam; +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; + +@Schema(description = "用户 App - 客服消息分页 Request VO") +@Data +@ToString(callSuper = true) +public class AppKeFuMessagePageReqVO extends PageParam { + + @Schema(description = "会话编号", example = "12580") + private Long conversationId; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessageRespVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessageRespVO.java new file mode 100644 index 000000000..fb7331afc --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessageRespVO.java @@ -0,0 +1,42 @@ +package cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Data; + +import java.time.LocalDateTime; + +@Schema(description = "用户 App - 客服消息 Response VO") +@Data +public class AppKeFuMessageRespVO { + + @Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "23202") + private Long id; + + @Schema(description = "会话编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "12580") + private Long conversationId; + + @Schema(description = "发送人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24571") + private Long senderId; + + @Schema(description = "发送人类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer senderType; + + @Schema(description = "接收人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "29124") + private Long receiverId; + + @Schema(description = "接收人类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "2") + private Integer receiverType; + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Integer contentType; + + @Schema(description = "消息", requiredMode = Schema.RequiredMode.REQUIRED) + private String content; + + @Schema(description = "是否已读", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Boolean readStatus; + + @Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED) + private LocalDateTime createTime; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessageSendReqVO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessageSendReqVO.java new file mode 100644 index 000000000..5795254f2 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/message/AppKeFuMessageSendReqVO.java @@ -0,0 +1,26 @@ +package cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +@Schema(description = "用户 App - 发送客服消息 Request VO") +@Data +public class AppKeFuMessageSendReqVO { + + @Schema(description = "消息类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + @NotNull(message = "消息类型不能为空") + private Integer contentType; + @Schema(description = "消息", requiredMode = Schema.RequiredMode.REQUIRED) + @NotEmpty(message = "消息不能为空") + private String content; + + // ========== 后端设置的参数,前端无需传递 ========== + + @Schema(description = "发送人编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "24571", hidden = true) + private Long senderId; + @Schema(description = "发送人类型", requiredMode = Schema.RequiredMode.REQUIRED, example = "1", hidden = true) + private Integer senderType; + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/package-info.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/package-info.java new file mode 100644 index 000000000..9116686b0 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/kefu/vo/package-info.java @@ -0,0 +1 @@ +package cn.iocoder.yudao.module.promotion.controller.app.kefu.vo; \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/kefu/KeFuConversationDO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/kefu/KeFuConversationDO.java new file mode 100644 index 000000000..e9a73284f --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/kefu/KeFuConversationDO.java @@ -0,0 +1,83 @@ +package cn.iocoder.yudao.module.promotion.dal.dataobject.kefu; + +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO; +import cn.iocoder.yudao.module.promotion.enums.kehu.KeFuMessageContentTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 客服会话 DO + * + * @author HUIHUI + */ +@TableName("promotion_kefu_conversation") +@KeySequence("promotion_kefu_conversation_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KeFuConversationDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 会话所属用户 + * + * 关联 {@link MemberUserRespDTO#getId()} + */ + private Long userId; + + /** + * 最后聊天时间 + */ + private LocalDateTime lastMessageTime; + /** + * 最后聊天内容 + */ + private String lastMessageContent; + /** + * 最后发送的消息类型 + * + * 枚举 {@link KeFuMessageContentTypeEnum} + */ + private Integer lastMessageContentType; + + //======================= 会话操作相关 ======================= + + /** + * 管理端置顶 + */ + private Boolean adminPinned; + /** + * 用户是否可见 + * + * false - 可见,默认值 + * true - 不可见,用户删除时设置为 true + */ + private Boolean userDeleted; + /** + * 管理员是否可见 + * + * false - 可见,默认值 + * true - 不可见,管理员删除时设置为 true + */ + private Boolean adminDeleted; + + /** + * 管理员未读消息数 + * + * 用户发送消息时增加,管理员查看后扣减 + */ + private Integer adminUnreadMessageCount; + +} diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/kefu/KeFuMessageDO.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/kefu/KeFuMessageDO.java new file mode 100644 index 000000000..bd542f890 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/dataobject/kefu/KeFuMessageDO.java @@ -0,0 +1,81 @@ +package cn.iocoder.yudao.module.promotion.dal.dataobject.kefu; + +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO; +import cn.iocoder.yudao.module.promotion.enums.kehu.KeFuMessageContentTypeEnum; +import com.baomidou.mybatisplus.annotation.KeySequence; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.*; + +/** + * 客服消息 DO + * + * @author HUIHUI + */ +@TableName("promotion_kefu_message") +@KeySequence("promotion_kefu_message_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。 +@Data +@EqualsAndHashCode(callSuper = true) +@ToString(callSuper = true) +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class KeFuMessageDO extends BaseDO { + + /** + * 编号 + */ + @TableId + private Long id; + /** + * 会话编号 + * + * 关联 {@link KeFuConversationDO#getId()} + */ + private Long conversationId; + + /** + * 发送人编号 + * + * 存储的是用户编号 + */ + private Long senderId; + /** + * 发送人类型 + * + * 枚举,{@link UserTypeEnum} + */ + private Integer senderType; + /** + * 接收人编号 + * + * 存储的是用户编号 + */ + private Long receiverId; + /** + * 接收人类型 + * + * 枚举,{@link UserTypeEnum} + */ + private Integer receiverType; + + /** + * 消息类型 + * + * 枚举 {@link KeFuMessageContentTypeEnum} + */ + private Integer contentType; + /** + * 消息 + */ + private String content; + + //======================= 消息相关状态 ======================= + + /** + * 是/否已读 + */ + private Boolean readStatus; + +} diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/kefu/KeFuConversationMapper.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/kefu/KeFuConversationMapper.java new file mode 100644 index 000000000..40efa44a5 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/kefu/KeFuConversationMapper.java @@ -0,0 +1,35 @@ +package cn.iocoder.yudao.module.promotion.dal.mysql.kefu; + +import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX; +import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuConversationDO; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.List; + +/** + * 客服会话 Mapper + * + * @author HUIHUI + */ +@Mapper +public interface KeFuConversationMapper extends BaseMapperX { + + default List selectConversationList() { + return selectList(new LambdaQueryWrapperX() + .eq(KeFuConversationDO::getAdminDeleted, Boolean.FALSE) + .orderByDesc(KeFuConversationDO::getCreateTime)); + } + + default void updateAdminUnreadMessageCountIncrement(Long id) { + update(new LambdaUpdateWrapper() + .eq(KeFuConversationDO::getId, id) + .setSql("admin_unread_message_count = admin_unread_message_count + 1")); + } + + default KeFuConversationDO selectByUserId(Long userId) { + return selectOne(KeFuConversationDO::getUserId, userId); + } + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/kefu/KeFuMessageMapper.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/kefu/KeFuMessageMapper.java new file mode 100644 index 000000000..f565fd7f0 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/dal/mysql/kefu/KeFuMessageMapper.java @@ -0,0 +1,49 @@ +package cn.iocoder.yudao.module.promotion.dal.mysql.kefu; + +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.module.promotion.controller.admin.kefu.vo.message.KeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message.AppKeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuMessageDO; +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import org.apache.ibatis.annotations.Mapper; + +import java.util.Collection; +import java.util.List; + +/** + * 客服消息 Mapper + * + * @author HUIHUI + */ +@Mapper +public interface KeFuMessageMapper extends BaseMapperX { + + default PageResult selectPage(KeFuMessagePageReqVO reqVO) { + return selectPage(reqVO, new LambdaQueryWrapperX() + .eqIfPresent(KeFuMessageDO::getConversationId, reqVO.getConversationId()) + .orderByDesc(KeFuMessageDO::getCreateTime)); + } + + default List selectListByConversationIdAndUserTypeAndReadStatus(Long conversationId, Integer userType, + Boolean readStatus) { + return selectList(new LambdaQueryWrapper() + .eq(KeFuMessageDO::getConversationId, conversationId) + .ne(KeFuMessageDO::getSenderType, userType) // 管理员:查询出未读的会员消息,会员:查询出未读的客服消息 + .eq(KeFuMessageDO::getReadStatus, readStatus)); + } + + default void updateReadStatusBatchByIds(Collection ids, KeFuMessageDO keFuMessageDO) { + update(keFuMessageDO, new LambdaUpdateWrapper() + .in(KeFuMessageDO::getId, ids)); + } + + default PageResult selectPage(AppKeFuMessagePageReqVO pageReqVO) { + return selectPage(pageReqVO, new LambdaQueryWrapperX() + .eqIfPresent(KeFuMessageDO::getConversationId, pageReqVO.getConversationId()) + .orderByDesc(KeFuMessageDO::getCreateTime)); + } + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuConversationService.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuConversationService.java new file mode 100644 index 000000000..2da8d0bc6 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuConversationService.java @@ -0,0 +1,85 @@ +package cn.iocoder.yudao.module.promotion.service.kefu; + +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.conversation.KeFuConversationUpdatePinnedReqVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuConversationDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuMessageDO; + +import java.util.List; + +/** + * 客服会话 Service 接口 + * + * @author HUIHUI + */ +public interface KeFuConversationService { + + /** + * 【管理员】删除客服会话 + * + * @param id 编号 + */ + void deleteKefuConversation(Long id); + + /** + * 【管理员】客服会话置顶 + * + * @param updateReqVO 请求 + */ + void updateConversationPinnedByAdmin(KeFuConversationUpdatePinnedReqVO updateReqVO); + + /** + * 更新会话客服消息冗余信息 + * + * @param kefuMessage 消息 + */ + void updateConversationLastMessage(KeFuMessageDO kefuMessage); + + /** + * 【管理员】将管理员未读消息计数更新为零 + * + * @param id 编号 + */ + void updateAdminUnreadMessageCountToZero(Long id); + + /** + * 【管理员】更新会话对于管理员是否可见 + * + * @param id 编号 + * @param adminDeleted 管理员是否可见 + */ + void updateConversationAdminDeleted(Long id, Boolean adminDeleted); + + /** + * 【管理员】获得客服会话列表 + * + * @return 会话列表 + */ + List getKefuConversationList(); + + /** + * 【会员】获得或创建会话 + * + * 对于【会员】来说,有且仅有一个对话 + * + * @param userId 用户编号 + * @return 客服会话 + */ + KeFuConversationDO getOrCreateConversation(Long userId); + + /** + * 校验客服会话是否存在 + * + * @param id 编号 + * @return 客服会话 + */ + KeFuConversationDO validateKefuConversationExists(Long id); + + /** + * 【会员】获得客服会话 + * + * @param userId 用户编号 + * @return 客服会话 + */ + KeFuConversationDO getConversationByUserId(Long userId); + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuConversationServiceImpl.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuConversationServiceImpl.java new file mode 100644 index 000000000..fbc658abc --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuConversationServiceImpl.java @@ -0,0 +1,118 @@ +package cn.iocoder.yudao.module.promotion.service.kefu; + +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.conversation.KeFuConversationUpdatePinnedReqVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuConversationDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuMessageDO; +import cn.iocoder.yudao.module.promotion.dal.mysql.kefu.KeFuConversationMapper; +import cn.iocoder.yudao.module.promotion.enums.kehu.KeFuMessageContentTypeEnum; +import jakarta.annotation.Resource; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.time.LocalDateTime; +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.KEFU_CONVERSATION_NOT_EXISTS; + +/** + * 客服会话 Service 实现类 + * + * @author HUIHUI + */ +@Service +@Validated +public class KeFuConversationServiceImpl implements KeFuConversationService { + + @Resource + private KeFuConversationMapper conversationMapper; + + @Override + public void deleteKefuConversation(Long id) { + // 校验存在 + validateKefuConversationExists(id); + + // 只有管理员端可以删除会话,也不真的删,只是管理员端看不到啦 + conversationMapper.updateById(new KeFuConversationDO().setId(id).setAdminDeleted(Boolean.TRUE)); + } + + @Override + public void updateConversationPinnedByAdmin(KeFuConversationUpdatePinnedReqVO updateReqVO) { + // 校验存在 + validateKefuConversationExists(updateReqVO.getId()); + + // 更新管理员会话置顶状态 + conversationMapper.updateById(new KeFuConversationDO().setId(updateReqVO.getId()).setAdminPinned(updateReqVO.getAdminPinned())); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateConversationLastMessage(KeFuMessageDO kefuMessage) { + // 1.1 校验会话是否存在 + KeFuConversationDO conversation = validateKefuConversationExists(kefuMessage.getConversationId()); + // 1.2 更新会话消息冗余 + conversationMapper.updateById(new KeFuConversationDO().setId(kefuMessage.getConversationId()) + .setLastMessageTime(kefuMessage.getCreateTime()).setLastMessageContent(kefuMessage.getContent()) + .setLastMessageContentType(kefuMessage.getContentType())); + + // 2.1 更新管理员未读消息数 + if (UserTypeEnum.MEMBER.getValue().equals(kefuMessage.getSenderType())) { + conversationMapper.updateAdminUnreadMessageCountIncrement(kefuMessage.getConversationId()); + } + // 2.2 会员用户发送消息时,如果管理员删除过会话则进行恢复 + if (Boolean.TRUE.equals(conversation.getAdminDeleted())) { + updateConversationAdminDeleted(kefuMessage.getConversationId(), Boolean.FALSE); + } + } + + @Override + public void updateAdminUnreadMessageCountToZero(Long id) { + // 校验存在 + validateKefuConversationExists(id); + + // 管理员未读消息数归零 + conversationMapper.updateById(new KeFuConversationDO().setId(id).setAdminUnreadMessageCount(0)); + } + + @Override + public void updateConversationAdminDeleted(Long id, Boolean adminDeleted) { + conversationMapper.updateById(new KeFuConversationDO().setId(id).setAdminDeleted(adminDeleted)); + } + + @Override + public List getKefuConversationList() { + return conversationMapper.selectConversationList(); + } + + @Override + public KeFuConversationDO getOrCreateConversation(Long userId) { + KeFuConversationDO conversation = conversationMapper.selectOne(KeFuConversationDO::getUserId, userId); + // 没有历史会话,则初始化一个新会话 + if (conversation == null) { + conversation = new KeFuConversationDO().setUserId(userId).setLastMessageTime(LocalDateTime.now()) + .setLastMessageContent(StrUtil.EMPTY).setLastMessageContentType(KeFuMessageContentTypeEnum.TEXT.getType()) + .setAdminPinned(Boolean.FALSE).setUserDeleted(Boolean.FALSE).setAdminDeleted(Boolean.FALSE) + .setAdminUnreadMessageCount(0); + conversationMapper.insert(conversation); + } + return conversation; + } + + @Override + public KeFuConversationDO validateKefuConversationExists(Long id) { + KeFuConversationDO conversation = conversationMapper.selectById(id); + if (conversation == null) { + throw exception(KEFU_CONVERSATION_NOT_EXISTS); + } + return conversation; + } + + @Override + public KeFuConversationDO getConversationByUserId(Long userId) { + return conversationMapper.selectByUserId(userId); + } + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuMessageService.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuMessageService.java new file mode 100644 index 000000000..8af4f128e --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuMessageService.java @@ -0,0 +1,60 @@ +package cn.iocoder.yudao.module.promotion.service.kefu; + +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessageSendReqVO; +import cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message.AppKeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message.AppKeFuMessageSendReqVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuMessageDO; +import jakarta.validation.Valid; + +/** + * 客服消息 Service 接口 + * + * @author HUIHUI + */ +public interface KeFuMessageService { + + /** + * 【管理员】发送客服消息 + * + * @param sendReqVO 信息 + * @return 编号 + */ + Long sendKefuMessage(@Valid KeFuMessageSendReqVO sendReqVO); + + /** + * 【会员】发送客服消息 + * + * @param sendReqVO 信息 + * @return 编号 + */ + Long sendKefuMessage(AppKeFuMessageSendReqVO sendReqVO); + + /** + * 【管理员】更新消息已读状态 + * + * @param conversationId 会话编号 + * @param userId 用户编号 + * @param userType 用户类型 + */ + void updateKeFuMessageReadStatus(Long conversationId, Long userId, Integer userType); + + /** + * 获得客服消息分页 + * + * @param pageReqVO 分页查询 + * @return 客服消息分页 + */ + PageResult getKeFuMessagePage(KeFuMessagePageReqVO pageReqVO); + + /** + * 【会员】获得客服消息分页 + * + * @param pageReqVO 请求 + * @param userId 用户编号 + * @return 客服消息分页 + */ + PageResult getKeFuMessagePage(AppKeFuMessagePageReqVO pageReqVO, Long userId); + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuMessageServiceImpl.java b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuMessageServiceImpl.java new file mode 100644 index 000000000..b47cd9004 --- /dev/null +++ b/yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/service/kefu/KeFuMessageServiceImpl.java @@ -0,0 +1,161 @@ +package cn.iocoder.yudao.module.promotion.service.kefu; + +import cn.hutool.core.collection.CollUtil; +import cn.hutool.core.util.ObjUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.extra.spring.SpringUtil; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; +import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.infra.api.websocket.WebSocketSenderApi; +import cn.iocoder.yudao.module.member.api.user.MemberUserApi; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.controller.admin.kefu.vo.message.KeFuMessageSendReqVO; +import cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message.AppKeFuMessagePageReqVO; +import cn.iocoder.yudao.module.promotion.controller.app.kefu.vo.message.AppKeFuMessageSendReqVO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuConversationDO; +import cn.iocoder.yudao.module.promotion.dal.dataobject.kefu.KeFuMessageDO; +import cn.iocoder.yudao.module.promotion.dal.mysql.kefu.KeFuMessageMapper; +import cn.iocoder.yudao.module.system.api.user.AdminUserApi; +import jakarta.annotation.Resource; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.*; +import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.KEFU_CONVERSATION_NOT_EXISTS; +import static cn.iocoder.yudao.module.promotion.enums.WebSocketMessageTypeConstants.KEFU_MESSAGE_ADMIN_READ; +import static cn.iocoder.yudao.module.promotion.enums.WebSocketMessageTypeConstants.KEFU_MESSAGE_TYPE; + +/** + * 客服消息 Service 实现类 + * + * @author HUIHUI + */ +@Service +@Validated +public class KeFuMessageServiceImpl implements KeFuMessageService { + + @Resource + private KeFuMessageMapper keFuMessageMapper; + @Resource + private KeFuConversationService conversationService; + @Resource + private AdminUserApi adminUserApi; + @Resource + private MemberUserApi memberUserApi; + @Resource + private WebSocketSenderApi webSocketSenderApi; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long sendKefuMessage(KeFuMessageSendReqVO sendReqVO) { + // 1.1 校验会话是否存在 + KeFuConversationDO conversation = conversationService.validateKefuConversationExists(sendReqVO.getConversationId()); + // 1.2 校验接收人是否存在 + validateReceiverExist(conversation.getUserId(), UserTypeEnum.MEMBER.getValue()); + + // 2.1 保存消息 + KeFuMessageDO kefuMessage = BeanUtils.toBean(sendReqVO, KeFuMessageDO.class); + kefuMessage.setReceiverId(conversation.getUserId()).setReceiverType(UserTypeEnum.MEMBER.getValue()); // 设置接收人 + keFuMessageMapper.insert(kefuMessage); + // 2.2 更新会话消息冗余 + conversationService.updateConversationLastMessage(kefuMessage); + + // 3.1 发送消息给会员 + getSelf().sendAsyncMessageToMember(conversation.getUserId(), KEFU_MESSAGE_TYPE, kefuMessage); + // 3.2 通知所有管理员更新对话 + getSelf().sendAsyncMessageToAdmin(KEFU_MESSAGE_TYPE, kefuMessage); + return kefuMessage.getId(); + } + + @Override + public Long sendKefuMessage(AppKeFuMessageSendReqVO sendReqVO) { + // 1.1 设置会话编号 + KeFuMessageDO kefuMessage = BeanUtils.toBean(sendReqVO, KeFuMessageDO.class); + KeFuConversationDO conversation = conversationService.getOrCreateConversation(sendReqVO.getSenderId()); + kefuMessage.setConversationId(conversation.getId()); + // 1.2 保存消息 + keFuMessageMapper.insert(kefuMessage); + + // 2. 更新会话消息冗余 + conversationService.updateConversationLastMessage(kefuMessage); + // 3. 通知所有管理员更新对话 + getSelf().sendAsyncMessageToAdmin(KEFU_MESSAGE_TYPE, kefuMessage); + return kefuMessage.getId(); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public void updateKeFuMessageReadStatus(Long conversationId, Long userId, Integer userType) { + // 1.1 校验会话是否存在 + KeFuConversationDO conversation = conversationService.validateKefuConversationExists(conversationId); + // 1.2 如果是会员端处理已读,需要传递 userId;万一用户模拟一个 conversationId + if (UserTypeEnum.MEMBER.getValue().equals(userType) && ObjUtil.notEqual(conversation.getUserId(), userId)) { + throw exception(KEFU_CONVERSATION_NOT_EXISTS); + } + // 1.3 查询会话所有的未读消息 (tips: 多个客服,一个人点了,就都点了) + List messageList = keFuMessageMapper.selectListByConversationIdAndUserTypeAndReadStatus(conversationId, userType, Boolean.FALSE); + if (CollUtil.isEmpty(messageList)) { + return; + } + + // 2.1 情况二:更新未读消息状态为已读 + keFuMessageMapper.updateReadStatusBatchByIds(convertSet(messageList, KeFuMessageDO::getId), + new KeFuMessageDO().setReadStatus(Boolean.TRUE)); + // 2.2 将管理员未读消息计数更新为零 + conversationService.updateAdminUnreadMessageCountToZero(conversationId); + + // 2.3 发送消息通知会员,管理员已读 -> 会员更新发送的消息状态 + KeFuMessageDO keFuMessage = getFirst(filterList(messageList, message -> UserTypeEnum.MEMBER.getValue().equals(message.getSenderType()))); + assert keFuMessage != null; // 断言避免警告 + getSelf().sendAsyncMessageToMember(keFuMessage.getSenderId(), KEFU_MESSAGE_ADMIN_READ, StrUtil.EMPTY); + // 2.4 通知所有管理员消息已读 + getSelf().sendAsyncMessageToAdmin(KEFU_MESSAGE_ADMIN_READ, StrUtil.EMPTY); + } + + private void validateReceiverExist(Long receiverId, Integer receiverType) { + if (UserTypeEnum.ADMIN.getValue().equals(receiverType)) { + adminUserApi.validateUser(receiverId); + } + if (UserTypeEnum.MEMBER.getValue().equals(receiverType)) { + memberUserApi.validateUser(receiverId); + } + } + + @Async + public void sendAsyncMessageToMember(Long userId, String messageType, Object content) { + webSocketSenderApi.sendObject(UserTypeEnum.MEMBER.getValue(), userId, messageType, content); + } + + @Async + public void sendAsyncMessageToAdmin(String messageType, Object content) { + webSocketSenderApi.sendObject(UserTypeEnum.ADMIN.getValue(), messageType, content); + } + + @Override + public PageResult getKeFuMessagePage(KeFuMessagePageReqVO pageReqVO) { + return keFuMessageMapper.selectPage(pageReqVO); + } + + @Override + public PageResult getKeFuMessagePage(AppKeFuMessagePageReqVO pageReqVO, Long userId) { + // 1. 获得客服会话 + KeFuConversationDO conversation = conversationService.getConversationByUserId(userId); + if (conversation == null) { + return PageResult.empty(); + } + // 2. 设置会话编号 + pageReqVO.setConversationId(conversation.getId()); + return keFuMessageMapper.selectPage(pageReqVO); + } + + private KeFuMessageServiceImpl getSelf() { + return SpringUtil.getBean(getClass()); + } + +} \ No newline at end of file diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java index bb2ba59fe..202ed3c42 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/AppBrokerageUserController.java @@ -20,13 +20,13 @@ import cn.iocoder.yudao.module.trade.service.brokerage.bo.BrokerageWithdrawSumma 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 lombok.extern.slf4j.Slf4j; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; import java.time.LocalDateTime; import java.util.Collections; import java.util.Map; @@ -50,7 +50,6 @@ public class AppBrokerageUserController { private BrokerageRecordService brokerageRecordService; @Resource private BrokerageWithdrawService brokerageWithdrawService; - @Resource private MemberUserApi memberUserApi; @@ -58,7 +57,7 @@ public class AppBrokerageUserController { @Operation(summary = "获得个人分销信息") @PreAuthenticated public CommonResult getBrokerageUser() { - Optional user = Optional.ofNullable(brokerageUserService.getBrokerageUser(getLoginUserId())); + Optional user = Optional.ofNullable(brokerageUserService.getOrCreateBrokerageUser(getLoginUserId())); // 返回数据 AppBrokerageUserRespVO respVO = new AppBrokerageUserRespVO() .setBrokerageEnabled(user.map(BrokerageUserDO::getBrokerageEnabled).orElse(false)) @@ -79,21 +78,22 @@ public class AppBrokerageUserController { @PreAuthenticated public CommonResult getBrokerageUserSummary() { // 查询当前登录用户信息 - BrokerageUserDO brokerageUser = brokerageUserService.getBrokerageUser(getLoginUserId()); + Long userId = getLoginUserId(); + BrokerageUserDO brokerageUser = brokerageUserService.getBrokerageUser(userId); // 统计用户昨日的佣金 LocalDateTime yesterday = LocalDateTime.now().minusDays(1); LocalDateTime beginTime = LocalDateTimeUtil.beginOfDay(yesterday); LocalDateTime endTime = LocalDateTimeUtil.endOfDay(yesterday); - Integer yesterdayPrice = brokerageRecordService.getSummaryPriceByUserId(brokerageUser.getId(), + Integer yesterdayPrice = brokerageRecordService.getSummaryPriceByUserId(userId, BrokerageRecordBizTypeEnum.ORDER, BrokerageRecordStatusEnum.SETTLEMENT, beginTime, endTime); // 统计用户提现的佣金 - Integer withdrawPrice = brokerageWithdrawService.getWithdrawSummaryListByUserId(Collections.singleton(brokerageUser.getId()), + Integer withdrawPrice = brokerageWithdrawService.getWithdrawSummaryListByUserId(Collections.singleton(userId), BrokerageWithdrawStatusEnum.AUDIT_SUCCESS).stream() .findFirst().map(BrokerageWithdrawSummaryRespBO::getPrice).orElse(0); // 统计分销用户数量(一级) - Long firstBrokerageUserCount = brokerageUserService.getBrokerageUserCountByBindUserId(brokerageUser.getId(), 1); + Long firstBrokerageUserCount = brokerageUserService.getBrokerageUserCountByBindUserId(userId, 1); // 统计分销用户数量(二级) - Long secondBrokerageUserCount = brokerageUserService.getBrokerageUserCountByBindUserId(brokerageUser.getId(), 2); + Long secondBrokerageUserCount = brokerageUserService.getBrokerageUserCountByBindUserId(userId, 2); // 拼接返回 return success(BrokerageUserConvert.INSTANCE.convert(yesterdayPrice, withdrawPrice, firstBrokerageUserCount, secondBrokerageUserCount, brokerageUser)); diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserBindReqVO.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserBindReqVO.java index e6f2982eb..db23d8e20 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserBindReqVO.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/controller/app/brokerage/vo/user/AppBrokerageUserBindReqVO.java @@ -8,7 +8,7 @@ import jakarta.validation.constraints.NotNull; @Schema(description = "应用 App - 绑定推广员 Request VO") @Data -public class AppBrokerageUserBindReqVO extends PageParam { +public class AppBrokerageUserBindReqVO { @Schema(description = "推广员编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024") @NotNull(message = "推广员编号不能为空") diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java index a50eedda1..7b6e4b110 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserService.java @@ -7,8 +7,8 @@ import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokera import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankByUserCountRespVO; import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.user.AppBrokerageUserRankPageReqVO; import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageUserDO; - import jakarta.validation.constraints.NotNull; + import java.util.Collection; import java.util.List; @@ -67,6 +67,14 @@ public interface BrokerageUserService { */ BrokerageUserDO getBindBrokerageUser(Long id); + /** + * 获得或创建分销用户 + * + * @param id 用户编号 + * @return 分销用户 + */ + BrokerageUserDO getOrCreateBrokerageUser(Long id); + /** * 更新用户佣金 * @@ -104,8 +112,8 @@ public interface BrokerageUserService { /** * 【会员】绑定推广员 * - * @param userId 用户编号 - * @param bindUserId 推广员编号 + * @param userId 用户编号 + * @param bindUserId 推广员编号 * @return 是否绑定 */ boolean bindBrokerageUser(@NotNull Long userId, @NotNull Long bindUserId); @@ -134,4 +142,5 @@ public interface BrokerageUserService { * @return 下级分销统计分页 */ PageResult getBrokerageUserChildSummaryPage(AppBrokerageUserChildSummaryPageReqVO pageReqVO, Long userId); + } diff --git a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java index 4e998f412..e8e546902 100644 --- a/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java +++ b/yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageUserServiceImpl.java @@ -4,6 +4,7 @@ import cn.hutool.core.collection.CollUtil; import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ArrayUtil; import cn.hutool.core.util.BooleanUtil; +import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.StrUtil; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils; @@ -25,10 +26,10 @@ import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum; import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordStatusEnum; import cn.iocoder.yudao.module.trade.service.config.TradeConfigService; import com.baomidou.mybatisplus.core.metadata.IPage; +import jakarta.annotation.Resource; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; -import jakarta.annotation.Resource; import java.time.LocalDateTime; import java.util.*; @@ -127,6 +128,19 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { .orElse(null); } + @Override + public BrokerageUserDO getOrCreateBrokerageUser(Long id) { + BrokerageUserDO brokerageUser = brokerageUserMapper.selectById(id); + // 特殊:人人分销的情况下,如果分销人为空则创建分销人 + if (brokerageUser == null && ObjUtil.equal(BrokerageEnabledConditionEnum.ALL.getCondition(), + tradeConfigService.getTradeConfig().getBrokerageEnabledCondition())) { + brokerageUser = new BrokerageUserDO().setId(id).setBrokerageEnabled(true).setBrokeragePrice(0) + .setBrokerageTime(LocalDateTime.now()).setFrozenPrice(0); + brokerageUserMapper.insert(brokerageUser); + } + return brokerageUser; + } + @Override public boolean updateUserPrice(Long id, Integer price) { if (price > 0) { @@ -184,7 +198,6 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { if (BrokerageEnabledConditionEnum.ALL.getCondition().equals(enabledCondition)) { // 人人分销:用户默认就有分销资格 brokerageUser.setBrokerageEnabled(true).setBrokerageTime(LocalDateTime.now()); } - brokerageUser.setBindUserId(bindUserId).setBindUserTime(LocalDateTime.now()); brokerageUserMapper.insert(fillBindUserData(bindUserId, brokerageUser)); } else { brokerageUserMapper.updateById(fillBindUserData(bindUserId, new BrokerageUserDO().setId(userId))); @@ -294,18 +307,23 @@ public class BrokerageUserServiceImpl implements BrokerageUserService { } private void validateCanBindUser(BrokerageUserDO user, Long bindUserId) { - // 校验要绑定的用户有无推广资格 - BrokerageUserDO bindUser = brokerageUserMapper.selectById(bindUserId); + // 1.1 校验推广人是否存在 + MemberUserRespDTO bindUserInfo = memberUserApi.getUser(bindUserId); + if (bindUserInfo == null) { + throw exception(BROKERAGE_USER_NOT_EXISTS); + } + // 1.2 校验要绑定的用户有无推广资格 + BrokerageUserDO bindUser = getOrCreateBrokerageUser(bindUserId); if (bindUser == null || BooleanUtil.isFalse(bindUser.getBrokerageEnabled())) { throw exception(BROKERAGE_BIND_USER_NOT_ENABLED); } - // 校验绑定自己 + // 2. 校验绑定自己 if (Objects.equals(user.getId(), bindUserId)) { throw exception(BROKERAGE_BIND_SELF); } - // 下级不能绑定自己的上级 + // 3. 下级不能绑定自己的上级 for (int i = 0; i <= Short.MAX_VALUE; i++) { if (Objects.equals(bindUser.getBindUserId(), user.getId())) { throw exception(BROKERAGE_BIND_LOOP); diff --git a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java index c9fb80100..da74aaa92 100644 --- a/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java +++ b/yudao-module-member/yudao-module-member-api/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApi.java @@ -57,4 +57,12 @@ public interface MemberUserApi { * @return 用户信息 */ MemberUserRespDTO getUserByMobile(String mobile); + + /** + * 校验用户是否存在 + * + * @param id 用户编号 + */ + void validateUser(Long id); + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java index 659c39b57..960930ddc 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/api/user/MemberUserApiImpl.java @@ -11,6 +11,9 @@ import jakarta.annotation.Resource; import java.util.Collection; import java.util.List; +import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception; +import static cn.iocoder.yudao.module.member.enums.ErrorCodeConstants.USER_MOBILE_NOT_EXISTS; + /** * 会员用户的 API 实现类 * @@ -44,4 +47,12 @@ public class MemberUserApiImpl implements MemberUserApi { return MemberUserConvert.INSTANCE.convert2(userService.getUserByMobile(mobile)); } + @Override + public void validateUser(Long id) { + MemberUserDO user = userService.getUser(id); + if (user == null) { + throw exception(USER_MOBILE_NOT_EXISTS); + } + } + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/social/AppSocialUserController.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/social/AppSocialUserController.java index fcdd2132d..de76856c3 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/social/AppSocialUserController.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/social/AppSocialUserController.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.member.controller.app.social; +import cn.hutool.core.codec.Base64; import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.pojo.CommonResult; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; @@ -7,18 +8,20 @@ import cn.iocoder.yudao.framework.security.core.annotations.PreAuthenticated; import cn.iocoder.yudao.module.member.controller.app.social.vo.AppSocialUserBindReqVO; import cn.iocoder.yudao.module.member.controller.app.social.vo.AppSocialUserRespVO; import cn.iocoder.yudao.module.member.controller.app.social.vo.AppSocialUserUnbindReqVO; +import cn.iocoder.yudao.module.member.controller.app.social.vo.AppSocialWxQrcodeReqVO; +import cn.iocoder.yudao.module.system.api.social.SocialClientApi; import cn.iocoder.yudao.module.system.api.social.SocialUserApi; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserUnbindReqDTO; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; +import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; -import io.swagger.v3.oas.annotations.Operation; -import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - import jakarta.annotation.Resource; import jakarta.validation.Valid; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success; import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId; @@ -31,6 +34,8 @@ public class AppSocialUserController { @Resource private SocialUserApi socialUserApi; + @Resource + private SocialClientApi socialClientApi; @PostMapping("/bind") @Operation(summary = "社交绑定,使用 code 授权码") @@ -60,4 +65,11 @@ public class AppSocialUserController { return success(BeanUtils.toBean(socialUser, AppSocialUserRespVO.class)); } + @PostMapping("/wxa-qrcode") + @Operation(summary = "获得微信小程序码(base64 image)") + public CommonResult getWxaQrcode(@RequestBody @Valid AppSocialWxQrcodeReqVO reqVO) { + byte[] wxQrcode = socialClientApi.getWxaQrcode(BeanUtils.toBean(reqVO, SocialWxQrcodeReqDTO.class)); + return success(Base64.encode(wxQrcode)); + } + } diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/social/vo/AppSocialWxQrcodeReqVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/social/vo/AppSocialWxQrcodeReqVO.java new file mode 100644 index 000000000..8927a34c9 --- /dev/null +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/social/vo/AppSocialWxQrcodeReqVO.java @@ -0,0 +1,38 @@ +package cn.iocoder.yudao.module.member.controller.app.social.vo; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + + +@Schema(description = "用户 APP - 获得获取小程序码 Request VO") +@Data +public class AppSocialWxQrcodeReqVO { + + /** + * 页面路径不能携带参数(参数请放在scene字段里) + */ + @Schema(description = "场景值", requiredMode = Schema.RequiredMode.REQUIRED, example = "1001") + private String scene; + + /** + * 默认是主页,页面 page,例如 pages/index/index,根路径前不要填加 /,不能携带参数(参数请放在scene字段里), + * 如果不填写这个字段,默认跳主页面。scancode_time为系统保留参数,不允许配置 + */ + @Schema(description = "页面路径", requiredMode = Schema.RequiredMode.REQUIRED, example = "pages/goods/index") + @NotEmpty(message = "页面路径不能为空") + private String path; + + @Schema(description = "二维码宽度", requiredMode = Schema.RequiredMode.REQUIRED, example = "430") + private Integer width; + + @Schema(description = "是/否自动配置线条颜色", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean autoColor; + + @Schema(description = "是/否检查 page 是否存在", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean checkPath; + + @Schema(description = "是/否需要透明底色", requiredMode = Schema.RequiredMode.REQUIRED, example = "true") + private Boolean hyaline; + +} diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserInfoRespVO.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserInfoRespVO.java index fa05e16d0..72e4fa3fa 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserInfoRespVO.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/controller/app/user/vo/AppMemberUserInfoRespVO.java @@ -11,6 +11,9 @@ import lombok.NoArgsConstructor; @AllArgsConstructor public class AppMemberUserInfoRespVO { + @Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1") + private Long id; + @Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋艿") private String nickname; diff --git a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java index aae9a7601..eaa5ab50e 100644 --- a/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java +++ b/yudao-module-member/yudao-module-member-biz/src/main/java/cn/iocoder/yudao/module/member/convert/user/MemberUserConvert.java @@ -12,6 +12,7 @@ import cn.iocoder.yudao.module.member.dal.dataobject.tag.MemberTagDO; import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO; import org.mapstruct.Mapper; import org.mapstruct.Mapping; +import org.mapstruct.Mappings; import org.mapstruct.factory.Mappers; import java.util.List; @@ -27,8 +28,12 @@ public interface MemberUserConvert { AppMemberUserInfoRespVO convert(MemberUserDO bean); - @Mapping(source = "level", target = "level") - @Mapping(source = "bean.experience", target = "experience") + + @Mappings({ + @Mapping(source = "level", target = "level"), + @Mapping(source = "bean.id", target = "id"), + @Mapping(source = "bean.experience", target = "experience") + }) AppMemberUserInfoRespVO convert(MemberUserDO bean, MemberLevelDO level); MemberUserRespDTO convert2(MemberUserDO bean); diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApi.java index 7fdb35a32..cdc609b3f 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApi.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApi.java @@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.system.api.social; import cn.iocoder.yudao.module.system.api.social.dto.SocialWxJsapiSignatureRespDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialWxPhoneNumberInfoRespDTO; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum; +import jakarta.validation.Valid; /** * 社交应用的 API 接口 @@ -39,4 +41,12 @@ public interface SocialClientApi { */ SocialWxPhoneNumberInfoRespDTO getWxMaPhoneNumberInfo(Integer userType, String phoneCode); + /** + * 获得小程序二维码 + * + * @param reqVO 请求信息 + * @return 小程序二维码 + */ + byte[] getWxaQrcode(@Valid SocialWxQrcodeReqDTO reqVO); + } diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApi.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApi.java index e24f8356d..6102c4eca 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApi.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApi.java @@ -4,7 +4,7 @@ import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserUnbindReqDTO; - +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import jakarta.validation.Valid; /** @@ -32,8 +32,8 @@ public interface SocialUserApi { /** * 获得社交用户,基于 userId * - * @param userType 用户类型 - * @param userId 用户编号 + * @param userType 用户类型 + * @param userId 用户编号 * @param socialType 社交平台的类型 * @return 社交用户 */ @@ -44,10 +44,10 @@ public interface SocialUserApi { * * 在认证信息不正确的情况下,也会抛出 {@link ServiceException} 业务异常 * - * @param userType 用户类型 + * @param userType 用户类型 * @param socialType 社交平台的类型 - * @param code 授权码 - * @param state state + * @param code 授权码 + * @param state state * @return 社交用户 */ SocialUserRespDTO getSocialUserByCode(Integer userType, Integer socialType, String code, String state); diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/dto/SocialWxQrcodeReqDTO.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/dto/SocialWxQrcodeReqDTO.java new file mode 100644 index 000000000..6f4b96f4e --- /dev/null +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/api/social/dto/SocialWxQrcodeReqDTO.java @@ -0,0 +1,66 @@ +package cn.iocoder.yudao.module.system.api.social.dto; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Data; + +/** + * 获取小程序码 Request DTO + * + * @author HUIHUI + * @see 获取不限制的小程序码 + */ +@Data +public class SocialWxQrcodeReqDTO { + + /** + * 页面路径不能携带参数(参数请放在scene字段里) + */ + public static final String SCENE = ""; + /** + * 二维码宽度 + */ + public static final Integer WIDTH = 430; + /** + * 自动配置线条颜色,如果颜色依然是黑色,则说明不建议配置主色调 + */ + public static final Boolean AUTO_COLOR = true; + /** + * 检查 page 是否存在 + */ + public static final Boolean CHECK_PATH = true; + /** + * 是否需要透明底色 + * + * hyaline 为 true 时,生成透明底色的小程序码 + */ + public static final Boolean HYALINE = true; + + /** + * 场景 + */ + @NotEmpty(message = "场景不能为空") + private String scene; + /** + * 页面路径 + */ + @NotEmpty(message = "页面路径不能为空") + private String path; + /** + * 二维码宽度 + */ + private Integer width; + + /** + * 是否需要透明底色 + */ + private Boolean autoColor; + /** + * 是否检查 page 是否存在 + */ + private Boolean checkPath; + /** + * 是否需要透明底色 + */ + private Boolean hyaline; + +} diff --git a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java index 1b4c313c8..412ac413e 100644 --- a/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java +++ b/yudao-module-system/yudao-module-system-api/src/main/java/cn/iocoder/yudao/module/system/enums/ErrorCodeConstants.java @@ -120,8 +120,10 @@ public interface ErrorCodeConstants { ErrorCode SOCIAL_USER_NOT_FOUND = new ErrorCode(1_002_018_001, "社交授权失败,找不到对应的用户"); ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_PHONE_CODE_ERROR = new ErrorCode(1_002_018_200, "获得手机号失败"); - ErrorCode SOCIAL_CLIENT_NOT_EXISTS = new ErrorCode(1_002_018_201, "社交客户端不存在"); - ErrorCode SOCIAL_CLIENT_UNIQUE = new ErrorCode(1_002_018_202, "社交客户端已存在配置"); + ErrorCode SOCIAL_CLIENT_WEIXIN_MINI_APP_QRCODE_ERROR = new ErrorCode(1_002_018_201, "获得小程序码失败"); + ErrorCode SOCIAL_CLIENT_NOT_EXISTS = new ErrorCode(1_002_018_202, "社交客户端不存在"); + ErrorCode SOCIAL_CLIENT_UNIQUE = new ErrorCode(1_002_018_203, "社交客户端已存在配置"); + // ========== OAuth2 客户端 1-002-020-000 ========= ErrorCode OAUTH2_CLIENT_NOT_EXISTS = new ErrorCode(1_002_020_000, "OAuth2 客户端不存在"); diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApiImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApiImpl.java index 2e82ad492..2a7b69cfe 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApiImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialClientApiImpl.java @@ -4,6 +4,7 @@ import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; import cn.iocoder.yudao.module.system.api.social.dto.SocialWxJsapiSignatureRespDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialWxPhoneNumberInfoRespDTO; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import cn.iocoder.yudao.module.system.service.social.SocialClientService; import me.chanjar.weixin.common.bean.WxJsapiSignature; import org.springframework.stereotype.Service; @@ -40,4 +41,9 @@ public class SocialClientApiImpl implements SocialClientApi { return BeanUtils.toBean(info, SocialWxPhoneNumberInfoRespDTO.class); } + @Override + public byte[] getWxaQrcode(SocialWxQrcodeReqDTO reqVO) { + return socialClientService.getWxaQrcode(reqVO); + } + } diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApiImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApiImpl.java index eb8331618..a8c30a0f8 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApiImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/api/social/SocialUserApiImpl.java @@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.system.api.social; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserUnbindReqDTO; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import cn.iocoder.yudao.module.system.service.social.SocialUserService; import org.springframework.stereotype.Service; import org.springframework.validation.annotation.Validated; diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java index 1604c53e7..4b68e7b6b 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/controller/admin/sms/SmsCallbackController.java @@ -42,4 +42,14 @@ public class SmsCallbackController { return success(true); } + + @PostMapping("/huawei") + @PermitAll + @Operation(summary = "华为云短信的回调", description = "参见 https://support.huaweicloud.com/api-msgsms/sms_05_0003.html 文档") + public CommonResult receiveHuaweiSmsStatus(HttpServletRequest request) throws Throwable { + String text = ServletUtils.getBody(request); + smsSendService.receiveSmsStatus(SmsChannelEnum.HUAWEI.getCode(), text); + return success(true); + } + } diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClient.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClient.java new file mode 100644 index 000000000..1508f9a47 --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/HuaweiSmsClient.java @@ -0,0 +1,221 @@ +package cn.iocoder.yudao.module.system.framework.sms.core.client.impl; + +import cn.hutool.core.lang.Assert; +import cn.hutool.core.util.HexUtil; +import cn.hutool.core.util.StrUtil; +import cn.hutool.crypto.SecureUtil; +import cn.hutool.crypto.digest.DigestUtil; +import cn.hutool.json.JSONArray; +import cn.iocoder.yudao.framework.common.core.KeyValue; +import cn.iocoder.yudao.framework.common.util.json.JsonUtils; +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO; +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO; +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO; +import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum; +import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; +import org.apache.http.HttpResponse; +import org.apache.http.client.methods.HttpUriRequest; +import org.apache.http.client.methods.RequestBuilder; +import org.apache.http.entity.StringEntity; +import org.apache.http.impl.client.CloseableHttpClient; +import org.apache.http.impl.client.HttpClientBuilder; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.text.SimpleDateFormat; +import java.time.LocalDateTime; +import java.util.*; + +import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList; +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND; +import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DEFAULT; + +/** + * 华为短信客户端的实现类 + * + * @author scholar + * @since 2024/6/02 11:55 + */ +@Slf4j +public class HuaweiSmsClient extends AbstractSmsClient { + + /** + * 调用成功 code + */ + public static final String API_CODE_SUCCESS = "OK"; + + public HuaweiSmsClient(SmsChannelProperties properties) { + super(properties); + Assert.notEmpty(properties.getApiKey(), "apiKey 不能为空"); + Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空"); + } + + @Override + protected void doInit() { + } + + @Override + public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId, + List> templateParams) throws Throwable { + // TODO @scholar:https://smsapi.cn-north-4.myhuaweicloud.com:443 是不是枚举成静态变量 + String url = "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1"; //APP接入地址+接口访问URI + // 相比较阿里短信,华为短信发送的时候需要额外的参数“通道号”,考虑到不破坏原有的的结构 + // 所以将 通道号 拼接到 apiTemplateId 字段中,格式为 "apiTemplateId 通道号"。空格为分隔符。 + // TODO @scholar:暂时只考虑中国大陆,所以不需要 sender 哈 + String sender = StrUtil.subAfter(apiTemplateId, " ", true); //中国大陆短信签名通道号或全球短信通道号 + String templateId = StrUtil.subBefore(apiTemplateId, " ", true); //模板ID + + // 选填,短信状态报告接收地址,推荐使用域名,为空或者不填表示不接收状态报告 + String statusCallBack = properties.getCallbackUrl(); + + // TODO @scholar:1)是不是用 LocalDateTimeUtil.format();这样 3 行变成一行 + // TODO @scholar:singerDate 叫 sdkDate 会更合适哈,这样理解起来简单。另外,singer 应该是 signed 么? + SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ENGLISH); + sdf.setTimeZone(TimeZone.getTimeZone("UTC")); + String singerDate = sdf.format(new Date()); + + // TODO @scholar:整个处理加密的过程,是不是应该抽成一个 private 方法哈。这样整个调用的主干更清晰。 + // ************* 步骤 1:拼接规范请求串 ************* + String httpRequestMethod = "POST"; + String canonicalUri = "/sms/batchSendSms/v1/"; + String canonicalQueryString = ""; // 查询参数为空 + String canonicalHeaders = "content-type:application/x-www-form-urlencoded\n" + + "host:smsapi.cn-north-4.myhuaweicloud.com:443\n" + + "x-sdk-date:" + singerDate + "\n"; + // TODO @scholar:静态枚举了 + String signedHeaders = "content-type;host;x-sdk-date"; + // TODO @scholar:下面的注释,可以考虑去掉 + /* + * 选填,使用无变量模板时请赋空值 String templateParas = ""; + * 单变量模板示例:模板内容为"您的验证码是${NUM_6}"时,templateParas可填写为"[\"111111\"]" + * 双变量模板示例:模板内容为"您有${NUM_2}件快递请到${TXT_20}领取"时,templateParas可填写为"[\"3\",\"人民公园正门\"]" + */ + // TODO @scholar:CollectionUtils.convertList 可以把 4 行变成 1 行。 + // TODO @scholar:templateParams 拼写错误哈 + List templateParas = new ArrayList<>(); + for (KeyValue kv : templateParams) { + templateParas.add(String.valueOf(kv.getValue())); + } + + // 请求Body,不携带签名名称时,signature请填null + String body = buildRequestBody(sender, mobile, templateId, templateParas, statusCallBack, null); + // TODO @scholar:Assert 断言,抛出异常 + if (null == body || body.isEmpty()) { + return null; + } + String hashedRequestBody = HexUtil.encodeHexStr(DigestUtil.sha256(body)); + String canonicalRequest = httpRequestMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody; + + // ************* 步骤 2:拼接待签名字符串 ************* + // TODO @scholar:sha256Hex 是不是更简洁哈 + String hashedCanonicalRequest = HexUtil.encodeHexStr(DigestUtil.sha256(canonicalRequest)); + String stringToSign = "SDK-HMAC-SHA256" + "\n" + singerDate + "\n" + hashedCanonicalRequest; + + // ************* 步骤 3:计算签名 ************* + String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); + + // ************* 步骤 4:拼接 Authorization ************* + String authorization = "SDK-HMAC-SHA256" + " " + "Access=" + properties.getApiKey() + ", " + + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature; + + // ************* 步骤 5:构造HttpRequest 并执行request请求,获得response ************* + // TODO @scholar:考虑了下,还是换 hutool 的 httpUtils。因为未来 httpclient 我们可能会移除掉 + HttpUriRequest postMethod = RequestBuilder.post() + .setUri(url) + .setEntity(new StringEntity(body, StandardCharsets.UTF_8)) + .setHeader("Content-Type","application/x-www-form-urlencoded") + .setHeader("X-Sdk-Date", singerDate) + .setHeader("Authorization", authorization) + .build(); + // TODO @scholar:这种不太适合一直 new 的哈 + CloseableHttpClient client = HttpClientBuilder.create().build(); + HttpResponse response = client.execute(postMethod); + // TODO @scholar:失败的情况下的处理 + // TODO @scholar:setSerialNo(Integer.toString(response.getStatusLine().getStatusCode())) 这部分,空一行。一行代码太多了,阅读性不太好哈 + return new SmsSendRespDTO().setSuccess(Objects.equals(response.getStatusLine().getReasonPhrase(), API_CODE_SUCCESS)).setSerialNo(Integer.toString(response.getStatusLine().getStatusCode())) + .setApiRequestId(null).setApiCode(null).setApiMsg(null); + } + + static String buildRequestBody(String sender, String receiver, String templateId, List templateParas, + String statusCallBack, @SuppressWarnings("SameParameterValue") String signature) { + // TODO @scholar:参数不满足,是不是抛出异常更好哈;通过 hutool 的 Assert 去断言 + if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty() + || templateId.isEmpty()) { + System.out.println("buildRequestBody(): sender, receiver or templateId is null."); + return null; + } + + StringBuilder body = new StringBuilder(); + appendToBody(body, "from=", sender); + appendToBody(body, "&to=", receiver); + appendToBody(body, "&templateId=", templateId); + // TODO @scholar:new JSONArray(templateParas).toString(),是不是 JsonUtils.toString 呀? + appendToBody(body, "&templateParas=", new JSONArray(templateParas).toString()); + appendToBody(body, "&statusCallback=", statusCallBack); + appendToBody(body, "&signature=", signature); + return body.toString(); + } + + private static void appendToBody(StringBuilder body, String key, String val) { + // TODO @scholar:StrUtils.isNotEmpty(val),是不是更简洁哈 + if (null != val && !val.isEmpty()) { + body.append(key).append(URLEncoder.encode(val, StandardCharsets.UTF_8)); + } + } + + @Override + public List parseSmsReceiveStatus(String text) { + List statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class); + return convertList(statuses, status -> new SmsReceiveRespDTO().setSuccess(Objects.equals(status.getStatus(),"DELIVRD")) + .setErrorCode(status.getStatus()).setErrorMsg(status.getStatus()) + .setMobile(status.getPhoneNumber()).setReceiveTime(status.getUpdateTime()) + .setSerialNo(status.getSmsMsgId())); + } + + @Override + public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable { + // 华为短信模板查询和发送短信,是不同的两套 key 和 secret,与阿里、腾讯的区别较大,这里模板查询校验暂不实现 + // 对应文档 https://support.huaweicloud.com/api-msgsms/sms_05_0040.html + return new SmsTemplateRespDTO().setId(apiTemplateId).setContent(null) + .setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(null); + } + + /** + * 短信接收状态 + * + * 参见 文档 + * + * @author scholar + */ + @Data + public static class SmsReceiveStatus { + + /** + * 本条状态报告对应的短信的接收方号码,仅当状态报告中携带了extend参数时才会同时携带该参数 + */ + @JsonProperty("to") + private String phoneNumber; + + /** + * 短信资源的更新时间,通常为短信平台接收短信状态报告的时间 + */ + @JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT) + private LocalDateTime updateTime; + + /** + * 短信状态报告枚举值 + */ + private String status; + + /** + * 发送短信成功时返回的短信唯一标识。 + */ + private String smsMsgId; + } + +} diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientFactoryImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientFactoryImpl.java index 94fe88da9..326cad058 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientFactoryImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientFactoryImpl.java @@ -78,6 +78,7 @@ public class SmsClientFactoryImpl implements SmsClientFactory { case ALIYUN: return new AliyunSmsClient(properties); case DEBUG_DING_TALK: return new DebugDingTalkSmsClient(properties); case TENCENT: return new TencentSmsClient(properties); + case HUAWEI: return new HuaweiSmsClient(properties); } // 创建失败,错误日志 + 抛出异常 log.error("[createSmsClient][配置({}) 找不到合适的客户端实现]", properties); diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java index 7bd192223..88f578a18 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/enums/SmsChannelEnum.java @@ -17,7 +17,7 @@ public enum SmsChannelEnum { DEBUG_DING_TALK("DEBUG_DING_TALK", "调试(钉钉)"), ALIYUN("ALIYUN", "阿里云"), TENCENT("TENCENT", "腾讯云"), -// HUA_WEI("HUA_WEI", "华为云"), + HUAWEI("HUAWEI", "华为云"), ; /** diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientService.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientService.java index f41e8d371..7757d35d1 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientService.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientService.java @@ -2,14 +2,14 @@ package cn.iocoder.yudao.module.system.service.social; import cn.binarywang.wx.miniapp.bean.WxMaPhoneNumberInfo; import cn.iocoder.yudao.framework.common.pojo.PageResult; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import cn.iocoder.yudao.module.system.controller.admin.socail.vo.client.SocialClientPageReqVO; import cn.iocoder.yudao.module.system.controller.admin.socail.vo.client.SocialClientSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialClientDO; import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum; import com.xingyuv.jushauth.model.AuthUser; -import me.chanjar.weixin.common.bean.WxJsapiSignature; - import jakarta.validation.Valid; +import me.chanjar.weixin.common.bean.WxJsapiSignature; /** * 社交应用 Service 接口 @@ -21,8 +21,8 @@ public interface SocialClientService { /** * 获得社交平台的授权 URL * - * @param socialType 社交平台的类型 {@link SocialTypeEnum} - * @param userType 用户类型 + * @param socialType 社交平台的类型 {@link SocialTypeEnum} + * @param userType 用户类型 * @param redirectUri 重定向 URL * @return 社交平台的授权 URL */ @@ -32,9 +32,9 @@ public interface SocialClientService { * 请求社交平台,获得授权的用户 * * @param socialType 社交平台的类型 - * @param userType 用户类型 - * @param code 授权码 - * @param state 授权 state + * @param userType 用户类型 + * @param code 授权码 + * @param state 授权 state * @return 授权的用户 */ AuthUser getAuthUser(Integer socialType, Integer userType, String code, String state); @@ -45,7 +45,7 @@ public interface SocialClientService { * 创建微信公众号的 JS SDK 初始化所需的签名 * * @param userType 用户类型 - * @param url 访问的 URL 地址 + * @param url 访问的 URL 地址 * @return 签名 */ WxJsapiSignature createWxMpJsapiSignature(Integer userType, String url); @@ -55,12 +55,20 @@ public interface SocialClientService { /** * 获得微信小程序的手机信息 * - * @param userType 用户类型 + * @param userType 用户类型 * @param phoneCode 手机授权码 * @return 手机信息 */ WxMaPhoneNumberInfo getWxMaPhoneNumberInfo(Integer userType, String phoneCode); + /** + * 获得小程序二维码 + * + * @param reqVO 请求信息 + * @return 小程序二维码 + */ + byte[] getWxaQrcode(SocialWxQrcodeReqDTO reqVO); + // =================== 客户端管理 =================== /** diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java index 84da0e5e1..b9a339223 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialClientServiceImpl.java @@ -9,10 +9,12 @@ import cn.hutool.core.lang.Assert; import cn.hutool.core.util.ObjUtil; import cn.hutool.core.util.ReflectUtil; import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum; +import cn.iocoder.yudao.framework.common.enums.UserTypeEnum; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.util.cache.CacheUtils; import cn.iocoder.yudao.framework.common.util.http.HttpUtils; import cn.iocoder.yudao.framework.common.util.object.BeanUtils; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import cn.iocoder.yudao.module.system.controller.admin.socail.vo.client.SocialClientPageReqVO; import cn.iocoder.yudao.module.system.controller.admin.socail.vo.client.SocialClientSaveReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialClientDO; @@ -39,6 +41,7 @@ import me.chanjar.weixin.common.redis.RedisTemplateWxRedisOps; import me.chanjar.weixin.mp.api.WxMpService; import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl; import me.chanjar.weixin.mp.config.impl.WxMpRedisConfigImpl; +import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.stereotype.Service; @@ -58,6 +61,12 @@ import static cn.iocoder.yudao.module.system.enums.ErrorCodeConstants.*; @Slf4j public class SocialClientServiceImpl implements SocialClientService { + /** + * 小程序版本 + */ + @Value("${yudao.wxa-code.env-version}") + public String envVersion; + @Resource private AuthRequestFactory authRequestFactory; @@ -139,7 +148,7 @@ public class SocialClientServiceImpl implements SocialClientService { * 构建 AuthRequest 对象,支持多租户配置 * * @param socialType 社交类型 - * @param userType 用户类型 + * @param userType 用户类型 * @return AuthRequest 对象 */ @VisibleForTesting @@ -196,7 +205,7 @@ public class SocialClientServiceImpl implements SocialClientService { /** * 创建 clientId + clientSecret 对应的 WxMpService 对象 * - * @param clientId 微信公众号 appId + * @param clientId 微信公众号 appId * @param clientSecret 微信公众号 secret * @return WxMpService 对象 */ @@ -227,6 +236,25 @@ public class SocialClientServiceImpl implements SocialClientService { } } + @Override + public byte[] getWxaQrcode(SocialWxQrcodeReqDTO reqVO) { + WxMaService service = getWxMaService(UserTypeEnum.MEMBER.getValue()); + try { + return service.getQrcodeService().createWxaCodeUnlimitBytes( + ObjUtil.defaultIfEmpty(reqVO.getScene(), SocialWxQrcodeReqDTO.SCENE), + reqVO.getPath(), + ObjUtil.defaultIfNull(reqVO.getCheckPath(), SocialWxQrcodeReqDTO.CHECK_PATH), + envVersion, + ObjUtil.defaultIfNull(reqVO.getWidth(), SocialWxQrcodeReqDTO.WIDTH), + ObjUtil.defaultIfNull(reqVO.getAutoColor(), SocialWxQrcodeReqDTO.AUTO_COLOR), + null, + ObjUtil.defaultIfNull(reqVO.getHyaline(), SocialWxQrcodeReqDTO.HYALINE)); + } catch (WxErrorException e) { + log.error("[getWxQrcode][reqVO({})) 获得小程序码失败]", reqVO, e); + throw exception(SOCIAL_CLIENT_WEIXIN_MINI_APP_QRCODE_ERROR); + } + } + /** * 获得 clientId + clientSecret 对应的 WxMpService 对象 * @@ -248,7 +276,7 @@ public class SocialClientServiceImpl implements SocialClientService { /** * 创建 clientId + clientSecret 对应的 WxMaService 对象 * - * @param clientId 微信小程序 appId + * @param clientId 微信小程序 appId * @param clientSecret 微信小程序 secret * @return WxMaService 对象 */ @@ -310,8 +338,8 @@ public class SocialClientServiceImpl implements SocialClientService { * * 原因是,不同端(userType)选择某个社交登录(socialType)时,需要通过 {@link #buildAuthRequest(Integer, Integer)} 构建对应的请求 * - * @param id 编号 - * @param userType 用户类型 + * @param id 编号 + * @param userType 用户类型 * @param socialType 社交类型 */ private void validateSocialClientUnique(Long id, Integer userType, Integer socialType) { diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserService.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserService.java index 739ff2d94..73d57687e 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserService.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserService.java @@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import cn.iocoder.yudao.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialUserDO; import cn.iocoder.yudao.module.system.enums.social.SocialTypeEnum; diff --git a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserServiceImpl.java b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserServiceImpl.java index 7f1b271ad..dedab0db3 100644 --- a/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserServiceImpl.java +++ b/yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/service/social/SocialUserServiceImpl.java @@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.common.exception.ServiceException; import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserBindReqDTO; import cn.iocoder.yudao.module.system.api.social.dto.SocialUserRespDTO; +import cn.iocoder.yudao.module.system.api.social.dto.SocialWxQrcodeReqDTO; import cn.iocoder.yudao.module.system.controller.admin.socail.vo.user.SocialUserPageReqVO; import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialUserBindDO; import cn.iocoder.yudao.module.system.dal.dataobject.social.SocialUserDO; diff --git a/yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java b/yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java new file mode 100644 index 000000000..677bf986e --- /dev/null +++ b/yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java @@ -0,0 +1,36 @@ +package cn.iocoder.yudao.module.system.framework.sms.core.client.impl; + +import cn.iocoder.yudao.framework.common.core.KeyValue; +import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO; +import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +import java.util.List; + +/** + * 各种 {@link SmsClientTests 集成测试 + * + * @author 芋道源码 + */ +public class SmsClientTests { + + @Test + @Disabled + public void testHuaweiSmsClient() throws Throwable { + SmsChannelProperties properties = new SmsChannelProperties() + .setApiKey("123") + .setApiSecret("456"); + HuaweiSmsClient client = new HuaweiSmsClient(properties); + // 准备参数 + Long sendLogId = System.currentTimeMillis(); + String mobile = "15601691323"; + String apiTemplateId = "xx test01"; + List> templateParams = List.of(new KeyValue<>("code", "1024")); + // 调用 + SmsSendRespDTO smsSendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, templateParams); + // 打印结果 + System.out.println(smsSendRespDTO); + } + +} diff --git a/yudao-server/pom.xml b/yudao-server/pom.xml index 3b16fa192..81139ad93 100644 --- a/yudao-server/pom.xml +++ b/yudao-server/pom.xml @@ -46,11 +46,11 @@ - - - - - + + cn.iocoder.boot + yudao-module-bpm-biz + ${revision} + @@ -88,11 +88,11 @@ - - - - - + + cn.iocoder.boot + yudao-module-crm-biz + ${revision} + diff --git a/yudao-server/src/main/resources/application-dev.yaml b/yudao-server/src/main/resources/application-dev.yaml index 936aaae69..cc105790d 100644 --- a/yudao-server/src/main/resources/application-dev.yaml +++ b/yudao-server/src/main/resources/application-dev.yaml @@ -171,6 +171,8 @@ yudao: order-notify-url: http://yunai.natapp1.cc/admin-api/pay/notify/order # 支付渠道的【支付】回调地址 refund-notify-url: http://yunai.natapp1.cc/admin-api/pay/notify/refund # 支付渠道的【退款】回调地址 demo: true # 开启演示模式 + wxa-code: + env-version: release # 小程序版本: 正式版为 "release";体验版为 "trial";开发版为 "develop" tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc justauth: diff --git a/yudao-server/src/main/resources/application-local.yaml b/yudao-server/src/main/resources/application-local.yaml index 53f2add0e..269e20e83 100644 --- a/yudao-server/src/main/resources/application-local.yaml +++ b/yudao-server/src/main/resources/application-local.yaml @@ -223,6 +223,8 @@ yudao: access-log: # 访问日志的配置项 enable: false demo: false # 关闭演示模式 + wxa-code: + env-version: develop # 小程序版本: 正式版为 "release";体验版为 "trial";开发版为 "develop" tencent-lbs-key: TVDBZ-TDILD-4ON4B-PFDZA-RNLKH-VVF6E # QQ 地图的密钥 https://lbs.qq.com/service/staticV2/staticGuide/staticDoc justauth: diff --git a/yudao-server/src/main/resources/application.yaml b/yudao-server/src/main/resources/application.yaml index 594ef0611..b62294cb6 100644 --- a/yudao-server/src/main/resources/application.yaml +++ b/yudao-server/src/main/resources/application.yaml @@ -231,6 +231,8 @@ yudao: license-url: https://gitee.com/zhijiantianya/ruoyi-vue-pro/blob/master/LICENSE captcha: enable: true # 验证码的开关,默认为 true + wxa-code: + env-version: release # 小程序版本: 正式版为 "release";体验版为 "trial";开发版为 "develop"。默认为 release codegen: base-package: ${yudao.info.base-package} db-schemas: ${spring.datasource.dynamic.datasource.master.name}