Merge remote-tracking branch 'origin/master-jdk17' into master-jdk17

This commit is contained in:
yinyilong 2024-08-12 14:09:44 +08:00
commit 7a86121585
66 changed files with 1655 additions and 560 deletions

View File

@ -74,9 +74,6 @@
<okhttp3.version>4.11.0</okhttp3.version>
<commons-io.version>2.15.1</commons-io.version>
<minio.version>8.5.7</minio.version>
<aliyun-java-sdk-core.version>4.6.4</aliyun-java-sdk-core.version>
<aliyun-java-sdk-dysmsapi.version>2.2.1</aliyun-java-sdk-dysmsapi.version>
<tencentcloud-sdk-java.version>3.1.880</tencentcloud-sdk-java.version>
<justauth.version>2.0.5</justauth.version>
<jimureport.version>1.7.8</jimureport.version>
<xercesImpl.version>2.12.2</xercesImpl.version>
@ -598,34 +595,6 @@
<version>${weixin-java.version}</version>
</dependency>
<!-- SMS SDK begin -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId>
<version>${aliyun-java-sdk-core.version}</version>
<exclusions>
<exclusion>
<artifactId>opentracing-api</artifactId>
<groupId>io.opentracing</groupId>
</exclusion>
<exclusion>
<artifactId>opentracing-util</artifactId>
<groupId>io.opentracing</groupId>
</exclusion>
</exclusions>
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId>
<version>${aliyun-java-sdk-dysmsapi.version}</version>
</dependency>
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-sms</artifactId>
<version>${tencentcloud-sdk-java.version}</version>
</dependency>
<!-- SMS SDK end -->
<dependency>
<groupId>com.xingyuv</groupId>
<artifactId>spring-boot-starter-justauth</artifactId> <!-- 社交登陆(例如说,个人微信、企业微信等等) -->

View File

@ -5,6 +5,8 @@ import cn.hutool.core.map.TableMap;
import cn.hutool.core.net.url.UrlBuilder;
import cn.hutool.core.util.ReflectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
@ -109,7 +111,7 @@ public class HttpUtils {
authorization = Base64.decodeStr(authorization);
clientId = StrUtil.subBefore(authorization, ":", false);
clientSecret = StrUtil.subAfter(authorization, ":", false);
// 再从 Param 中获取
// 再从 Param 中获取
} else {
clientId = request.getParameter("client_id");
clientSecret = request.getParameter("client_secret");
@ -122,5 +124,23 @@ public class HttpUtils {
return null;
}
/**
* HTTP post 请求基于 {@link cn.hutool.http.HttpUtil} 实现
*
* 为什么要封装该方法因为 HttpUtil 默认封装的方法没有允许传递 headers 参数
*
* @param url URL
* @param headers 请求头
* @param requestBody 请求体
* @return 请求结果
*/
public static String post(String url, Map<String, String> headers, String requestBody) {
try (HttpResponse response = HttpRequest.post(url)
.addHeaders(headers)
.body(requestBody)
.execute()) {
return response.body();
}
}
}

View File

@ -185,10 +185,6 @@ public interface BaseMapperX<T> extends MPJBaseMapper<T> {
return Db.updateBatchById(entities, size);
}
default boolean insertOrUpdate(T entity) {
return Db.saveOrUpdate(entity);
}
default Boolean insertOrUpdateBatch(Collection<T> collection) {
return Db.saveOrUpdateBatch(collection);
}

View File

@ -18,7 +18,7 @@
<name>${project.artifactId}</name>
<description>
ai 模块下,接入 LLM 大模型,支持聊天、绘图、音乐、写作、思维图等功能。
ai 模块下,接入 LLM 大模型,支持聊天、绘图、音乐、写作、思维图等功能。
目前已接入各种模型,不限于:
国内:通义千问、文心一言、讯飞星火、智谱 GLM、DeepSeek
国外OpenAI、Ollama、Midjourney、StableDiffusion、Suno

View File

