# Conflicts:
#	yudao-module-mall/yudao-module-promotion-biz/src/main/java/cn/iocoder/yudao/module/promotion/controller/app/combination/AppCombinationRecordController.java
#	yudao-module-mall/yudao-module-statistics-biz/src/main/java/cn/iocoder/yudao/module/statistics/job/product/ProductStatisticsJob.java
#	yudao-module-mall/yudao-module-trade-biz/src/main/java/cn/iocoder/yudao/module/trade/framework/order/config/TradeOrderProperties.java
#	yudao-module-pay/yudao-module-pay-api/src/main/java/cn/iocoder/yudao/module/pay/api/order/dto/PayOrderCreateReqDTO.java
#	yudao-module-pay/yudao-module-pay-api/src/main/java/cn/iocoder/yudao/module/pay/api/refund/dto/PayRefundCreateReqDTO.java
#	yudao-module-pay/yudao-module-pay-api/src/main/java/cn/iocoder/yudao/module/pay/api/transfer/dto/PayTransferCreateReqDTO.java
#	yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/app/PayAppService.java
#	yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/app/PayAppServiceImpl.java
#	yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/refund/PayRefundServiceImpl.java
#	yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/transfer/PayTransferServiceImpl.java
#	yudao-module-pay/yudao-module-pay-biz/src/main/java/cn/iocoder/yudao/module/pay/service/wallet/PayWalletTransactionServiceImpl.java
#	yudao-module-system/yudao-module-system-biz/src/main/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/TencentSmsClient.java
#	yudao-module-system/yudao-module-system-biz/src/test/java/cn/iocoder/yudao/module/system/framework/sms/core/client/impl/SmsClientTests.java
This commit is contained in:
YunaiV 2024-08-18 17:22:47 +08:00
commit e2197551ec
80 changed files with 1455 additions and 642 deletions

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

@ -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

@ -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

@ -2,9 +2,7 @@ package cn.iocoder.yudao.module.ai.service.image;
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.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;
@ -28,6 +26,14 @@ public interface AiImageService {
*/
PageResult<AiImageDO> getImagePageMy(Long userId, AiImagePageReqVO pageReqVO);
/**
* 获取公开的绘图分页
*
* @param pageReqVO 分页条件
* @return 绘图分页
*/
PageResult<AiImageDO> getImagePagePublic(AiImagePublicPageReqVO pageReqVO);
/**
* 获得绘图记录
*

View File

@ -12,9 +12,7 @@ import cn.iocoder.yudao.framework.ai.core.enums.AiPlatformEnum;
import cn.iocoder.yudao.framework.ai.core.model.midjourney.api.MidjourneyApi;
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.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;
@ -70,6 +68,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

@ -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.boot</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:
@ -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

@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.product.api.spu.dto;
import cn.iocoder.yudao.module.product.enums.spu.ProductSpuStatusEnum;
import lombok.Data;
// TODO @LeeYan9: ProductSpuRespDTO
/**
* 商品 SPU 信息 Response DTO
*

View File

@ -148,5 +148,4 @@ public class AppProductSpuController {
return price - newPrice;
}
// TODO 芋艿商品的浏览记录
}

View File

@ -48,6 +48,4 @@ public class ProductBrandDO extends BaseDO {
*/
private Integer status;
// TODO 芋艿firstLetter 首字母
}

View File

@ -130,11 +130,5 @@ public class ProductSkuDO extends BaseDO {
}
// TODO 芋艿integral from y
// TODO 芋艿pinkPrice from y
// TODO 芋艿seckillPrice from y
// TODO 芋艿pinkStock from y
// TODO 芋艿seckillStock from y
}

View File

@ -10,23 +10,20 @@ import cn.iocoder.yudao.module.promotion.controller.app.combination.vo.record.Ap
import cn.iocoder.yudao.module.promotion.controller.app.combination.vo.record.AppCombinationRecordSummaryRespVO;
import cn.iocoder.yudao.module.promotion.convert.combination.CombinationActivityConvert;
import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationRecordDO;
import cn.iocoder.yudao.module.promotion.enums.combination.CombinationRecordStatusEnum;
import cn.iocoder.yudao.module.promotion.service.combination.CombinationRecordService;
import cn.iocoder.yudao.module.trade.api.order.TradeOrderApi;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.context.annotation.Lazy;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.annotation.Resource;
import javax.validation.Valid;
import javax.validation.constraints.Max;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@ -43,9 +40,6 @@ public class AppCombinationRecordController {
@Resource
private CombinationRecordService combinationRecordService;
@Resource
@Lazy
private TradeOrderApi tradeOrderApi;
@GetMapping("/get-summary")
@Operation(summary = "获得拼团记录的概要信息", description = "用于小程序首页")
@ -117,26 +111,4 @@ public class AppCombinationRecordController {
return success(CombinationActivityConvert.INSTANCE.convert(getLoginUserId(), headRecord, memberRecords));
}
@GetMapping("/cancel")
@Operation(summary = "取消拼团")
@Parameter(name = "id", description = "拼团记录编号", required = true, example = "1024")
public CommonResult<Boolean> cancelCombinationRecord(@RequestParam("id") Long id) {
Long userId = getLoginUserId();
// 1查找这条拼团记录
CombinationRecordDO record = combinationRecordService.getCombinationRecordByIdAndUser(userId, id);
if (record == null) {
return success(Boolean.FALSE);
}
// 1.1需要先校验拼团记录未完成
if (!CombinationRecordStatusEnum.isInProgress(record.getStatus())) {
return success(Boolean.FALSE);
}
// 2. 取消已支付的订单
tradeOrderApi.cancelPaidOrder(userId, record.getOrderId());
// 3. 取消拼团记录
combinationRecordService.cancelCombinationRecord(userId, record.getId(), record.getHeadId());
return success(Boolean.TRUE);
}
}

View File