@ -22,7 +22,7 @@ public enum AiChatRoleEnum implements IntArrayValuable {
除此之外不需要除了正文内容外的其他回复如标题开头任何解释性语句或道歉
"""),
AI_MIND_MAP_ROLE(2, "图助手", """
AI_MIND_MAP_ROLE(2, "图助手", """
你是一位非常优秀的思维导图助手你会把用户的所有提问都总结成思维导图然后以 Markdown 格式输出markdown 只需要输出一级标题二级标题三级标题四级标题最多输出四级除此之外不要输出任何其他 markdown 标记下面是一个合格的例子
# Geek-AI 助手
## 完整的开源系统

View File

@ -45,9 +45,11 @@ public interface ErrorCodeConstants {
// ========== API 音乐 1-040-006-000 ==========
ErrorCode MUSIC_NOT_EXISTS = new ErrorCode(1_022_006_000, "音乐不存在!");
// ========== API 写作 1-022-007-000 ==========
ErrorCode WRITE_NOT_EXISTS = new ErrorCode(1_022_007_000, "作文不存在!");
ErrorCode WRITE_STREAM_ERROR = new ErrorCode(1_022_07_001, "写作生成异常!");
// ========== API 思维导图 1-040-008-000 ==========
ErrorCode MIND_MAP_NOT_EXISTS = new ErrorCode(1_040_008_000, "思维导图不存在!");
}

View File

@ -12,7 +12,7 @@
<name>${project.artifactId}</name>
<description>
ai 模块下,接入 LLM 大模型,支持聊天、绘图、音乐、写作、思维图等功能。
ai 模块下,接入 LLM 大模型,支持聊天、绘图、音乐、写作、思维图等功能。
目前已接入各种模型,不限于:
国内:通义千问、文心一言、讯飞星火、智谱 GLM、DeepSeek
国外OpenAI、Ollama、Midjourney、StableDiffusion、Suno

View File

@ -3,9 +3,7 @@ package cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Data;
import lombok.experimental.Accessors;
@Schema(description = "管理后台 - AI 聊天消息发送 Request VO")
@Data

View File

@ -5,10 +5,7 @@ import cn.iocoder.yudao.framework.ai.core.model.midjourney.api.MidjourneyApi;
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.ai.controller.admin.image.vo.AiImageDrawReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImagePageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImageRespVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImageUpdateReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.*;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyActionReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyImagineReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.image.AiImageDO;
@ -45,6 +42,13 @@ public class AiImageController {
return success(BeanUtils.toBean(pageResult, AiImageRespVO.class));
}
@GetMapping("/public-page")
@Operation(summary = "获取公开的绘图分页")
public CommonResult<PageResult<AiImageRespVO>> getImagePagePublic(AiImagePublicPageReqVO pageReqVO) {
PageResult<AiImageDO> pageResult = imageService.getImagePagePublic(pageReqVO);
return success(BeanUtils.toBean(pageResult, AiImageRespVO.class));
}
@GetMapping("/get-my")
@Operation(summary = "获取【我的】绘图记录")
@Parameter(name = "id", required = true, description = "绘画编号", example = "1024")

View File

@ -0,0 +1,14 @@
package cn.iocoder.yudao.module.ai.controller.admin.image.vo;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - AI 绘画公开的分页 Request VO")
@Data
public class AiImagePublicPageReqVO extends PageParam {
@Schema(description = "提示词")
private String prompt;
}

View File

@ -1,20 +1,25 @@
package cn.iocoder.yudao.module.ai.controller.admin.mindmap;
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.ai.controller.admin.mindmap.vo.AiMindMapGenerateReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo.AiMindMapPageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo.AiMindMapRespVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.mindmap.AiMindMapDO;
import cn.iocoder.yudao.module.ai.service.mindmap.AiMindMapService;
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.annotation.security.PermitAll;
import jakarta.validation.Valid;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.framework.security.core.util.SecurityFrameworkUtils.getLoginUserId;
@Tag(name = "管理后台 - AI 思维导图")
@ -26,10 +31,29 @@ public class AiMindMapController {
private AiMindMapService mindMapService;
@PostMapping(value = "/generate-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@Operation(summary = "图生成(流式)", description = "流式返回,响应较快")
@Operation(summary = "图生成(流式)", description = "流式返回,响应较快")
@PermitAll // 解决 SSE 最终响应的时候会被 Access Denied 拦截的问题
public Flux<CommonResult<String>> generateMindMap(@RequestBody @Valid AiMindMapGenerateReqVO generateReqVO) {
return mindMapService.generateMindMap(generateReqVO, getLoginUserId());
}
// ================ 导图管理 ================
@DeleteMapping("/delete")
@Operation(summary = "删除思维导图")
@Parameter(name = "id", description = "编号", required = true)
@PreAuthorize("@ss.hasPermission('ai:mind-map:delete')")
public CommonResult<Boolean> deleteMindMap(@RequestParam("id") Long id) {
mindMapService.deleteMindMap(id);
return success(true);
}
@GetMapping("/page")
@Operation(summary = "获得思维导图分页")
@PreAuthorize("@ss.hasPermission('ai:mind-map:query')")
public CommonResult<PageResult<AiMindMapRespVO>> getMindMapPage(@Valid AiMindMapPageReqVO pageReqVO) {
PageResult<AiMindMapDO> pageResult = mindMapService.getMindMapPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, AiMindMapRespVO.class));
}
}

View File

@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo;
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;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - AI 思维导图分页 Request VO")
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
public class AiMindMapPageReqVO extends PageParam {
@Schema(description = "用户编号", example = "4325")
private Long userId;
@Schema(description = "生成内容提示", example = "Java 学习路线")
private String prompt;
@Schema(description = "创建时间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;
}

View File

@ -0,0 +1,36 @@
package cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - AI 思维导图 Response VO")
@Data
public class AiMindMapRespVO {
@Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "3373")
private Long id;
@Schema(description = "用户编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "4325")
private Long userId;
@Schema(description = "生成内容提示", requiredMode = Schema.RequiredMode.REQUIRED, example = "Java 学习路线")
private String prompt;
@Schema(description = "生成的思维导图内容")
private String generatedContent;
@Schema(description = "平台", requiredMode = Schema.RequiredMode.REQUIRED, example = "OpenAI")
private String platform;
@Schema(description = "模型", requiredMode = Schema.RequiredMode.REQUIRED, example = "gpt-3.5-turbo-0125")
private String model;
@Schema(description = "错误信息")
private String errorMessage;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
}

View File

@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.ai.controller.admin.model.vo.apikey.AiApiKeyRespV
import cn.iocoder.yudao.module.ai.controller.admin.model.vo.apikey.AiApiKeySaveReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatModel.AiChatModelRespVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiApiKeyDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatModelDO;
import cn.iocoder.yudao.module.ai.service.model.AiApiKeyService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;

View File

@ -1,13 +1,8 @@
package cn.iocoder.yudao.module.ai.controller.admin.model.vo.apikey;
import lombok.*;
import java.util.*;
import io.swagger.v3.oas.annotations.media.Schema;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - AI API 密钥分页 Request VO")
@Data

View File

@ -1,7 +1,7 @@
package cn.iocoder.yudao.module.ai.controller.admin.model.vo.apikey;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import lombok.Data;
@Schema(description = "管理后台 - AI API 密钥 Response VO")
@Data

View File

@ -1,9 +1,9 @@
package cn.iocoder.yudao.module.ai.controller.admin.model.vo.apikey;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import java.util.*;
import jakarta.validation.constraints.*;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - AI API 密钥新增/修改 Request VO")
@Data

View File

@ -1,8 +1,8 @@
package cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatModel;
import lombok.*;
import io.swagger.v3.oas.annotations.media.Schema;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - API 聊天模型分页 Request VO")
@Data

View File

@ -3,8 +3,9 @@ package cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatModel;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import jakarta.validation.constraints.*;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
@Schema(description = "管理后台 - API 聊天模型新增/修改 Request VO")
@Data

View File

@ -1,8 +1,8 @@
package cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatRole;
import lombok.*;
import io.swagger.v3.oas.annotations.media.Schema;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@Schema(description = "管理后台 - AI 聊天角色分页 Request VO")
@Data

View File

@ -3,8 +3,9 @@ package cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatRole;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import jakarta.validation.constraints.*;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.URL;
@Schema(description = "管理后台 - AI 聊天角色新增/修改 Request VO")

View File

@ -6,8 +6,6 @@ import cn.iocoder.yudao.module.ai.enums.music.AiMusicGenerateModeEnum;
import cn.iocoder.yudao.module.ai.enums.music.AiMusicStatusEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;

View File

@ -1,13 +1,13 @@
package cn.iocoder.yudao.module.ai.dal.dataobject.chat;
import com.baomidou.mybatisplus.annotation.TableId;
import org.springframework.ai.chat.messages.MessageType;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatModelDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatRoleDO;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
import org.springframework.ai.chat.messages.MessageType;
/**
* AI Chat 消息 DO

View File

@ -2,7 +2,9 @@ package cn.iocoder.yudao.module.ai.dal.dataobject.model;
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
import com.baomidou.mybatisplus.annotation.*;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.*;
/**

View File

@ -6,9 +6,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation.AiChatConversationPageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message.AiChatMessagePageReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatConversationDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatMessageDO;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import org.apache.ibatis.annotations.Mapper;

View File

@ -4,6 +4,7 @@ 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.ai.controller.admin.image.vo.AiImagePageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImagePublicPageReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.image.AiImageDO;
import org.apache.ibatis.annotations.Mapper;
@ -41,6 +42,13 @@ public interface AiImageMapper extends BaseMapperX<AiImageDO> {
.orderByDesc(AiImageDO::getId));
}
default PageResult<AiImageDO> selectPage(AiImagePublicPageReqVO pageReqVO) {
return selectPage(pageReqVO, new LambdaQueryWrapperX<AiImageDO>()
.eqIfPresent(AiImageDO::getPublicStatus, Boolean.TRUE)
.likeIfPresent(AiImageDO::getPrompt, pageReqVO.getPrompt())
.orderByDesc(AiImageDO::getId));
}
default List<AiImageDO> selectListByStatusAndPlatform(Integer status, String platform) {
return selectList(AiImageDO::getStatus, status,
AiImageDO::getPlatform, platform);

View File

@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.ai.dal.mysql.mindmap;
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.ai.controller.admin.mindmap.vo.AiMindMapPageReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.mindmap.AiMindMapDO;
import org.apache.ibatis.annotations.Mapper;
@ -11,4 +14,13 @@ import org.apache.ibatis.annotations.Mapper;
*/
@Mapper
public interface AiMindMapMapper extends BaseMapperX<AiMindMapDO> {
default PageResult<AiMindMapDO> selectPage(AiMindMapPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<AiMindMapDO>()
.eqIfPresent(AiMindMapDO::getUserId, reqVO.getUserId())
.eqIfPresent(AiMindMapDO::getPrompt, reqVO.getPrompt())
.betweenIfPresent(AiMindMapDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(AiMindMapDO::getId));
}
}

View File

@ -8,7 +8,6 @@ import cn.iocoder.yudao.module.ai.controller.admin.model.vo.chatModel.AiChatMode
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatModelDO;
import org.apache.ibatis.annotations.Mapper;
import java.util.Collection;
import java.util.List;
/**

View File

@ -1,5 +1,5 @@
/**
* ai 模块下接入 LLM 大模型支持聊天绘图音乐写作思维图等功能
* ai 模块下接入 LLM 大模型支持聊天绘图音乐写作思维图等功能
* 目前已接入各种模型不限于
* 国内通义千问文心一言讯飞星火智谱 GLMDeepSeek
* 国外OpenAIOllamaMidjourneyStableDiffusionSuno

View File

@ -4,7 +4,6 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation.AiChatConversationCreateMyReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation.AiChatConversationPageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.conversation.AiChatConversationUpdateMyReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.chat.vo.message.AiChatMessageRespVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.chat.AiChatConversationDO;
import java.util.List;

View File

@ -21,7 +21,10 @@ import cn.iocoder.yudao.module.ai.service.model.AiApiKeyService;
import cn.iocoder.yudao.module.ai.service.model.AiChatModelService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.MessageType;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.model.StreamingChatModel;

View File

@ -4,6 +4,7 @@ import cn.iocoder.yudao.framework.ai.core.model.midjourney.api.MidjourneyApi;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImageDrawReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImagePageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImagePublicPageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImageUpdateReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyActionReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyImagineReqVO;
@ -28,6 +29,14 @@ public interface AiImageService {
*/
PageResult<AiImageDO> getImagePageMy(Long userId, AiImagePageReqVO pageReqVO);
/**
* 获取公开的绘图分页
*
* @param pageReqVO 分页条件
* @return 绘图分页
*/
PageResult<AiImageDO> getImagePagePublic(AiImagePublicPageReqVO pageReqVO);
/**
* 获得绘图记录
*

View File

@ -14,6 +14,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImageDrawReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImagePageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImagePublicPageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.AiImageUpdateReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyActionReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.image.vo.midjourney.AiMidjourneyImagineReqVO;
@ -70,6 +71,11 @@ public class AiImageServiceImpl implements AiImageService {
return imageMapper.selectPageMy(userId, pageReqVO);
}
@Override
public PageResult<AiImageDO> getImagePagePublic(AiImagePublicPageReqVO pageReqVO) {
return imageMapper.selectPage(pageReqVO);
}
@Override
public AiImageDO getImage(Long id) {
return imageMapper.selectById(id);

View File

@ -0,0 +1,15 @@
package cn.iocoder.yudao.module.ai.service.knowledge;
/**
* AI 知识库 Service 接口
*
* @author xiaoxin
*/
public interface DocService {
/**
* 向量化文档
*/
void embeddingDoc();
}

View File

@ -0,0 +1,42 @@
package cn.iocoder.yudao.module.ai.service.knowledge;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.tika.TikaDocumentReader;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.RedisVectorStore;
import org.springframework.beans.factory.annotation.Value;
import java.util.List;
/**
* AI 知识库 Service 实现类
*
* @author xiaoxin
*/
//@Service // TODO 芋艿临时注释避免无法启动
@Slf4j
public class DocServiceImpl implements DocService {
@Resource
private RedisVectorStore vectorStore;
@Resource
private TokenTextSplitter tokenTextSplitter;
// TODO @xin 临时测试用后续删
@Value("classpath:/webapp/test/Fel.pdf")
private org.springframework.core.io.Resource data;
@Override
public void embeddingDoc() {
// 读取文件
TikaDocumentReader loader = new TikaDocumentReader(data);
List<Document> documents = loader.get();
// 文档分段
List<Document> segments = tokenTextSplitter.apply(documents);
// 向量化并存储
vectorStore.add(segments);
}
}

View File

@ -1,7 +1,10 @@
package cn.iocoder.yudao.module.ai.service.mindmap;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo.AiMindMapGenerateReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo.AiMindMapPageReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.mindmap.AiMindMapDO;
import reactor.core.publisher.Flux;
/**
@ -20,4 +23,19 @@ public interface AiMindMapService {
*/
Flux<CommonResult<String>> generateMindMap(AiMindMapGenerateReqVO generateReqVO, Long userId);
/**
* 删除思维导图
*
* @param id 编号
*/
void deleteMindMap(Long id);
/**
* 获得思维导图分页
*
* @param pageReqVO 分页查询
* @return 思维导图分页
*/
PageResult<AiMindMapDO> getMindMapPage(AiMindMapPageReqVO pageReqVO);
}

View File

@ -6,9 +6,11 @@ import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.ai.core.enums.AiPlatformEnum;
import cn.iocoder.yudao.framework.ai.core.util.AiUtils;
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.tenant.core.util.TenantUtils;
import cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo.AiMindMapGenerateReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.mindmap.vo.AiMindMapPageReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.mindmap.AiMindMapDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatModelDO;
import cn.iocoder.yudao.module.ai.dal.dataobject.model.AiChatRoleDO;
@ -33,8 +35,10 @@ import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.error;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.MIND_MAP_NOT_EXISTS;
/**
* AI 思维导图 Service 实现类
@ -57,10 +61,10 @@ public class AiMindMapServiceImpl implements AiMindMapService {
@Override
public Flux<CommonResult<String>> generateMindMap(AiMindMapGenerateReqVO generateReqVO, Long userId) {
// 1. 获取图模型尝试获取思维导图助手角色如果没有则使用默认模型
// 1. 获取图模型尝试获取思维导图助手角色如果没有则使用默认模型
AiChatRoleDO role = CollUtil.getFirst(
chatRoleService.getChatRoleListByName(AiChatRoleEnum.AI_MIND_MAP_ROLE.getName()));
// 1.1 获取图执行模型
// 1.1 获取图执行模型
AiChatModelDO model = getModel(role);
// 1.2 获取角色设定消息
String systemMessage = role != null && StrUtil.isNotBlank(role.getSystemMessage())
@ -131,4 +135,23 @@ public class AiMindMapServiceImpl implements AiMindMapService {
return model;
}
@Override
public void deleteMindMap(Long id) {
// 校验存在
validateMindMapExists(id);
// 删除
mindMapMapper.deleteById(id);
}
private void validateMindMapExists(Long id) {
if (mindMapMapper.selectById(id) == null) {
throw exception(MIND_MAP_NOT_EXISTS);
}
}
@Override
public PageResult<AiMindMapDO> getMindMapPage(AiMindMapPageReqVO pageReqVO) {
return mindMapMapper.selectPage(pageReqVO);
}
}

View File

@ -9,8 +9,6 @@ import jakarta.validation.Valid;
import java.util.Collection;
import java.util.List;
import java.util.Set;
/**
* AI 聊天模型 Service 接口
*

View File

@ -21,7 +21,8 @@ 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.convertList;
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_ROLE_DISABLE;
import static cn.iocoder.yudao.module.ai.enums.ErrorCodeConstants.CHAT_ROLE_NOT_EXISTS;
/**
* AI 聊天角色 Service 实现类

View File

@ -1,7 +1,10 @@
package cn.iocoder.yudao.module.ai.service.music;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.ai.controller.admin.music.vo.*;
import cn.iocoder.yudao.module.ai.controller.admin.music.vo.AiMusicPageReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.music.vo.AiMusicUpdateMyReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.music.vo.AiMusicUpdateReqVO;
import cn.iocoder.yudao.module.ai.controller.admin.music.vo.AiSunoGenerateReqVO;
import cn.iocoder.yudao.module.ai.dal.dataobject.music.AiMusicDO;
import jakarta.validation.Valid;

View File

@ -104,14 +104,22 @@ xxl:
spring:
ai:
vectorstore: # 向量存储
redis:
index: default-index
prefix: "default:"
qianfan: # 文心一言
api-key: x0cuLZ7XsaTCU08vuJWO87Lg
secret-key: R9mYF9dl9KASgi5RUq0FQt3wRisSnOcK
zhipuai: # 智谱 AI
api-key: 32f84543e54eee31f8d56b2bd6020573.3vh9idLJZ2ZhxDEs
openai:
openai: # OpenAI 官方
api-key: sk-yzKea6d8e8212c3bdd99f9f44ced1cae37c097e5aa3BTS7z
base-url: https://api.gptsapi.net
azure: # OpenAI 微软
openai:
endpoint: https://eastusprejade.openai.azure.com
api-key: xxx
ollama:
base-url: http://127.0.0.1:11434
chat:

View File

@ -23,12 +23,16 @@
<artifactId>spring-ai-zhipuai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-azure-openai-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
@ -40,6 +44,30 @@
<version>${spring-ai.version}</version>
</dependency>
<!-- 向量化,基于 Redis 存储Tika 解析内容 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-transformers-spring-boot-starter</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-tika-document-reader</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-store</artifactId>
<version>${spring-ai.version}</version>
</dependency>
<!-- TODO @xin引入我们项目的 starter -->
<dependency>
<groupId>org.springframework.data</groupId>
<artifactId>spring-data-redis</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.iocoder.cloud</groupId>
<artifactId>yudao-common</artifactId>

View File

@ -10,11 +10,20 @@ import cn.iocoder.yudao.framework.ai.core.model.xinghuo.XingHuoChatModel;
import cn.iocoder.yudao.framework.ai.core.model.xinghuo.XingHuoChatOptions;
import com.alibaba.cloud.ai.tongyi.TongYiAutoConfiguration;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.autoconfigure.vectorstore.redis.RedisVectorStoreProperties;
import org.springframework.ai.document.MetadataMode;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.ai.transformers.TransformersEmbeddingModel;
import org.springframework.ai.vectorstore.RedisVectorStore;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.data.redis.RedisProperties;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Import;
import org.springframework.context.annotation.Lazy;
import redis.clients.jedis.JedisPooled;
/**
* 芋道 AI 自动配置
@ -73,4 +82,36 @@ public class YudaoAiAutoConfiguration {
return new SunoApi(yudaoAiProperties.getSuno().getBaseUrl());
}
// ========== rag 相关 ==========
@Bean
@Lazy // TODO 芋艿临时注释避免无法启动
public EmbeddingModel transformersEmbeddingClient() {
return new TransformersEmbeddingModel(MetadataMode.EMBED);
}
/**
* 我们启动有加载很多 Embedding 模型不晓得取哪个好 new TransformersEmbeddingModel
*/
@Bean
@Lazy // TODO 芋艿临时注释避免无法启动
public RedisVectorStore vectorStore(TransformersEmbeddingModel transformersEmbeddingModel, RedisVectorStoreProperties properties,
RedisProperties redisProperties) {
var config = RedisVectorStore.RedisVectorStoreConfig.builder()
.withIndexName(properties.getIndex())
.withPrefix(properties.getPrefix())
.build();
RedisVectorStore redisVectorStore = new RedisVectorStore(config, transformersEmbeddingModel,
new JedisPooled(redisProperties.getHost(), redisProperties.getPort()),
properties.isInitializeSchema());
redisVectorStore.afterPropertiesSet();
return redisVectorStore;
}
@Bean
@Lazy // TODO 芋艿临时注释避免无法启动
public TokenTextSplitter tokenTextSplitter() {
return new TokenTextSplitter(500, 100, 5, 10000, true);
}
}

View File

@ -22,7 +22,8 @@ public enum AiPlatformEnum {
// ========== 国外平台 ==========
OPENAI("OpenAI", "OpenAI"),
OPENAI("OpenAI", "OpenAI"), // OpenAI 官方
AZURE_OPENAI("AzureOpenAI", "AzureOpenAI"), // OpenAI 微软
OLLAMA("Ollama", "Ollama"),
STABLE_DIFFUSION("StableDiffusion", "StableDiffusion"), // Stability AI

View File

@ -21,6 +21,10 @@ import com.alibaba.cloud.ai.tongyi.image.TongYiImagesModel;
import com.alibaba.cloud.ai.tongyi.image.TongYiImagesProperties;
import com.alibaba.dashscope.aigc.generation.Generation;
import com.alibaba.dashscope.aigc.imagesynthesis.ImageSynthesis;
import com.azure.ai.openai.OpenAIClient;
import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiAutoConfiguration;
import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiChatProperties;
import org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiConnectionProperties;
import org.springframework.ai.autoconfigure.ollama.OllamaAutoConfiguration;
import org.springframework.ai.autoconfigure.openai.OpenAiAutoConfiguration;
import org.springframework.ai.autoconfigure.qianfan.QianFanAutoConfiguration;
@ -31,6 +35,7 @@ import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiAutoConfiguration;
import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiChatProperties;
import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiConnectionProperties;
import org.springframework.ai.autoconfigure.zhipuai.ZhiPuAiImageProperties;
import org.springframework.ai.azure.openai.AzureOpenAiChatModel;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.image.ImageModel;
import org.springframework.ai.model.function.FunctionCallbackContext;
@ -82,6 +87,8 @@ public class AiModelFactoryImpl implements AiModelFactory {
return buildXingHuoChatModel(apiKey);
case OPENAI:
return buildOpenAiChatModel(apiKey, url);
case AZURE_OPENAI:
return buildAzureOpenAiChatModel(apiKey, url);
case OLLAMA:
return buildOllamaChatModel(url);
default:
@ -106,6 +113,8 @@ public class AiModelFactoryImpl implements AiModelFactory {
return SpringUtil.getBean(XingHuoChatModel.class);
case OPENAI:
return SpringUtil.getBean(OpenAiChatModel.class);
case AZURE_OPENAI:
return SpringUtil.getBean(AzureOpenAiChatModel.class);
case OLLAMA:
return SpringUtil.getBean(OllamaChatModel.class);
default:
@ -179,7 +188,7 @@ public class AiModelFactoryImpl implements AiModelFactory {
* 可参考 {@link TongYiAutoConfiguration#tongYiChatClient(Generation, TongYiChatProperties, TongYiConnectionProperties)}
*/
private static TongYiChatModel buildTongYiChatModel(String key) {
com.alibaba.dashscope.aigc.generation.Generation generation = SpringUtil.getBean(Generation.class);
Generation generation = SpringUtil.getBean(Generation.class);
TongYiChatProperties chatOptions = SpringUtil.getBean(TongYiChatProperties.class);
// TODO @芋艿貌似 apiKey 是全局唯一的得测试下
// TODO @芋艿貌似阿里云不是增量返回的
@ -268,6 +277,21 @@ public class AiModelFactoryImpl implements AiModelFactory {
return new OpenAiChatModel(openAiApi);
}
/**
* 可参考 {@link AzureOpenAiAutoConfiguration}
*/
private static AzureOpenAiChatModel buildAzureOpenAiChatModel(String apiKey, String url) {
AzureOpenAiAutoConfiguration azureOpenAiAutoConfiguration = new AzureOpenAiAutoConfiguration();
// 创建 OpenAIClient 对象
AzureOpenAiConnectionProperties connectionProperties = new AzureOpenAiConnectionProperties();
connectionProperties.setApiKey(apiKey);
connectionProperties.setEndpoint(url);
OpenAIClient openAIClient = azureOpenAiAutoConfiguration.openAIClient(connectionProperties);
// 获取 AzureOpenAiChatProperties 对象
AzureOpenAiChatProperties chatProperties = SpringUtil.getBean(AzureOpenAiChatProperties.class);
return azureOpenAiAutoConfiguration.azureOpenAiChatModel(openAIClient, chatProperties, null, null);
}
/**
* 可参考 {@link OpenAiAutoConfiguration}
*/

View File

@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.ai.core.enums.AiPlatformEnum;
import cn.iocoder.yudao.framework.ai.core.model.deepseek.DeepSeekChatOptions;
import cn.iocoder.yudao.framework.ai.core.model.xinghuo.XingHuoChatOptions;
import com.alibaba.cloud.ai.tongyi.chat.TongYiChatOptions;
import org.springframework.ai.azure.openai.AzureOpenAiChatOptions;
import org.springframework.ai.chat.messages.*;
import org.springframework.ai.chat.prompt.ChatOptions;
import org.springframework.ai.ollama.api.OllamaOptions;
@ -35,6 +36,9 @@ public class AiUtils {
return XingHuoChatOptions.builder().model(model).temperature(temperatureF).maxTokens(maxTokens).build();
case OPENAI:
return OpenAiChatOptions.builder().withModel(model).withTemperature(temperatureF).withMaxTokens(maxTokens).build();
case AZURE_OPENAI:
// TODO 芋艿貌似没 model 字段
return AzureOpenAiChatOptions.builder().withDeploymentName(model).withTemperature(temperatureF).withMaxTokens(maxTokens).build();
case OLLAMA:
return OllamaOptions.create().withModel(model).withTemperature(temperatureF).withNumPredict(maxTokens);
default:

View File

@ -0,0 +1,59 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.autoconfigure.vectorstore.redis;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.RedisVectorStore;
import org.springframework.ai.vectorstore.RedisVectorStore.RedisVectorStoreConfig;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.data.redis.RedisAutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.data.redis.connection.jedis.JedisConnectionFactory;
import redis.clients.jedis.JedisPooled;
/**
* TODO @xin 先拿 spring-ai 最新代码覆盖1.0.0-M1 redis 自动配置会冲突
*
* TODO 这个官方有说啥时候 fix
*
* @author Christian Tzolov
* @author Eddú Meléndez
*/
@AutoConfiguration(after = RedisAutoConfiguration.class)
@ConditionalOnClass({JedisPooled.class, JedisConnectionFactory.class, RedisVectorStore.class, EmbeddingModel.class})
//@ConditionalOnBean(JedisConnectionFactory.class)
@EnableConfigurationProperties(RedisVectorStoreProperties.class)
public class RedisVectorStoreAutoConfiguration {
@Bean
@ConditionalOnMissingBean
public RedisVectorStore vectorStore(EmbeddingModel embeddingModel, RedisVectorStoreProperties properties,
JedisConnectionFactory jedisConnectionFactory) {
var config = RedisVectorStoreConfig.builder()
.withIndexName(properties.getIndex())
.withPrefix(properties.getPrefix())
.build();
return new RedisVectorStore(config, embeddingModel,
new JedisPooled(jedisConnectionFactory.getHostName(), jedisConnectionFactory.getPort()),
properties.isInitializeSchema());
}
}

View File

@ -0,0 +1,456 @@
/*
* Copyright 2023 - 2024 the original author or authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springframework.ai.vectorstore;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.filter.FilterExpressionConverter;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.util.Assert;
import org.springframework.util.CollectionUtils;
import redis.clients.jedis.JedisPooled;
import redis.clients.jedis.Pipeline;
import redis.clients.jedis.json.Path2;
import redis.clients.jedis.search.*;
import redis.clients.jedis.search.Schema.FieldType;
import redis.clients.jedis.search.schemafields.*;
import redis.clients.jedis.search.schemafields.VectorField.VectorAlgorithm;
import java.text.MessageFormat;
import java.util.*;
import java.util.function.Function;
import java.util.function.Predicate;
import java.util.stream.Collectors;
/**
* The RedisVectorStore is for managing and querying vector data in a Redis database. It
* offers functionalities like adding, deleting, and performing similarity searches on
* documents.
*
* The store utilizes RedisJSON and RedisSearch to handle JSON documents and to index and
* search vector data. It supports various vector algorithms (e.g., FLAT, HSNW) for
* efficient similarity searches. Additionally, it allows for custom metadata fields in
* the documents to be stored alongside the vector and content data.
*
* This class requires a RedisVectorStoreConfig configuration object for initialization,
* which includes settings like Redis URI, index name, field names, and vector algorithms.
* It also requires an EmbeddingModel to convert documents into embeddings before storing
* them.
*
* @author Julien Ruaux
* @author Christian Tzolov
* @author Eddú Meléndez
* @see VectorStore
* @see RedisVectorStoreConfig
* @see EmbeddingModel
*/
public class RedisVectorStore implements VectorStore, InitializingBean {
public enum Algorithm {
FLAT, HSNW
}
public record MetadataField(String name, FieldType fieldType) {
public static MetadataField text(String name) {
return new MetadataField(name, FieldType.TEXT);
}
public static MetadataField numeric(String name) {
return new MetadataField(name, FieldType.NUMERIC);
}
public static MetadataField tag(String name) {
return new MetadataField(name, FieldType.TAG);
}
}
/**
* Configuration for the Redis vector store.
*/
public static final class RedisVectorStoreConfig {
private final String indexName;
private final String prefix;
private final String contentFieldName;
private final String embeddingFieldName;
private final Algorithm vectorAlgorithm;
private final List<MetadataField> metadataFields;
private RedisVectorStoreConfig() {
this(builder());
}
private RedisVectorStoreConfig(Builder builder) {
this.indexName = builder.indexName;
this.prefix = builder.prefix;
this.contentFieldName = builder.contentFieldName;
this.embeddingFieldName = builder.embeddingFieldName;
this.vectorAlgorithm = builder.vectorAlgorithm;
this.metadataFields = builder.metadataFields;
}
/**
* Start building a new configuration.
* @return The entry point for creating a new configuration.
*/
public static Builder builder() {
return new Builder();
}
/**
* {@return the default config}
*/
public static RedisVectorStoreConfig defaultConfig() {
return builder().build();
}
public static class Builder {
private String indexName = DEFAULT_INDEX_NAME;
private String prefix = DEFAULT_PREFIX;
private String contentFieldName = DEFAULT_CONTENT_FIELD_NAME;
private String embeddingFieldName = DEFAULT_EMBEDDING_FIELD_NAME;
private Algorithm vectorAlgorithm = DEFAULT_VECTOR_ALGORITHM;
private List<MetadataField> metadataFields = new ArrayList<>();
private Builder() {
}
/**
* Configures the Redis index name to use.
* @param name the index name to use
* @return this builder
*/
public Builder withIndexName(String name) {
this.indexName = name;
return this;
}
/**
* Configures the Redis key prefix to use (default: "embedding:").
* @param prefix the prefix to use
* @return this builder
*/
public Builder withPrefix(String prefix) {
this.prefix = prefix;
return this;
}
/**
* Configures the Redis content field name to use.
* @param name the content field name to use
* @return this builder
*/
public Builder withContentFieldName(String name) {
this.contentFieldName = name;
return this;
}
/**
* Configures the Redis embedding field name to use.
* @param name the embedding field name to use
* @return this builder
*/
public Builder withEmbeddingFieldName(String name) {
this.embeddingFieldName = name;
return this;
}
/**
* Configures the Redis vector algorithmto use.
* @param algorithm the vector algorithm to use
* @return this builder
*/
public Builder withVectorAlgorithm(Algorithm algorithm) {
this.vectorAlgorithm = algorithm;
return this;
}
public Builder withMetadataFields(MetadataField... fields) {
return withMetadataFields(Arrays.asList(fields));
}
public Builder withMetadataFields(List<MetadataField> fields) {
this.metadataFields = fields;
return this;
}
/**
* {@return the immutable configuration}
*/
public RedisVectorStoreConfig build() {
return new RedisVectorStoreConfig(this);
}
}
}
private final boolean initializeSchema;
public static final String DEFAULT_INDEX_NAME = "spring-ai-index";
public static final String DEFAULT_CONTENT_FIELD_NAME = "content";
public static final String DEFAULT_EMBEDDING_FIELD_NAME = "embedding";
public static final String DEFAULT_PREFIX = "embedding:";
public static final Algorithm DEFAULT_VECTOR_ALGORITHM = Algorithm.HSNW;
private static final String QUERY_FORMAT = "%s=>[KNN %s @%s $%s AS %s]";
private static final Path2 JSON_SET_PATH = Path2.of("$");
private static final String JSON_PATH_PREFIX = "$.";
private static final Logger logger = LoggerFactory.getLogger(RedisVectorStore.class);
private static final Predicate<Object> RESPONSE_OK = Predicate.isEqual("OK");
private static final Predicate<Object> RESPONSE_DEL_OK = Predicate.isEqual(1l);
private static final String VECTOR_TYPE_FLOAT32 = "FLOAT32";
private static final String EMBEDDING_PARAM_NAME = "BLOB";
public static final String DISTANCE_FIELD_NAME = "vector_score";
private static final String DEFAULT_DISTANCE_METRIC = "COSINE";
private final JedisPooled jedis;
private final EmbeddingModel embeddingModel;
private final RedisVectorStoreConfig config;
private FilterExpressionConverter filterExpressionConverter;
public RedisVectorStore(RedisVectorStoreConfig config, EmbeddingModel embeddingModel, JedisPooled jedis,
boolean initializeSchema) {
Assert.notNull(config, "Config must not be null");
Assert.notNull(embeddingModel, "Embedding model must not be null");
this.initializeSchema = initializeSchema;
this.jedis = jedis;
this.embeddingModel = embeddingModel;
this.config = config;
this.filterExpressionConverter = new RedisFilterExpressionConverter(this.config.metadataFields);
}
public JedisPooled getJedis() {
return this.jedis;
}
@Override
public void add(List<Document> documents) {
try (Pipeline pipeline = this.jedis.pipelined()) {
for (Document document : documents) {
var embedding = this.embeddingModel.embed(document);
document.setEmbedding(embedding);
var fields = new HashMap<String, Object>();
fields.put(this.config.embeddingFieldName, embedding);
fields.put(this.config.contentFieldName, document.getContent());
fields.putAll(document.getMetadata());
pipeline.jsonSetWithEscape(key(document.getId()), JSON_SET_PATH, fields);
}
List<Object> responses = pipeline.syncAndReturnAll();
Optional<Object> errResponse = responses.stream().filter(Predicate.not(RESPONSE_OK)).findAny();
if (errResponse.isPresent()) {
String message = MessageFormat.format("Could not add document: {0}", errResponse.get());
if (logger.isErrorEnabled()) {
logger.error(message);
}
throw new RuntimeException(message);
}
}
}
private String key(String id) {
return this.config.prefix + id;
}
@Override
public Optional<Boolean> delete(List<String> idList) {
try (Pipeline pipeline = this.jedis.pipelined()) {
for (String id : idList) {
pipeline.jsonDel(key(id));
}
List<Object> responses = pipeline.syncAndReturnAll();
Optional<Object> errResponse = responses.stream().filter(Predicate.not(RESPONSE_DEL_OK)).findAny();
if (errResponse.isPresent()) {
if (logger.isErrorEnabled()) {
logger.error("Could not delete document: {}", errResponse.get());
}
return Optional.of(false);
}
return Optional.of(true);
}
}
@Override
public List<Document> similaritySearch(SearchRequest request) {
Assert.isTrue(request.getTopK() > 0, "The number of documents to returned must be greater than zero");
Assert.isTrue(request.getSimilarityThreshold() >= 0 && request.getSimilarityThreshold() <= 1,
"The similarity score is bounded between 0 and 1; least to most similar respectively.");
String filter = nativeExpressionFilter(request);
String queryString = String.format(QUERY_FORMAT, filter, request.getTopK(), this.config.embeddingFieldName,
EMBEDDING_PARAM_NAME, DISTANCE_FIELD_NAME);
List<String> returnFields = new ArrayList<>();
this.config.metadataFields.stream().map(MetadataField::name).forEach(returnFields::add);
returnFields.add(this.config.embeddingFieldName);
returnFields.add(this.config.contentFieldName);
returnFields.add(DISTANCE_FIELD_NAME);
var embedding = toFloatArray(this.embeddingModel.embed(request.getQuery()));
Query query = new Query(queryString).addParam(EMBEDDING_PARAM_NAME, RediSearchUtil.toByteArray(embedding))
.returnFields(returnFields.toArray(new String[0]))
.setSortBy(DISTANCE_FIELD_NAME, true)
.dialect(2);
SearchResult result = this.jedis.ftSearch(this.config.indexName, query);
return result.getDocuments()
.stream()
.filter(d -> similarityScore(d) >= request.getSimilarityThreshold())
.map(this::toDocument)
.toList();
}
private Document toDocument(redis.clients.jedis.search.Document doc) {
var id = doc.getId().substring(this.config.prefix.length());
var content = doc.hasProperty(this.config.contentFieldName) ? doc.getString(this.config.contentFieldName)
: null;
Map<String, Object> metadata = this.config.metadataFields.stream()
.map(MetadataField::name)
.filter(doc::hasProperty)
.collect(Collectors.toMap(Function.identity(), doc::getString));
metadata.put(DISTANCE_FIELD_NAME, 1 - similarityScore(doc));
return new Document(id, content, metadata);
}
private float similarityScore(redis.clients.jedis.search.Document doc) {
return (2 - Float.parseFloat(doc.getString(DISTANCE_FIELD_NAME))) / 2;
}
private String nativeExpressionFilter(SearchRequest request) {
if (request.getFilterExpression() == null) {
return "*";
}
return "(" + this.filterExpressionConverter.convertExpression(request.getFilterExpression()) + ")";
}
@Override
public void afterPropertiesSet() {
if (!this.initializeSchema) {
return;
}
// If index already exists don't do anything
if (this.jedis.ftList().contains(this.config.indexName)) {
return;
}
String response = this.jedis.ftCreate(this.config.indexName,
FTCreateParams.createParams().on(IndexDataType.JSON).addPrefix(this.config.prefix), schemaFields());
if (!RESPONSE_OK.test(response)) {
String message = MessageFormat.format("Could not create index: {0}", response);
throw new RuntimeException(message);
}
}
private Iterable<SchemaField> schemaFields() {
Map<String, Object> vectorAttrs = new HashMap<>();
vectorAttrs.put("DIM", this.embeddingModel.dimensions());
vectorAttrs.put("DISTANCE_METRIC", DEFAULT_DISTANCE_METRIC);
vectorAttrs.put("TYPE", VECTOR_TYPE_FLOAT32);
List<SchemaField> fields = new ArrayList<>();
fields.add(TextField.of(jsonPath(this.config.contentFieldName)).as(this.config.contentFieldName).weight(1.0));
fields.add(VectorField.builder()
.fieldName(jsonPath(this.config.embeddingFieldName))
.algorithm(vectorAlgorithm())
.attributes(vectorAttrs)
.as(this.config.embeddingFieldName)
.build());
if (!CollectionUtils.isEmpty(this.config.metadataFields)) {
for (MetadataField field : this.config.metadataFields) {
fields.add(schemaField(field));
}
}
return fields;
}
private SchemaField schemaField(MetadataField field) {
String fieldName = jsonPath(field.name);
switch (field.fieldType) {
case NUMERIC:
return NumericField.of(fieldName).as(field.name);
case TAG:
return TagField.of(fieldName).as(field.name);
case TEXT:
return TextField.of(fieldName).as(field.name);
default:
throw new IllegalArgumentException(
MessageFormat.format("Field {0} has unsupported type {1}", field.name, field.fieldType));
}
}
private VectorAlgorithm vectorAlgorithm() {
if (config.vectorAlgorithm == Algorithm.HSNW) {
return VectorAlgorithm.HNSW;
}
return VectorAlgorithm.FLAT;
}
private String jsonPath(String field) {
return JSON_PATH_PREFIX + field;
}
private static float[] toFloatArray(List<Double> embeddingDouble) {
float[] embeddingFloat = new float[embeddingDouble.size()];
int i = 0;
for (Double d : embeddingDouble) {
embeddingFloat[i++] = d.floatValue();
}
return embeddingFloat;
}
}

View File

@ -0,0 +1,70 @@
package cn.iocoder.yudao.framework.ai.chat;
import com.azure.ai.openai.OpenAIClient;
import com.azure.ai.openai.OpenAIClientBuilder;
import com.azure.core.credential.AzureKeyCredential;
import com.azure.core.util.ClientOptions;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.ai.azure.openai.AzureOpenAiChatModel;
import org.springframework.ai.azure.openai.AzureOpenAiChatOptions;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.List;
import static org.springframework.ai.autoconfigure.azure.openai.AzureOpenAiChatProperties.DEFAULT_DEPLOYMENT_NAME;
/**
* {@link AzureOpenAiChatModel} 集成测试
*
* @author 芋道源码
*/
public class AzureOpenAIChatModelTests {
private final OpenAIClient openAiApi = (new OpenAIClientBuilder())
.endpoint("https://eastusprejade.openai.azure.com")
.credential(new AzureKeyCredential("xxx"))
.clientOptions((new ClientOptions()).setApplicationId("spring-ai"))
.buildClient();
private final AzureOpenAiChatModel chatModel = new AzureOpenAiChatModel(openAiApi,
AzureOpenAiChatOptions.builder().withDeploymentName(DEFAULT_DEPLOYMENT_NAME).build());
@Test
@Disabled
public void testCall() {
// 准备参数
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
// 调用
ChatResponse response = chatModel.call(new Prompt(messages));
// 打印结果
System.out.println(response);
System.out.println(response.getResult().getOutput());
}
@Test
@Disabled
public void testStream() {
// 准备参数
List<Message> messages = new ArrayList<>();
messages.add(new SystemMessage("你是一个优质的文言文作者,用文言文描述着各城市的人文风景。"));
messages.add(new UserMessage("1 + 1 = "));
// 调用
Flux<ChatResponse> flux = chatModel.stream(new Prompt(messages));
// 打印结果
flux.doOnNext(response -> {
// System.out.println(response);
System.out.println(response.getResult().getOutput());
}).then().block();
}
}

View File

@ -1,6 +1,5 @@
package cn.iocoder.yudao.framework.ai.chat;
import cn.iocoder.yudao.framework.ai.core.model.xinghuo.XingHuoChatModel;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.messages.Message;
@ -17,7 +16,7 @@ import java.util.ArrayList;
import java.util.List;
/**
* {@link XingHuoChatModel} 集成测试
* {@link OpenAiChatModel} 集成测试
*
* @author 芋道源码
*/

View File

@ -8,6 +8,7 @@ import cn.iocoder.yudao.module.promotion.enums.coupon.CouponStatusEnum;
import cn.iocoder.yudao.module.promotion.enums.coupon.CouponTakeTypeEnum;
import com.baomidou.mybatisplus.annotation.KeySequence;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
@ -30,6 +31,7 @@ public class CouponDO extends BaseDO {
/**
* 优惠劵编号
*/
@TableId
private Long id;
/**
* 优惠劵模板编号

View File

@ -19,12 +19,13 @@ import cn.iocoder.yudao.module.promotion.dal.mysql.coupon.CouponMapper;
import cn.iocoder.yudao.module.promotion.enums.coupon.CouponStatusEnum;
import cn.iocoder.yudao.module.promotion.enums.coupon.CouponTakeTypeEnum;
import cn.iocoder.yudao.module.promotion.enums.coupon.CouponTemplateValidityTypeEnum;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.validation.annotation.Validated;
import jakarta.annotation.Resource;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@ -132,7 +133,7 @@ public class CouponServiceImpl implements CouponService {
@Transactional
public void deleteCoupon(Long id) {
// 校验存在
validateCouponExists(id);
CouponDO coupon = validateCouponExists(id);
// 更新优惠劵
int deleteCount = couponMapper.delete(id,
@ -140,8 +141,9 @@ public class CouponServiceImpl implements CouponService {
if (deleteCount == 0) {
throw exception(COUPON_DELETE_FAIL_USED);
}
// 减少优惠劵模板的领取数量 -1
couponTemplateService.updateCouponTemplateTakeCount(id, -1);
couponTemplateService.updateCouponTemplateTakeCount(coupon.getTemplateId(), -1);
}
@Override
@ -149,10 +151,12 @@ public class CouponServiceImpl implements CouponService {
return couponMapper.selectListByUserIdAndStatus(userId, status);
}
private void validateCouponExists(Long id) {
if (couponMapper.selectById(id) == null) {
private CouponDO validateCouponExists(Long id) {
CouponDO coupon = couponMapper.selectById(id);
if (coupon == null) {
throw exception(COUPON_NOT_EXISTS);
}
return coupon;
}
@Override

View File

@ -22,4 +22,7 @@ public class AppProductSpuBaseRespVO {
@Schema(description = "商品主图地址", example = "https://www.iocoder.cn/xx.png")
private String picUrl;
@Schema(description = "商品分类编号", example = "1")
private Long categoryId;
}

View File

@ -2,11 +2,11 @@ package cn.iocoder.yudao.module.trade.controller.app.order.vo;
import cn.iocoder.yudao.module.trade.controller.app.base.property.AppProductPropertyValueDetailRespVO;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import jakarta.validation.constraints.NotNull;
import java.util.List;
@Schema(description = "用户 App - 交易订单结算信息 Response VO")
@ -26,7 +26,7 @@ public class AppTradeOrderSettlementRespVO {
private Address address;
@Schema(description = "已使用的积分", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
private Integer usedPoint;
private Integer usePoint;
@Schema(description = "总积分", requiredMode = Schema.RequiredMode.REQUIRED, example = "10")
private Integer totalPoint;

View File

@ -48,13 +48,17 @@ public class TradePriceCalculateRespBO {
*/
private Long couponId;
/**
* 会员剩余积分
*/
private Integer totalPoint;
/**
* 使用的积分
*/
private Integer usePoint;
/**
* 使用的积分
* 赠送的积分
*/
private Integer givePoint;

View File

@ -55,6 +55,7 @@ public class TradeDeliveryPriceCalculator implements TradePriceCalculator {
if (param.getDeliveryType() == null) {
return;
}
// TODO @puhui999需要校验是不是存在商品不能门店自提或者不能快递发货的情况就是说配送方式不匹配哈
if (DeliveryTypeEnum.PICK_UP.getType().equals(param.getDeliveryType())) {
calculateByPickUp(param);
} else if (DeliveryTypeEnum.EXPRESS.getType().equals(param.getDeliveryType())) {

View File

@ -9,11 +9,11 @@ import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
import cn.iocoder.yudao.module.promotion.enums.common.PromotionTypeEnum;
import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateReqBO;
import cn.iocoder.yudao.module.trade.service.price.bo.TradePriceCalculateRespBO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Component;
import jakarta.annotation.Resource;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -37,11 +37,12 @@ public class TradePointUsePriceCalculator implements TradePriceCalculator {
@Override
public void calculate(TradePriceCalculateReqBO param, TradePriceCalculateRespBO result) {
// 默认使用积分为 0
result.setUsePoint(0);
// 0. 初始化积分
MemberUserRespDTO user = memberUserApi.getUser(param.getUserId()).getCheckedData();
result.setTotalPoint(user.getPoint()).setUsePoint(0);
// 1.1 校验是否使用积分
if (!BooleanUtil.isTrue(param.getPointStatus())) {
result.setUsePoint(0);
return;
}
// 1.2 校验积分抵扣是否开启
@ -50,7 +51,6 @@ public class TradePointUsePriceCalculator implements TradePriceCalculator {
return;
}
// 1.3 校验用户积分余额
MemberUserRespDTO user = memberUserApi.getUser(param.getUserId()).getCheckedData();
if (user.getPoint() == null || user.getPoint() <= 0) {
return;
}

View File

@ -142,19 +142,6 @@
<artifactId>wx-java-miniapp-spring-boot-starter</artifactId> <!-- 微信登录(小程序) -->
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-core</artifactId> <!-- 短信(阿里云) -->
</dependency>
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>aliyun-java-sdk-dysmsapi</artifactId> <!-- 短信(阿里云) -->
</dependency>
<dependency>
<groupId>com.tencentcloudapi</groupId>
<artifactId>tencentcloud-sdk-java-sms</artifactId> <!-- 短信(腾讯云) -->
</dependency>
<dependency>
<groupId>com.xingyuv</groupId>
<artifactId>spring-boot-starter-captcha-plus</artifactId> <!-- 验证码,一般用于登录使用 -->

View File

@ -1,36 +1,33 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.date.format.FastDateFormat;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.crypto.SecureUtil;
import cn.hutool.crypto.digest.DigestUtil;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
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.client.impl.AbstractSmsClient;
import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;
import com.aliyuncs.DefaultAcsClient;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateRequest;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateResponse;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.aliyuncs.profile.DefaultProfile;
import com.aliyuncs.profile.IClientProfile;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import lombok.Data;
import lombok.SneakyThrows;
import lombok.extern.slf4j.Slf4j;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.stream.Collectors;
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;
/**
* 阿里短信客户端的实现类
@ -41,20 +38,11 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DE
@Slf4j
public class AliyunSmsClient extends AbstractSmsClient {
/**
* 调用成功 code
*/
public static final String API_CODE_SUCCESS = "OK";
private static final String URL = "https://dysmsapi.aliyuncs.com";
private static final String HOST = "dysmsapi.aliyuncs.com";
private static final String VERSION = "2017-05-25";
/**
* REGION, 使用杭州
*/
private static final String ENDPOINT = "cn-hangzhou";
/**
* 阿里云客户端
*/
private volatile IAcsClient client;
private static final String RESPONSE_CODE_SUCCESS = "OK";
public AliyunSmsClient(SmsChannelProperties properties) {
super(properties);
@ -64,47 +52,70 @@ public class AliyunSmsClient extends AbstractSmsClient {
@Override
protected void doInit() {
IClientProfile profile = DefaultProfile.getProfile(ENDPOINT, properties.getApiKey(), properties.getApiSecret());
client = new DefaultAcsClient(profile);
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
List<KeyValue<String, Object>> templateParams) throws Throwable {
// 构建请求
SendSmsRequest request = new SendSmsRequest();
request.setPhoneNumbers(mobile);
request.setSignName(properties.getSignature());
request.setTemplateCode(apiTemplateId);
request.setTemplateParam(JsonUtils.toJsonString(MapUtils.convertMap(templateParams)));
request.setOutId(String.valueOf(sendLogId));
// 执行请求
SendSmsResponse response = client.getAcsResponse(request);
return new SmsSendRespDTO().setSuccess(Objects.equals(response.getCode(), API_CODE_SUCCESS)).setSerialNo(response.getBizId())
.setApiRequestId(response.getRequestId()).setApiCode(response.getCode()).setApiMsg(response.getMessage());
Assert.notBlank(properties.getSignature(), "短信签名不能为空");
// 1. 执行请求
// 参考链接 https://api.aliyun.com/document/Dysmsapi/2017-05-25/SendSms
TreeMap<String, Object> queryParam = new TreeMap<>();
queryParam.put("PhoneNumbers", mobile);
queryParam.put("SignName", properties.getSignature());
queryParam.put("TemplateCode", apiTemplateId);
queryParam.put("TemplateParam", JsonUtils.toJsonString(MapUtils.convertMap(templateParams)));
queryParam.put("OutId", sendLogId);
JSONObject response = request("SendSms", queryParam);
// 2. 解析请求
return new SmsSendRespDTO()
.setSuccess(Objects.equals(response.getStr("Code"), RESPONSE_CODE_SUCCESS))
.setSerialNo(response.getStr("BizId"))
.setApiRequestId(response.getStr("RequestId"))
.setApiCode(response.getStr("Code"))
.setApiMsg(response.getStr("Message"));
}
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
List<SmsReceiveStatus> statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class);
return convertList(statuses, status -> new SmsReceiveRespDTO().setSuccess(status.getSuccess())
.setErrorCode(status.getErrCode()).setErrorMsg(status.getErrMsg())
.setMobile(status.getPhoneNumber()).setReceiveTime(status.getReportTime())
.setSerialNo(status.getBizId()).setLogId(Long.valueOf(status.getOutId())));
JSONArray statuses = JSONUtil.parseArray(text);
// 字段参考
return convertList(statuses, status -> {
JSONObject statusObj = (JSONObject) status;
return new SmsReceiveRespDTO()
.setSuccess(statusObj.getBool("success")) // 是否接收成功
.setErrorCode(statusObj.getStr("err_code")) // 状态报告编码
.setErrorMsg(statusObj.getStr("err_msg")) // 状态报告说明
.setMobile(statusObj.getStr("phone_number")) // 手机号
.setReceiveTime(statusObj.getLocalDateTime("report_time", null)) // 状态报告时间
.setSerialNo(statusObj.getStr("biz_id")) // 发送序列号
.setLogId(statusObj.getLong("out_id")); // 用户序列号
});
}
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
// 构建请求
QuerySmsTemplateRequest request = new QuerySmsTemplateRequest();
request.setTemplateCode(apiTemplateId);
// 执行请求
QuerySmsTemplateResponse response = client.getAcsResponse(request);
if (response.getTemplateStatus() == null) {
// 1. 执行请求
// 参考链接 https://api.aliyun.com/document/Dysmsapi/2017-05-25/QuerySmsTemplate
TreeMap<String, Object> queryParam = new TreeMap<>();
queryParam.put("TemplateCode", apiTemplateId);
JSONObject response = request("QuerySmsTemplate", queryParam);
System.out.println("getSmsTemplate response is =====" + response.toString());
// 2.1 请求失败
String code = response.getStr("Code");
if (ObjectUtil.notEqual(code, RESPONSE_CODE_SUCCESS)) {
log.error("[getSmsTemplate][模版编号({}) 响应不正确({})]", apiTemplateId, response);
return null;
}
return new SmsTemplateRespDTO().setId(response.getTemplateCode()).setContent(response.getTemplateContent())
.setAuditStatus(convertSmsTemplateAuditStatus(response.getTemplateStatus())).setAuditReason(response.getReason());
// 2.2 请求成功
return new SmsTemplateRespDTO()
.setId(response.getStr("TemplateCode"))
.setContent(response.getStr("TemplateContent"))
.setAuditStatus(convertSmsTemplateAuditStatus(response.getInt("TemplateStatus")))
.setAuditReason(response.getStr("Reason"));
}
@VisibleForTesting
@ -118,66 +129,71 @@ public class AliyunSmsClient extends AbstractSmsClient {
}
/**
* 短信接收状态
* 请求阿里云短信
*
* 参见 <a href="https://help.aliyun.com/document_detail/101867.html">文档</a>
*
* @author 芋道源码
* @see <a href="https://help.aliyun.com/zh/sdk/product-overview/v3-request-structure-and-signature">V3 版本请求体&签名机制</>
* @param apiName 请求的 API 名称
* @param queryParams 请求参数
* @return 请求结果
*/
@Data
public static class SmsReceiveStatus {
private JSONObject request(String apiName, TreeMap<String, Object> queryParams) {
// 1. 请求参数
String queryString = queryParams.entrySet().stream()
.map(entry -> percentCode(entry.getKey()) + "=" + percentCode(String.valueOf(entry.getValue())))
.collect(Collectors.joining("&"));
/**
* 手机号
*/
@JsonProperty("phone_number")
private String phoneNumber;
/**
* 发送时间
*/
@JsonProperty("send_time")
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
private LocalDateTime sendTime;
/**
* 状态报告时间
*/
@JsonProperty("report_time")
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
private LocalDateTime reportTime;
/**
* 是否接收成功
*/
private Boolean success;
/**
* 状态报告说明
*/
@JsonProperty("err_msg")
private String errMsg;
/**
* 状态报告编码
*/
@JsonProperty("err_code")
private String errCode;
/**
* 发送序列号
*/
@JsonProperty("biz_id")
private String bizId;
/**
* 用户序列号
*
* 这里我们传递的是 SysSmsLogDO 的日志编号
*/
@JsonProperty("out_id")
private String outId;
/**
* 短信长度例如说 123
*
* 140 字节算一条短信短信长度超过 140 字节时会拆分成多条短信发送
*/
@JsonProperty("sms_size")
private Integer smsSize;
// 2.1 请求 Header
TreeMap<String, String> headers = new TreeMap<>();
headers.put("host", HOST);
headers.put("x-acs-version", VERSION);
headers.put("x-acs-action", apiName);
headers.put("x-acs-date", FastDateFormat.getInstance("yyyy-MM-dd'T'HH:mm:ss'Z'", TimeZone.getTimeZone("GMT")).format(new Date()));
headers.put("x-acs-signature-nonce", IdUtil.randomUUID());
// 2.2 构建签名 Header
StringBuilder canonicalHeaders = new StringBuilder(); // 构造请求头多个规范化消息头按照消息头名称小写的字符代码顺序以升序排列后拼接在一起
StringBuilder signedHeadersBuilder = new StringBuilder(); // 已签名消息头列表多个请求头名称小写按首字母升序排列并以英文分号;分隔
headers.entrySet().stream().filter(entry -> entry.getKey().toLowerCase().startsWith("x-acs-")
|| entry.getKey().equalsIgnoreCase("host")
|| entry.getKey().equalsIgnoreCase("content-type"))
.sorted(Map.Entry.comparingByKey()).forEach(entry -> {
String lowerKey = entry.getKey().toLowerCase();
canonicalHeaders.append(lowerKey).append(":").append(String.valueOf(entry.getValue()).trim()).append("\n");
signedHeadersBuilder.append(lowerKey).append(";");
});
String signedHeaders = signedHeadersBuilder.substring(0, signedHeadersBuilder.length() - 1);
// 3. 请求 Body
String requestBody = ""; // 短信 API RPC 接口query parameters uri 中拼接因此 request body 如果没有特殊要求设置为空
String hashedRequestBody = DigestUtil.sha256Hex(requestBody);
// 4. 构建 Authorization 签名
String canonicalRequest = "POST" + "\n" + "/" + "\n" + queryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
String hashedCanonicalRequest = DigestUtil.sha256Hex(canonicalRequest);
String stringToSign = "ACS3-HMAC-SHA256" + "\n" + hashedCanonicalRequest;
String signature = SecureUtil.hmacSha256(properties.getApiSecret()).digestHex(stringToSign); // 计算签名
headers.put("Authorization", "ACS3-HMAC-SHA256" + " " + "Credential=" + properties.getApiKey()
+ ", " + "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature);
// 5. 发起请求
String responseBody = HttpUtils.post(URL + "?" + queryString, headers, requestBody);
return JSONUtil.parseObj(responseBody);
}
}
/**
* 对指定的字符串进行 URL 编码并对特定的字符进行替换以符合URL编码规范
*
* @param str 需要进行 URL 编码的字符串
* @return 编码后的字符串
*/
@SneakyThrows
private static String percentCode(String str) {
Assert.notNull(str, "str 不能为空");
return URLEncoder.encode(str, StandardCharsets.UTF_8.name())
.replace("+", "%20") // 加号 "+" 被替换为 "%20"
.replace("*", "%2A") // 星号 "*" 被替换为 "%2A"
.replace("%7E", "~"); // 波浪号 "%7E" 被替换为 "~"
}
}

View File

@ -13,7 +13,6 @@ 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.client.impl.AbstractSmsClient;
import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;

View File

@ -1,12 +1,15 @@
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.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
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;
@ -17,23 +20,19 @@ 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.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.*;
import static cn.hutool.crypto.digest.DigestUtil.sha256Hex;
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;
/**
* 华为短信客户端的实现类
*
@ -46,7 +45,14 @@ public class HuaweiSmsClient extends AbstractSmsClient {
/**
* 调用成功 code
*/
public static final String API_CODE_SUCCESS = "OK";
public static final String URL = "https://smsapi.cn-north-4.myhuaweicloud.com:443/sms/batchSendSms/v1";//APP接入地址+接口访问URI
public static final String HOST = "smsapi.cn-north-4.myhuaweicloud.com:443";
public static final String SIGNEDHEADERS = "content-type;host;x-sdk-date";
@Override
protected void doInit() {
}
public HuaweiSmsClient(SmsChannelProperties properties) {
super(properties);
@ -54,96 +60,79 @@ public class HuaweiSmsClient extends AbstractSmsClient {
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
}
@Override
protected void doInit() {
}
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
List<KeyValue<String, Object>> templateParams) throws Throwable {
// TODO @scholarhttps://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 @scholar1是不是用 LocalDateTimeUtil.format()这样 3 行变成一行
// TODO @scholarsingerDate sdkDate 会更合适哈这样理解起来简单另外singer 应该是 signed
List<String> templateParas = CollectionUtils.convertList(templateParams, kv -> String.valueOf(kv.getValue()));
JSONObject JsonResponse = sendSmsRequest(sender,mobile,templateId,templateParas,statusCallBack);
SmsResponse smsResponse = getSmsSendResponse(JsonResponse);
return new SmsSendRespDTO().setSuccess(smsResponse.success).setApiMsg(smsResponse.data.toString());
}
JSONObject sendSmsRequest(String sender,String mobile,String templateId,List<String> templateParas,String statusCallBack) throws UnsupportedEncodingException {
SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'", Locale.ENGLISH);
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
String singerDate = sdf.format(new Date());
String sdkDate = sdf.format(new Date());
// TODO @scholar整个处理加密的过程是不是应该抽成一个 private 方法哈这样整个调用的主干更清晰
// ************* 步骤 1拼接规范请求串 *************
String httpRequestMethod = "POST";
String canonicalUri = "/sms/batchSendSms/v1/";
String canonicalQueryString = ""; // 查询参数为空
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 @scholarCollectionUtils.convertList 可以把 4 行变成 1
// TODO @scholartemplateParams 拼写错误哈
List<String> templateParas = new ArrayList<>();
for (KeyValue<String, Object> kv : templateParams) {
templateParas.add(String.valueOf(kv.getValue()));
}
// 请求Body,不携带签名名称时,signature请填null
+ "host:"+ HOST +"\n"
+ "x-sdk-date:" + sdkDate + "\n";
//请求Body,不携带签名名称时,signature请填null
String body = buildRequestBody(sender, mobile, templateId, templateParas, statusCallBack, null);
// TODO @scholarAssert 断言抛出异常
if (null == body || body.isEmpty()) {
return null;
}
String hashedRequestBody = HexUtil.encodeHexStr(DigestUtil.sha256(body));
String hashedRequestBody = sha256Hex(body);
String canonicalRequest = httpRequestMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n"
+ canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
+ canonicalHeaders + "\n" + SIGNEDHEADERS + "\n" + hashedRequestBody;
// ************* 步骤 2拼接待签名字符串 *************
// TODO @scholarsha256Hex 是不是更简洁哈
String hashedCanonicalRequest = HexUtil.encodeHexStr(DigestUtil.sha256(canonicalRequest));
String stringToSign = "SDK-HMAC-SHA256" + "\n" + singerDate + "\n" + hashedCanonicalRequest;
String hashedCanonicalRequest = sha256Hex(canonicalRequest);
String stringToSign = "SDK-HMAC-SHA256" + "\n" + sdkDate + "\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;
+ "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 @scholarsetSerialNo(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);
HttpResponse response = HttpRequest.post(URL)
.header("Content-Type", "application/x-www-form-urlencoded")
.header("X-Sdk-Date", sdkDate)
.header("host",HOST)
.header("Authorization", authorization)
.body(body)
.execute();
return JSONUtil.parseObj(response.body());
}
private SmsResponse getSmsSendResponse(JSONObject resJson) {
SmsResponse smsResponse = new SmsResponse();
smsResponse.setSuccess("000000".equals(resJson.getStr("code")));
smsResponse.setData(resJson);
return smsResponse;
}
static String buildRequestBody(String sender, String receiver, String templateId, List<String> templateParas,
String statusCallBack, @SuppressWarnings("SameParameterValue") String signature) {
// TODO @scholar参数不满足是不是抛出异常更好哈通过 hutool Assert 去断言
String statusCallBack, String signature) throws UnsupportedEncodingException {
if (null == sender || null == receiver || null == templateId || sender.isEmpty() || receiver.isEmpty()
|| templateId.isEmpty()) {
System.out.println("buildRequestBody(): sender, receiver or templateId is null.");
@ -154,20 +143,17 @@ public class HuaweiSmsClient extends AbstractSmsClient {
appendToBody(body, "from=", sender);
appendToBody(body, "&to=", receiver);
appendToBody(body, "&templateId=", templateId);
// TODO @scholarnew JSONArray(templateParas).toString()是不是 JsonUtils.toString
appendToBody(body, "&templateParas=", new JSONArray(templateParas).toString());
appendToBody(body, "&templateParas=", JsonUtils.toJsonString(templateParas));
appendToBody(body, "&statusCallback=", statusCallBack);
appendToBody(body, "&signature=", signature);
return body.toString();
}
private static void appendToBody(StringBuilder body, String key, String val) {
// TODO @scholarStrUtils.isNotEmpty(val)是不是更简洁哈
private static void appendToBody(StringBuilder body, String key, String val) throws UnsupportedEncodingException {
if (null != val && !val.isEmpty()) {
body.append(key).append(URLEncoder.encode(val, StandardCharsets.UTF_8));
body.append(key).append(URLEncoder.encode(val, "UTF-8"));
}
}
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
List<SmsReceiveStatus> statuses = JsonUtils.parseArray(text, SmsReceiveStatus.class);
@ -179,12 +165,28 @@ public class HuaweiSmsClient extends AbstractSmsClient {
@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)
//华为短信模板查询和发送短信是不同的两套key和secret与阿里腾讯的区别较大这里模板查询校验暂不实现
return new SmsTemplateRespDTO().setId(null).setContent(null)
.setAuditStatus(SmsTemplateAuditStatusEnum.SUCCESS.getStatus()).setAuditReason(null);
}
@Data
public static class SmsResponse {
/**
* 是否成功
*/
private boolean success;
/**
* 厂商原返回体
*/
private Object data;
}
/**
* 短信接收状态
*

View File

@ -2,31 +2,38 @@ package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import cn.hutool.json.JSONArray;
import cn.hutool.json.JSONObject;
import cn.hutool.json.JSONUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
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.client.impl.AbstractSmsClient;
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 com.google.common.annotations.VisibleForTesting;
import com.tencentcloudapi.common.Credential;
import com.tencentcloudapi.sms.v20210111.SmsClient;
import com.tencentcloudapi.sms.v20210111.models.*;
import jakarta.xml.bind.DatatypeConverter;
import lombok.Data;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.text.SimpleDateFormat;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Objects;
import java.util.*;
import static cn.hutool.crypto.digest.DigestUtil.sha256Hex;
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;
// TODO @scholar 建议参考 AliyunSmsClient 优化下
/**
* 腾讯云短信功能实现
*
@ -41,11 +48,6 @@ public class TencentSmsClient extends AbstractSmsClient {
*/
public static final String API_CODE_SUCCESS = "Ok";
/**
* REGION使用南京
*/
private static final String ENDPOINT = "ap-nanjing";
/**
* 是否国际/港澳台短信
*
@ -54,7 +56,6 @@ public class TencentSmsClient extends AbstractSmsClient {
*/
private static final long INTERNATIONAL_CHINA = 0L;
private SmsClient client;
public TencentSmsClient(SmsChannelProperties properties) {
super(properties);
@ -64,9 +65,7 @@ public class TencentSmsClient extends AbstractSmsClient {
@Override
protected void doInit() {
// 实例化一个认证对象入参需要传入腾讯云账户密钥对 secretIdsecretKey
Credential credential = new Credential(getApiKey(), properties.getApiSecret());
client = new SmsClient(credential, ENDPOINT);
}
/**
@ -97,18 +96,87 @@ public class TencentSmsClient extends AbstractSmsClient {
public SmsSendRespDTO sendSms(Long sendLogId, String mobile,
String apiTemplateId, List<KeyValue<String, Object>> templateParams) throws Throwable {
// 构建请求
SendSmsRequest request = new SendSmsRequest();
request.setSmsSdkAppId(getSdkAppId());
request.setPhoneNumberSet(new String[]{mobile});
request.setSignName(properties.getSignature());
request.setTemplateId(apiTemplateId);
request.setTemplateParamSet(ArrayUtils.toArray(templateParams, e -> String.valueOf(e.getValue())));
request.setSessionContext(JsonUtils.toJsonString(new SessionContext().setLogId(sendLogId)));
// 执行请求
SendSmsResponse response = client.SendSms(request);
SendStatus status = response.getSendStatusSet()[0];
return new SmsSendRespDTO().setSuccess(Objects.equals(status.getCode(), API_CODE_SUCCESS)).setSerialNo(status.getSerialNo())
.setApiRequestId(response.getRequestId()).setApiCode(status.getCode()).setApiMsg(status.getMessage());
TreeMap<String, Object> body = new TreeMap<>();
String[] phones = {mobile};
body.put("PhoneNumberSet",phones);
body.put("SmsSdkAppId",getSdkAppId());
body.put("SignName",properties.getSignature());
body.put("TemplateId",apiTemplateId);
body.put("TemplateParamSet",ArrayUtils.toArray(templateParams, e -> String.valueOf(e.getValue())));
JSONObject JsonResponse = sendSmsRequest(body,"SendSms","2021-01-11","ap-guangzhou");
SmsResponse smsResponse = getSmsSendResponse(JsonResponse);
return new SmsSendRespDTO().setSuccess(smsResponse.success).setApiMsg(smsResponse.data.toString());
}
JSONObject sendSmsRequest(TreeMap<String, Object> body,String action,String version,String region) throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// 注意时区否则容易出错
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
String date = sdf.format(new Date(Long.valueOf(timestamp + "000")));
// ************* 步骤 1拼接规范请求串 *************
String host = "sms.tencentcloudapi.com"; //APP接入地址+接口访问URI
String httpMethod = "POST"; // 请求方式
String canonicalUri = "/";
String canonicalQueryString = "";
String canonicalHeaders = "content-type:application/json; charset=utf-8\n"
+ "host:" + host + "\n" + "x-tc-action:" + action.toLowerCase() + "\n";
String signedHeaders = "content-type;host;x-tc-action";
String hashedRequestBody = sha256Hex(JSONUtil.toJsonStr(body));
String canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
// ************* 步骤 2拼接待签名字符串 *************
String credentialScope = date + "/" + "sms" + "/" + "tc3_request";
String hashedCanonicalRequest = sha256Hex(canonicalRequest);
String stringToSign = "TC3-HMAC-SHA256" + "\n" + timestamp + "\n" + credentialScope + "\n" + hashedCanonicalRequest;
// ************* 步骤 3计算签名 *************
byte[] secretDate = hmac256(("TC3" + properties.getApiSecret()).getBytes(StandardCharsets.UTF_8), date);
byte[] secretService = hmac256(secretDate, "sms");
byte[] secretSigning = hmac256(secretService, "tc3_request");
String signature = DatatypeConverter.printHexBinary(hmac256(secretSigning, stringToSign)).toLowerCase();
// ************* 步骤 4拼接 Authorization *************
String authorization = "TC3-HMAC-SHA256" + " " + "Credential=" + getApiKey() + "/" + credentialScope + ", "
+ "SignedHeaders=" + signedHeaders + ", " + "Signature=" + signature;
// ************* 步骤 5构造HttpRequest 并执行request请求获得response *************
Map<String, String> headers = new HashMap<>();
headers.put("Authorization", authorization);
headers.put("Content-Type", "application/json; charset=utf-8");
headers.put("Host", host);
headers.put("X-TC-Action", action);
headers.put("X-TC-Timestamp", timestamp);
headers.put("X-TC-Version", version);
headers.put("X-TC-Region", region);
HttpResponse response = HttpRequest.post("https://"+host)
.addHeaders(headers)
.body(JSONUtil.toJsonStr(body))
.execute();
return JSONUtil.parseObj(response.body());
}
public static byte[] hmac256(byte[] key, String msg) throws Exception {
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKeySpec = new SecretKeySpec(key, mac.getAlgorithm());
mac.init(secretKeySpec);
return mac.doFinal(msg.getBytes(StandardCharsets.UTF_8));
}
private SmsResponse getSmsSendResponse(JSONObject resJson) {
SmsResponse smsResponse = new SmsResponse();
JSONArray statusJson =resJson.getJSONObject("Response").getJSONArray("SendStatusSet");
smsResponse.setSuccess("Ok".equals(statusJson.getJSONObject(0).getStr("Code")));
smsResponse.setData(resJson);
return smsResponse;
}
@Override
@ -123,18 +191,49 @@ public class TencentSmsClient extends AbstractSmsClient {
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
// 构建请求
DescribeSmsTemplateListRequest request = new DescribeSmsTemplateListRequest();
request.setTemplateIdSet(new Long[]{Long.parseLong(apiTemplateId)});
request.setInternational(INTERNATIONAL_CHINA);
// 执行请求
DescribeSmsTemplateListResponse response = client.DescribeSmsTemplateList(request);
DescribeTemplateListStatus status = response.getDescribeTemplateStatusSet()[0];
if (status == null || status.getStatusCode() == null) {
return null;
}
return new SmsTemplateRespDTO().setId(status.getTemplateId().toString()).setContent(status.getTemplateContent())
.setAuditStatus(convertSmsTemplateAuditStatus(status.getStatusCode().intValue())).setAuditReason(status.getReviewReply());
TreeMap<String, Object> body = new TreeMap<>();
body.put("International",0);
Integer[] templateIds = {Integer.valueOf(apiTemplateId)};
body.put("TemplateIdSet",templateIds);
JSONObject JsonResponse = sendSmsRequest(body,"DescribeSmsTemplateList","2021-01-11","ap-guangzhou");
QuerySmsTemplateResponse smsTemplateResponse = getSmsTemplateResponse(JsonResponse);
String templateId = Integer.toString(smsTemplateResponse.getDescribeTemplateStatusSet().get(0).getTemplateId());
String content = smsTemplateResponse.getDescribeTemplateStatusSet().get(0).getTemplateContent();
Integer templateStatus = smsTemplateResponse.getDescribeTemplateStatusSet().get(0).getStatusCode();
String auditReason = smsTemplateResponse.getDescribeTemplateStatusSet().get(0).getReviewReply();
return new SmsTemplateRespDTO().setId(templateId).setContent(content)
.setAuditStatus(convertSmsTemplateAuditStatus(templateStatus)).setAuditReason(auditReason);
}
private QuerySmsTemplateResponse getSmsTemplateResponse(JSONObject resJson) {
QuerySmsTemplateResponse smsTemplateResponse = new QuerySmsTemplateResponse();
smsTemplateResponse.setRequestId(resJson.getJSONObject("Response").getStr("RequestId"));
smsTemplateResponse.setDescribeTemplateStatusSet(new ArrayList<>());
QuerySmsTemplateResponse.TemplateInfo templateInfo = new QuerySmsTemplateResponse.TemplateInfo();
Object statusObject = resJson.getJSONObject("Response").getJSONArray("DescribeTemplateStatusSet").get(0);
JSONObject statusJSON = new JSONObject(statusObject);
templateInfo.setTemplateContent(statusJSON.get("TemplateContent").toString());
templateInfo.setStatusCode(Integer.parseInt(statusJSON.get("StatusCode").toString()));
templateInfo.setReviewReply(statusJSON.get("ReviewReply").toString());
templateInfo.setTemplateId(Integer.parseInt(statusJSON.get("TemplateId").toString()));
smsTemplateResponse.getDescribeTemplateStatusSet().add(templateInfo);
return smsTemplateResponse;
}
@VisibleForTesting
@ -147,6 +246,45 @@ public class TencentSmsClient extends AbstractSmsClient {
}
}
@Data
public static class SmsResponse {
/**
* 是否成功
*/
private boolean success;
/**
* 厂商原返回体
*/
private Object data;
}
/**
* <p>类名: QuerySmsTemplateResponse
* <p>说明 sms模板查询返回信息
*
* @author :scholar
* 2024/07/17 0:25
**/
@Data
public static class QuerySmsTemplateResponse {
private List<TemplateInfo> DescribeTemplateStatusSet;
private String RequestId;
@Data
static class TemplateInfo {
private String TemplateName;
private Integer TemplateId;
private Integer International;
private String ReviewReply;
private long CreateTime;
private String TemplateContent;
private Integer StatusCode;
}
}
@Data
private static class SmsReceiveStatus {

View File

@ -1,34 +1,27 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.util.ReflectUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
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.client.impl.AliyunSmsClient;
import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;
import com.aliyuncs.IAcsClient;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateRequest;
import com.aliyuncs.dysmsapi.model.v20170525.QuerySmsTemplateResponse;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsRequest;
import com.aliyuncs.dysmsapi.model.v20170525.SendSmsResponse;
import com.google.common.collect.Lists;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentMatcher;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.MockedStatic;
import java.time.LocalDateTime;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomLongId;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.anyMap;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mockStatic;
/**
* {@link AliyunSmsClient} 的单元测试
@ -45,9 +38,6 @@ public class AliyunSmsClientTest extends BaseMockitoUnitTest {
@InjectMocks
private final AliyunSmsClient smsClient = new AliyunSmsClient(properties);
@Mock
private IAcsClient client;
@Test
public void testDoInit() {
// 准备参数
@ -55,67 +45,55 @@ public class AliyunSmsClientTest extends BaseMockitoUnitTest {
// 调用
smsClient.doInit();
// 断言
assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "acsClient"));
}
@Test
public void tesSendSms_success() throws Throwable {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("code", 1234), new KeyValue<>("op", "login"));
// mock 方法
SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> o.setCode("OK"));
when(client.getAcsResponse(argThat((ArgumentMatcher<SendSmsRequest>) acsRequest -> {
assertEquals(mobile, acsRequest.getPhoneNumbers());
assertEquals(properties.getSignature(), acsRequest.getSignName());
assertEquals(apiTemplateId, acsRequest.getTemplateCode());
assertEquals(toJsonString(MapUtils.convertMap(templateParams)), acsRequest.getTemplateParam());
assertEquals(sendLogId.toString(), acsRequest.getOutId());
return true;
}))).thenReturn(response);
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("code", 1234), new KeyValue<>("op", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"Message\":\"OK\",\"RequestId\":\"30067CE9-3710-5984-8881-909B21D8DB28\",\"Code\":\"OK\",\"BizId\":\"800025323183427988\"}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertTrue(result.getSuccess());
assertEquals(response.getRequestId(), result.getApiRequestId());
assertEquals(response.getCode(), result.getApiCode());
assertEquals(response.getMessage(), result.getApiMsg());
assertEquals(response.getBizId(), result.getSerialNo());
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertTrue(result.getSuccess());
assertEquals("30067CE9-3710-5984-8881-909B21D8DB28", result.getApiRequestId());
assertEquals("OK", result.getApiCode());
assertEquals("OK", result.getApiMsg());
assertEquals("800025323183427988", result.getSerialNo());
}
}
@Test
public void tesSendSms_fail() throws Throwable {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("code", 1234), new KeyValue<>("op", "login"));
// mock 方法
SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> o.setCode("ERROR"));
when(client.getAcsResponse(argThat((ArgumentMatcher<SendSmsRequest>) acsRequest -> {
assertEquals(mobile, acsRequest.getPhoneNumbers());
assertEquals(properties.getSignature(), acsRequest.getSignName());
assertEquals(apiTemplateId, acsRequest.getTemplateCode());
assertEquals(toJsonString(MapUtils.convertMap(templateParams)), acsRequest.getTemplateParam());
assertEquals(sendLogId.toString(), acsRequest.getOutId());
return true;
}))).thenReturn(response);
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("code", 1234), new KeyValue<>("op", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"Message\":\"手机号码格式错误\",\"RequestId\":\"B7700B8E-227E-5886-9564-26036172F01F\",\"Code\":\"isv.MOBILE_NUMBER_ILLEGAL\"}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals(response.getRequestId(), result.getApiRequestId());
assertEquals(response.getCode(), result.getApiCode());
assertEquals(response.getMessage(), result.getApiMsg());
assertEquals(response.getBizId(), result.getSerialNo());
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals("B7700B8E-227E-5886-9564-26036172F01F", result.getApiRequestId());
assertEquals("isv.MOBILE_NUMBER_ILLEGAL", result.getApiCode());
assertEquals("手机号码格式错误", result.getApiMsg());
assertNull(result.getSerialNo());
}
}
@Test
@ -152,25 +130,21 @@ public class AliyunSmsClientTest extends BaseMockitoUnitTest {
@Test
public void testGetSmsTemplate() throws Throwable {
// 准备参数
String apiTemplateId = randomString();
// mock 方法
QuerySmsTemplateResponse response = randomPojo(QuerySmsTemplateResponse.class, o -> {
o.setCode("OK");
o.setTemplateStatus(1); // 设置模板通过
});
when(client.getAcsResponse(argThat((ArgumentMatcher<QuerySmsTemplateRequest>) acsRequest -> {
assertEquals(apiTemplateId, acsRequest.getTemplateCode());
return true;
}))).thenReturn(response);
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
String apiTemplateId = randomString();
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\"TemplateCode\":\"SMS_207945135\",\"RequestId\":\"6F4CC077-29C8-5BA5-AB62-5FF95068A5AC\",\"Message\":\"OK\",\"TemplateContent\":\"您的验证码${code}该验证码5分钟内有效请勿泄漏于他人\",\"TemplateName\":\"公告通知\",\"TemplateType\":0,\"Code\":\"OK\",\"CreateDate\":\"2020-12-23 17:34:42\",\"Reason\":\"无审批备注\",\"TemplateStatus\":1}");
// 调用
SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId);
// 断言
assertEquals(response.getTemplateCode(), result.getId());
assertEquals(response.getTemplateContent(), result.getContent());
assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus());
assertEquals(response.getReason(), result.getAuditReason());
// 调用
SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId);
// 断言
assertEquals("SMS_207945135", result.getId());
assertEquals("您的验证码${code}该验证码5分钟内有效请勿泄漏于他人", result.getContent());
assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus());
assertEquals("无审批备注", result.getAuditReason());
}
}
@Test

View File

@ -1,7 +1,9 @@
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.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.property.SmsChannelProperties;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
@ -17,7 +19,7 @@ public class SmsClientTests {
@Test
@Disabled
public void testHuaweiSmsClient() throws Throwable {
public void testHuaweiSmsClient_sendSms() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("123")
.setApiSecret("456");
@ -33,4 +35,68 @@ public class SmsClientTests {
System.out.println(smsSendRespDTO);
}
// ========== 阿里云 ==========
@Test
@Disabled
public void testAliyunSmsClient_getSmsTemplate() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz");
AliyunSmsClient client = new AliyunSmsClient(properties);
// 准备参数
String apiTemplateId = "SMS_207945135";
// 调用
SmsTemplateRespDTO template = client.getSmsTemplate(apiTemplateId);
// 打印结果
System.out.println(template);
}
@Test
@Disabled
public void testAliyunSmsClient_sendSms() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz")
.setSignature("Ballcat");
AliyunSmsClient client = new AliyunSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "173213154791";
String apiTemplateId = "SMS_207945135";
// 调用
SmsSendRespDTO sendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, List.of(new KeyValue<>("code", "1024")));
// 打印结果
System.out.println(sendRespDTO);
}
@Test
@Disabled
public void testAliyunSmsClient_parseSmsReceiveStatus() {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz");
AliyunSmsClient client = new AliyunSmsClient(properties);
// 准备参数
String text = "[\n" +
" {\n" +
" \"phone_number\" : \"13900000001\",\n" +
" \"send_time\" : \"2017-01-01 11:12:13\",\n" +
" \"report_time\" : \"2017-02-02 22:23:24\",\n" +
" \"success\" : true,\n" +
" \"err_code\" : \"DELIVERED\",\n" +
" \"err_msg\" : \"用户接收成功\",\n" +
" \"sms_size\" : \"1\",\n" +
" \"biz_id\" : \"12345\",\n" +
" \"out_id\" : \"67890\"\n" +
" }\n" +
"]";
// mock 方法
// 调用
List<SmsReceiveRespDTO> statuses = client.parseSmsReceiveStatus(text);
// 打印结果
System.out.println(statuses);
}
}

View File

@ -1,36 +1,22 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.util.ReflectUtil;
import cn.iocoder.yudao.framework.common.core.KeyValue;
import cn.iocoder.yudao.framework.common.util.collection.ArrayUtils;
import cn.iocoder.yudao.framework.common.util.collection.MapUtils;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
import cn.iocoder.yudao.module.system.framework.sms.core.client.SmsClient;
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.google.common.collect.Lists;
import com.tencentcloudapi.sms.v20210111.SmsClient;
import com.tencentcloudapi.sms.v20210111.models.DescribeSmsTemplateListResponse;
import com.tencentcloudapi.sms.v20210111.models.DescribeTemplateListStatus;
import com.tencentcloudapi.sms.v20210111.models.SendSmsResponse;
import com.tencentcloudapi.sms.v20210111.models.SendStatus;
import org.junit.jupiter.api.Test;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import static cn.iocoder.yudao.framework.common.util.json.JsonUtils.toJsonString;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.argThat;
import static org.mockito.Mockito.when;
// TODO @芋艿补全单测
/**
* {@link TencentSmsClient} 的单元测试
*
@ -73,87 +59,87 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest {
assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "client"));
}
@Test
public void testDoSendSms_success() throws Throwable {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
String requestId = randomString();
String serialNo = randomString();
// mock 方法
SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> {
o.setRequestId(requestId);
SendStatus[] sendStatuses = new SendStatus[1];
o.setSendStatusSet(sendStatuses);
SendStatus sendStatus = new SendStatus();
sendStatuses[0] = sendStatus;
sendStatus.setCode(TencentSmsClient.API_CODE_SUCCESS);
sendStatus.setMessage("send success");
sendStatus.setSerialNo(serialNo);
});
when(client.SendSms(argThat(request -> {
assertEquals(mobile, request.getPhoneNumberSet()[0]);
assertEquals(properties.getSignature(), request.getSignName());
assertEquals(apiTemplateId, request.getTemplateId());
assertEquals(toJsonString(ArrayUtils.toArray(new ArrayList<>(MapUtils.convertMap(templateParams).values()), String::valueOf)),
toJsonString(request.getTemplateParamSet()));
assertEquals(sendLogId, ReflectUtil.getFieldValue(JsonUtils.parseObject(request.getSessionContext(), TencentSmsClient.SessionContext.class), "logId"));
return true;
}))).thenReturn(response);
// @Test
// public void testDoSendSms_success() throws Throwable {
// // 准备参数
// Long sendLogId = randomLongId();
// String mobile = randomString();
// String apiTemplateId = randomString();
// List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
// new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// String requestId = randomString();
// String serialNo = randomString();
// // mock 方法
// SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> {
// o.setRequestId(requestId);
// SendStatus[] sendStatuses = new SendStatus[1];
// o.setSendStatusSet(sendStatuses);
// SendStatus sendStatus = new SendStatus();
// sendStatuses[0] = sendStatus;
// sendStatus.setCode(TencentSmsClient.API_CODE_SUCCESS);
// sendStatus.setMessage("send success");
// sendStatus.setSerialNo(serialNo);
// });
// when(client.SendSms(argThat(request -> {
// assertEquals(mobile, request.getPhoneNumberSet()[0]);
// assertEquals(properties.getSignature(), request.getSignName());
// assertEquals(apiTemplateId, request.getTemplateId());
// assertEquals(toJsonString(ArrayUtils.toArray(new ArrayList<>(MapUtils.convertMap(templateParams).values()), String::valueOf)),
// toJsonString(request.getTemplateParamSet()));
// assertEquals(sendLogId, ReflectUtil.getFieldValue(JsonUtils.parseObject(request.getSessionContext(), TencentSmsClient.SessionContext.class), "logId"));
// return true;
// }))).thenReturn(response);
//
// // 调用
// SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// // 断言
// assertTrue(result.getSuccess());
// assertEquals(response.getRequestId(), result.getApiRequestId());
// assertEquals(response.getSendStatusSet()[0].getCode(), result.getApiCode());
// assertEquals(response.getSendStatusSet()[0].getMessage(), result.getApiMsg());
// assertEquals(response.getSendStatusSet()[0].getSerialNo(), result.getSerialNo());
// }
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// 断言
assertTrue(result.getSuccess());
assertEquals(response.getRequestId(), result.getApiRequestId());
assertEquals(response.getSendStatusSet()[0].getCode(), result.getApiCode());
assertEquals(response.getSendStatusSet()[0].getMessage(), result.getApiMsg());
assertEquals(response.getSendStatusSet()[0].getSerialNo(), result.getSerialNo());
}
@Test
public void testDoSendSms_fail() throws Throwable {
// 准备参数
Long sendLogId = randomLongId();
String mobile = randomString();
String apiTemplateId = randomString();
List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
String requestId = randomString();
String serialNo = randomString();
// mock 方法
SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> {
o.setRequestId(requestId);
SendStatus[] sendStatuses = new SendStatus[1];
o.setSendStatusSet(sendStatuses);
SendStatus sendStatus = new SendStatus();
sendStatuses[0] = sendStatus;
sendStatus.setCode("ERROR");
sendStatus.setMessage("send success");
sendStatus.setSerialNo(serialNo);
});
when(client.SendSms(argThat(request -> {
assertEquals(mobile, request.getPhoneNumberSet()[0]);
assertEquals(properties.getSignature(), request.getSignName());
assertEquals(apiTemplateId, request.getTemplateId());
assertEquals(toJsonString(ArrayUtils.toArray(new ArrayList<>(MapUtils.convertMap(templateParams).values()), String::valueOf)),
toJsonString(request.getTemplateParamSet()));
assertEquals(sendLogId, ReflectUtil.getFieldValue(JsonUtils.parseObject(request.getSessionContext(), TencentSmsClient.SessionContext.class), "logId"));
return true;
}))).thenReturn(response);
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals(response.getRequestId(), result.getApiRequestId());
assertEquals(response.getSendStatusSet()[0].getCode(), result.getApiCode());
assertEquals(response.getSendStatusSet()[0].getMessage(), result.getApiMsg());
assertEquals(response.getSendStatusSet()[0].getSerialNo(), result.getSerialNo());
}
// @Test
// public void testDoSendSms_fail() throws Throwable {
// // 准备参数
// Long sendLogId = randomLongId();
// String mobile = randomString();
// String apiTemplateId = randomString();
// List<KeyValue<String, Object>> templateParams = Lists.newArrayList(
// new KeyValue<>("1", 1234), new KeyValue<>("2", "login"));
// String requestId = randomString();
// String serialNo = randomString();
// // mock 方法
// SendSmsResponse response = randomPojo(SendSmsResponse.class, o -> {
// o.setRequestId(requestId);
// SendStatus[] sendStatuses = new SendStatus[1];
// o.setSendStatusSet(sendStatuses);
// SendStatus sendStatus = new SendStatus();
// sendStatuses[0] = sendStatus;
// sendStatus.setCode("ERROR");
// sendStatus.setMessage("send success");
// sendStatus.setSerialNo(serialNo);
// });
// when(client.SendSms(argThat(request -> {
// assertEquals(mobile, request.getPhoneNumberSet()[0]);
// assertEquals(properties.getSignature(), request.getSignName());
// assertEquals(apiTemplateId, request.getTemplateId());
// assertEquals(toJsonString(ArrayUtils.toArray(new ArrayList<>(MapUtils.convertMap(templateParams).values()), String::valueOf)),
// toJsonString(request.getTemplateParamSet()));
// assertEquals(sendLogId, ReflectUtil.getFieldValue(JsonUtils.parseObject(request.getSessionContext(), TencentSmsClient.SessionContext.class), "logId"));
// return true;
// }))).thenReturn(response);
//
// // 调用
// SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// // 断言
// assertFalse(result.getSuccess());
// assertEquals(response.getRequestId(), result.getApiRequestId());
// assertEquals(response.getSendStatusSet()[0].getCode(), result.getApiCode());
// assertEquals(response.getSendStatusSet()[0].getMessage(), result.getApiMsg());
// assertEquals(response.getSendStatusSet()[0].getSerialNo(), result.getSerialNo());
// }
@Test
public void testParseSmsReceiveStatus() {
@ -185,35 +171,35 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest {
assertEquals(67890L, statuses.get(0).getLogId());
}
@Test
public void testGetSmsTemplate() throws Throwable {
// 准备参数
Long apiTemplateId = randomLongId();
String requestId = randomString();
// mock 方法
DescribeSmsTemplateListResponse response = randomPojo(DescribeSmsTemplateListResponse.class, o -> {
DescribeTemplateListStatus[] describeTemplateListStatuses = new DescribeTemplateListStatus[1];
DescribeTemplateListStatus templateStatus = new DescribeTemplateListStatus();
templateStatus.setTemplateId(apiTemplateId);
templateStatus.setStatusCode(0L);// 设置模板通过
describeTemplateListStatuses[0] = templateStatus;
o.setDescribeTemplateStatusSet(describeTemplateListStatuses);
o.setRequestId(requestId);
});
when(client.DescribeSmsTemplateList(argThat(request -> {
assertEquals(apiTemplateId, request.getTemplateIdSet()[0]);
return true;
}))).thenReturn(response);
// 调用
SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId.toString());
// 断言
assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateId().toString(), result.getId());
assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateContent(), result.getContent());
assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus());
assertEquals(response.getDescribeTemplateStatusSet()[0].getReviewReply(), result.getAuditReason());
}
// @Test
// public void testGetSmsTemplate() throws Throwable {
// // 准备参数
// Long apiTemplateId = randomLongId();
// String requestId = randomString();
//
// // mock 方法
// DescribeSmsTemplateListResponse response = randomPojo(DescribeSmsTemplateListResponse.class, o -> {
// DescribeTemplateListStatus[] describeTemplateListStatuses = new DescribeTemplateListStatus[1];
// DescribeTemplateListStatus templateStatus = new DescribeTemplateListStatus();
// templateStatus.setTemplateId(apiTemplateId);
// templateStatus.setStatusCode(0L);// 设置模板通过
// describeTemplateListStatuses[0] = templateStatus;
// o.setDescribeTemplateStatusSet(describeTemplateListStatuses);
// o.setRequestId(requestId);
// });
// when(client.DescribeSmsTemplateList(argThat(request -> {
// assertEquals(apiTemplateId, request.getTemplateIdSet()[0]);
// return true;
// }))).thenReturn(response);
//
// // 调用
// SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId.toString());
// // 断言
// assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateId().toString(), result.getId());
// assertEquals(response.getDescribeTemplateStatusSet()[0].getTemplateContent(), result.getContent());
// assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus());
// assertEquals(response.getDescribeTemplateStatusSet()[0].getReviewReply(), result.getAuditReason());
// }
@Test
public void testConvertSmsTemplateAuditStatus() {