@ -72,7 +72,6 @@ public interface SeckillActivityMapper extends BaseMapperX<SeckillActivityDO> {
default PageResult<SeckillActivityDO> selectPage(AppSeckillActivityPageReqVO pageReqVO, Integer status) {
return selectPage(pageReqVO, new LambdaQueryWrapperX<SeckillActivityDO>()
.eqIfPresent(SeckillActivityDO::getStatus, status)
// TODO 芋艿 find in set 的想法
.apply(ObjectUtil.isNotNull(pageReqVO.getConfigId()), "FIND_IN_SET(" + pageReqVO.getConfigId() + ",config_ids) > 0"));
}

View File

@ -8,7 +8,6 @@ import org.springframework.stereotype.Component;
import javax.annotation.Resource;
// TODO 芋艿配置一个 Job
/**
* 优惠券过期 Job
*

View File

@ -139,24 +139,6 @@ public interface CombinationRecordService {
@Nullable Integer status,
@Nullable Long headId);
/**
* 获取拼团记录
*
* @param userId 用户编号
* @param id 拼团记录编号
* @return 拼团记录
*/
CombinationRecordDO getCombinationRecordByIdAndUser(Long userId, Long id);
/**
* 取消拼团
*
* @param userId 用户编号
* @param id 拼团记录编号
* @param headId 团长编号
*/
void cancelCombinationRecord(Long userId, Long id, Long headId);
/**
* 处理过期拼团
*

View File

@ -69,7 +69,6 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
private ProductSpuApi productSpuApi;
@Resource
private ProductSkuApi productSkuApi;
@Resource
@Lazy // 延迟加载避免循环依赖
private TradeOrderApi tradeOrderApi;
@ -289,61 +288,6 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
return combinationRecordMapper.selectCombinationRecordCountMapByActivityIdAndStatusAndHeadId(activityIds, status, headId);
}
@Override
public CombinationRecordDO getCombinationRecordByIdAndUser(Long userId, Long id) {
return combinationRecordMapper.selectOne(CombinationRecordDO::getUserId, userId, CombinationRecordDO::getId, id);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelCombinationRecord(Long userId, Long id, Long headId) {
// 删除记录
combinationRecordMapper.deleteById(id);
// 需要更新的记录
List<CombinationRecordDO> updateRecords = new ArrayList<>();
// 如果它是团长则顺序下单时间继承
if (Objects.equals(headId, CombinationRecordDO.HEAD_ID_GROUP)) { // 情况一团长
// 团员
List<CombinationRecordDO> list = getCombinationRecordListByHeadId(id);
if (CollUtil.isEmpty(list)) {
return;
}
// 按照创建时间升序排序
list.sort(Comparator.comparing(CombinationRecordDO::getCreateTime)); // 影响原 list
CombinationRecordDO newHead = list.get(0); // 新团长继位
list.forEach(item -> {
CombinationRecordDO recordDO = new CombinationRecordDO();
recordDO.setId(item.getId());
if (ObjUtil.equal(item.getId(), newHead.getId())) { // 新团长
recordDO.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
} else {
recordDO.setHeadId(newHead.getId());
}
recordDO.setUserCount(list.size());
updateRecords.add(recordDO);
});
} else { // 情况二团员
// 团长
CombinationRecordDO recordHead = combinationRecordMapper.selectById(headId);
// 团员
List<CombinationRecordDO> records = getCombinationRecordListByHeadId(headId);
if (CollUtil.isEmpty(records)) {
return;
}
records.add(recordHead); // 加入团长团长数据也需要更新
records.forEach(item -> {
CombinationRecordDO recordDO = new CombinationRecordDO();
recordDO.setId(item.getId());
recordDO.setUserCount(records.size());
updateRecords.add(recordDO);
});
}
// 更新拼团记录
combinationRecordMapper.updateBatch(updateRecords);
}
@Override
public KeyValue<Integer, Integer> expireCombinationRecord() {
// 1. 获取所有正在进行中的过期的父拼团

View File

@ -7,12 +7,9 @@ import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.quartz.core.handler.JobHandler;
import cn.iocoder.yudao.framework.tenant.core.job.TenantJob;
import cn.iocoder.yudao.module.statistics.service.product.ProductStatisticsService;
import jakarta.annotation.Resource;
import org.springframework.stereotype.Component;
import javax.annotation.Resource;
// TODO 芋艿缺个 Job 的配置等和 Product 一起配置
/**
* 商品统计 Job
*

View File

@ -11,7 +11,6 @@ import org.springframework.stereotype.Component;
import javax.annotation.Resource;
// TODO 芋艿缺个 Job 的配置等和 Product 一起配置
/**
* 交易统计 Job
*

View File

@ -21,6 +21,9 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_
@ToString(callSuper = true)
public class AfterSalePageReqVO extends PageParam {
@Schema(description = "用户编号", example = "1024")
private Long userId;
@Schema(description = "售后流水号", example = "202211190847450020500077")
private String no;

View File

@ -101,7 +101,7 @@ public interface TradeOrderConvert {
default PayOrderCreateReqDTO convert(TradeOrderDO order, List<TradeOrderItemDO> orderItems,
TradeOrderProperties orderProperties) {
PayOrderCreateReqDTO createReqDTO = new PayOrderCreateReqDTO()
.setAppId(orderProperties.getAppId()).setUserIp(order.getUserIp());
.setAppKey(orderProperties.getPayAppKey()).setUserIp(order.getUserIp());
// 商户相关字段
createReqDTO.setMerchantOrderId(String.valueOf(order.getId()));
String subject = orderItems.get(0).getSpuName();

View File

@ -16,6 +16,7 @@ public interface AfterSaleMapper extends BaseMapperX<AfterSaleDO> {
default PageResult<AfterSaleDO> selectPage(AfterSalePageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<AfterSaleDO>()
.eqIfPresent(AfterSaleDO::getUserId, reqVO.getUserId())
.likeIfPresent(AfterSaleDO::getNo, reqVO.getNo())
.eqIfPresent(AfterSaleDO::getStatus, reqVO.getStatus())
.eqIfPresent(AfterSaleDO::getType, reqVO.getType())

View File

@ -1,4 +0,0 @@
/**
* TODO 占位
*/
package cn.iocoder.yudao.module.trade.dal.mysql;

View File

@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.trade.framework.order.config;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
// TODO @LeeYan9: 可以直接给 TradeOrderProperties 一个 @Component生效哈
/**
* @author LeeYan9
* @since 2022-09-15

View File

@ -1,10 +1,12 @@
package cn.iocoder.yudao.module.trade.framework.order.config;
import jakarta.validation.constraints.NotEmpty;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import javax.validation.constraints.NotNull;
import jakarta.validation.constraints.NotNull;
import java.time.Duration;
/**
@ -18,11 +20,15 @@ import java.time.Duration;
@Validated
public class TradeOrderProperties {
private static final String PAY_APP_KEY_DEFAULT = "mall";
/**
* 应用编号
* 支付应用标识
*
* pay 模块的 [支付管理 -> 应用信息] 里添加
*/
@NotNull(message = "应用编号不能为空")
private Long appId;
@NotEmpty(message = "Pay 应用标识不能为空")
private String payAppKey = PAY_APP_KEY_DEFAULT;
/**
* 支付超时时间

View File

@ -125,7 +125,6 @@ public class BrokerageUserServiceImpl implements BrokerageUserService {
@Override
public BrokerageUserDO getOrCreateBrokerageUser(Long id) {
// TODO @芋艿这块优化下统一到注册时处理
BrokerageUserDO brokerageUser = brokerageUserMapper.selectById(id);
// 特殊人人分销的情况下如果分销人为空则创建分销人
if (brokerageUser == null && ObjUtil.equal(BrokerageEnabledConditionEnum.ALL.getCondition(),

View File

@ -855,12 +855,14 @@ public class TradeOrderUpdateServiceImpl implements TradeOrderUpdateService {
@Override
@Transactional(rollbackFor = Exception.class)
public void cancelPaidOrder(Long userId, Long orderId) {
// TODO 芋艿这里实现要优化下
// TODO @puhui999需要校验状态已支付的情况下才可以
TradeOrderDO order = tradeOrderMapper.selectOrderByIdAndUserId(orderId, userId);
if (order == null) {
throw exception(ORDER_NOT_FOUND);
}
cancelOrder0(order, TradeOrderCancelTypeEnum.MEMBER_CANCEL);
// TODO @puhui999需要退款
}
/**

View File

@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.trade.service.price.calculator;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO;
import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
@ -61,7 +62,7 @@ public class TradePriceCalculatorHelper {
orderItem.setSpuName(spu.getName()).setCategoryId(spu.getCategoryId())
.setDeliveryTemplateId(spu.getDeliveryTemplateId())
.setGivePoint(spu.getGiveIntegral()).setUsePoint(0);
if (orderItem.getPicUrl() == null) {
if (StrUtil.isBlank(orderItem.getPicUrl())) {
orderItem.setPicUrl(spu.getPicUrl());
}
});
@ -240,7 +241,7 @@ public class TradePriceCalculatorHelper {
*
* {@link #dividePrice(List, Integer)} 逻辑一致只是传入的是 TradeOrderItemDO 对象
*
* @param items 订单项
* @param items 订单项
* @param price 订单支付金额
* @return 分摊金额数组和传入的 orderItems 一一对应
*/

View File

@ -99,7 +99,7 @@ public class TradeOrderUpdateServiceTest extends BaseDbUnitTest {
@BeforeEach
public void setUp() {
when(tradeOrderProperties.getAppId()).thenReturn(888L);
when(tradeOrderProperties.getPayAppKey()).thenReturn("mall");
when(tradeOrderProperties.getPayExpireTime()).thenReturn(Duration.ofDays(1));
when(tradeNoRedisDAO.generate(anyString())).thenReturn(IdUtil.randomUUID());
}

View File

@ -1,11 +1,11 @@
package cn.iocoder.yudao.module.pay.api.order.dto;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.DecimalMin;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.io.Serializable;
import java.time.LocalDateTime;
@ -18,10 +18,10 @@ public class PayOrderCreateReqDTO implements Serializable {
public static final int SUBJECT_MAX_LENGTH = 32;
/**
* 应用编号
* 应用标识
*/
@NotNull(message = "应用编号不能为空")
private Long appId;
@NotNull(message = "应用标识不能为空")
private String appKey;
/**
* 用户 IP
*/

View File

@ -1,12 +1,11 @@
package cn.iocoder.yudao.module.pay.api.refund.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import org.hibernate.validator.constraints.Length;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
/**
* 退款单创建 Request DTO
*
@ -16,10 +15,10 @@ import javax.validation.constraints.NotNull;
public class PayRefundCreateReqDTO {
/**
* 应用编号
* 应用标识
*/
@NotNull(message = "应用编号不能为空")
private Long appId;
@NotNull(message = "应用标识不能为空")
private String appKey;
/**
* 用户 IP
*/

View File

@ -2,12 +2,12 @@ package cn.iocoder.yudao.module.pay.api.transfer.dto;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferTypeEnum;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import javax.validation.constraints.Min;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotEmpty;
import javax.validation.constraints.NotNull;
import java.util.Map;
/**
@ -19,10 +19,10 @@ import java.util.Map;
public class PayTransferCreateReqDTO {
/**
* 应用编号
* 应用标识
*/
@NotNull(message = "应用编号不能为空")
private Long appId;
@NotNull(message = "应用标识不能为空")
private String appKey;
@NotEmpty(message = "转账渠道不能为空")
private String channelCode;

View File

@ -14,6 +14,7 @@ public interface ErrorCodeConstants {
ErrorCode APP_IS_DISABLE = new ErrorCode(1_007_000_002, "App 已经被禁用");
ErrorCode APP_EXIST_ORDER_CANT_DELETE = new ErrorCode(1_007_000_003, "支付应用存在支付订单,无法删除");
ErrorCode APP_EXIST_REFUND_CANT_DELETE = new ErrorCode(1_007_000_004, "支付应用存在退款订单,无法删除");
ErrorCode APP_KEY_EXISTS = new ErrorCode(1_007_000_005, "支付应用标识已经存在");
// ========== CHANNEL 模块 1-007-001-000 ==========
ErrorCode CHANNEL_NOT_FOUND = new ErrorCode(1_007_001_000, "支付渠道的配置不存在");

View File

@ -14,6 +14,10 @@ import javax.validation.constraints.*;
@Data
public class PayAppBaseVO {
@Schema(description = "应用标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao")
@NotEmpty(message = "应用标识不能为空")
private String appKey;
@Schema(description = "应用名", requiredMode = Schema.RequiredMode.REQUIRED, example = "小豆")
@NotNull(message = "应用名不能为空")
private String name;

View File

@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.pay.controller.admin.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
@Schema(description = "管理后台 - 支付应用信息创建 Request VO")
@Data

View File

@ -20,6 +20,9 @@ public class PayAppPageReqVO extends PageParam {
@Schema(description = "应用名", example = "小豆")
private String name;
@Schema(description = "应用标识", example = "yudao")
private String appKey;
@Schema(description = "开启状态", example = "0")
private Integer status;

View File

@ -1,6 +1,9 @@
package cn.iocoder.yudao.module.pay.controller.admin.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import java.time.LocalDateTime;
@ -13,6 +16,9 @@ public class PayAppRespVO extends PayAppBaseVO {
@Schema(description = "应用编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "应用标识", requiredMode = Schema.RequiredMode.REQUIRED, example = "yudao")
private String appKey;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;

View File

@ -1,4 +1,5 @@
package cn.iocoder.yudao.module.pay.controller.admin.app.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import javax.validation.constraints.*;

View File

@ -1,6 +1,8 @@
package cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.transaction;
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.validation.InEnum;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.Data;
@ -8,7 +10,14 @@ import lombok.Data;
@Data
public class PayWalletTransactionPageReqVO extends PageParam {
@Schema(description = "钱包编号", example = "1")
@Schema(description = "钱包编号", example = "888")
private Long walletId;
@Schema(description = "用户编号", example = "1024")
private Long userId;
@Schema(description = "用户类型", example = "1")
@InEnum(UserTypeEnum.class)
private Integer userType;
}

View File

@ -31,6 +31,10 @@ public class PayAppDO extends BaseDO {
*/
@TableId
private Long id;
/**
* 应用标识
*/
private String appKey;
/**
* 应用名
*/

View File

@ -3,7 +3,6 @@ package cn.iocoder.yudao.module.pay.dal.mysql.app;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.QueryWrapperX;
import cn.iocoder.yudao.module.pay.controller.admin.app.vo.PayAppPageReqVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import org.apache.ibatis.annotations.Mapper;
@ -14,9 +13,14 @@ public interface PayAppMapper extends BaseMapperX<PayAppDO> {
default PageResult<PayAppDO> selectPage(PayAppPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<PayAppDO>()
.likeIfPresent(PayAppDO::getName, reqVO.getName())
.likeIfPresent(PayAppDO::getAppKey, reqVO.getAppKey())
.eqIfPresent(PayAppDO::getStatus, reqVO.getStatus())
.betweenIfPresent(PayAppDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(PayAppDO::getId));
}
default PayAppDO selectByAppKey(String appKey) {
return selectOne(PayAppDO::getAppKey, appKey);
}
}

View File

@ -15,6 +15,8 @@ public class PayProperties {
private static final String ORDER_NO_PREFIX = "P";
private static final String REFUND_NO_PREFIX = "R";
private static final String WALLET_PAY_APP_KEY_DEFAULT = "wallet";
/**
* 支付回调地址
*
@ -49,4 +51,10 @@ public class PayProperties {
@NotEmpty(message = "退款订单 no 的前缀不能为空")
private String refundNoPrefix = REFUND_NO_PREFIX;
/**
* 钱包支付应用 AppKey
*/
@NotEmpty(message = "钱包支付应用 AppKey 不能为空")
private String walletPayAppKey = WALLET_PAY_APP_KEY_DEFAULT;
}

View File

@ -7,8 +7,8 @@ import cn.iocoder.yudao.module.pay.controller.admin.app.vo.PayAppCreateReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.app.vo.PayAppPageReqVO;
import cn.iocoder.yudao.module.pay.controller.admin.app.vo.PayAppUpdateReqVO;
import cn.iocoder.yudao.module.pay.dal.dataobject.app.PayAppDO;
import jakarta.validation.Valid;
import javax.validation.Valid;
import java.util.Collection;
import java.util.List;
import java.util.Map;
@ -88,13 +88,13 @@ public interface PayAppService {
* @return 商户 Map
*/
default Map<Long, PayAppDO> getAppMap(Collection<Long> ids) {
List<PayAppDO> list = getAppList(ids);
List<PayAppDO> list = getAppList(ids);
return CollectionUtils.convertMap(list, PayAppDO::getId);
}
/**
* 支付应用的合法性
*
* <p>
* 如果不合法抛出 {@link ServiceException} 业务异常
*
* @param id 应用编号
@ -102,4 +102,14 @@ public interface PayAppService {
*/
PayAppDO validPayApp(Long id);
/**
* 支付应用的合法性
* <p>
* 如果不合法抛出 {@link ServiceException} 业务异常
*
* @param appKey 应用标识
* @return 应用
*/
PayAppDO validPayApp(String appKey);
}

View File

@ -11,11 +11,11 @@ import cn.iocoder.yudao.module.pay.dal.mysql.app.PayAppMapper;
import cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants;
import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
import cn.iocoder.yudao.module.pay.service.refund.PayRefundService;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Lazy;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.util.Collection;
import java.util.List;
@ -43,6 +43,9 @@ public class PayAppServiceImpl implements PayAppService {
@Override
public Long createApp(PayAppCreateReqVO createReqVO) {
// 验证 appKey 是否重复
validateAppKeyUnique(null, createReqVO.getAppKey());
// 插入
PayAppDO app = PayAppConvert.INSTANCE.convert(createReqVO);
appMapper.insert(app);
@ -54,11 +57,28 @@ public class PayAppServiceImpl implements PayAppService {
public void updateApp(PayAppUpdateReqVO updateReqVO) {
// 校验存在
validateAppExists(updateReqVO.getId());
// 验证 appKey 是否重复
validateAppKeyUnique(updateReqVO.getId(), updateReqVO.getAppKey());
// 更新
PayAppDO updateObj = PayAppConvert.INSTANCE.convert(updateReqVO);
appMapper.updateById(updateObj);
}
void validateAppKeyUnique(Long id, String appKey) {
PayAppDO app = appMapper.selectByAppKey(appKey);
if (app == null) {
return;
}
// 如果 id 为空说明不用比较是否为相同 appKey 的应用
if (id == null) {
throw exception(APP_KEY_EXISTS);
}
if (!app.getId().equals(id)) {
throw exception(APP_KEY_EXISTS);
}
}
@Override
public void updateAppStatus(Long id, Integer status) {
// 校验商户存在
@ -101,7 +121,7 @@ public class PayAppServiceImpl implements PayAppService {
@Override
public List<PayAppDO> getAppList() {
return appMapper.selectList();
return appMapper.selectList();
}
@Override
@ -110,14 +130,30 @@ public class PayAppServiceImpl implements PayAppService {
}
@Override
public PayAppDO validPayApp(Long id) {
PayAppDO app = appMapper.selectById(id);
public PayAppDO validPayApp(Long appId) {
PayAppDO app = appMapper.selectById(appId);
return validatePayApp(app);
}
@Override
public PayAppDO validPayApp(String appKey) {
PayAppDO app = appMapper.selectByAppKey(appKey);
return validatePayApp(app);
}
/**
* 校验支付应用实体的有效性存在 + 开启
*
* @param app 待校验的支付应用实体
* @return 校验通过的支付应用实体
*/
private PayAppDO validatePayApp(PayAppDO app) {
// 校验是否存在
if (app == null) {
throw exception(ErrorCodeConstants.APP_NOT_FOUND);
}
// 校验是否禁用
if (CommonStatusEnum.DISABLE.getStatus().equals(app.getStatus())) {
if (CommonStatusEnum.isDisable(app.getStatus())) {
throw exception(ErrorCodeConstants.APP_IS_DISABLE);
}
return app;

View File

@ -43,11 +43,11 @@ import static cn.iocoder.yudao.module.pay.enums.ErrorCodeConstants.*;
public class PayDemoOrderServiceImpl implements PayDemoOrderService {
/**
* 接入的实力应用编号
* 接入的支付应用标识
*
* [支付管理 -> 应用信息] 里添加
*/
private static final Long PAY_APP_ID = 7L;
private static final String PAY_APP_KEY = "demo";
/**
* 商品信息 Map
@ -88,7 +88,7 @@ public class PayDemoOrderServiceImpl implements PayDemoOrderService {
// 2.1 创建支付单
Long payOrderId = payOrderApi.createOrder(new PayOrderCreateReqDTO()
.setAppId(PAY_APP_ID).setUserIp(getClientIP()) // 支付应用
.setAppKey(PAY_APP_KEY).setUserIp(getClientIP()) // 支付应用
.setMerchantOrderId(demoOrder.getId().toString()) // 业务的订单编号
.setSubject(spuName).setBody("").setPrice(price) // 价格信息
.setExpireTime(addTime(Duration.ofHours(2L)))); // 支付的过期时间
@ -190,7 +190,7 @@ public class PayDemoOrderServiceImpl implements PayDemoOrderService {
String refundId = order.getId() + "-refund";
// 2.2 创建退款单
Long payRefundId = payRefundApi.createRefund(new PayRefundCreateReqDTO()
.setAppId(PAY_APP_ID).setUserIp(getClientIP()) // 支付应用
.setAppKey(PAY_APP_KEY).setUserIp(getClientIP()) // 支付应用
.setMerchantOrderId(String.valueOf(order.getId())) // 支付单号
.setMerchantRefundId(refundId)
.setReason("想退钱").setPrice(order.getPrice()));// 价格信息

View File

@ -111,11 +111,11 @@ public class PayOrderServiceImpl implements PayOrderService {
@Override
public Long createOrder(PayOrderCreateReqDTO reqDTO) {
// 校验 App
PayAppDO app = appService.validPayApp(reqDTO.getAppId());
PayAppDO app = appService.validPayApp(reqDTO.getAppKey());
// 查询对应的支付交易单是否已经存在如果是则直接返回
PayOrderDO order = orderMapper.selectByAppIdAndMerchantOrderId(
reqDTO.getAppId(), reqDTO.getMerchantOrderId());
app.getId(), reqDTO.getMerchantOrderId());
if (order != null) {
log.warn("[createOrder][appId({}) merchantOrderId({}) 已经存在对应的支付单({})]", order.getAppId(),
order.getMerchantOrderId(), toJsonString(order)); // 理论来说不会出现这个情况

View File

@ -26,12 +26,12 @@ import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService;
import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
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 javax.annotation.Resource;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -93,9 +93,9 @@ public class PayRefundServiceImpl implements PayRefundService {
@Override
public Long createPayRefund(PayRefundCreateReqDTO reqDTO) {
// 1.1 校验 App
PayAppDO app = appService.validPayApp(reqDTO.getAppId());
PayAppDO app = appService.validPayApp(reqDTO.getAppKey());
// 1.2 校验支付订单
PayOrderDO order = validatePayOrderCanRefund(reqDTO);
PayOrderDO order = validatePayOrderCanRefund(reqDTO, app.getId());
// 1.3 校验支付渠道是否有效
PayChannelDO channel = channelService.validPayChannel(order.getChannelId());
PayClient client = channelService.getPayClient(channel.getId());
@ -113,7 +113,7 @@ public class PayRefundServiceImpl implements PayRefundService {
// 2.1 插入退款单
String no = noRedisDAO.generate(payProperties.getRefundNoPrefix());
refund = PayRefundConvert.INSTANCE.convert(reqDTO)
.setNo(no).setOrderId(order.getId()).setOrderNo(order.getNo())
.setNo(no).setAppId(app.getId()).setOrderId(order.getId()).setOrderNo(order.getNo())
.setChannelId(order.getChannelId()).setChannelCode(order.getChannelCode())
// 商户相关的字段
.setNotifyUrl(app.getRefundNotifyUrl())
@ -153,8 +153,8 @@ public class PayRefundServiceImpl implements PayRefundService {
* @param reqDTO 退款申请信息
* @return 支付订单
*/
private PayOrderDO validatePayOrderCanRefund(PayRefundCreateReqDTO reqDTO) {
PayOrderDO order = orderService.getOrder(reqDTO.getAppId(), reqDTO.getMerchantOrderId());
private PayOrderDO validatePayOrderCanRefund(PayRefundCreateReqDTO reqDTO, Long appId) {
PayOrderDO order = orderService.getOrder(appId, reqDTO.getMerchantOrderId());
if (order == null) {
throw exception(PAY_ORDER_NOT_FOUND);
}
@ -164,11 +164,11 @@ public class PayRefundServiceImpl implements PayRefundService {
}
// 校验金额退款金额不能大于原定的金额
if (reqDTO.getPrice() + order.getRefundPrice() > order.getPrice()){
if (reqDTO.getPrice() + order.getRefundPrice() > order.getPrice()) {
throw exception(REFUND_PRICE_EXCEED);
}
// 是否有退款中的订单
if (refundMapper.selectCountByAppIdAndOrderId(reqDTO.getAppId(), order.getId(),
if (refundMapper.selectCountByAppIdAndOrderId(appId, order.getId(),
PayRefundStatusEnum.WAITING.getStatus()) > 0) {
throw exception(REFUND_HAS_REFUNDING);
}
@ -197,9 +197,10 @@ public class PayRefundServiceImpl implements PayRefundService {
* 通知并更新订单的退款结果
*
* @param channel 支付渠道
* @param notify 通知
* @param notify 通知
*/
@Transactional(rollbackFor = Exception.class) // 注意如果是方法内调用该方法需要通过 getSelf().notifyRefund(channel, notify) 调用否则事务不生效
// 注意如果是方法内调用该方法需要通过 getSelf().notifyRefund(channel, notify) 调用否则事务不生效
@Transactional(rollbackFor = Exception.class)
public void notifyRefund(PayChannelDO channel, PayRefundRespDTO notify) {
// 情况一退款成功
if (PayRefundStatusRespEnum.isSuccess(notify.getStatus())) {

View File

@ -24,12 +24,12 @@ import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
import cn.iocoder.yudao.module.pay.service.app.PayAppService;
import cn.iocoder.yudao.module.pay.service.channel.PayChannelService;
import cn.iocoder.yudao.module.pay.service.notify.PayNotifyService;
import jakarta.annotation.Resource;
import jakarta.validation.Validator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.annotation.Resource;
import javax.validation.Validator;
import java.util.List;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
@ -79,16 +79,16 @@ public class PayTransferServiceImpl implements PayTransferService {
@Override
public Long createTransfer(PayTransferCreateReqDTO reqDTO) {
// 1.1 校验 App
PayAppDO payApp = appService.validPayApp(reqDTO.getAppId());
PayAppDO payApp = appService.validPayApp(reqDTO.getAppKey());
// 1.2 校验支付渠道是否有效
PayChannelDO channel = channelService.validPayChannel(reqDTO.getAppId(), reqDTO.getChannelCode());
PayChannelDO channel = channelService.validPayChannel(payApp.getId(), reqDTO.getChannelCode());
PayClient client = channelService.getPayClient(channel.getId());
if (client == null) {
log.error("[createTransfer][渠道编号({}) 找不到对应的支付客户端]", channel.getId());
throw exception(CHANNEL_NOT_FOUND);
}
// 1.3 校验转账单已经发起过转账
PayTransferDO transfer = validateTransferCanCreate(reqDTO);
PayTransferDO transfer = validateTransferCanCreate(reqDTO, payApp.getId());
if (transfer == null) {
// 2.不存在创建转账单. 否则允许使用相同的 no 再次发起转账
@ -116,8 +116,8 @@ public class PayTransferServiceImpl implements PayTransferService {
return transfer.getId();
}
private PayTransferDO validateTransferCanCreate(PayTransferCreateReqDTO dto) {
PayTransferDO transfer = transferMapper.selectByAppIdAndMerchantTransferId(dto.getAppId(), dto.getMerchantTransferId());
private PayTransferDO validateTransferCanCreate(PayTransferCreateReqDTO dto, Long appId) {
PayTransferDO transfer = transferMapper.selectByAppIdAndMerchantTransferId(appId, dto.getMerchantTransferId());
if (transfer != null) {
// 已经存在,并且状态不为等待状态说明已经调用渠道转账并返回结果.
if (!PayTransferStatusEnum.isWaiting(transfer.getStatus())) {

View File

@ -18,6 +18,7 @@ import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletRechargeMapper;
import cn.iocoder.yudao.module.pay.enums.order.PayOrderStatusEnum;
import cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
import cn.iocoder.yudao.module.pay.service.order.PayOrderService;
import cn.iocoder.yudao.module.pay.service.refund.PayRefundService;
import cn.iocoder.yudao.module.system.api.social.SocialClientApi;
@ -51,11 +52,6 @@ import static cn.iocoder.yudao.module.pay.enums.refund.PayRefundStatusEnum.*;
@Slf4j
public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
/**
* TODO 芋艿放到 payconfig
*/
private static final Long WALLET_PAY_APP_ID = 8L;
private static final String WALLET_RECHARGE_ORDER_SUBJECT = "钱包余额充值";
@Resource
@ -68,9 +64,13 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
private PayRefundService payRefundService;
@Resource
private PayWalletRechargePackageService payWalletRechargePackageService;
@Resource
public SocialClientApi socialClientApi;
@Resource
private PayProperties payProperties;
@Override
@Transactional(rollbackFor = Exception.class)
public PayWalletRechargeDO createWalletRecharge(Long userId, Integer userType, String userIp,
@ -92,7 +92,7 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
// 2.1 创建支付单
Long payOrderId = payOrderService.createOrder(new PayOrderCreateReqDTO()
.setAppId(WALLET_PAY_APP_ID).setUserIp(userIp)
.setAppKey(payProperties.getWalletPayAppKey()).setUserIp(userIp)
.setMerchantOrderId(recharge.getId().toString()) // 业务的订单编号
.setSubject(WALLET_RECHARGE_ORDER_SUBJECT).setBody("")
.setPrice(recharge.getPayPrice())
@ -174,7 +174,7 @@ public class PayWalletRechargeServiceImpl implements PayWalletRechargeService {
String walletRechargeId = String.valueOf(id);
String refundId = walletRechargeId + "-refund";
Long payRefundId = payRefundService.createPayRefund(new PayRefundCreateReqDTO()
.setAppId(WALLET_PAY_APP_ID).setUserIp(userIp)
.setAppKey(payProperties.getWalletPayAppKey()).setUserIp(userIp)
.setMerchantOrderId(walletRechargeId)
.setMerchantRefundId(refundId)
.setReason("想退钱").setPrice(walletRecharge.getPayPrice()));

View File

@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.pay.service.wallet;
import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.module.pay.controller.admin.wallet.vo.transaction.PayWalletTransactionPageReqVO;
import cn.iocoder.yudao.module.pay.controller.app.wallet.vo.transaction.AppPayWalletTransactionPageReqVO;
@ -11,12 +12,11 @@ import cn.iocoder.yudao.module.pay.dal.mysql.wallet.PayWalletTransactionMapper;
import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
import cn.iocoder.yudao.module.pay.enums.wallet.PayWalletBizTypeEnum;
import cn.iocoder.yudao.module.pay.service.wallet.bo.WalletTransactionCreateReqBO;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.validation.annotation.Validated;
import javax.annotation.Resource;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.module.pay.controller.app.wallet.vo.transaction.AppPayWalletTransactionPageReqVO.TYPE_EXPENSE;
@ -53,6 +53,16 @@ public class PayWalletTransactionServiceImpl implements PayWalletTransactionServ
@Override
public PageResult<PayWalletTransactionDO> getWalletTransactionPage(PayWalletTransactionPageReqVO pageVO) {
// 基于 userId + userType 查询钱包
if (pageVO.getWalletId() == null
&& ObjectUtil.isAllNotEmpty(pageVO.getUserId(), pageVO.getUserType())) {
PayWalletDO wallet = payWalletService.getOrCreateWallet(pageVO.getUserId(), pageVO.getUserType());
if (wallet != null) {
pageVO.setWalletId(wallet.getId());
}
}
// 查询分页
return payWalletTransactionMapper.selectPage(pageVO.getWalletId(), null, pageVO, null);
}

View File

@ -218,11 +218,11 @@ public class PayOrderServiceTest extends BaseDbAndRedisUnitTest {
public void testCreateOrder_success() {
// mock 参数
PayOrderCreateReqDTO reqDTO = randomPojo(PayOrderCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("10")
o -> o.setAppKey("demo").setMerchantOrderId("10")
.setSubject(randomString()).setBody(randomString()));
// mock 方法
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setOrderNotifyUrl("http://127.0.0.1"));
when(appService.validPayApp(eq(reqDTO.getAppId()))).thenReturn(app);
when(appService.validPayApp(eq(reqDTO.getAppKey()))).thenReturn(app);
// 调用
Long orderId = orderService.createOrder(reqDTO);
@ -239,10 +239,13 @@ public class PayOrderServiceTest extends BaseDbAndRedisUnitTest {
public void testCreateOrder_exists() {
// mock 参数
PayOrderCreateReqDTO reqDTO = randomPojo(PayOrderCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("10"));
o -> o.setAppKey("demo").setMerchantOrderId("10"));
// mock 数据
PayOrderDO dbOrder = randomPojo(PayOrderDO.class, o -> o.setAppId(1L).setMerchantOrderId("10"));
orderMapper.insert(dbOrder);
// mock 方法
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L).setOrderNotifyUrl("http://127.0.0.1"));
when(appService.validPayApp(eq(reqDTO.getAppKey()))).thenReturn(app);
// 调用
Long orderId = orderService.createOrder(reqDTO);

View File

@ -209,10 +209,10 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
@Test
public void testCreateRefund_orderNotFound() {
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L));
o -> o.setAppKey("demo"));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// 调用并断言异常
assertServiceException(() -> refundService.createPayRefund(reqDTO),
@ -232,10 +232,10 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
private void testCreateRefund_orderWaitingOrClosed(Integer status) {
// 准备参数
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("100"));
o -> o.setAppKey("demo").setMerchantOrderId("100"));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// mock 数据order
PayOrderDO order = randomPojo(PayOrderDO.class, o -> o.setStatus(status));
when(orderService.getOrder(eq(1L), eq("100"))).thenReturn(order);
@ -249,10 +249,10 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
public void testCreateRefund_refundPriceExceed() {
// 准备参数
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(10));
o -> o.setAppKey("demo").setMerchantOrderId("100").setPrice(10));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// mock 数据order
PayOrderDO order = randomPojo(PayOrderDO.class, o ->
o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
@ -268,10 +268,10 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
public void testCreateRefund_orderHasRefunding() {
// 准备参数
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(10));
o -> o.setAppKey("demo").setMerchantOrderId("100").setPrice(10));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// mock 数据order
PayOrderDO order = randomPojo(PayOrderDO.class, o ->
o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
@ -291,10 +291,10 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
public void testCreateRefund_channelNotFound() {
// 准备参数
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(9));
o -> o.setAppKey("demo").setMerchantOrderId("100").setPrice(9));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// mock 数据order
PayOrderDO order = randomPojo(PayOrderDO.class, o ->
o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
@ -315,11 +315,11 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
public void testCreateRefund_refundExists() {
// 准备参数
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(9)
o -> o.setAppKey("demo").setMerchantOrderId("100").setPrice(9)
.setMerchantRefundId("200").setReason("测试退款"));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// mock 数据order
PayOrderDO order = randomPojo(PayOrderDO.class, o ->
o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
@ -347,11 +347,11 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
public void testCreateRefund_invokeException() {
// 准备参数
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(9)
o -> o.setAppKey("demo").setMerchantOrderId("100").setPrice(9)
.setMerchantRefundId("200").setReason("测试退款"));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// mock 数据order
PayOrderDO order = randomPojo(PayOrderDO.class, o ->
o.setStatus(PayOrderStatusEnum.REFUND.getStatus())
@ -391,11 +391,11 @@ public class PayRefundServiceTest extends BaseDbAndRedisUnitTest {
// 准备参数
PayRefundCreateReqDTO reqDTO = randomPojo(PayRefundCreateReqDTO.class,
o -> o.setAppId(1L).setMerchantOrderId("100").setPrice(9)
o -> o.setAppKey("demo").setMerchantOrderId("100").setPrice(9)
.setMerchantRefundId("200").setReason("测试退款"));
// mock 方法app
PayAppDO app = randomPojo(PayAppDO.class, o -> o.setId(1L));
when(appService.validPayApp(eq(1L))).thenReturn(app);
when(appService.validPayApp(eq("demo"))).thenReturn(app);
// mock 数据order
PayOrderDO order = randomPojo(PayOrderDO.class, o ->
o.setStatus(PayOrderStatusEnum.REFUND.getStatus())

View File

@ -1,5 +1,6 @@
CREATE TABLE IF NOT EXISTS "pay_app" (
"id" number NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"app_key" varchar(64) NOT NULL,
"name" varchar(64) NOT NULL,
"status" tinyint NOT NULL,
"remark" varchar(255) DEFAULT NULL,

View File

@ -28,6 +28,11 @@ public class AlipayPayClientConfig implements PayClientConfig {
*/
public static final Integer MODE_CERTIFICATE = 2;
/**
* 接口内容加密方式 - AES 加密
*/
public static final String ENC_TYPE_AES = "AES";
/**
* 签名算法类型 - RSA
*/
@ -94,6 +99,22 @@ public class AlipayPayClientConfig implements PayClientConfig {
@NotBlank(message = "指定根证书内容字符串不能为空", groups = {ModeCertificate.class})
private String rootCertContent;
/**
* 接口内容加密方式
*
* 1. 如果为空将使用无加密方式
* 2. 如果要加密目前支付宝只有 AES 一种加密方式
*
* @see <a href="https://opendocs.alipay.com/common/02mse3">支付宝开放平台</a>
* @see AlipayPayClientConfig#ENC_TYPE_AES
*/
private String encryptType;
/**
* 接口内容加密的私钥
*/
private String encryptKey;
public interface ModePublicKey {
}

View File

@ -102,8 +102,6 @@ public class AliyunSmsClient extends AbstractSmsClient {
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)) {
@ -170,7 +168,6 @@ public class AliyunSmsClient extends AbstractSmsClient {
// 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()

View File

@ -1,6 +1,5 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
@ -31,13 +30,12 @@ import java.util.*;
import java.time.LocalDateTime;
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参考阿里云在优化下
/**
* 华为短信客户端的实现类
*
@ -56,7 +54,6 @@ public class HuaweiSmsClient extends AbstractSmsClient {
@Override
protected void doInit() {
}
public HuaweiSmsClient(SmsChannelProperties properties) {
@ -68,6 +65,7 @@ public class HuaweiSmsClient extends AbstractSmsClient {
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile, String apiTemplateId,
List<KeyValue<String, Object>> templateParams) throws Throwable {
// 参考链接 https://support.huaweicloud.com/api-msgsms/sms_05_0001.html
// 相比较阿里短信华为短信发送的时候需要额外的参数通道号考虑到不破坏原有的的结构
// 所以将 通道号 拼接到 apiTemplateId 字段中格式为 "apiTemplateId 通道号"空格为分隔符
String sender = StrUtil.subAfter(apiTemplateId, " ", true); //中国大陆短信签名通道号或全球短信通道号

View File

@ -2,38 +2,29 @@ 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.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsReceiveRespDTO;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsSendRespDTO;
import cn.iocoder.yudao.module.system.framework.sms.core.client.dto.SmsTemplateRespDTO;
import cn.iocoder.yudao.module.system.framework.sms.core.enums.SmsTemplateAuditStatusEnum;
import cn.iocoder.yudao.module.system.framework.sms.core.property.SmsChannelProperties;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.google.common.annotations.VisibleForTesting;
import lombok.Data;
import jakarta.xml.bind.DatatypeConverter;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.bind.DatatypeConverter;
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;
// TODO @scholar 建议参考 AliyunSmsClient 优化下
/**
* 腾讯云短信功能实现
*
@ -43,6 +34,9 @@ import static cn.iocoder.yudao.framework.common.util.date.DateUtils.TIME_ZONE_DE
*/
public class TencentSmsClient extends AbstractSmsClient {
private static final String VERSION = "2021-01-11";
private static final String REGION = "ap-guangzhou";
/**
* 调用成功 code
*/
@ -56,7 +50,6 @@ public class TencentSmsClient extends AbstractSmsClient {
*/
private static final long INTERNATIONAL_CHINA = 0L;
public TencentSmsClient(SmsChannelProperties properties) {
super(properties);
Assert.notEmpty(properties.getApiSecret(), "apiSecret 不能为空");
@ -65,7 +58,6 @@ public class TencentSmsClient extends AbstractSmsClient {
@Override
protected void doInit() {
}
/**
@ -95,31 +87,96 @@ public class TencentSmsClient extends AbstractSmsClient {
@Override
public SmsSendRespDTO sendSms(Long sendLogId, String mobile,
String apiTemplateId, List<KeyValue<String, Object>> templateParams) throws Throwable {
// 构建请求
// 1. 执行请求
// 参考链接 https://cloud.tencent.com/document/product/382/55981
TreeMap<String, Object> body = new TreeMap<>();
String[] phones = {mobile};
body.put("PhoneNumberSet",phones);
body.put("SmsSdkAppId",getSdkAppId());
body.put("SignName",properties.getSignature());
body.put("PhoneNumberSet", new String[]{mobile});
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());
body.put("TemplateParamSet", ArrayUtils.toArray(templateParams, param -> String.valueOf(param.getValue())));
JSONObject response = request("SendSms", body);
// 2. 解析请求
JSONObject responseResult = response.getJSONObject("Response");
JSONObject error = responseResult.getJSONObject("Error");
if (error != null) {
return new SmsSendRespDTO().setSuccess(false)
.setApiRequestId(responseResult.getStr("RequestId"))
.setApiCode(error.getStr("Code"))
.setApiMsg(error.getStr("Message"));
}
JSONObject responseData = responseResult.getJSONArray("SendStatusSet").getJSONObject(0);
return new SmsSendRespDTO().setSuccess(Objects.equals(API_CODE_SUCCESS, responseData.getStr("Code")))
.setApiRequestId(responseResult.getStr("RequestId"))
.setSerialNo(responseData.getStr("SerialNo"))
.setApiMsg(responseData.getStr("Message"));
}
JSONObject sendSmsRequest(TreeMap<String, Object> body,String action,String version,String region) throws Exception {
@Override
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
JSONArray statuses = JSONUtil.parseArray(text);
// 字段参考
return convertList(statuses, status -> {
JSONObject statusObj = (JSONObject) status;
return new SmsReceiveRespDTO()
.setSuccess("SUCCESS".equals(statusObj.getStr("report_status"))) // 是否接收成功
.setErrorCode(statusObj.getStr("errmsg")) // 状态报告编码
.setMobile(statusObj.getStr("mobile")) // 手机号
.setReceiveTime(statusObj.getLocalDateTime("user_receive_time", null)) // 状态报告时间
.setSerialNo(statusObj.getStr("sid")); // 发送序列号
});
}
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
// 1. 构建请求
// 参考链接 https://cloud.tencent.com/document/product/382/52067
TreeMap<String, Object> body = new TreeMap<>();
body.put("International", INTERNATIONAL_CHINA);
body.put("TemplateIdSet", new Integer[]{Integer.valueOf(apiTemplateId)});
JSONObject response = request("DescribeSmsTemplateList", body);
// TODO @scholar会有请求失败的情况么类似发送的那块逻辑我补充了
JSONObject TemplateStatusSet = response.getJSONObject("Response").getJSONArray("DescribeTemplateStatusSet").getJSONObject(0);
String content = TemplateStatusSet.get("TemplateContent").toString();
int templateStatus = Integer.parseInt(TemplateStatusSet.get("StatusCode").toString());
String auditReason = TemplateStatusSet.get("ReviewReply").toString();
return new SmsTemplateRespDTO().setId(apiTemplateId).setContent(content)
.setAuditStatus(convertSmsTemplateAuditStatus(templateStatus)).setAuditReason(auditReason);
}
@VisibleForTesting
Integer convertSmsTemplateAuditStatus(int templateStatus) {
switch (templateStatus) {
case 1: return SmsTemplateAuditStatusEnum.CHECKING.getStatus();
case 0: return SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
case -1: return SmsTemplateAuditStatusEnum.FAIL.getStatus();
default: throw new IllegalArgumentException(String.format("未知审核状态(%d)", templateStatus));
}
}
/**
* 请求腾讯云短信
*
* @see <a href="https://cloud.tencent.com/document/product/382/52072">签名方法 v3</a>
*
* @param action 请求的 API 名称
* @param body 请求参数
* @return 请求结果
*/
private JSONObject request(String action, TreeMap<String, Object> body) throws Exception {
String timestamp = String.valueOf(System.currentTimeMillis() / 1000);
// TODO @scholar这个 format看看怎么写的可以简化点
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd");
// 注意时区否则容易出错
sdf.setTimeZone(TimeZone.getTimeZone("UTC"));
String date = sdf.format(new Date(Long.valueOf(timestamp + "000")));
// TODO @scholar这个步骤看看怎么参考阿里云 client归类下1. 2.1 2.2 这种
// ************* 步骤 1拼接规范请求串 *************
// TODO @scholar这个 hsot 枚举下
String host = "sms.tencentcloudapi.com"; //APP接入地址+接口访问URI
String httpMethod = "POST"; // 请求方式
String canonicalUri = "/";
@ -129,6 +186,7 @@ public class TencentSmsClient extends AbstractSmsClient {
+ "host:" + host + "\n" + "x-tc-action:" + action.toLowerCase() + "\n";
String signedHeaders = "content-type;host;x-tc-action";
String hashedRequestBody = sha256Hex(JSONUtil.toJsonStr(body));
// TODO @scholar换行下不然单行太长了
String canonicalRequest = httpMethod + "\n" + canonicalUri + "\n" + canonicalQueryString + "\n" + canonicalHeaders + "\n" + signedHeaders + "\n" + hashedRequestBody;
// ************* 步骤 2拼接待签名字符串 *************
@ -153,205 +211,19 @@ public class TencentSmsClient extends AbstractSmsClient {
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);
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();
String responseBody = HttpUtils.post("https://" + host, headers, JSONUtil.toJsonStr(body));
return JSONUtil.parseObj(response.body());
return JSONUtil.parseObj(responseBody);
}
public static byte[] hmac256(byte[] key, String msg) throws Exception {
// TODO @scholar使用 hutool 简化下
private 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
public List<SmsReceiveRespDTO> parseSmsReceiveStatus(String text) {
List<SmsReceiveStatus> callback = JsonUtils.parseArray(text, SmsReceiveStatus.class);
return convertList(callback, status -> new SmsReceiveRespDTO()
.setSuccess(SmsReceiveStatus.SUCCESS_CODE.equalsIgnoreCase(status.getStatus()))
.setErrorCode(status.getErrCode()).setErrorMsg(status.getDescription())
.setMobile(status.getMobile()).setReceiveTime(status.getReceiveTime())
.setSerialNo(status.getSerialNo()).setLogId(status.getSessionContext().getLogId()));
}
@Override
public SmsTemplateRespDTO getSmsTemplate(String apiTemplateId) throws Throwable {
// 构建请求
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
Integer convertSmsTemplateAuditStatus(int templateStatus) {
switch (templateStatus) {
case 1: return SmsTemplateAuditStatusEnum.CHECKING.getStatus();
case 0: return SmsTemplateAuditStatusEnum.SUCCESS.getStatus();
case -1: return SmsTemplateAuditStatusEnum.FAIL.getStatus();
default: throw new IllegalArgumentException(String.format("未知审核状态(%d)", templateStatus));
}
}
@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 {
/**
* 短信接受成功 code
*/
public static final String SUCCESS_CODE = "SUCCESS";
/**
* 用户实际接收到短信的时间
*/
@JsonProperty("user_receive_time")
@JsonFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND, timezone = TIME_ZONE_DEFAULT)
private LocalDateTime receiveTime;
/**
* 国家或地区
*/
@JsonProperty("nationcode")
private String nationCode;
/**
* 手机号码
*/
private String mobile;
/**
* 实际是否收到短信接收状态SUCCESS成功FAIL失败
*/
@JsonProperty("report_status")
private String status;
/**
* 用户接收短信状态码错误信息
*/
@JsonProperty("errmsg")
private String errCode;
/**
* 用户接收短信状态描述
*/
@JsonProperty("description")
private String description;
/**
* 本次发送标识 ID与发送接口返回的SerialNo对应
*/
@JsonProperty("sid")
private String serialNo;
/**
* 用户的 session 内容与发送接口的请求参数 SessionContext 一致
*/
@JsonProperty("ext")
private SessionContext sessionContext;
}
@VisibleForTesting
@Data
static class SessionContext {
/**
* 发送短信记录id
*/
private Long logId;
}
}
}

View File

@ -38,15 +38,6 @@ public class AliyunSmsClientTest extends BaseMockitoUnitTest {
@InjectMocks
private final AliyunSmsClient smsClient = new AliyunSmsClient(properties);
@Test
public void testDoInit() {
// 准备参数
// mock 方法
// 调用
smsClient.doInit();
}
@Test
public void tesSendSms_success() throws Throwable {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {

View File

@ -1,6 +1,5 @@
package cn.iocoder.yudao.module.system.framework.sms.core.client.impl;
import cn.hutool.core.collection.ListUtil;
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;
@ -18,24 +17,6 @@ import java.util.List;
*/
public class SmsClientTests {
@Test
@Disabled
public void testHuaweiSmsClient_sendSms() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("123")
.setApiSecret("456");
HuaweiSmsClient client = new HuaweiSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "15601691323";
String apiTemplateId = "xx test01";
List<KeyValue<String, Object>> templateParams = ListUtil.of(new KeyValue<>("code", "1024"));
// 调用
SmsSendRespDTO smsSendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// 打印结果
System.out.println(smsSendRespDTO);
}
// ========== 阿里云 ==========
@Test
@ -59,14 +40,14 @@ public class SmsClientTests {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz")
.setSignature("Ballcat");
.setSignature("runpu");
AliyunSmsClient client = new AliyunSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "173213154791";
String mobile = "15601691323";
String apiTemplateId = "SMS_207945135";
// 调用
SmsSendRespDTO sendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, ListUtil.of(new KeyValue<>("code", "1024")));
SmsSendRespDTO sendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, List.of(new KeyValue<>("code", "1024")));
// 打印结果
System.out.println(sendRespDTO);
}
@ -100,4 +81,62 @@ public class SmsClientTests {
System.out.println(statuses);
}
// ========== 腾讯云 ==========
@Test
@Disabled
public void testTencentSmsClient_sendSms() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR 1428926523")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz")
.setSignature("芋道源码");
TencentSmsClient client = new TencentSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "15601691323";
String apiTemplateId = "2136358";
// 调用
SmsSendRespDTO sendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, List.of(new KeyValue<>("code", "1024")));
// 打印结果
System.out.println(sendRespDTO);
}
@Test
@Disabled
public void testTencentSmsClient_getSmsTemplate() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("LTAI5tAicJAxaSFiZuGGeXHR 1428926523")
.setApiSecret("Fdr9vadxnDvS6GJU0W1tijQ0VmLhYz")
.setSignature("芋道源码");
TencentSmsClient client = new TencentSmsClient(properties);
// 准备参数
String apiTemplateId = "2136358";
// 调用
SmsTemplateRespDTO template = client.getSmsTemplate(apiTemplateId);
// 打印结果
System.out.println(template);
}
// ========== 华为云 ==========
@Test
@Disabled
public void testHuaweiSmsClient_sendSms() throws Throwable {
SmsChannelProperties properties = new SmsChannelProperties()
.setApiKey("123")
.setApiSecret("456")
.setSignature("runpu");
HuaweiSmsClient client = new HuaweiSmsClient(properties);
// 准备参数
Long sendLogId = System.currentTimeMillis();
String mobile = "15601691323";
String apiTemplateId = "xx test01";
List<KeyValue<String, Object>> templateParams = List.of(new KeyValue<>("code", "1024"));
// 调用
SmsSendRespDTO smsSendRespDTO = client.sendSms(sendLogId, mobile, apiTemplateId, templateParams);
// 打印结果
System.out.println(smsSendRespDTO);
}
}

View File

@ -1,22 +1,28 @@
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.http.HttpUtils;
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 org.junit.jupiter.api.Test;
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.test.core.util.RandomUtils.randomString;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.mockStatic;
// TODO @芋艿补全单测
/**
* {@link TencentSmsClient} 的单元测试
*
@ -32,115 +38,85 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest {
@InjectMocks
private TencentSmsClient smsClient = new TencentSmsClient(properties);
@Mock
private SmsClient client;
@Test
public void testDoInit() {
// 准备参数
// mock 方法
public void testDoSendSms_success() throws Throwable {
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<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\n" +
" \"Response\": {\n" +
" \"SendStatusSet\": [\n" +
" {\n" +
" \"SerialNo\": \"5000:1045710669157053657849499619\",\n" +
" \"PhoneNumber\": \"+8618511122233\",\n" +
" \"Fee\": 1,\n" +
" \"SessionContext\": \"test\",\n" +
" \"Code\": \"Ok\",\n" +
" \"Message\": \"send success\",\n" +
" \"IsoCode\": \"CN\"\n" +
" },\n" +
" ],\n" +
" \"RequestId\": \"a0aabda6-cf91-4f3e-a81f-9198114a2279\"\n" +
" }\n" +
"}");
// 调用
smsClient.doInit();
// 断言
assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "client"));
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertTrue(result.getSuccess());
assertEquals("5000:1045710669157053657849499619", result.getSerialNo());
assertEquals("a0aabda6-cf91-4f3e-a81f-9198114a2279", result.getApiRequestId());
assertEquals("send success", result.getApiMsg());
}
}
@Test
public void testRefresh() {
// 准备参数
SmsChannelProperties p = new SmsChannelProperties()
.setApiKey(randomString() + " " + randomString()) // 随机一个 apiKey避免构建报错
.setApiSecret(randomString()) // 随机一个 apiSecret避免构建报错
.setSignature("芋道源码");
// 调用
smsClient.refresh(p);
// 断言
assertNotSame(client, ReflectUtil.getFieldValue(smsClient, "client"));
public void testDoSendSms_fail() throws Throwable {
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<>("1", 1234), new KeyValue<>("2", "login"));
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{\n" +
" \"Response\": {\n" +
" \"SendStatusSet\": [\n" +
" {\n" +
" \"SerialNo\": \"5000:1045710669157053657849499619\",\n" +
" \"PhoneNumber\": \"+8618511122233\",\n" +
" \"Fee\": 1,\n" +
" \"SessionContext\": \"test\",\n" +
" \"Code\": \"ERROR\",\n" +
" \"Message\": \"send success\",\n" +
" \"IsoCode\": \"CN\"\n" +
" },\n" +
" ],\n" +
" \"RequestId\": \"a0aabda6-cf91-4f3e-a81f-9198114a2279\"\n" +
" }\n" +
"}");
// 调用
SmsSendRespDTO result = smsClient.sendSms(sendLogId, mobile,
apiTemplateId, templateParams);
// 断言
assertFalse(result.getSuccess());
assertEquals("5000:1045710669157053657849499619", result.getSerialNo());
assertEquals("a0aabda6-cf91-4f3e-a81f-9198114a2279", result.getApiRequestId());
assertEquals("send success", result.getApiMsg());
}
}
// @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());
// }
// @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() {
// 准备参数
@ -156,7 +132,6 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest {
" \"ext\": {\"logId\":\"67890\"}\n" +
" }\n" +
"]";
// mock 方法
// 调用
List<SmsReceiveRespDTO> statuses = smsClient.parseSmsReceiveStatus(text);
@ -164,42 +139,44 @@ public class TencentSmsClientTest extends BaseMockitoUnitTest {
assertEquals(1, statuses.size());
assertTrue(statuses.get(0).getSuccess());
assertEquals("DELIVRD", statuses.get(0).getErrorCode());
assertEquals("用户短信送达成功", statuses.get(0).getErrorMsg());
assertEquals("13900000001", statuses.get(0).getMobile());
assertEquals(LocalDateTime.of(2015, 10, 17, 8, 3, 4), statuses.get(0).getReceiveTime());
assertEquals("12345", statuses.get(0).getSerialNo());
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 {
try (MockedStatic<HttpUtils> httpUtilsMockedStatic = mockStatic(HttpUtils.class)) {
// 准备参数
String apiTemplateId = "1122";
// mock 方法
httpUtilsMockedStatic.when(() -> HttpUtils.post(anyString(), anyMap(), anyString()))
.thenReturn("{ \"Response\": {\n" +
" \"DescribeTemplateStatusSet\": [\n" +
" {\n" +
" \"TemplateName\": \"验证码\",\n" +
" \"TemplateId\": 1122,\n" +
" \"International\": 0,\n" +
" \"ReviewReply\": \"审批备注\",\n" +
" \"CreateTime\": 1617379200,\n" +
" \"TemplateContent\": \"您的验证码是{1}\",\n" +
" \"StatusCode\": 0\n" +
" },\n" +
" \n" +
" ],\n" +
" \"RequestId\": \"f36e4f00-605e-49b1-ad0d-bfaba81c7325\"\n" +
" }}");
// 调用
SmsTemplateRespDTO result = smsClient.getSmsTemplate(apiTemplateId);
// 断言
assertEquals("1122", result.getId());
assertEquals("您的验证码是{1}", result.getContent());
assertEquals(SmsTemplateAuditStatusEnum.SUCCESS.getStatus(), result.getAuditStatus());
assertEquals("审批备注", result.getAuditReason());
}
}
@Test
public void testConvertSmsTemplateAuditStatus() {

View File

@ -147,14 +147,22 @@ spring:
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:
@ -301,7 +309,6 @@ yudao:
end-code: 9999 # 这里配置 9999 的原因是,测试方便。
trade:
order:
app-id: 1 # 商户编号
pay-expire-time: 2h # 支付的过期时间
receive-expire-time: 14d # 收货的过期时间
comment-expire-time: 7d # 评论的过期时间