# Conflicts:
#	yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/file/FileController.java
#	yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/framework/file/core/s3/S3FileClientTest.java
#	yudao-module-iot/yudao-module-iot-gateway/src/main/resources/application.yaml
This commit is contained in:
YunaiV
2026-06-07 22:10:18 +08:00
75 changed files with 3100 additions and 343 deletions
@@ -9,6 +9,7 @@ import lombok.SneakyThrows;
import org.springframework.util.StringUtils;
import org.springframework.web.util.UriComponents;
import org.springframework.web.util.UriComponentsBuilder;
import org.springframework.web.util.UriUtils;
import javax.servlet.http.HttpServletRequest;
import java.net.URI;
@@ -59,11 +60,61 @@ public class HttpUtils {
*/
@SneakyThrows
public static String decodeUrlPath(String path) {
if (StrUtil.isEmpty(path)) {
return path;
}
// 先将 + 替换为 %2B,避免被 URLDecoder 解码为空格
String encoded = path.replace("+", "%2B");
return URLDecoder.decode(encoded, StandardCharsets.UTF_8.name());
}
/**
* 编码 URL 路径,按路径段编码,保留 / 分隔符
*
* @param path URL 路径,例如 20250602/xxx.pdf
* @return 编码后的路径
*/
public static String encodeUrlPath(String path) {
if (StrUtil.isEmpty(path)) {
return path;
}
String[] segments = path.split(StrUtil.SLASH, -1);
StringBuilder result = new StringBuilder(path.length());
for (int i = 0; i < segments.length; i++) {
if (i > 0) {
result.append(StrUtil.SLASH);
}
result.append(encodeUrlPathSegment(segments[i]));
}
return result.toString();
}
/**
* 编码 URL 路径段
*
* @param segment URL 路径段
* @return 编码后的路径段
*/
public static String encodeUrlPathSegment(String segment) {
return UriUtils.encodePathSegment(segment, StandardCharsets.UTF_8);
}
public static String removeUrlPathQueryAndFragment(String path) {
if (StrUtil.isEmpty(path)) {
return path;
}
int endIndex = path.length();
int queryIndex = path.indexOf('?');
if (queryIndex >= 0) {
endIndex = queryIndex;
}
int fragmentIndex = path.indexOf('#');
if (fragmentIndex >= 0 && fragmentIndex < endIndex) {
endIndex = fragmentIndex;
}
return path.substring(0, endIndex);
}
public static String replaceUrlQuery(String url, String key, String value) {
UrlBuilder builder = UrlBuilder.of(url, Charset.defaultCharset());
// 先移除;再添加
@@ -9,6 +9,36 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
*/
public class HttpUtilsTest {
@Test
public void testEncodeUrlPath() {
// 准备参数
String path = "avatar/中文 100%+文件.jpg";
// 调用
String result = HttpUtils.encodeUrlPath(path);
// 断言
assertEquals("avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result);
}
@Test
public void testDecodeUrlPath() {
// 准备参数:+ 是路径字符,不应该按 query parameter 语义解码为空格
String path = "avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg";
// 调用
String result = HttpUtils.decodeUrlPath(path);
// 断言
assertEquals("avatar/中文 100%+文件.jpg", result);
}
@Test
public void testRemoveUrlPathQueryAndFragment() {
assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg?token=1#preview"));
assertEquals("avatar/test.jpg", HttpUtils.removeUrlPathQueryAndFragment("avatar/test.jpg#preview?token=1"));
}
@Test
public void testReplaceUrlQuery_replace() {
// 准备参数
@@ -12,7 +12,6 @@ import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessag
import cn.iocoder.yudao.framework.redis.config.YudaoRedisAutoConfiguration;
import lombok.extern.slf4j.Slf4j;
import org.redisson.api.RedissonClient;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnBean;
import org.springframework.context.annotation.Bean;
@@ -70,7 +69,8 @@ public class YudaoRedisMQConsumerAutoConfiguration {
public RedisPendingMessageResendJob redisPendingMessageResendJob(List<AbstractRedisStreamMessageListener<?>> listeners,
RedisMQTemplate redisTemplate,
RedissonClient redissonClient) {
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient);
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient,
RedisPendingMessageResendJob.DEFAULT_RESEND_LOCK_KEY);
}
/**
@@ -81,7 +81,8 @@ public class YudaoRedisMQConsumerAutoConfiguration {
public RedisStreamMessageCleanupJob redisStreamMessageCleanupJob(List<AbstractRedisStreamMessageListener<?>> listeners,
RedisMQTemplate redisTemplate,
RedissonClient redissonClient) {
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient);
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient,
RedisStreamMessageCleanupJob.DEFAULT_CLEANUP_LOCK_KEY);
}
/**
@@ -23,7 +23,9 @@ import java.util.Objects;
@AllArgsConstructor
public class RedisPendingMessageResendJob {
private static final String LOCK_KEY = "redis:stream:pending-message-resend:lock";
public static final String DEFAULT_RESEND_LOCK_KEY = "redis:stream:pending-message-resend:lock";
public static final String IOT_RESEND_LOCK_KEY = "redis:stream:pending-message-resend:lock:iot";
/**
* 消息超时时间,默认 5 分钟
@@ -36,22 +38,26 @@ public class RedisPendingMessageResendJob {
private final List<AbstractRedisStreamMessageListener<?>> listeners;
private final RedisMQTemplate redisTemplate;
private final RedissonClient redissonClient;
private final String resendLockKey;
/**
* 一分钟执行一次,这里选择每分钟的 35 秒执行,是为了避免整点任务过多的问题
*/
@Scheduled(cron = "35 * * * * ?")
public void messageResend() {
RLock lock = redissonClient.getLock(LOCK_KEY);
// 尝试加锁
RLock lock = redissonClient.getLock(resendLockKey);
if (lock.tryLock()) {
try {
execute();
} catch (Exception ex) {
log.error("[messageResend][执行异常]", ex);
log.error("[messageResend][执行异常][lockKey={}]", resendLockKey, ex);
} finally {
lock.unlock();
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} else {
log.debug("[messageResend][未获取到锁,跳过本轮][lockKey={}]", resendLockKey);
}
}
@@ -23,7 +23,16 @@ import java.util.List;
@AllArgsConstructor
public class RedisStreamMessageCleanupJob {
private static final String LOCK_KEY = "redis:stream:message-cleanup:lock";
/**
* 业务 MQ(Spring 容器内 AbstractRedisStreamMessageListener)清理任务使用的分布式锁
*/
public static final String DEFAULT_CLEANUP_LOCK_KEY = "redis:stream:message-cleanup:lock";
/**
* IoT Redis 总线清理任务使用的分布式锁(须与 {@link #DEFAULT_CLEANUP_LOCK_KEY} 区分,否则会共抢一把锁,
* 同一时刻只有一侧能执行 XTRIM,另一侧 Stream 可能无限积压)
*/
public static final String IOT_CLEANUP_LOCK_KEY = "redis:stream:message-cleanup:lock:iot";
/**
* 保留的消息数量,默认保留最近 10000 条消息
@@ -33,22 +42,29 @@ public class RedisStreamMessageCleanupJob {
private final List<AbstractRedisStreamMessageListener<?>> listeners;
private final RedisMQTemplate redisTemplate;
private final RedissonClient redissonClient;
/**
* Redisson 锁键(多 Bean 注册清理任务时必须各不相同)
*/
private final String cleanupLockKey;
/**
* 每小时执行一次清理任务
*/
@Scheduled(cron = "0 0 * * * ?")
public void cleanup() {
RLock lock = redissonClient.getLock(LOCK_KEY);
// 尝试加锁
RLock lock = redissonClient.getLock(cleanupLockKey);
if (lock.tryLock()) {
try {
execute();
} catch (Exception ex) {
log.error("[cleanup][执行异常]", ex);
log.error("[cleanup][执行异常][lockKey={}]", cleanupLockKey, ex);
} finally {
lock.unlock();
if (lock.isHeldByCurrentThread()) {
lock.unlock();
}
}
} else {
log.debug("[cleanup][未获取到锁,跳过本轮][lockKey={}]", cleanupLockKey);
}
}
@@ -59,8 +75,8 @@ public class RedisStreamMessageCleanupJob {
StreamOperations<String, Object, Object> ops = redisTemplate.getRedisTemplate().opsForStream();
listeners.forEach(listener -> {
try {
// 使用 XTRIM 命令清理消息,只保留最近的 MAX_LEN 条消息
Long trimCount = ops.trim(listener.getStreamKey(), MAX_COUNT, true);
// 使用 XTRIM MAXLEN 精确裁剪(approximate=false),避免 ~ 模式下长期明显高于上限
Long trimCount = ops.trim(listener.getStreamKey(), MAX_COUNT, false);
if (trimCount != null && trimCount > 0) {
log.info("[execute][Stream({}) 清理消息数量({})]", listener.getStreamKey(), trimCount);
}
@@ -20,51 +20,49 @@ public enum DbTypeEnum {
/**
* H2
*
* 注意:H2 不支持 find_in_set 函数
*/
H2(DbType.H2, "H2", ""),
H2(DbType.H2, "H2", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
/**
* MySQL
*/
MY_SQL(DbType.MYSQL, "MySQL", "FIND_IN_SET('#{value}', #{column}) <> 0"),
MY_SQL(DbType.MYSQL, "MySQL", "FIND_IN_SET(#{value}, #{column}) <> 0"),
/**
* Oracle
*/
ORACLE(DbType.ORACLE, "Oracle", "FIND_IN_SET('#{value}', #{column}) <> 0"),
ORACLE(DbType.ORACLE, "Oracle", "INSTR(',' || #{column} || ',', ',' || #{value} || ',') > 0"),
/**
* PostgreSQL
*
* 华为 openGauss 使用 ProductName 与 PostgreSQL 相同
*/
POSTGRE_SQL(DbType.POSTGRE_SQL,"PostgreSQL", "POSITION('#{value}' IN #{column}) <> 0"),
POSTGRE_SQL(DbType.POSTGRE_SQL, "PostgreSQL", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
/**
* SQL Server
*/
SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"),
SQL_SERVER(DbType.SQL_SERVER, "Microsoft SQL Server", "CHARINDEX(',' + CAST(#{value} AS varchar(255)) + ',', ',' + #{column} + ',') > 0"),
/**
* SQL Server 2005
*/
SQL_SERVER2005(DbType.SQL_SERVER2005, "Microsoft SQL Server 2005", "CHARINDEX(',' + #{value} + ',', ',' + #{column} + ',') <> 0"),
SQL_SERVER2005(DbType.SQL_SERVER2005, "Microsoft SQL Server 2005", "CHARINDEX(',' + CAST(#{value} AS varchar(255)) + ',', ',' + #{column} + ',') > 0"),
/**
* 达梦
*/
DM(DbType.DM, "DM DBMS", "FIND_IN_SET('#{value}', #{column}) <> 0"),
DM(DbType.DM, "DM DBMS", "FIND_IN_SET(#{value}, #{column}) <> 0"),
/**
* 人大金仓
*/
KINGBASE_ES(DbType.KINGBASE_ES, "KingbaseES", "POSITION('#{value}' IN #{column}) <> 0"),
KINGBASE_ES(DbType.KINGBASE_ES, "KingbaseES", "POSITION(',' || CAST(#{value} AS VARCHAR) || ',' IN ',' || #{column} || ',') > 0"),
/**
* OceanBase
*/
OCEAN_BASE(DbType.OCEAN_BASE, "OceanBase", "FIND_IN_SET('#{value}', #{column}) <> 0")
OCEAN_BASE(DbType.OCEAN_BASE, "OceanBase", "FIND_IN_SET(#{value}, #{column}) <> 0")
;
@@ -95,7 +93,9 @@ public enum DbTypeEnum {
}
public static String getFindInSetTemplate(DbType dbType) {
return Optional.of(MAP_BY_MP.get(dbType).getFindInSetTemplate())
return Optional.ofNullable(MAP_BY_MP.get(dbType))
.map(DbTypeEnum::getFindInSetTemplate)
.filter(StrUtil::isNotBlank)
.orElseThrow(() -> new IllegalArgumentException("FIND_IN_SET not supported"));
}
}
@@ -34,6 +34,10 @@ public class MyBatisUtils {
private static final Pattern SAFE_COLUMN_NAME_PATTERN = Pattern.compile("^[a-zA-Z0-9_]+(\\.[a-zA-Z0-9_]+)*$");
private static final String FIND_IN_SET_VALUE_PLACEHOLDER = "#{value}";
private static final String FIND_IN_SET_COLUMN_PLACEHOLDER = "#{column}";
public static <T> Page<T> buildPage(PageParam pageParam) {
return buildPage(pageParam, null);
}
@@ -157,15 +161,43 @@ public class MyBatisUtils {
/**
* 跨数据库的 find_in_set 实现
*
* @param column 字段名称
* @param value 查询值(不带单引号)
* @param columnName 字段名称
* @return sql
*/
public static String findInSet(String column, Object value) {
public static String findInSet(String columnName) {
return findInSet(columnName, 0);
}
/**
* 跨数据库的 find_in_set 实现,适用于同一个 apply 语句中有多个参数的场景
*
* @param columnName 字段名称
* @param paramIndex apply 参数序号
* @return sql
*/
public static String findInSetWithParamIndex(String columnName, int paramIndex) {
return findInSet(columnName, paramIndex);
}
private static String findInSet(String columnName, int paramIndex) {
DbType dbType = JdbcUtils.getDbType();
return findInSet(dbType, columnName, paramIndex);
}
static String findInSet(DbType dbType, String columnName, int paramIndex) {
if (!isSafeColumnName(columnName)) {
throw new IllegalArgumentException("Invalid column name: " + columnName);
}
if (paramIndex < 0) {
throw new IllegalArgumentException("Invalid param index: " + paramIndex);
}
return DbTypeEnum.getFindInSetTemplate(dbType)
.replace("#{column}", column)
.replace("#{value}", StrUtil.toString(value));
.replace(FIND_IN_SET_COLUMN_PLACEHOLDER, columnName)
.replace(FIND_IN_SET_VALUE_PLACEHOLDER, "{" + paramIndex + "}");
}
private static boolean isSafeColumnName(String columnName) {
return StrUtil.isNotEmpty(columnName) && SAFE_COLUMN_NAME_PATTERN.matcher(columnName).matches();
}
/**
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.framework.mybatis.core.util;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.SortingField;
import com.baomidou.mybatisplus.annotation.DbType;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.metadata.OrderItem;
@@ -13,6 +14,7 @@ import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
/**
@@ -98,6 +100,71 @@ public class MyBatisUtilsTest {
assertEquals("DESC", MyBatisUtils.getOrderDirection(null));
}
@Test
public void testFindInSet() {
assertEquals("FIND_IN_SET({0}, websites) <> 0",
MyBatisUtils.findInSet(DbType.MYSQL, "websites", 0));
assertEquals("POSITION(',' || CAST({0} AS VARCHAR) || ',' IN ',' || websites || ',') > 0",
MyBatisUtils.findInSet(DbType.H2, "websites", 0));
assertEquals("INSTR(',' || t.websites || ',', ',' || {0} || ',') > 0",
MyBatisUtils.findInSet(DbType.ORACLE, "t.websites", 0));
assertEquals("POSITION(',' || CAST({1} AS VARCHAR) || ',' IN ',' || websites || ',') > 0",
MyBatisUtils.findInSet(DbType.POSTGRE_SQL, "websites", 1));
assertEquals("CHARINDEX(',' + CAST({2} AS varchar(255)) + ',', ',' + websites + ',') > 0",
MyBatisUtils.findInSet(DbType.SQL_SERVER, "websites", 2));
}
@Test
public void testFindInSet_invalidColumnName() {
assertThrows(IllegalArgumentException.class,
() -> MyBatisUtils.findInSet(DbType.MYSQL, "websites;drop table system_tenant", 0));
assertThrows(IllegalArgumentException.class,
() -> MyBatisUtils.findInSet(DbType.MYSQL, "FIND_IN_SET(value, websites)", 0));
}
@Test
public void testFindInSet_invalidParamIndex() {
assertThrows(IllegalArgumentException.class,
() -> MyBatisUtils.findInSet(DbType.MYSQL, "websites", -1));
}
@Test
public void testFindInSet_applyBindsValue() {
// 准备参数
QueryWrapper<Object> query = new QueryWrapper<>();
String value = "test' OR 1 = 1";
// 调用
query.apply(MyBatisUtils.findInSet(DbType.MYSQL, "to_mails", 0), value);
// 断言:SQL 片段里只有 MyBatis Plus 参数占位,用户输入不会被直接拼接进去
assertEquals("(FIND_IN_SET(#{ew.paramNameValuePairs.MPGENVAL1}, to_mails) <> 0)",
query.getSqlSegment());
assertFalse(query.getSqlSegment().contains(value));
assertEquals(value, query.getParamNameValuePairs().get("MPGENVAL1"));
}
@Test
public void testFindInSet_applyBindsMultipleValues() {
// 准备参数
QueryWrapper<Object> query = new QueryWrapper<>();
String value1 = "1' OR 1 = 1";
String value2 = "2' OR 1 = 1";
// 调用
query.apply(MyBatisUtils.findInSet(DbType.MYSQL, "tag_ids", 0)
+ " OR " + MyBatisUtils.findInSet(DbType.MYSQL, "tag_ids", 1), value1, value2);
// 断言:多个参数都由 MyBatis Plus 生成占位符,不拼接用户输入
assertEquals("(FIND_IN_SET(#{ew.paramNameValuePairs.MPGENVAL1}, tag_ids) <> 0"
+ " OR FIND_IN_SET(#{ew.paramNameValuePairs.MPGENVAL2}, tag_ids) <> 0)",
query.getSqlSegment());
assertFalse(query.getSqlSegment().contains(value1));
assertFalse(query.getSqlSegment().contains(value2));
assertEquals(value1, query.getParamNameValuePairs().get("MPGENVAL1"));
assertEquals(value2, query.getParamNameValuePairs().get("MPGENVAL2"));
}
private void assertOrderItem(OrderItem orderItem, String column, boolean asc) {
assertEquals(column, orderItem.getColumn());
assertEquals(asc, orderItem.isAsc());
@@ -456,6 +456,54 @@ public class BpmnModelUtils {
return new ArrayList<>();
}
/**
* 根据节点,递归获取上游 source 为 UserTask 的入口连线
*
* 1. 如果当前节点的直接入口连线 source 就是 UserTask,则直接返回该连线
* 2. 如果当前节点的直接入口连线 source 不是 UserTask,则继续向上递归查找
* 3. 如果递归过程中遇到 StartEvent 或 SubProcess,则停止该分支继续向上查找
*
* @param source 起始节点
* @return 上游连接 UserTask 的入口连线列表
*/
public static List<SequenceFlow> getElementIncomingUserTaskFlows(FlowElement source) {
List<SequenceFlow> result = new ArrayList<>();
collectElementIncomingUserTaskFlows(source, new HashSet<>(), new HashSet<>(), result);
return result;
}
private static void collectElementIncomingUserTaskFlows(FlowElement source, Set<String> visitedSequenceFlowIds,
Set<String> resultSequenceFlowIds, List<SequenceFlow> result) {
// 如果是开始节点或子流程,则停止该分支向上查找
if (source == null || source instanceof StartEvent || source instanceof SubProcess) {
return;
}
// 获取入口连线
List<SequenceFlow> incomingFlows = getElementIncomingFlows(source);
if (CollUtil.isEmpty(incomingFlows)) {
return;
}
// 循环找到目标元素
for (SequenceFlow incomingFlow : incomingFlows) {
// 如果发现连线重复,说明连线已经走过。跳过
if (incomingFlow == null || !visitedSequenceFlowIds.add(incomingFlow.getId())) {
continue;
}
// 如果 source 是 UserTask,则添加到结果中
FlowElement sourceFlowElement = incomingFlow.getSourceFlowElement();
if (sourceFlowElement instanceof UserTask) {
if (resultSequenceFlowIds.add(incomingFlow.getId())) {
result.add(incomingFlow);
}
continue;
}
// 递归向上查找 UserTask
collectElementIncomingUserTaskFlows(sourceFlowElement, visitedSequenceFlowIds,
resultSequenceFlowIds, result);
}
}
/**
* 根据节点,获取出口连线
*
@@ -618,7 +618,14 @@ public class BpmTaskServiceImpl implements BpmTaskService {
runtimeService.setVariable(task.getProcessInstanceId(), BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_NEED_SIMULATE_TASK_IDS, needSimulateTaskIdsByReturn);
}
// 6. 调用 BPM complete 去完成任务
// 6. 清理退回设置的不自动通过的变量。仅在该标记存在时才删除,避免每次完成任务都产生无谓的 DB delete
String returnFlagKey = String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, task.getTaskDefinitionKey());
if (runtimeService.hasVariable(task.getProcessInstanceId(), returnFlagKey)) {
log.info("[approveTask][taskId({}) 清理退回标记变量({})]", task.getId(), returnFlagKey);
runtimeService.removeVariable(task.getProcessInstanceId(), returnFlagKey);
}
// 7. 调用 BPM complete 去完成任务
taskService.complete(task.getId(), variables, true);
// 【加签专属】处理加签任务
@@ -914,7 +921,7 @@ public class BpmTaskServiceImpl implements BpmTaskService {
* @param reqVO 前端参数封装
*/
public void returnTask(Long userId, BpmnModel bpmnModel, Task currentTask, FlowElement targetElement, BpmTaskReturnReqVO reqVO) {
// 1. 获得所有需要回撤的任务 taskDefinitionKey,用于稍后的 moveActivityIdsToSingleActivityId 回撤
// 1. 获得所有需要回撤的任务 taskDefinitionKey,用于稍后的 moveExecutionsToSingleActivityId 回撤
// 1.1 获取所有正常进行的任务节点 Key
List<Task> taskList = taskService.createTaskQuery().processInstanceId(currentTask.getProcessInstanceId()).list();
List<String> runTaskKeyList = convertList(taskList, Task::getTaskDefinitionKey);
@@ -922,14 +929,16 @@ public class BpmTaskServiceImpl implements BpmTaskService {
// 为什么不直接使用 runTaskKeyList 呢?因为可能存在多个审批分支,例如说:A -> B -> C 和 D -> F,而只要 C 撤回到 A,需要排除掉 F
List<UserTask> returnUserTaskList = BpmnModelUtils.iteratorFindChildUserTasks(targetElement, runTaskKeyList, null, null);
List<String> returnTaskKeyList = convertList(returnUserTaskList, UserTask::getId);
List<String> runExecutionIds = new ArrayList<>();
// 2. 给当前要被退回的 task 数组,设置退回意见
taskList.forEach(task -> {
// 需要排除掉,不需要设置退回意见的任务
if (!returnTaskKeyList.contains(task.getTaskDefinitionKey())) {
return;
}
if (task.getExecutionId() != null) {
runExecutionIds.add(task.getExecutionId());
}
// 判断是否分配给自己任务,因为会签任务,一个节点会有多个任务
if (isAssignUserTask(userId, task)) { // 情况一:自己的任务,进行 RETURN 标记
// 2.1.1 添加评论
@@ -946,18 +955,25 @@ public class BpmTaskServiceImpl implements BpmTaskService {
Set<String> needSimulateTaskDefinitionKeys = getNeedSimulateTaskDefinitionKeys(bpmnModel, currentTask, targetElement);
// 4. 执行驳回
// 4.1 校验是否有可回撤的 execution,避免 moveExecutionsToSingleActivityId 传入空集合时 Flowable 内部报错
if (CollUtil.isEmpty(runExecutionIds)) {
throw exception(TASK_RETURN_FAIL_SOURCE_TARGET_ERROR);
}
// 4.2 执行驳回
// ① 使用 moveExecutionsToSingleActivityId 替换 moveActivityIdsToSingleActivityId。原因:当多实例任务回退的时候有问题。
// 相关 issue: https://github.com/flowable/flowable-engine/issues/3944
// ② flowable 7.2.0 版本后,继续使用 moveActivityIdsToSingleActivityId 方法。原因:flowable 7.2.0 版本修复了该问题。
// 相关 issue:https://github.com/YunaiV/ruoyi-vue-pro/issues/1018
// ③ moveActivityIdsToSingleActivityId 使用遇到问题, 相关 issue https://gitee.com/zhijiantianya/yudao-cloud/issues/IJM8MS
// 改成 moveExecutionsToSingleActivityId 好像并没有遇到 ② 提到的超时提醒失效的问题。暂时先改回 moveExecutionsToSingleActivityId
// 目前还有的相关问题 https://t.zsxq.com/z4d9i。 估计需要升级 flowable 8 版本试试
runtimeService.createChangeActivityStateBuilder()
.processInstanceId(currentTask.getProcessInstanceId())
.moveActivityIdsToSingleActivityId(returnTaskKeyList, reqVO.getTargetTaskDefinitionKey())
.moveExecutionsToSingleActivityId(runExecutionIds, reqVO.getTargetTaskDefinitionKey())
// 设置需要预测的任务 ids 的流程变量,用于辅助预测
.processVariable(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_NEED_SIMULATE_TASK_IDS, needSimulateTaskDefinitionKeys)
// 设置流程变量(local)节点退回标记, 用于退回到节点,不执行 BpmUserTaskAssignStartUserHandlerTypeEnum 策略,导致自动通过
.localVariable(reqVO.getTargetTaskDefinitionKey(),
String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, reqVO.getTargetTaskDefinitionKey()), Boolean.TRUE)
// 设置流程变量节点退回标记, 用于退回到节点,不执行 BpmUserTaskAssignStartUserHandlerTypeEnum 策略,导致自动通过
.processVariable(String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, reqVO.getTargetTaskDefinitionKey()), Boolean.TRUE)
.changeState();
}
@@ -1464,101 +1480,105 @@ public class BpmTaskServiceImpl implements BpmTaskService {
return;
}
// 自动去重,通过自动审批的方式
BpmProcessDefinitionInfoDO processDefinitionInfo = bpmProcessDefinitionService.getProcessDefinitionInfo(task.getProcessDefinitionId());
if (processDefinitionInfo == null) {
log.error("[processTaskAssigned][taskId({}) 没有找到流程定义({})]", task.getId(), task.getProcessDefinitionId());
return;
}
if (processDefinitionInfo.getAutoApprovalType() != null) {
HistoricTaskInstanceQuery sameAssigneeQuery = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(task.getProcessInstanceId())
.taskAssignee(task.getAssignee()) // 相同审批人
.taskVariableValueEquals(BpmnVariableConstants.TASK_VARIABLE_STATUS, BpmTaskStatusEnum.APPROVE.getStatus())
.finished();
if (BpmAutoApproveTypeEnum.APPROVE_ALL.getType().equals(processDefinitionInfo.getAutoApprovalType())
&& sameAssigneeQuery.count() > 0) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmAutoApproveTypeEnum.APPROVE_ALL.getName()));
// 需要基于 instance 设置租户编号,避免 Flowable 内部异步执行时【例如:超时自动通过】 丢失租户编号
FlowableUtils.execute(processInstance.getTenantId(), () -> {
// 自动去重,通过自动审批的方式
BpmProcessDefinitionInfoDO processDefinitionInfo = bpmProcessDefinitionService.getProcessDefinitionInfo(task.getProcessDefinitionId());
if (processDefinitionInfo == null) {
log.error("[processTaskAssigned][taskId({}) 没有找到流程定义({})]", task.getId(), task.getProcessDefinitionId());
return;
}
if (BpmAutoApproveTypeEnum.APPROVE_SEQUENT.getType().equals(processDefinitionInfo.getAutoApprovalType())) {
BpmnModel bpmnModel = modelService.getBpmnModelByDefinitionId(processInstance.getProcessDefinitionId());
if (bpmnModel == null) {
log.error("[processTaskAssigned][taskId({}) 没有找到流程模型({})]", task.getId(), task.getProcessDefinitionId());
return;
}
List<String> sourceTaskIds = convertList(BpmnModelUtils.getElementIncomingFlows( // 获取所有上一个节点
BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey())),
SequenceFlow::getSourceRef);
if (sameAssigneeQuery.taskDefinitionKeys(sourceTaskIds).count() > 0) {
if (processDefinitionInfo.getAutoApprovalType() != null) {
HistoricTaskInstanceQuery approvedTaskQuery = historyService.createHistoricTaskInstanceQuery()
.processInstanceId(task.getProcessInstanceId())
.taskVariableValueEquals(BpmnVariableConstants.TASK_VARIABLE_STATUS, BpmTaskStatusEnum.APPROVE.getStatus())
.finished();
if (BpmAutoApproveTypeEnum.APPROVE_ALL.getType().equals(processDefinitionInfo.getAutoApprovalType())
&& approvedTaskQuery.taskAssignee(task.getAssignee()).count() > 0) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmAutoApproveTypeEnum.APPROVE_SEQUENT.getName()));
.setReason(BpmAutoApproveTypeEnum.APPROVE_ALL.getName()));
return;
}
}
}
// 获取发起人节点
BpmnModel bpmnModel = modelService.getBpmnModelByDefinitionId(processInstance.getProcessDefinitionId());
if (bpmnModel == null) {
log.error("[processTaskAssigned][taskId({}) 没有找到流程模型]", task.getId());
return;
}
FlowElement userTaskElement = BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey());
// 判断是否为退回或者驳回:如果是退回或者驳回不走这个策略(使用 local variable)
Boolean returnTaskFlag = runtimeService.getVariableLocal(task.getExecutionId(),
String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, task.getTaskDefinitionKey()), Boolean.class);
Boolean skipStartUserNodeFlag = Convert.toBool(runtimeService.getVariable(processInstance.getProcessInstanceId(),
BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_SKIP_START_USER_NODE, String.class));
if (userTaskElement.getId().equals(START_USER_NODE_ID)
&& (skipStartUserNodeFlag == null // 目的:一般是“主流程”,发起人节点,自动通过审核
|| BooleanUtil.isTrue(skipStartUserNodeFlag)) // 目的:一般是“子流程”,发起人节点,按配置自动通过审核
&& ObjUtil.notEqual(returnTaskFlag, Boolean.TRUE)) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP_START_USER_NODE.getReason()));
return;
}
// 当不为发起人节点时,审批人与提交人为同一人时,根据 BpmUserTaskAssignStartUserHandlerTypeEnum 策略进行处理
if (ObjectUtil.notEqual(userTaskElement.getId(), START_USER_NODE_ID)
&& StrUtil.equals(task.getAssignee(), processInstance.getStartUserId())) {
if (ObjUtil.notEqual(returnTaskFlag, Boolean.TRUE)) {
Integer assignStartUserHandlerType = BpmnModelUtils.parseAssignStartUserHandlerType(userTaskElement);
// 情况一:自动跳过
if (ObjectUtils.equalsAny(assignStartUserHandlerType,
BpmUserTaskAssignStartUserHandlerTypeEnum.SKIP.getType())) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP.getReason()));
return;
}
// 情况二:转交给部门负责人审批
if (ObjectUtils.equalsAny(assignStartUserHandlerType,
BpmUserTaskAssignStartUserHandlerTypeEnum.TRANSFER_DEPT_LEADER.getType())) {
AdminUserRespDTO startUser = adminUserApi.getUser(Long.valueOf(processInstance.getStartUserId()));
Assert.notNull(startUser, "提交人({})信息为空", processInstance.getStartUserId());
DeptRespDTO dept = startUser.getDeptId() != null ? deptApi.getDept(startUser.getDeptId()) : null;
Assert.notNull(dept, "提交人({})部门({})信息为空", processInstance.getStartUserId(), startUser.getDeptId());
// 找不到部门负责人的情况下,自动审批通过
// noinspection DataFlowIssue
if (dept.getLeaderUserId() == null) {
// 连续审批的节点自动通过
if (BpmAutoApproveTypeEnum.APPROVE_SEQUENT.getType().equals(processDefinitionInfo.getAutoApprovalType())) {
BpmnModel bpmnModel = modelService.getBpmnModelByDefinitionId(processInstance.getProcessDefinitionId());
if (bpmnModel == null) {
log.error("[processTaskAssigned][taskId({}) 没有找到流程模型({})]", task.getId(), task.getProcessDefinitionId());
return;
}
List<String> sourceTaskIds = convertList(BpmnModelUtils.getElementIncomingUserTaskFlows( // 获取所有的上一个 UserTask 节点连线
BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey())),
SequenceFlow::getSourceRef);
approvedTaskQuery.taskDefinitionKeys(sourceTaskIds).orderByTaskCreateTime().desc(); // 设置 taskIds, 并按创建时间倒序排序
HistoricTaskInstance firstHisTask = CollUtil.getFirst(approvedTaskQuery.list());
if (firstHisTask != null && StrUtil.equals(firstHisTask.getAssignee(), task.getAssignee())) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_DEPT_LEADER_NOT_FOUND.getReason()));
.setReason(BpmAutoApproveTypeEnum.APPROVE_SEQUENT.getName()));
return;
}
// 找得到部门负责人的情况下,修改负责人
if (ObjectUtil.notEqual(dept.getLeaderUserId(), startUser.getId())) {
getSelf().transferTask(Long.valueOf(task.getAssignee()), new BpmTaskTransferReqVO()
.setId(task.getId()).setAssigneeUserId(dept.getLeaderUserId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_TRANSFER_DEPT_LEADER.getReason()));
return;
}
// 如果部门负责人是自己,还是自己审批吧~
}
}
}
// 注意:需要基于 instance 设置租户编号,避免 Flowable 内部异步时,丢失租户编号
FlowableUtils.execute(processInstance.getTenantId(), () -> {
// 获取发起人节点
BpmnModel bpmnModel = modelService.getBpmnModelByDefinitionId(processInstance.getProcessDefinitionId());
if (bpmnModel == null) {
log.error("[processTaskAssigned][taskId({}) 没有找到流程模型]", task.getId());
return;
}
FlowElement userTaskElement = BpmnModelUtils.getFlowElementById(bpmnModel, task.getTaskDefinitionKey());
// 判断是否为退回或者驳回:如果是退回或者驳回不走这个策略
Boolean returnTaskFlag = runtimeService.getVariable(processInstance.getProcessInstanceId(),
String.format(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_RETURN_FLAG, task.getTaskDefinitionKey()), Boolean.class);
Boolean skipStartUserNodeFlag = Convert.toBool(runtimeService.getVariable(processInstance.getProcessInstanceId(),
BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_SKIP_START_USER_NODE, String.class));
if (userTaskElement.getId().equals(START_USER_NODE_ID)
&& (skipStartUserNodeFlag == null // 目的:一般是“主流程”,发起人节点,自动通过审核
|| BooleanUtil.isTrue(skipStartUserNodeFlag)) // 目的:一般是“子流程”,发起人节点,按配置自动通过审核
&& ObjUtil.notEqual(returnTaskFlag, Boolean.TRUE)) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP_START_USER_NODE.getReason()));
return;
}
// 当不为发起人节点时,审批人与提交人为同一人时,根据 BpmUserTaskAssignStartUserHandlerTypeEnum 策略进行处理
if (ObjectUtil.notEqual(userTaskElement.getId(), START_USER_NODE_ID)
&& StrUtil.equals(task.getAssignee(), processInstance.getStartUserId())) {
if (ObjUtil.notEqual(returnTaskFlag, Boolean.TRUE)) {
Integer assignStartUserHandlerType = BpmnModelUtils.parseAssignStartUserHandlerType(userTaskElement);
// 情况一:自动跳过
if (ObjectUtils.equalsAny(assignStartUserHandlerType,
BpmUserTaskAssignStartUserHandlerTypeEnum.SKIP.getType())) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_SKIP.getReason()));
return;
}
// 情况二:转交给部门负责人审批
if (ObjectUtils.equalsAny(assignStartUserHandlerType,
BpmUserTaskAssignStartUserHandlerTypeEnum.TRANSFER_DEPT_LEADER.getType())) {
AdminUserRespDTO startUser = adminUserApi.getUser(Long.valueOf(processInstance.getStartUserId()));
Assert.notNull(startUser, "提交人({})信息为空", processInstance.getStartUserId());
DeptRespDTO dept = startUser.getDeptId() != null ? deptApi.getDept(startUser.getDeptId()) : null;
Assert.notNull(dept, "提交人({})部门({})信息为空", processInstance.getStartUserId(), startUser.getDeptId());
// 找不到部门负责人的情况下,自动审批通过
// noinspection DataFlowIssue
if (dept.getLeaderUserId() == null) {
getSelf().approveTask(Long.valueOf(task.getAssignee()), new BpmTaskApproveReqVO().setId(task.getId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_APPROVE_WHEN_DEPT_LEADER_NOT_FOUND.getReason()));
return;
}
// 找得到部门负责人的情况下,修改负责人
if (ObjectUtil.notEqual(dept.getLeaderUserId(), startUser.getId())) {
getSelf().transferTask(Long.valueOf(task.getAssignee()), new BpmTaskTransferReqVO()
.setId(task.getId()).setAssigneeUserId(dept.getLeaderUserId())
.setReason(BpmReasonEnum.ASSIGN_START_USER_TRANSFER_DEPT_LEADER.getReason()));
return;
}
// 如果部门负责人是自己,还是自己审批吧~
}
}
}
// 发送消息
AdminUserRespDTO startUser = adminUserApi.getUser(Long.valueOf(processInstance.getStartUserId()));
messageService.sendMessageWhenTaskAssigned(BpmTaskConvert.INSTANCE.convert(processInstance, startUser, task));
});
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.crm.dal.mysql.customer;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.crm.controller.admin.customer.vo.limitconfig.CrmCustomerLimitConfigPageReqVO;
import cn.iocoder.yudao.module.crm.dal.dataobject.customer.CrmCustomerLimitConfigDO;
import org.apache.ibatis.annotations.Mapper;
@@ -28,9 +29,9 @@ public interface CrmCustomerLimitConfigMapper extends BaseMapperX<CrmCustomerLim
LambdaQueryWrapperX<CrmCustomerLimitConfigDO> query = new LambdaQueryWrapperX<CrmCustomerLimitConfigDO>()
.eq(CrmCustomerLimitConfigDO::getType, type);
query.and(w -> {
w.apply("FIND_IN_SET({0}, user_ids) > 0", userId);
w.apply(MyBatisUtils.findInSet("user_ids"), userId);
if (deptId != null) {
w.or().apply("FIND_IN_SET({0}, dept_ids) > 0", deptId);
w.or().apply(MyBatisUtils.findInSet("dept_ids"), deptId);
}
});
return selectList(query);
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.dal.mysql.message;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessagePageReqVO;
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
import org.apache.ibatis.annotations.Mapper;
@@ -32,7 +33,7 @@ public interface ImChannelMessageMapper extends BaseMapperX<ImChannelMessageDO>
.gt(ImChannelMessageDO::getId, minId)
.and(w -> w.isNull(ImChannelMessageDO::getReceiverUserIds)
.or().eq(ImChannelMessageDO::getReceiverUserIds, "")
.or().apply("FIND_IN_SET({0}, receiver_user_ids)", userId))
.or().apply(MyBatisUtils.findInSet("receiver_user_ids"), userId))
.orderByAsc(ImChannelMessageDO::getId)
.last("LIMIT " + size));
}
@@ -1,10 +1,11 @@
package cn.iocoder.yudao.module.infra.controller.admin.file;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.io.IoUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.core.util.URLUtil;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.*;
@@ -15,6 +16,11 @@ import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Parameters;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.annotation.Resource;
import jakarta.annotation.security.PermitAll;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -22,12 +28,6 @@ import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import javax.annotation.Resource;
import javax.annotation.security.PermitAll;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
import java.nio.charset.StandardCharsets;
import java.util.List;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
@@ -111,10 +111,10 @@ public class FileController {
if (StrUtil.isEmpty(path)) {
throw new IllegalArgumentException("结尾的 path 路径必须传递");
}
// 解码,解决中文路径的问题
// 解码,解决中文、%、+ 等特殊字符路径的问题
// https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/807/
// https://gitee.com/zhijiantianya/ruoyi-vue-pro/pulls/1432/
path = URLUtil.decode(path, StandardCharsets.UTF_8, false);
path = HttpUtils.decodeUrlPath(path);
// 读取内容
byte[] content = fileService.getFileContent(configId, path);
@@ -123,7 +123,9 @@ public class FileController {
response.setStatus(HttpStatus.NOT_FOUND.value());
return;
}
writeAttachment(response, path, content);
FileDO file = fileService.getFileByConfigIdAndPath(configId, path);
String filename = file != null && StrUtil.isNotEmpty(file.getName()) ? file.getName() : FileUtil.getName(path);
writeAttachment(response, filename, content);
}
@GetMapping("/page")
@@ -23,4 +23,11 @@ public interface FileMapper extends BaseMapperX<FileDO> {
.orderByDesc(FileDO::getId));
}
default FileDO selectLatestByConfigIdAndPath(Long configId, String path) {
return selectLastOne(new LambdaQueryWrapperX<FileDO>()
.eq(FileDO::getConfigId, configId)
.eq(FileDO::getPath, path)
.orderByAsc(FileDO::getId));
}
}
@@ -1,6 +1,7 @@
package cn.iocoder.yudao.module.infra.framework.file.core.client;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.util.http.HttpUtils;
import lombok.extern.slf4j.Slf4j;
/**
@@ -72,7 +73,7 @@ public abstract class AbstractFileClient<Config extends FileClientConfig> implem
* @return URL 访问地址
*/
protected String formatFileUrl(String domain, String path) {
return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), path);
return StrUtil.format("{}/admin-api/infra/file/{}/get/{}", domain, getId(), HttpUtils.encodeUrlPath(path));
}
}
@@ -116,13 +116,18 @@ public class S3FileClient extends AbstractFileClient<S3FileClientConfig> {
@Override
public String presignGetUrl(String url, Integer expirationSeconds) {
// 1. 将 url 转换为 path
String path = StrUtil.removePrefix(url, config.getDomain() + "/");
path = HttpUtils.decodeUrlPath(HttpUtils.removeUrlQuery(path));
// 完整 Domain URL 会解码还原为原始对象名;裸 path 约定已经是未编码的对象名
boolean domainUrl = StrUtil.startWith(url, config.getDomain() + "/");
String path = domainUrl ? StrUtil.removePrefix(url, config.getDomain() + "/") : url;
if (domainUrl) {
path = HttpUtils.removeUrlPathQueryAndFragment(path);
path = HttpUtils.decodeUrlPath(path);
}
// 2.1 情况一:公开访问:无需签名
// 考虑到老版本的兼容,所以必须是 config.getEnablePublicAccess() 为 false 时,才进行签名
if (!BooleanUtil.isFalse(config.getEnablePublicAccess())) {
return config.getDomain() + "/" + path;
return config.getDomain() + "/" + HttpUtils.encodeUrlPath(path);
}
// 2.2 情况二:私有访问:生成 GET 预签名 URL
@@ -85,9 +85,9 @@ public class FileTypeUtils {
// 设置内容显示、下载文件名:https://www.cnblogs.com/wq-9/articles/12165056.html
if (isImage(mineType)) {
// 参见 https://github.com/YunaiV/ruoyi-vue-pro/issues/692 讨论
response.setHeader("Content-Disposition", "inline;filename=" + HttpUtils.encodeUtf8(filename));
response.setHeader("Content-Disposition", buildContentDisposition("inline", filename));
} else {
response.setHeader("Content-Disposition", "attachment;filename=" + HttpUtils.encodeUtf8(filename));
response.setHeader("Content-Disposition", buildContentDisposition("attachment", filename));
}
// 针对 video 的特殊处理,解决视频地址在移动端播放的兼容性问题
if (StrUtil.containsIgnoreCase(mineType, "video")) {
@@ -98,6 +98,29 @@ public class FileTypeUtils {
IoUtil.write(response.getOutputStream(), false, content);
}
private static String buildContentDisposition(String dispositionType, String filename) {
return StrUtil.format("{};filename=\"{}\";filename*=UTF-8''{}",
dispositionType, buildFallbackFilename(filename), HttpUtils.encodeUrlPathSegment(filename));
}
private static String buildFallbackFilename(String filename) {
if (StrUtil.isEmpty(filename)) {
return "download";
}
StringBuilder result = new StringBuilder(filename.length());
for (int i = 0; i < filename.length(); i++) {
char ch = filename.charAt(i);
if (ch == '"' || ch == '\\') {
result.append('\\').append(ch);
} else if (ch >= 0x20 && ch <= 0x7E) {
result.append(ch);
} else {
result.append('_');
}
}
return result.toString();
}
/**
* 判断是否是图片
*
@@ -86,4 +86,13 @@ public interface FileService {
*/
byte[] getFileContent(Long configId, String path) throws Exception;
/**
* 获得文件
*
* @param configId 配置编号
* @param path 文件路径
* @return 文件
*/
FileDO getFileByConfigIdAndPath(Long configId, String path);
}
@@ -238,4 +238,9 @@ public class FileServiceImpl implements FileService {
return client.getContent(path);
}
@Override
public FileDO getFileByConfigIdAndPath(Long configId, String path) {
return fileMapper.selectLatestByConfigIdAndPath(configId, path);
}
}
@@ -177,7 +177,7 @@ const emit = defineEmits<{
reset: []
}>()
const visible = ref(false)
const visible = ref(false) // 搜索弹窗显示状态
const formData = reactive({
#foreach($column in $columns)
#if ($column.listOperation)
@@ -201,7 +201,7 @@ const formData = reactive({
#end
#end
#end
})
}) // 搜索表单数据
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
@@ -237,21 +237,20 @@ const placeholder = computed(() => {
})
#if ($hasDateTimeBetween == 1)
// 时间范围选择器状态
#foreach($column in $columns)
#if ($column.listOperation && $column.htmlType == "datetime" && $column.listOperationCondition == "BETWEEN")
#set ($javaField = $column.javaField)
#set ($AttrName = $javaField.substring(0,1).toUpperCase() + ${javaField.substring(1)})
const visible${AttrName} = ref<[boolean, boolean]>([false, false])
const temp${AttrName} = ref<[number, number]>([Date.now(), Date.now()])
const visible${AttrName} = ref<[boolean, boolean]>([false, false]) // ${column.columnComment}选择器状态
const temp${AttrName} = ref<[number, number]>([Date.now(), Date.now()]) // ${column.columnComment}临时值
/** ${column.columnComment}[0]确认 */
/** 确认${column.columnComment}开始日期 */
function handle${AttrName}0Confirm() {
formData.${javaField} = [temp${AttrName}.value[0], formData.${javaField}?.[1]]
visible${AttrName}.value[0] = false
}
/** ${column.columnComment}[1]确认 */
/** 确认${column.columnComment}结束日期 */
function handle${AttrName}1Confirm() {
formData.${javaField} = [formData.${javaField}?.[0], temp${AttrName}.value[1]]
visible${AttrName}.value[1] = false
@@ -260,7 +259,7 @@ function handle${AttrName}1Confirm() {
#end
#end
/** 搜索 */
/** 搜索按钮操作 */
function handleSearch() {
visible.value = false
emit('search', {
@@ -277,7 +276,7 @@ function handleSearch() {
})
}
/** 重置 */
/** 重置按钮操作 */
function handleReset() {
#foreach($column in $columns)
#if ($column.listOperation)
@@ -62,6 +62,7 @@
#end
#end
import type { ${simpleClassName} } from '@/api/${table.moduleName}/${table.businessName}'
import { onUnload } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { delete${simpleClassName}, get${simpleClassName} } from '@/api/${table.moduleName}/${table.businessName}'
@@ -87,8 +88,8 @@ definePage({
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<${simpleClassName}>()
const deleting = ref(false)
const formData = ref<${simpleClassName}>() // 详情数据
const deleting = ref(false) // 删除状态
/** 返回上一页 */
function handleBack() {
@@ -97,7 +98,7 @@ function handleBack() {
/** 加载${table.classComment}详情 */
async function getDetail() {
if (!props.id) {
if (!props.id || deleting.value) {
return
}
try {
@@ -131,6 +132,7 @@ function handleDelete() {
try {
await delete${simpleClassName}(props.id)
toast.success('删除成功')
uni.$emit('${table.moduleName}:${table.businessName}:reload')
setTimeout(() => {
handleBack()
}, 500)
@@ -143,8 +145,14 @@ function handleDelete() {
/** 初始化 */
onMounted(() => {
uni.$on('${table.moduleName}:${table.businessName}:reload', getDetail)
getDetail()
})
/** 卸载 */
onUnload(() => {
uni.$off('${table.moduleName}:${table.businessName}:reload', getDetail)
})
</script>
<style lang="scss" scoped>
@@ -175,7 +175,7 @@ definePage({
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑${table.classComment}' : '新增${table.classComment}')
const formLoading = ref(false)
const formLoading = ref(false) // 表单提交状态
const formData = ref<${simpleClassName}>({
#foreach($column in $columns)
#if (($column.createOperation || $column.updateOperation) || $column.primaryKey)
@@ -195,7 +195,7 @@ const formData = ref<${simpleClassName}>({
#end
#end
#end
})
}) // 表单数据
const formRules = {
#foreach($column in $columns)
#set ($javaFieldLower = $column.javaField.toLowerCase())
@@ -204,8 +204,8 @@ const formRules = {
${column.javaField}: [{ required: true, message: '${column.columnComment}不能为空' }],
#end
#end
}
const formRef = ref<FormInstance>()
} // 表单校验规则
const formRef = ref<FormInstance>() // 表单组件引用
/** 返回上一页 */
function handleBack() {
@@ -236,6 +236,7 @@ async function handleSubmit() {
await create${simpleClassName}(formData.value)
toast.success('新增成功')
}
uni.$emit('${table.moduleName}:${table.businessName}:reload')
setTimeout(() => {
handleBack()
}, 500)
@@ -1,5 +1,5 @@
<template>
<view class="yd-page-container">
<view class="yd-page-container yd-page-container-paging">
<!-- 顶部导航栏 -->
<wd-navbar
title="${table.classComment}管理"
@@ -11,14 +11,26 @@
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- ${table.classComment}列表 -->
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.${primaryColumn.javaField}"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<z-paging
ref="pagingRef"
v-model="list"
:fixed="false"
class="min-h-0 flex-1"
:default-page-size="10"
:refresher-enabled="true"
:inside-more="true"
:loading-more-default-as-loading="true"
empty-view-text="暂无${table.classComment}数据"
@query="queryList"
>
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.${primaryColumn.javaField}"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
#set ($titleField = "")
#set ($statusField = "")
#set ($statusDictType = "")
@@ -35,14 +47,14 @@
#if ($titleField == "")
#set ($titleField = $primaryColumn.javaField)
#end
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.${titleField} }}
</view>
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.${titleField} }}
</view>
#if($statusField != "")
<dict-tag :type="DICT_TYPE.${statusDictType.toUpperCase()}" :value="item.${statusField}" />
<dict-tag :type="DICT_TYPE.${statusDictType.toUpperCase()}" :value="item.${statusField}" />
#end
</view>
</view>
#foreach($column in $columns)
#if ($column.listOperationResult && !$column.primaryKey && $column.javaField != $titleField && $column.javaField != $statusField)
#set ($javaField = $column.javaField)
@@ -50,36 +62,27 @@
#set ($dictType = $column.dictType)
#set ($javaType = $column.javaType)
#if ($dictType && "" != $dictType)
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">${comment}:</text>
<dict-tag :type="DICT_TYPE.${dictType.toUpperCase()}" :value="item.${javaField}" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">${comment}:</text>
<dict-tag :type="DICT_TYPE.${dictType.toUpperCase()}" :value="item.${javaField}" />
</view>
#elseif ($javaType == "LocalDateTime")
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">${comment}:</text>
<text class="line-clamp-1">{{ formatDateTime(item.${javaField}) || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">${comment}:</text>
<text class="line-clamp-1">{{ formatDateTime(item.${javaField}) || '-' }}</text>
</view>
#else
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">${comment}:</text>
<text class="line-clamp-1">{{ item.${javaField} }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">${comment}:</text>
<text class="line-clamp-1">{{ item.${javaField} }}</text>
</view>
#end
#end
#end
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="loadMoreState !== 'loading' && list.length === 0" class="py-100rpx text-center">
<wd-status-tip image="content" tip="暂无${table.classComment}数据" />
</view>
<wd-loadmore
v-if="list.length > 0"
:state="loadMoreState"
@reload="loadMore"
/>
</view>
</z-paging>
<!-- 新增按钮 -->
<wd-fab
@@ -108,8 +111,7 @@
#end
#end
import type { ${simpleClassName} } from '@/api/${table.moduleName}/${table.businessName}'
import type { LoadMoreState } from '@/http/types'
import { onReachBottom } from '@dcloudio/uni-app'
import { onUnload } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { get${simpleClassName}Page } from '@/api/${table.moduleName}/${table.businessName}'
import { useAccess } from '@/hooks/useAccess'
@@ -130,13 +132,9 @@ definePage({
})
const { hasAccessByCodes } = useAccess()
const total = ref(0)
const list = ref<${simpleClassName}[]>([])
const loadMoreState = ref<LoadMoreState>('loading')
const queryParams = ref({
pageNo: 1,
pageSize: 10,
})
const list = ref<${simpleClassName}[]>([]) // 列表数据
const pagingRef = ref<any>() // 分页组件引用
const queryParams = ref<Record<string, any>>({}) // 查询参数
/** 返回上一页 */
function handleBack() {
@@ -144,28 +142,24 @@ function handleBack() {
}
/** 查询${table.classComment}列表 */
async function getList() {
loadMoreState.value = 'loading'
async function queryList(pageNo: number, pageSize: number) {
try {
const data = await get${simpleClassName}Page(queryParams.value)
list.value = [...list.value, ...data.list]
total.value = data.total
loadMoreState.value = list.value.length >= total.value ? 'finished' : 'loading'
const params = {
...queryParams.value,
pageNo,
pageSize,
}
const data = await get${simpleClassName}Page(params)
pagingRef.value?.completeByTotal(data.list, data.total)
} catch {
queryParams.value.pageNo = queryParams.value.pageNo > 1 ? queryParams.value.pageNo - 1 : 1
loadMoreState.value = 'error'
pagingRef.value?.complete(false)
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = {
...data,
pageNo: 1,
pageSize: queryParams.value.pageSize,
}
list.value = []
getList()
queryParams.value = { ...data }
reload()
}
/** 重置按钮操作 */
@@ -173,13 +167,9 @@ function handleReset() {
handleQuery()
}
/** 加载更多 */
function loadMore() {
if (loadMoreState.value === 'finished') {
return
}
queryParams.value.pageNo++
getList()
/** 重新加载 */
function reload() {
pagingRef.value?.reload()
}
/** 新增${table.classComment} */
@@ -196,14 +186,14 @@ function handleDetail(item: ${simpleClassName}) {
})
}
/** 触底加载更多 */
onReachBottom(() => {
loadMore()
})
/** 初始化 */
onMounted(() => {
getList()
uni.$on('${table.moduleName}:${table.businessName}:reload', reload)
})
/** 卸载 */
onUnload(() => {
uni.$off('${table.moduleName}:${table.businessName}:reload', reload)
})
</script>
@@ -41,6 +41,21 @@ public class LocalFileClientTest {
assertFalse(FileUtil.exist(new File(tempDir, path)));
}
@Test
public void testUpload_encodeUrlPath() {
// 准备参数
LocalFileClient client = createClient();
byte[] content = "test".getBytes(StandardCharsets.UTF_8);
String path = "avatar/中文 100%+文件.txt";
// 调用
String url = client.upload(content, path, "text/plain");
// 断言
assertEquals("http://127.0.0.1:48080/admin-api/infra/file/0/get/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.txt", url);
assertArrayEquals(content, FileUtil.readBytes(new File(tempDir, path)));
}
@Test
public void testUpload_pathInvalid() {
// 准备参数
@@ -5,14 +5,70 @@ import cn.hutool.core.util.IdUtil;
import cn.iocoder.yudao.framework.common.util.validation.ValidationUtils;
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClient;
import cn.iocoder.yudao.module.infra.framework.file.core.client.s3.S3FileClientConfig;
import jakarta.validation.Validation;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import javax.validation.Validation;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
@SuppressWarnings("resource")
public class S3FileClientTest {
@Test
public void testPresignGetUrl_publicAccess_encodeUrlPath() {
// 准备参数
S3FileClientConfig config = new S3FileClientConfig();
config.setDomain("https://static.iocoder.cn");
config.setEnablePublicAccess(true);
S3FileClient client = new S3FileClient(0L, config);
// 调用
String result = client.presignGetUrl("avatar/中文 100%+文件.jpg", 300);
// 断言
assertEquals("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result);
}
@Test
public void testPresignGetUrl_publicAccess_decodeDomainUrl() {
// 准备参数
S3FileClientConfig config = new S3FileClientConfig();
config.setDomain("https://static.iocoder.cn");
config.setEnablePublicAccess(true);
S3FileClient client = new S3FileClient(0L, config);
// 调用
String result = client.presignGetUrl("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg?token=1", 300);
// 断言
assertEquals("https://static.iocoder.cn/avatar/%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.jpg", result);
}
@Test
public void testPresignGetUrl_privateAccess_rawPath() {
// 准备参数
S3FileClientConfig config = new S3FileClientConfig();
config.setAccessKey("admin");
config.setAccessSecret("password");
config.setBucket("yudao");
config.setDomain("http://127.0.0.1:9000/yudao");
config.setEndpoint("http://127.0.0.1:9000");
config.setEnablePathStyleAccess(true);
config.setEnablePublicAccess(false);
S3FileClient client = new S3FileClient(0L, config);
client.init();
// 调用
String result = client.presignGetUrl("avatar/中文 100%+文件.jpg", 300);
// 断言
assertTrue(result.contains("/yudao/avatar/%E4%B8%AD%E6%96%87%20100%25"));
assertTrue(result.contains("%E6%96%87%E4%BB%B6.jpg"));
assertFalse(result.contains("%25E4%25B8%25AD"));
}
@Test
@Disabled // MinIO,如果要集成测试,可以注释本行
public void testMinIO() throws Exception {
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.infra.framework.file.core.utils;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletResponse;
import java.nio.charset.StandardCharsets;
import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link FileTypeUtils} 的单元测试
*/
public class FileTypeUtilsTest {
@Test
public void testWriteAttachment_contentDispositionEncodeFilename() throws Exception {
// 准备参数
MockHttpServletResponse response = new MockHttpServletResponse();
byte[] content = "test".getBytes(StandardCharsets.UTF_8);
// 调用
FileTypeUtils.writeAttachment(response, "中文 100%+文件.txt", content);
// 断言
assertEquals("attachment;filename=\"__ 100%+__.txt\";"
+ "filename*=UTF-8''%E4%B8%AD%E6%96%87%20100%25+%E6%96%87%E4%BB%B6.txt",
response.getHeader("Content-Disposition"));
assertArrayEquals(content, response.getContentAsByteArray());
}
}
@@ -0,0 +1,34 @@
package cn.iocoder.yudao.module.infra.service.codegen.inner;
import cn.iocoder.yudao.module.infra.dal.dataobject.codegen.CodegenColumnDO;
import cn.iocoder.yudao.module.infra.dal.dataobject.codegen.CodegenTableDO;
import cn.iocoder.yudao.module.infra.enums.codegen.CodegenFrontTypeEnum;
import cn.iocoder.yudao.module.infra.enums.codegen.CodegenTemplateTypeEnum;
import com.baomidou.mybatisplus.annotation.DbType;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
/**
* {@link CodegenEngine} 的 Vue3 Admin Uniapp + Wot UI 单元测试
*
* @author 芋道源码
*/
public class CodegenEngineUniappTest extends CodegenEngineAbstractTest {
@Test
public void testExecute_one() {
// 准备参数
CodegenTableDO table = getTable("student")
.setFrontType(CodegenFrontTypeEnum.VUE3_ADMIN_UNIAPP_WOT.getType())
.setTemplateType(CodegenTemplateTypeEnum.ONE.getType());
List<CodegenColumnDO> columns = getColumnList("student");
// 调用
Map<String, String> result = codegenEngine.execute(DbType.MYSQL, table, columns, null, null);
// 断言
assertResult(result, "/vue3_admin_uniapp_one");
}
}
@@ -211,6 +211,23 @@ public class FileServiceImplTest extends BaseDbUnitTest {
assertServiceException(() -> fileService.getFileContent(configId, path), FILE_PATH_INVALID);
}
@Test
public void testGetFileByConfigIdAndPath() {
// mock 数据
FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("avatar/中文 100%+文件.jpg"));
fileMapper.insert(dbFile);
FileDO latestFile = ObjectUtils.cloneIgnoreId(dbFile, o -> o.setName("最新文件名.jpg"));
fileMapper.insert(latestFile);
fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setPath("avatar/other.jpg")));
fileMapper.insert(ObjectUtils.cloneIgnoreId(dbFile, o -> o.setConfigId(20L)));
// 调用
FileDO result = fileService.getFileByConfigIdAndPath(10L, "avatar/中文 100%+文件.jpg");
// 断言
AssertUtils.assertPojoEquals(latestFile, result);
}
@Test
public void testCreateFileByPresignedPath_success() {
// 准备参数
@@ -0,0 +1,55 @@
[ {
"contentPath" : "java/InfraStudentPageReqVO",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/demo/vo/InfraStudentPageReqVO.java"
}, {
"contentPath" : "java/InfraStudentRespVO",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/demo/vo/InfraStudentRespVO.java"
}, {
"contentPath" : "java/InfraStudentSaveReqVO",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/demo/vo/InfraStudentSaveReqVO.java"
}, {
"contentPath" : "java/InfraStudentController",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/controller/admin/demo/InfraStudentController.java"
}, {
"contentPath" : "java/InfraStudentDO",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/dataobject/demo/InfraStudentDO.java"
}, {
"contentPath" : "java/InfraStudentMapper",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/dal/mysql/demo/InfraStudentMapper.java"
}, {
"contentPath" : "xml/InfraStudentMapper",
"filePath" : "yudao-module-infra/src/main/resources/mapper/demo/InfraStudentMapper.xml"
}, {
"contentPath" : "java/InfraStudentServiceImpl",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/demo/InfraStudentServiceImpl.java"
}, {
"contentPath" : "java/InfraStudentService",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/service/demo/InfraStudentService.java"
}, {
"contentPath" : "java/InfraStudentServiceImplTest",
"filePath" : "yudao-module-infra/src/test/java/cn/iocoder/yudao/module/infra/service/demo/InfraStudentServiceImplTest.java"
}, {
"contentPath" : "java/ErrorCodeConstants_手动操作",
"filePath" : "yudao-module-infra/src/main/java/cn/iocoder/yudao/module/infra/enums/ErrorCodeConstants_手动操作.java"
}, {
"contentPath" : "sql/sql",
"filePath" : "sql/sql.sql"
}, {
"contentPath" : "sql/h2",
"filePath" : "sql/h2.sql"
}, {
"contentPath" : "ts/index",
"filePath" : "yudao-ui-admin-uniapp/src/api/infra/demo/index.ts"
}, {
"contentPath" : "vue/index",
"filePath" : "yudao-ui-admin-uniapp/src/pages-infra/demo/index.vue"
}, {
"contentPath" : "vue/search-form",
"filePath" : "yudao-ui-admin-uniapp/src/pages-infra/demo/components/search-form.vue"
}, {
"contentPath" : "vue/form/index",
"filePath" : "yudao-ui-admin-uniapp/src/pages-infra/demo/form/index.vue"
}, {
"contentPath" : "vue/detail/index",
"filePath" : "yudao-ui-admin-uniapp/src/pages-infra/demo/detail/index.vue"
} ]
@@ -0,0 +1,3 @@
// TODO 待办:请将下面的错误码复制到 yudao-module-infra 模块的 ErrorCodeConstants 类中。注意,请给“TODO 补充编号”设置一个错误码编号!!!
// ========== 学生 TODO 补充编号 ==========
ErrorCode STUDENT_NOT_EXISTS = new ErrorCode(TODO 补充编号, "学生不存在");
@@ -0,0 +1,104 @@
package cn.iocoder.yudao.module.infra.controller.admin.demo;
import org.springframework.web.bind.annotation.*;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.security.access.prepost.PreAuthorize;
import io.swagger.v3.oas.annotations.tags.Tag;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.constraints.*;
import jakarta.validation.*;
import jakarta.servlet.http.*;
import java.util.*;
import java.io.IOException;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import static cn.iocoder.yudao.framework.common.pojo.CommonResult.success;
import cn.iocoder.yudao.framework.excel.core.util.ExcelUtils;
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
import static cn.iocoder.yudao.framework.apilog.core.enums.OperateTypeEnum.*;
import cn.iocoder.yudao.module.infra.controller.admin.demo.vo.*;
import cn.iocoder.yudao.module.infra.dal.dataobject.demo.InfraStudentDO;
import cn.iocoder.yudao.module.infra.service.demo.InfraStudentService;
@Tag(name = "管理后台 - 学生")
@RestController
@RequestMapping("/infra/student")
@Validated
public class InfraStudentController {
@Resource
private InfraStudentService studentService;
@PostMapping("/create")
@Operation(summary = "创建学生")
@PreAuthorize("@ss.hasPermission('infra:student:create')")
public CommonResult<Long> createStudent(@Valid @RequestBody InfraStudentSaveReqVO createReqVO) {
return success(studentService.createStudent(createReqVO));
}
@PutMapping("/update")
@Operation(summary = "更新学生")
@PreAuthorize("@ss.hasPermission('infra:student:update')")
public CommonResult<Boolean> updateStudent(@Valid @RequestBody InfraStudentSaveReqVO updateReqVO) {
studentService.updateStudent(updateReqVO);
return success(true);
}
@DeleteMapping("/delete")
@Operation(summary = "删除学生")
@Parameter(name = "id", description = "编号", required = true)
@PreAuthorize("@ss.hasPermission('infra:student:delete')")
public CommonResult<Boolean> deleteStudent(@RequestParam("id") Long id) {
studentService.deleteStudent(id);
return success(true);
}
@DeleteMapping("/delete-list")
@Parameter(name = "ids", description = "编号", required = true)
@Operation(summary = "批量删除学生")
@PreAuthorize("@ss.hasPermission('infra:student:delete')")
public CommonResult<Boolean> deleteStudentList(@RequestParam("ids") List<Long> ids) {
studentService.deleteStudentListByIds(ids);
return success(true);
}
@GetMapping("/get")
@Operation(summary = "获得学生")
@Parameter(name = "id", description = "编号", required = true, example = "1024")
@PreAuthorize("@ss.hasPermission('infra:student:query')")
public CommonResult<InfraStudentRespVO> getStudent(@RequestParam("id") Long id) {
InfraStudentDO student = studentService.getStudent(id);
return success(BeanUtils.toBean(student, InfraStudentRespVO.class));
}
@GetMapping("/page")
@Operation(summary = "获得学生分页")
@PreAuthorize("@ss.hasPermission('infra:student:query')")
public CommonResult<PageResult<InfraStudentRespVO>> getStudentPage(@Valid InfraStudentPageReqVO pageReqVO) {
PageResult<InfraStudentDO> pageResult = studentService.getStudentPage(pageReqVO);
return success(BeanUtils.toBean(pageResult, InfraStudentRespVO.class));
}
@GetMapping("/export-excel")
@Operation(summary = "导出学生 Excel")
@PreAuthorize("@ss.hasPermission('infra:student:export')")
@ApiAccessLog(operateType = EXPORT)
public void exportStudentExcel(@Valid InfraStudentPageReqVO pageReqVO,
HttpServletResponse response) throws IOException {
pageReqVO.setPageSize(PageParam.PAGE_SIZE_NONE);
List<InfraStudentDO> list = studentService.getStudentPage(pageReqVO).getList();
// 导出 Excel
ExcelUtils.write(response, "学生.xls", "数据", InfraStudentRespVO.class,
BeanUtils.toBean(list, InfraStudentRespVO.class));
}
}
@@ -0,0 +1,68 @@
package cn.iocoder.yudao.module.infra.dal.dataobject.demo;
import lombok.*;
import java.util.*;
import java.time.LocalDateTime;
import java.time.LocalDateTime;
import com.baomidou.mybatisplus.annotation.*;
import cn.iocoder.yudao.framework.mybatis.core.dataobject.BaseDO;
/**
* 学生 DO
*
* @author 芋道源码
*/
@TableName("infra_student")
@KeySequence("infra_student_seq") // 用于 Oracle、PostgreSQL、Kingbase、DB2、H2 数据库的主键自增。如果是 MySQL 等数据库,可不写。
@Data
@EqualsAndHashCode(callSuper = true)
@ToString(callSuper = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class InfraStudentDO extends BaseDO {
/**
* 编号
*/
@TableId
private Long id;
/**
* 名字
*/
private String name;
/**
* 简介
*/
private String description;
/**
* 出生日期
*/
private LocalDateTime birthday;
/**
* 性别
*
* 枚举 {@link TODO system_user_sex 对应的类}
*/
private Integer sex;
/**
* 是否有效
*
* 枚举 {@link TODO infra_boolean_string 对应的类}
*/
private Boolean enabled;
/**
* 头像
*/
private String avatar;
/**
* 附件
*/
private String video;
/**
* 备注
*/
private String memo;
}
@@ -0,0 +1,30 @@
package cn.iocoder.yudao.module.infra.dal.mysql.demo;
import java.util.*;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.module.infra.dal.dataobject.demo.InfraStudentDO;
import org.apache.ibatis.annotations.Mapper;
import cn.iocoder.yudao.module.infra.controller.admin.demo.vo.*;
/**
* 学生 Mapper
*
* @author 芋道源码
*/
@Mapper
public interface InfraStudentMapper extends BaseMapperX<InfraStudentDO> {
default PageResult<InfraStudentDO> selectPage(InfraStudentPageReqVO reqVO) {
return selectPage(reqVO, new LambdaQueryWrapperX<InfraStudentDO>()
.likeIfPresent(InfraStudentDO::getName, reqVO.getName())
.eqIfPresent(InfraStudentDO::getBirthday, reqVO.getBirthday())
.eqIfPresent(InfraStudentDO::getSex, reqVO.getSex())
.eqIfPresent(InfraStudentDO::getEnabled, reqVO.getEnabled())
.betweenIfPresent(InfraStudentDO::getCreateTime, reqVO.getCreateTime())
.orderByDesc(InfraStudentDO::getId));
}
}
@@ -0,0 +1,32 @@
package cn.iocoder.yudao.module.infra.controller.admin.demo.vo;
import lombok.*;
import java.util.*;
import io.swagger.v3.oas.annotations.media.Schema;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND;
@Schema(description = "管理后台 - 学生分页 Request VO")
@Data
public class InfraStudentPageReqVO extends PageParam {
@Schema(description = "名字", example = "芋头")
private String name;
@Schema(description = "出生日期")
private LocalDateTime birthday;
@Schema(description = "性别", example = "1")
private Integer sex;
@Schema(description = "是否有效", example = "true")
private Boolean enabled;
@Schema(description = "创建时间")
@DateTimeFormat(pattern = FORMAT_YEAR_MONTH_DAY_HOUR_MINUTE_SECOND)
private LocalDateTime[] createTime;
}
@@ -0,0 +1,59 @@
package cn.iocoder.yudao.module.infra.controller.admin.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import java.util.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
import cn.idev.excel.annotation.*;
import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat;
import cn.iocoder.yudao.framework.excel.core.convert.DictConvert;
@Schema(description = "管理后台 - 学生 Response VO")
@Data
@ExcelIgnoreUnannotated
public class InfraStudentRespVO {
@Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
@ExcelProperty("编号")
private Long id;
@Schema(description = "名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋头")
@ExcelProperty("名字")
private String name;
@Schema(description = "简介", requiredMode = Schema.RequiredMode.REQUIRED, example = "我是介绍")
@ExcelProperty("简介")
private String description;
@Schema(description = "出生日期", requiredMode = Schema.RequiredMode.REQUIRED)
@ExcelProperty("出生日期")
private LocalDateTime birthday;
@Schema(description = "性别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@ExcelProperty(value = "性别", converter = DictConvert.class)
@DictFormat("system_user_sex") // TODO 代码优化:建议设置到对应的 DictTypeConstants 枚举类中
private Integer sex;
@Schema(description = "是否有效", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
@ExcelProperty(value = "是否有效", converter = DictConvert.class)
@DictFormat("infra_boolean_string") // TODO 代码优化:建议设置到对应的 DictTypeConstants 枚举类中
private Boolean enabled;
@Schema(description = "头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/1.png")
@ExcelProperty("头像")
private String avatar;
@Schema(description = "附件", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/1.mp4")
@ExcelProperty("附件")
private String video;
@Schema(description = "备注", requiredMode = Schema.RequiredMode.REQUIRED, example = "我是备注")
@ExcelProperty("备注")
private String memo;
@Schema(description = "创建时间")
@ExcelProperty("创建时间")
private LocalDateTime createTime;
}
@@ -0,0 +1,49 @@
package cn.iocoder.yudao.module.infra.controller.admin.demo.vo;
import io.swagger.v3.oas.annotations.media.Schema;
import lombok.*;
import java.util.*;
import jakarta.validation.constraints.*;
import org.springframework.format.annotation.DateTimeFormat;
import java.time.LocalDateTime;
@Schema(description = "管理后台 - 学生新增/修改 Request VO")
@Data
public class InfraStudentSaveReqVO {
@Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
private Long id;
@Schema(description = "名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "芋头")
@NotEmpty(message = "名字不能为空")
private String name;
@Schema(description = "简介", requiredMode = Schema.RequiredMode.REQUIRED, example = "我是介绍")
@NotEmpty(message = "简介不能为空")
private String description;
@Schema(description = "出生日期", requiredMode = Schema.RequiredMode.REQUIRED)
@NotNull(message = "出生日期不能为空")
private LocalDateTime birthday;
@Schema(description = "性别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
@NotNull(message = "性别不能为空")
private Integer sex;
@Schema(description = "是否有效", requiredMode = Schema.RequiredMode.REQUIRED, example = "true")
@NotNull(message = "是否有效不能为空")
private Boolean enabled;
@Schema(description = "头像", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/1.png")
@NotEmpty(message = "头像不能为空")
private String avatar;
@Schema(description = "附件", requiredMode = Schema.RequiredMode.REQUIRED, example = "https://www.iocoder.cn/1.mp4")
@NotEmpty(message = "附件不能为空")
private String video;
@Schema(description = "备注", requiredMode = Schema.RequiredMode.REQUIRED, example = "我是备注")
@NotEmpty(message = "备注不能为空")
private String memo;
}
@@ -0,0 +1,62 @@
package cn.iocoder.yudao.module.infra.service.demo;
import java.util.*;
import jakarta.validation.*;
import cn.iocoder.yudao.module.infra.controller.admin.demo.vo.*;
import cn.iocoder.yudao.module.infra.dal.dataobject.demo.InfraStudentDO;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
/**
* 学生 Service 接口
*
* @author 芋道源码
*/
public interface InfraStudentService {
/**
* 创建学生
*
* @param createReqVO 创建信息
* @return 编号
*/
Long createStudent(@Valid InfraStudentSaveReqVO createReqVO);
/**
* 更新学生
*
* @param updateReqVO 更新信息
*/
void updateStudent(@Valid InfraStudentSaveReqVO updateReqVO);
/**
* 删除学生
*
* @param id 编号
*/
void deleteStudent(Long id);
/**
* 批量删除学生
*
* @param ids 编号
*/
void deleteStudentListByIds(List<Long> ids);
/**
* 获得学生
*
* @param id 编号
* @return 学生
*/
InfraStudentDO getStudent(Long id);
/**
* 获得学生分页
*
* @param pageReqVO 分页查询
* @return 学生分页
*/
PageResult<InfraStudentDO> getStudentPage(InfraStudentPageReqVO pageReqVO);
}
@@ -0,0 +1,85 @@
package cn.iocoder.yudao.module.infra.service.demo;
import cn.hutool.core.collection.CollUtil;
import org.springframework.stereotype.Service;
import jakarta.annotation.Resource;
import org.springframework.validation.annotation.Validated;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
import cn.iocoder.yudao.module.infra.controller.admin.demo.vo.*;
import cn.iocoder.yudao.module.infra.dal.dataobject.demo.InfraStudentDO;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.pojo.PageParam;
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
import cn.iocoder.yudao.module.infra.dal.mysql.demo.InfraStudentMapper;
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.diffList;
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*;
/**
* 学生 Service 实现类
*
* @author 芋道源码
*/
@Service
@Validated
public class InfraStudentServiceImpl implements InfraStudentService {
@Resource
private InfraStudentMapper studentMapper;
@Override
public Long createStudent(InfraStudentSaveReqVO createReqVO) {
// 插入
InfraStudentDO student = BeanUtils.toBean(createReqVO, InfraStudentDO.class);
studentMapper.insert(student);
// 返回
return student.getId();
}
@Override
public void updateStudent(InfraStudentSaveReqVO updateReqVO) {
// 校验存在
validateStudentExists(updateReqVO.getId());
// 更新
InfraStudentDO updateObj = BeanUtils.toBean(updateReqVO, InfraStudentDO.class);
studentMapper.updateById(updateObj);
}
@Override
public void deleteStudent(Long id) {
// 校验存在
validateStudentExists(id);
// 删除
studentMapper.deleteById(id);
}
@Override
public void deleteStudentListByIds(List<Long> ids) {
// 删除
studentMapper.deleteByIds(ids);
}
private void validateStudentExists(Long id) {
if (studentMapper.selectById(id) == null) {
throw exception(STUDENT_NOT_EXISTS);
}
}
@Override
public InfraStudentDO getStudent(Long id) {
return studentMapper.selectById(id);
}
@Override
public PageResult<InfraStudentDO> getStudentPage(InfraStudentPageReqVO pageReqVO) {
return studentMapper.selectPage(pageReqVO);
}
}
@@ -0,0 +1,145 @@
package cn.iocoder.yudao.module.infra.service.demo;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import jakarta.annotation.Resource;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
import cn.iocoder.yudao.module.infra.controller.admin.demo.vo.*;
import cn.iocoder.yudao.module.infra.dal.dataobject.demo.InfraStudentDO;
import cn.iocoder.yudao.module.infra.dal.mysql.demo.InfraStudentMapper;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import jakarta.annotation.Resource;
import org.springframework.context.annotation.Import;
import java.util.*;
import java.time.LocalDateTime;
import static cn.hutool.core.util.RandomUtil.*;
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.*;
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.*;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.*;
import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.*;
import static cn.iocoder.yudao.framework.common.util.date.DateUtils.*;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.Mockito.*;
/**
* {@link InfraStudentServiceImpl} 的单元测试类
*
* @author 芋道源码
*/
@Import(InfraStudentServiceImpl.class)
public class InfraStudentServiceImplTest extends BaseDbUnitTest {
@Resource
private InfraStudentServiceImpl studentService;
@Resource
private InfraStudentMapper studentMapper;
@Test
public void testCreateStudent_success() {
// 准备参数
InfraStudentSaveReqVO createReqVO = randomPojo(InfraStudentSaveReqVO.class).setId(null);
// 调用
Long studentId = studentService.createStudent(createReqVO);
// 断言
assertNotNull(studentId);
// 校验记录的属性是否正确
InfraStudentDO student = studentMapper.selectById(studentId);
assertPojoEquals(createReqVO, student, "id");
}
@Test
public void testUpdateStudent_success() {
// mock 数据
InfraStudentDO dbStudent = randomPojo(InfraStudentDO.class);
studentMapper.insert(dbStudent);// @Sql: 先插入出一条存在的数据
// 准备参数
InfraStudentSaveReqVO updateReqVO = randomPojo(InfraStudentSaveReqVO.class, o -> {
o.setId(dbStudent.getId()); // 设置更新的 ID
});
// 调用
studentService.updateStudent(updateReqVO);
// 校验是否更新正确
InfraStudentDO student = studentMapper.selectById(updateReqVO.getId()); // 获取最新的
assertPojoEquals(updateReqVO, student);
}
@Test
public void testUpdateStudent_notExists() {
// 准备参数
InfraStudentSaveReqVO updateReqVO = randomPojo(InfraStudentSaveReqVO.class);
// 调用, 并断言异常
assertServiceException(() -> studentService.updateStudent(updateReqVO), STUDENT_NOT_EXISTS);
}
@Test
public void testDeleteStudent_success() {
// mock 数据
InfraStudentDO dbStudent = randomPojo(InfraStudentDO.class);
studentMapper.insert(dbStudent);// @Sql: 先插入出一条存在的数据
// 准备参数
Long id = dbStudent.getId();
// 调用
studentService.deleteStudent(id);
// 校验数据不存在了
assertNull(studentMapper.selectById(id));
}
@Test
public void testDeleteStudent_notExists() {
// 准备参数
Long id = randomLongId();
// 调用, 并断言异常
assertServiceException(() -> studentService.deleteStudent(id), STUDENT_NOT_EXISTS);
}
@Test
@Disabled // TODO 请修改 null 为需要的值,然后删除 @Disabled 注解
public void testGetStudentPage() {
// mock 数据
InfraStudentDO dbStudent = randomPojo(InfraStudentDO.class, o -> { // 等会查询到
o.setName(null);
o.setBirthday(null);
o.setSex(null);
o.setEnabled(null);
o.setCreateTime(null);
});
studentMapper.insert(dbStudent);
// 测试 name 不匹配
studentMapper.insert(cloneIgnoreId(dbStudent, o -> o.setName(null)));
// 测试 birthday 不匹配
studentMapper.insert(cloneIgnoreId(dbStudent, o -> o.setBirthday(null)));
// 测试 sex 不匹配
studentMapper.insert(cloneIgnoreId(dbStudent, o -> o.setSex(null)));
// 测试 enabled 不匹配
studentMapper.insert(cloneIgnoreId(dbStudent, o -> o.setEnabled(null)));
// 测试 createTime 不匹配
studentMapper.insert(cloneIgnoreId(dbStudent, o -> o.setCreateTime(null)));
// 准备参数
InfraStudentPageReqVO reqVO = new InfraStudentPageReqVO();
reqVO.setName(null);
reqVO.setBirthday(null);
reqVO.setSex(null);
reqVO.setEnabled(null);
reqVO.setCreateTime(buildBetweenTime(2023, 2, 1, 2023, 2, 28));
// 调用
PageResult<InfraStudentDO> pageResult = studentService.getStudentPage(reqVO);
// 断言
assertEquals(1, pageResult.getTotal());
assertEquals(1, pageResult.getList().size());
assertPojoEquals(dbStudent, pageResult.getList().get(0));
}
}
@@ -0,0 +1,17 @@
-- 将该建表 SQL 语句,添加到 yudao-module-infra-biz 模块的 test/resources/sql/create_tables.sql 文件里
CREATE TABLE IF NOT EXISTS "infra_student" (
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
"name" varchar NOT NULL,
"description" varchar NOT NULL,
"birthday" varchar NOT NULL,
"sex" int NOT NULL,
"enabled" bit NOT NULL,
"avatar" varchar NOT NULL,
"video" varchar NOT NULL,
"memo" varchar NOT NULL,
"create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY ("id")
) COMMENT '学生表';
-- 将该删表 SQL 语句,添加到 yudao-module-infra-biz 模块的 test/resources/sql/clean.sql 文件里
DELETE FROM "infra_student";
@@ -0,0 +1,54 @@
-- 菜单 SQL
INSERT INTO system_menu(
name, permission, type, sort, parent_id,
path, icon, component, status, component_name
)
VALUES (
'学生管理', '', 2, 0, 888,
'student', '', 'infra/demo/index', 0, 'InfraStudent'
);
-- 按钮父菜单ID
SELECT @parentId := LAST_INSERT_ID();
-- 按钮 SQL
INSERT INTO system_menu(
name, permission, type, sort, parent_id,
path, icon, component, status
)
VALUES (
'学生查询', 'infra:student:query', 3, 1, @parentId,
'', '', '', 0
);
INSERT INTO system_menu(
name, permission, type, sort, parent_id,
path, icon, component, status
)
VALUES (
'学生创建', 'infra:student:create', 3, 2, @parentId,
'', '', '', 0
);
INSERT INTO system_menu(
name, permission, type, sort, parent_id,
path, icon, component, status
)
VALUES (
'学生更新', 'infra:student:update', 3, 3, @parentId,
'', '', '', 0
);
INSERT INTO system_menu(
name, permission, type, sort, parent_id,
path, icon, component, status
)
VALUES (
'学生删除', 'infra:student:delete', 3, 4, @parentId,
'', '', '', 0
);
INSERT INTO system_menu(
name, permission, type, sort, parent_id,
path, icon, component, status
)
VALUES (
'学生导出', 'infra:student:export', 3, 5, @parentId,
'', '', '', 0
);
@@ -0,0 +1,41 @@
import type { PageParam, PageResult } from '@/http/types'
import { http } from '@/http/http'
/** 学生信息 */
export interface Student {
id?: number
name: string
description: string
birthday: Date
sex: number
enabled: boolean
avatar: string
video: string
memo: string
createTime?: Date
}
/** 获取学生分页列表 */
export function getStudentPage(params: PageParam) {
return http.get<PageResult<Student>>('/infra/student/page', params)
}
/** 获取学生详情 */
export function getStudent(id: number) {
return http.get<Student>(`/infra/student/get?id=${id}`)
}
/** 创建学生 */
export function createStudent(data: Student) {
return http.post<number>('/infra/student/create', data)
}
/** 更新学生 */
export function updateStudent(data: Student) {
return http.put<boolean>('/infra/student/update', data)
}
/** 删除学生 */
export function deleteStudent(id: number) {
return http.delete<boolean>(`/infra/student/delete?id=${id}`)
}
@@ -0,0 +1,142 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
title="学生详情"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 详情内容 -->
<view>
<wd-cell-group border>
<wd-cell title="编号" :value="formData?.id ?? '-'" />
<wd-cell title="名字" :value="formData?.name ?? '-'" />
<wd-cell title="简介" :value="formData?.description ?? '-'" />
<wd-cell title="出生日期" :value="formatDateTime(formData?.birthday) || '-'" />
<wd-cell title="性别">
<dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="formData?.sex" />
</wd-cell>
<wd-cell title="是否有效">
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="formData?.enabled" />
</wd-cell>
<wd-cell title="头像" :value="formData?.avatar ?? '-'" />
<wd-cell title="附件" :value="formData?.video ?? '-'" />
<wd-cell title="备注" :value="formData?.memo ?? '-'" />
<wd-cell title="创建时间" :value="formatDateTime(formData?.createTime) || '-'" />
</wd-cell-group>
</view>
<!-- 底部操作按钮 -->
<view class="yd-detail-footer">
<view class="yd-detail-footer-actions">
<wd-button
v-if="hasAccessByCodes(['infra:student:update'])"
class="flex-1" type="warning" @click="handleEdit"
>
编辑
</wd-button>
<wd-button
v-if="hasAccessByCodes(['infra:student:delete'])"
class="flex-1" type="error" :loading="deleting" @click="handleDelete"
>
删除
</wd-button>
</view>
</view>
</view>
</template>
<script lang="ts" setup>
import type { Student } from '@/api/infra/demo'
import { onUnload } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { deleteStudent, getStudent } from '@/api/infra/demo'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const toast = useToast()
const formData = ref<Student>() // 详情数据
const deleting = ref(false) // 删除状态
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/demo/index')
}
/** 加载学生详情 */
async function getDetail() {
if (!props.id || deleting.value) {
return
}
try {
toast.loading('加载中...')
formData.value = await getStudent(props.id)
} finally {
toast.close()
}
}
/** 编辑学生 */
function handleEdit() {
uni.navigateTo({
url: `/pages-infra/demo/form/index?id=${props.id}`,
})
}
/** 删除学生 */
function handleDelete() {
if (!props.id) {
return
}
uni.showModal({
title: '提示',
content: '确定要删除该学生吗?',
success: async (res) => {
if (!res.confirm) {
return
}
deleting.value = true
try {
await deleteStudent(props.id)
toast.success('删除成功')
uni.$emit('infra:demo:reload')
setTimeout(() => {
handleBack()
}, 500)
} finally {
deleting.value = false
}
},
})
}
/** 初始化 */
onMounted(() => {
uni.$on('infra:demo:reload', getDetail)
getDetail()
})
/** 卸载 */
onUnload(() => {
uni.$off('infra:demo:reload', getDetail)
})
</script>
<style lang="scss" scoped>
</style>
@@ -0,0 +1,194 @@
<template>
<view class="yd-page-container">
<!-- 顶部导航栏 -->
<wd-navbar
:title="getTitle"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 表单区域 -->
<view>
<wd-form ref="formRef" :model="formData" :rules="formRules">
<wd-cell-group border>
<wd-input
v-model="formData.name"
label="名字"
label-width="180rpx"
prop="name"
clearable
placeholder="请输入名字"
/>
<wd-textarea
v-model="formData.description"
label="简介"
label-width="180rpx"
placeholder="请输入简介"
:maxlength="200"
show-word-limit
clearable
/>
<wd-datetime-picker
v-model="formData.birthday"
type="datetime"
label="出生日期"
label-width="180rpx"
prop="birthday"
/>
<wd-cell title="性别" title-width="180rpx" prop="sex" center>
<wd-radio-group v-model="formData.sex" shape="button">
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-cell title="是否有效" title-width="180rpx" prop="enabled" center>
<wd-radio-group v-model="formData.enabled" shape="button">
<wd-radio
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</wd-cell>
<wd-input
v-model="formData.avatar"
label="头像"
label-width="180rpx"
prop="avatar"
clearable
placeholder="请输入头像"
/>
<wd-input
v-model="formData.video"
label="附件"
label-width="180rpx"
prop="video"
clearable
placeholder="请输入附件"
/>
<wd-input
v-model="formData.memo"
label="备注"
label-width="180rpx"
prop="memo"
clearable
placeholder="请输入备注"
/>
</wd-cell-group>
</wd-form>
</view>
<!-- 底部保存按钮 -->
<view class="yd-detail-footer">
<wd-button
type="primary"
block
:loading="formLoading"
@click="handleSubmit"
>
保存
</wd-button>
</view>
</view>
</template>
<script lang="ts" setup>
import type { FormInstance } from 'wot-design-uni/components/wd-form/types'
import type { Student } from '@/api/infra/demo'
import { computed, onMounted, ref } from 'vue'
import { useToast } from 'wot-design-uni'
import { createStudent, getStudent, updateStudent } from '@/api/infra/demo'
import { getIntDictOptions, getBoolDictOptions } from '@/hooks/useDict'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
const props = defineProps<{
id?: number | any
}>()
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const toast = useToast()
const getTitle = computed(() => props.id ? '编辑学生' : '新增学生')
const formLoading = ref(false) // 表单提交状态
const formData = ref<Student>({
id: undefined,
name: '',
description: '',
birthday: undefined,
sex: 0,
enabled: false,
avatar: '',
video: '',
memo: '',
}) // 表单数据
const formRules = {
name: [{ required: true, message: '名字不能为空' }],
description: [{ required: true, message: '简介不能为空' }],
birthday: [{ required: true, message: '出生日期不能为空' }],
sex: [{ required: true, message: '性别不能为空' }],
enabled: [{ required: true, message: '是否有效不能为空' }],
avatar: [{ required: true, message: '头像不能为空' }],
video: [{ required: true, message: '附件不能为空' }],
memo: [{ required: true, message: '备注不能为空' }],
} // 表单校验规则
const formRef = ref<FormInstance>() // 表单组件引用
/** 返回上一页 */
function handleBack() {
navigateBackPlus('/pages-infra/demo/index')
}
/** 加载学生详情 */
async function getDetail() {
if (!props.id) {
return
}
formData.value = await getStudent(props.id)
}
/** 提交表单 */
async function handleSubmit() {
const { valid } = await formRef.value.validate()
if (!valid) {
return
}
formLoading.value = true
try {
if (props.id) {
await updateStudent(formData.value)
toast.success('修改成功')
} else {
await createStudent(formData.value)
toast.success('新增成功')
}
uni.$emit('infra:demo:reload')
setTimeout(() => {
handleBack()
}, 500)
} finally {
formLoading.value = false
}
}
/** 初始化 */
onMounted(() => {
getDetail()
})
</script>
<style lang="scss" scoped>
</style>
@@ -0,0 +1,169 @@
<template>
<view class="yd-page-container yd-page-container-paging">
<!-- 顶部导航栏 -->
<wd-navbar
title="学生管理"
left-arrow placeholder safe-area-inset-top fixed
@click-left="handleBack"
/>
<!-- 搜索组件 -->
<SearchForm @search="handleQuery" @reset="handleReset" />
<!-- 学生列表 -->
<z-paging
ref="pagingRef"
v-model="list"
:fixed="false"
class="min-h-0 flex-1"
:default-page-size="10"
:refresher-enabled="true"
:inside-more="true"
:loading-more-default-as-loading="true"
empty-view-text="暂无学生数据"
@query="queryList"
>
<view class="p-24rpx">
<view
v-for="item in list"
:key="item.id"
class="mb-24rpx overflow-hidden rounded-12rpx bg-white shadow-sm"
@click="handleDetail(item)"
>
<view class="p-24rpx">
<view class="mb-16rpx flex items-center justify-between">
<view class="text-32rpx text-[#333] font-semibold">
{{ item.name }}
</view>
<dict-tag :type="DICT_TYPE.SYSTEM_USER_SEX" :value="item.sex" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">简介:</text>
<text class="line-clamp-1">{{ item.description }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">出生日期:</text>
<text class="line-clamp-1">{{ formatDateTime(item.birthday) || '-' }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">是否有效:</text>
<dict-tag :type="DICT_TYPE.INFRA_BOOLEAN_STRING" :value="item.enabled" />
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">头像:</text>
<text class="line-clamp-1">{{ item.avatar }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">附件:</text>
<text class="line-clamp-1">{{ item.video }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">备注:</text>
<text class="line-clamp-1">{{ item.memo }}</text>
</view>
<view class="mb-12rpx flex items-center text-28rpx text-[#666]">
<text class="mr-8rpx text-[#999]">创建时间:</text>
<text class="line-clamp-1">{{ formatDateTime(item.createTime) || '-' }}</text>
</view>
</view>
</view>
</view>
</z-paging>
<!-- 新增按钮 -->
<wd-fab
v-if="hasAccessByCodes(['infra:student:create'])"
position="right-bottom"
type="primary"
:expandable="false"
@click="handleAdd"
/>
</view>
</template>
<script lang="ts" setup>
import type { Student } from '@/api/infra/demo'
import { onUnload } from '@dcloudio/uni-app'
import { onMounted, ref } from 'vue'
import { getStudentPage } from '@/api/infra/demo'
import { useAccess } from '@/hooks/useAccess'
import { navigateBackPlus } from '@/utils'
import { DICT_TYPE } from '@/utils/constants'
import { formatDateTime } from '@/utils/date'
import SearchForm from './components/search-form.vue'
definePage({
style: {
navigationBarTitleText: '',
navigationStyle: 'custom',
},
})
const { hasAccessByCodes } = useAccess()
const list = ref<Student[]>([]) // 列表数据
const pagingRef = ref<any>() // 分页组件引用
const queryParams = ref<Record<string, any>>({}) // 查询参数
/** 返回上一页 */
function handleBack() {
navigateBackPlus()
}
/** 查询学生列表 */
async function queryList(pageNo: number, pageSize: number) {
try {
const params = {
...queryParams.value,
pageNo,
pageSize,
}
const data = await getStudentPage(params)
pagingRef.value?.completeByTotal(data.list, data.total)
} catch {
pagingRef.value?.complete(false)
}
}
/** 搜索按钮操作 */
function handleQuery(data?: Record<string, any>) {
queryParams.value = { ...data }
reload()
}
/** 重置按钮操作 */
function handleReset() {
handleQuery()
}
/** 重新加载 */
function reload() {
pagingRef.value?.reload()
}
/** 新增学生 */
function handleAdd() {
uni.navigateTo({
url: '/pages-infra/demo/form/index',
})
}
/** 查看详情 */
function handleDetail(item: Student) {
uni.navigateTo({
url: `/pages-infra/demo/detail/index?id=${item.id}`,
})
}
/** 初始化 */
onMounted(() => {
uni.$on('infra:demo:reload', reload)
})
/** 卸载 */
onUnload(() => {
uni.$off('infra:demo:reload', reload)
})
</script>
<style lang="scss" scoped>
</style>
@@ -0,0 +1,190 @@
<template>
<!-- 搜索框入口 -->
<view @click="visible = true">
<wd-search :placeholder="placeholder" hide-cancel disabled />
</view>
<!-- 搜索弹窗 -->
<wd-popup v-model="visible" position="top" @close="visible = false">
<view class="yd-search-form-container" :style="{ paddingTop: `${getNavbarHeight()}px` }">
<view class="yd-search-form-item">
<view class="yd-search-form-label">
名字
</view>
<wd-input
v-model="formData.name"
placeholder="请输入名字"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
出生日期
</view>
<wd-input
v-model="formData.birthday"
placeholder="请输入出生日期"
clearable
/>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
性别
</view>
<wd-radio-group v-model="formData.sex" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getIntDictOptions(DICT_TYPE.SYSTEM_USER_SEX)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
是否有效
</view>
<wd-radio-group v-model="formData.enabled" shape="button">
<wd-radio :value="-1">
全部
</wd-radio>
<wd-radio
v-for="dict in getBoolDictOptions(DICT_TYPE.INFRA_BOOLEAN_STRING)"
:key="dict.value"
:value="dict.value"
>
{{ dict.label }}
</wd-radio>
</wd-radio-group>
</view>
<view class="yd-search-form-item">
<view class="yd-search-form-label">
创建时间
</view>
<view class="yd-search-form-date-range-container">
<view @click="visibleCreateTime[0] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[0]) || '开始日期' }}
</view>
</view>
-
<view @click="visibleCreateTime[1] = true">
<view class="yd-search-form-date-range-picker">
{{ formatDate(formData.createTime?.[1]) || '结束日期' }}
</view>
</view>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[0]" v-model="tempCreateTime[0]" type="date" />
<view v-if="visibleCreateTime[0]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[0] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime0Confirm">
确定
</wd-button>
</view>
<wd-datetime-picker-view v-if="visibleCreateTime[1]" v-model="tempCreateTime[1]" type="date" />
<view v-if="visibleCreateTime[1]" class="yd-search-form-date-range-actions">
<wd-button size="small" plain @click="visibleCreateTime[1] = false">
取消
</wd-button>
<wd-button size="small" type="primary" @click="handleCreateTime1Confirm">
确定
</wd-button>
</view>
</view>
<view class="yd-search-form-actions">
<wd-button class="flex-1" plain @click="handleReset">
重置
</wd-button>
<wd-button class="flex-1" type="primary" @click="handleSearch">
搜索
</wd-button>
</view>
</view>
</wd-popup>
</template>
<script lang="ts" setup>
import { computed, reactive, ref } from 'vue'
import { getNavbarHeight } from '@/utils'
import { formatDate, formatDateRange } from '@/utils/date'
import { getDictLabel, getIntDictOptions, getBoolDictOptions } from '@/hooks/useDict'
import { DICT_TYPE } from '@/utils/constants'
const emit = defineEmits<{
search: [data: Record<string, any>]
reset: []
}>()
const visible = ref(false) // 搜索弹窗显示状态
const formData = reactive({
name: undefined as string | undefined,
birthday: undefined as string | undefined,
sex: -1, // -1 表示全部
enabled: -1 as -1 | boolean, // -1 表示全部
createTime: [undefined, undefined] as [number | undefined, number | undefined],
}) // 搜索表单数据
/** 搜索条件 placeholder 拼接 */
const placeholder = computed(() => {
const conditions: string[] = []
if (formData.name) {
conditions.push(`名字:${formData.name}`)
}
if (formData.birthday) {
conditions.push(`出生日期:${formData.birthday}`)
}
if (formData.sex !== -1) {
conditions.push(`性别:${getDictLabel(DICT_TYPE.SYSTEM_USER_SEX, formData.sex)}`)
}
if (formData.enabled !== -1) {
conditions.push(`是否有效:${getDictLabel(DICT_TYPE.INFRA_BOOLEAN_STRING, formData.enabled)}`)
}
if (formData.createTime?.[0] && formData.createTime?.[1]) {
conditions.push(`创建时间:${formatDate(formData.createTime[0])}~${formatDate(formData.createTime[1])}`)
}
return conditions.length > 0 ? conditions.join(' | ') : '搜索学生'
})
const visibleCreateTime = ref<[boolean, boolean]>([false, false]) // 创建时间选择器状态
const tempCreateTime = ref<[number, number]>([Date.now(), Date.now()]) // 创建时间临时值
/** 确认创建时间开始日期 */
function handleCreateTime0Confirm() {
formData.createTime = [tempCreateTime.value[0], formData.createTime?.[1]]
visibleCreateTime.value[0] = false
}
/** 确认创建时间结束日期 */
function handleCreateTime1Confirm() {
formData.createTime = [formData.createTime?.[0], tempCreateTime.value[1]]
visibleCreateTime.value[1] = false
}
/** 搜索按钮操作 */
function handleSearch() {
visible.value = false
emit('search', {
...formData,
sex: formData.sex === -1 ? undefined : formData.sex,
enabled: formData.enabled === -1 ? undefined : formData.enabled,
createTime: formatDateRange(formData.createTime),
})
}
/** 重置按钮操作 */
function handleReset() {
formData.name = undefined
formData.birthday = undefined
formData.sex = -1
formData.enabled = -1
formData.createTime = [undefined, undefined]
visible.value = false
emit('reset')
}
</script>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="cn.iocoder.yudao.module.infra.dal.mysql.demo.InfraStudentMapper">
<!--
一般情况下,尽可能使用 Mapper 进行 CRUD 增删改查即可。
无法满足的场景,例如说多表关联查询,才使用 XML 编写 SQL。
代码生成器暂时只生成 Mapper XML 文件本身,更多推荐 MybatisX 快速开发插件来生成查询。
文档可见:https://www.iocoder.cn/MyBatis/x-plugins/
-->
</mapper>
@@ -29,6 +29,9 @@ public class IotSceneRuleRespVO {
@Schema(description = "执行器数组", requiredMode = Schema.RequiredMode.REQUIRED)
private List<IotSceneRuleDO.Action> actions;
@Schema(description = "最后触发时间")
private LocalDateTime lastTriggerTime;
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
private LocalDateTime createTime;
@@ -33,7 +33,7 @@ public interface IotAlertConfigMapper extends BaseMapperX<IotAlertConfigDO> {
default List<IotAlertConfigDO> selectListBySceneRuleIdAndStatus(Long sceneRuleId, Integer status) {
return selectList(new LambdaQueryWrapperX<IotAlertConfigDO>()
.eq(IotAlertConfigDO::getStatus, status)
.apply(MyBatisUtils.findInSet("scene_rule_ids", sceneRuleId)));
.apply(MyBatisUtils.findInSet("scene_rule_ids"), sceneRuleId));
}
}
}
@@ -4,6 +4,7 @@ import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
import cn.iocoder.yudao.module.iot.enums.product.IotProductDeviceTypeEnum;
@@ -34,7 +35,7 @@ public interface IotDeviceMapper extends BaseMapperX<IotDeviceDO> {
.likeIfPresent(IotDeviceDO::getNickname, reqVO.getNickname())
.eqIfPresent(IotDeviceDO::getState, reqVO.getStatus())
.eqIfPresent(IotDeviceDO::getGatewayId, reqVO.getGatewayId())
.apply(ObjectUtil.isNotNull(reqVO.getGroupId()), "FIND_IN_SET(" + reqVO.getGroupId() + ",group_ids) > 0")
.apply(ObjectUtil.isNotNull(reqVO.getGroupId()), MyBatisUtils.findInSet("group_ids"), reqVO.getGroupId())
.orderByDesc(IotDeviceDO::getId));
}
@@ -72,7 +73,7 @@ public interface IotDeviceMapper extends BaseMapperX<IotDeviceDO> {
default Long selectCountByGroupId(Long groupId) {
return selectCount(new LambdaQueryWrapperX<IotDeviceDO>()
.apply("FIND_IN_SET(" + groupId + ",group_ids) > 0"));
.apply(MyBatisUtils.findInSet("group_ids"), groupId));
}
default Long selectCountByCreateTime(@Nullable LocalDateTime createTime) {
@@ -28,7 +28,7 @@ public interface IotDataRuleMapper extends BaseMapperX<IotDataRuleDO> {
default List<IotDataRuleDO> selectListBySinkId(Long sinkId) {
return selectList(new LambdaQueryWrapperX<IotDataRuleDO>()
.apply(MyBatisUtils.findInSet("sink_ids", sinkId)));
.apply(MyBatisUtils.findInSet("sink_ids"), sinkId));
}
default List<IotDataRuleDO> selectListByStatus(Integer status) {
@@ -39,4 +39,4 @@ public interface IotDataRuleMapper extends BaseMapperX<IotDataRuleDO> {
return selectOne(IotDataRuleDO::getName, name);
}
}
}
@@ -6,6 +6,7 @@ import cn.iocoder.yudao.framework.mq.redis.core.job.RedisStreamMessageCleanupJob
import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessage;
import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessageListener;
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
import cn.iocoder.yudao.module.iot.core.messagebus.core.kafka.IotKafkaMessageBus;
import cn.iocoder.yudao.module.iot.core.messagebus.core.local.IotLocalMessageBus;
import cn.iocoder.yudao.module.iot.core.messagebus.core.rabbitmq.IotRabbitMQMessageBus;
import cn.iocoder.yudao.module.iot.core.messagebus.core.redis.IotRedisMessageBus;
@@ -18,6 +19,7 @@ import org.redisson.api.RedissonClient;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
@@ -27,6 +29,7 @@ import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.kafka.core.KafkaTemplate;
import java.util.List;
@@ -77,6 +80,21 @@ public class IotMessageBusAutoConfiguration {
}
// ==================== Kafka 实现 ====================
@Configuration
@ConditionalOnProperty(prefix = "yudao.iot.message-bus", name = "type", havingValue = "kafka")
@ConditionalOnClass(KafkaTemplate.class)
public static class IotKafkaMessageBusConfiguration {
@Bean
public IotKafkaMessageBus iotKafkaMessageBus(KafkaProperties kafkaProperties) {
log.info("[iotKafkaMessageBus][创建 IoT Kafka 消息总线]");
return new IotKafkaMessageBus(kafkaProperties);
}
}
// ==================== Redis 实现 ====================
/**
@@ -103,7 +121,8 @@ public class IotMessageBusAutoConfiguration {
RedisMQTemplate redisTemplate,
RedissonClient redissonClient) {
List<AbstractRedisStreamMessageListener<?>> listeners = getListeners(messageBus);
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient);
return new RedisPendingMessageResendJob(listeners, redisTemplate, redissonClient,
RedisPendingMessageResendJob.IOT_RESEND_LOCK_KEY);
}
/**
@@ -114,7 +133,8 @@ public class IotMessageBusAutoConfiguration {
RedisMQTemplate redisTemplate,
RedissonClient redissonClient) {
List<AbstractRedisStreamMessageListener<?>> listeners = getListeners(messageBus);
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient);
return new RedisStreamMessageCleanupJob(listeners, redisTemplate, redissonClient,
RedisStreamMessageCleanupJob.IOT_CLEANUP_LOCK_KEY);
}
private List<AbstractRedisStreamMessageListener<?>> getListeners(IotRedisMessageBus messageBus) {
@@ -19,9 +19,9 @@ public class IotMessageBusProperties {
/**
* 消息总线类型
*
* 可选值:local、redis、rocketmq、rabbitmq
* 可选值:local、redis、rocketmq、kafka、rabbitmq
*/
@NotNull(message = "IoT 消息总线类型不能为空")
private String type = "local";
}
}
@@ -0,0 +1,127 @@
package cn.iocoder.yudao.module.iot.core.messagebus.core.kafka;
import cn.hutool.core.util.TypeUtil;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
import jakarta.annotation.PreDestroy;
import lombok.Getter;
import lombok.extern.slf4j.Slf4j;
import org.apache.kafka.clients.consumer.ConsumerConfig;
import org.apache.kafka.clients.producer.ProducerConfig;
import org.apache.kafka.common.serialization.StringDeserializer;
import org.apache.kafka.common.serialization.StringSerializer;
import org.springframework.boot.autoconfigure.kafka.KafkaProperties;
import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
import org.springframework.kafka.core.DefaultKafkaProducerFactory;
import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.kafka.listener.AcknowledgingMessageListener;
import org.springframework.kafka.listener.ConcurrentMessageListenerContainer;
import org.springframework.kafka.listener.ContainerProperties;
import java.lang.reflect.Type;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
/**
* 基于 Kafka 的 {@link IotMessageBus} 实现类
*
* @author 芋道源码
*/
@Slf4j
public class IotKafkaMessageBus implements IotMessageBus {
private final KafkaTemplate<String, String> kafkaTemplate;
private final KafkaProperties kafkaProperties;
@Getter
private final List<IotMessageSubscriber<?>> subscribers = new ArrayList<>();
private final List<ConcurrentMessageListenerContainer<String, String>> containers = new ArrayList<>();
public IotKafkaMessageBus(KafkaProperties kafkaProperties) {
this.kafkaProperties = kafkaProperties;
this.kafkaTemplate = new KafkaTemplate<>(new DefaultKafkaProducerFactory<>(buildProducerProperties(kafkaProperties)));
}
@Override
public void post(String topic, Object message) {
String messageJson = JsonUtils.toJsonString(message);
try {
kafkaTemplate.send(topic, messageJson).get();
log.info("[post][topic({}) 发送消息({})]", topic, message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IllegalStateException(String.format("发送 Kafka 消息失败,topic(%s) message(%s)", topic, message), e);
} catch (ExecutionException e) {
throw new IllegalStateException(String.format("发送 Kafka 消息失败,topic(%s) message(%s)", topic, message), e);
}
}
@Override
public void register(IotMessageSubscriber<?> subscriber) {
Type type = TypeUtil.getTypeArgument(subscriber.getClass(), 0);
if (type == null) {
throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName()));
}
// 1. 创建消费容器
ContainerProperties containerProperties = new ContainerProperties(subscriber.getTopic());
containerProperties.setGroupId(subscriber.getGroup());
containerProperties.setAckMode(ContainerProperties.AckMode.MANUAL_IMMEDIATE);
containerProperties.setMissingTopicsFatal(false);
containerProperties.setMessageListener((AcknowledgingMessageListener<String, String>) (message, acknowledgment) -> {
try {
subscriber.onMessage(JsonUtils.parseObject(message.value(), type));
acknowledgment.acknowledge();
} catch (Exception ex) {
log.error("[onMessage][topic({}/{}) message({}) 消费者({}) 处理异常]",
subscriber.getTopic(), subscriber.getGroup(), message, subscriber.getClass().getName(), ex);
throw ex;
}
});
ConcurrentMessageListenerContainer<String, String> container = new ConcurrentMessageListenerContainer<>(
new DefaultKafkaConsumerFactory<>(buildConsumerProperties(kafkaProperties, subscriber.getGroup())),
containerProperties);
container.start();
// 2. 保存消费者引用
containers.add(container);
subscribers.add(subscriber);
}
@PreDestroy
public void destroy() {
for (ConcurrentMessageListenerContainer<String, String> container : containers) {
try {
container.stop();
log.info("[destroy][关闭 Kafka 消费者容器成功]");
} catch (Exception e) {
log.error("[destroy][关闭 Kafka 消费者容器异常]", e);
}
}
kafkaTemplate.destroy();
}
private static Map<String, Object> buildProducerProperties(KafkaProperties kafkaProperties) {
Map<String, Object> properties = new HashMap<>(kafkaProperties.buildProducerProperties());
properties.put(ProducerConfig.KEY_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
properties.put(ProducerConfig.VALUE_SERIALIZER_CLASS_CONFIG, StringSerializer.class);
return properties;
}
private static Map<String, Object> buildConsumerProperties(KafkaProperties kafkaProperties, String group) {
Map<String, Object> properties = new HashMap<>(kafkaProperties.buildConsumerProperties());
properties.put(ConsumerConfig.GROUP_ID_CONFIG, group);
properties.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
properties.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
properties.put(ConsumerConfig.ENABLE_AUTO_COMMIT_CONFIG, false);
properties.putIfAbsent(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "earliest");
return properties;
}
}
@@ -29,10 +29,14 @@
</dependency>
<!-- 消息队列相关 -->
<dependency>
<groupId>org.springframework.kafka</groupId>
<artifactId>spring-kafka</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.apache.rocketmq</groupId>
<artifactId>rocketmq-spring-boot-starter</artifactId>
<!-- TODO @芋艿:消息队列,后续可能去掉,默认不使用 rocketmq -->
<optional>true</optional>
</dependency>
@@ -12,6 +12,7 @@ import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentLinkedQueue;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
/**
@@ -19,6 +20,8 @@ import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.
* <p>
* 封装通用的定时器管理、per-device 请求队列限速逻辑。
* 子类只需实现 {@link #pollPoint(Long, Long)} 定义具体的轮询动作。
* 如需将多个点位合并为一个轮询任务,可覆盖 {@link #buildPollTasks(IotModbusDeviceConfigRespDTO)}
* 和 {@link #pollTask(Long, String)}。
* <p>
*
* @author 芋道源码
@@ -38,9 +41,9 @@ public abstract class AbstractIotModbusPollScheduler {
private static final int MAX_QUEUE_SIZE = 1000;
/**
* 设备点位的定时器映射:deviceId -> (pointId -> PointTimerInfo)
* 设备轮询任务的定时器映射:deviceId -> (taskKey -> PollTimerInfo)
*/
private final Map<Long, Map<Long, PointTimerInfo>> devicePointTimers = new ConcurrentHashMap<>();
private final Map<Long, Map<String, PollTimerInfo>> devicePollTimers = new ConcurrentHashMap<>();
/**
* per-device 请求队列:deviceId -> 待执行请求队列
@@ -60,11 +63,29 @@ public abstract class AbstractIotModbusPollScheduler {
}
/**
* 点位定时器信息
* 轮询任务信息
*/
@Data
@AllArgsConstructor
private static class PointTimerInfo {
protected static class PollTask {
/**
* 任务标识
*/
private String key;
/**
* 轮询间隔(用于判断是否需要更新定时器)
*/
private Integer pollInterval;
}
/**
* 轮询定时器信息
*/
@Data
@AllArgsConstructor
private static class PollTimerInfo {
/**
* Vert.x 定时器 ID
@@ -82,75 +103,84 @@ public abstract class AbstractIotModbusPollScheduler {
/**
* 更新轮询任务(增量更新)
*
* 1. 【删除】点位:停止对应的轮询定时器
* 2. 【新增】点位:创建对应的轮询定时器
* 3. 【修改】点位:pollInterval 变化,重建对应的轮询定时器
* 【修改】其他属性变化:不需要重建定时器(pollPoint 运行时从 configCache 取最新 point)
* 1. 【删除】任务:停止对应的轮询定时器
* 2. 【新增】任务:创建对应的轮询定时器
* 3. 【修改】任务:pollInterval 变化,重建对应的轮询定时器
* 【修改】其他属性变化:不需要重建定时器(pollTask 运行时从 configCache 取最新配置)
*/
public void updatePolling(IotModbusDeviceConfigRespDTO config) {
Long deviceId = config.getDeviceId();
List<IotModbusPointRespDTO> newPoints = config.getPoints();
Map<Long, PointTimerInfo> currentTimers = devicePointTimers
List<PollTask> newTasks = buildPollTasks(config);
Map<String, PollTimerInfo> currentTimers = devicePollTimers
.computeIfAbsent(deviceId, k -> new ConcurrentHashMap<>());
// 1.1 计算新配置中的点位 ID 集合
Set<Long> newPointIds = convertSet(newPoints, IotModbusPointRespDTO::getId);
// 1.2 计算删除的点位 ID 集合
Set<Long> removedPointIds = new HashSet<>(currentTimers.keySet());
removedPointIds.removeAll(newPointIds);
// 1.1 计算新配置中的任务 Key 集合
Set<String> newTaskKeys = convertSet(newTasks, PollTask::getKey);
// 1.2 计算删除的任务 Key 集合
Set<String> removedTaskKeys = new HashSet<>(currentTimers.keySet());
removedTaskKeys.removeAll(newTaskKeys);
// 2. 处理删除的点位:停止不再存在的定时器
for (Long pointId : removedPointIds) {
PointTimerInfo timerInfo = currentTimers.remove(pointId);
// 2. 处理删除的任务:停止不再存在的定时器
for (String taskKey : removedTaskKeys) {
PollTimerInfo timerInfo = currentTimers.remove(taskKey);
if (timerInfo != null) {
vertx.cancelTimer(timerInfo.getTimerId());
log.debug("[updatePolling][设备 {} 点位 {} 定时器已删除]", deviceId, pointId);
log.debug("[updatePolling][设备 {} 轮询任务 {} 定时器已删除]", deviceId, taskKey);
}
}
// 3. 处理新增和修改的点位
if (CollUtil.isEmpty(newPoints)) {
// 3. 处理新增和修改的任务
if (CollUtil.isEmpty(newTasks)) {
return;
}
for (IotModbusPointRespDTO point : newPoints) {
Long pointId = point.getId();
Integer newPollInterval = point.getPollInterval();
PointTimerInfo existingTimer = currentTimers.get(pointId);
// 3.1 新增点位:创建定时器
for (PollTask task : newTasks) {
String taskKey = task.getKey();
Integer newPollInterval = task.getPollInterval();
PollTimerInfo existingTimer = currentTimers.get(taskKey);
// 3.1 新增任务:创建定时器
if (existingTimer == null) {
Long timerId = createPollTimer(deviceId, pointId, newPollInterval);
Long timerId = createPollTimer(deviceId, taskKey, newPollInterval);
if (timerId != null) {
currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval));
log.debug("[updatePolling][设备 {} 点位 {} 定时器已创建, interval={}ms]",
deviceId, pointId, newPollInterval);
currentTimers.put(taskKey, new PollTimerInfo(timerId, newPollInterval));
log.debug("[updatePolling][设备 {} 轮询任务 {} 定时器已创建, interval={}ms]",
deviceId, taskKey, newPollInterval);
}
} else if (!Objects.equals(existingTimer.getPollInterval(), newPollInterval)) {
// 3.2 pollInterval 变化:重建定时器
vertx.cancelTimer(existingTimer.getTimerId());
Long timerId = createPollTimer(deviceId, pointId, newPollInterval);
Long timerId = createPollTimer(deviceId, taskKey, newPollInterval);
if (timerId != null) {
currentTimers.put(pointId, new PointTimerInfo(timerId, newPollInterval));
log.debug("[updatePolling][设备 {} 点位 {} 定时器已更新, interval={}ms -> {}ms]",
deviceId, pointId, existingTimer.getPollInterval(), newPollInterval);
currentTimers.put(taskKey, new PollTimerInfo(timerId, newPollInterval));
log.debug("[updatePolling][设备 {} 轮询任务 {} 定时器已更新, interval={}ms -> {}ms]",
deviceId, taskKey, existingTimer.getPollInterval(), newPollInterval);
} else {
currentTimers.remove(pointId);
currentTimers.remove(taskKey);
}
}
// 3.3 其他属性变化:无需重建定时器,因为 pollPoint() 运行时从 configCache 获取最新 point,自动使用新配置
// 3.3 其他属性变化:无需重建定时器,因为 pollTask() 运行时从 configCache 获取最新配置
}
}
/**
* 构建轮询任务列表
*
* 默认每个点位一个轮询任务。TCP Client 等协议可覆盖该方法,将多个点位合并为一个批量读取任务。
*/
protected List<PollTask> buildPollTasks(IotModbusDeviceConfigRespDTO config) {
return convertList(config.getPoints(), point -> new PollTask(String.valueOf(point.getId()), point.getPollInterval()));
}
/**
* 创建轮询定时器
*/
private Long createPollTimer(Long deviceId, Long pointId, Integer pollInterval) {
private Long createPollTimer(Long deviceId, String taskKey, Integer pollInterval) {
if (pollInterval == null || pollInterval <= 0) {
return null;
}
return vertx.setPeriodic(pollInterval, timerId -> {
try {
submitPollRequest(deviceId, pointId);
submitPollRequest(deviceId, taskKey);
} catch (Exception e) {
log.error("[createPollTimer][轮询点位失败, deviceId={}, pointId={}]", deviceId, pointId, e);
log.error("[createPollTimer][轮询任务失败, deviceId={}, taskKey={}]", deviceId, taskKey, e);
}
});
}
@@ -160,7 +190,7 @@ public abstract class AbstractIotModbusPollScheduler {
/**
* 提交轮询请求到设备请求队列(保证同设备请求间隔)
*/
private void submitPollRequest(Long deviceId, Long pointId) {
private void submitPollRequest(Long deviceId, String taskKey) {
// 1. 【重要】将请求添加到设备的请求队列
Queue<Runnable> queue = deviceRequestQueues.computeIfAbsent(deviceId, k -> new ConcurrentLinkedQueue<>());
while (queue.size() >= MAX_QUEUE_SIZE) {
@@ -168,7 +198,7 @@ public abstract class AbstractIotModbusPollScheduler {
queue.poll();
log.warn("[submitPollRequest][设备 {} 请求队列已满({}), 丢弃最旧请求]", deviceId, MAX_QUEUE_SIZE);
}
queue.offer(() -> pollPoint(deviceId, pointId));
queue.offer(() -> pollTask(deviceId, taskKey));
// 2. 处理设备请求队列(如果没有延迟 timer 在等待)
processDeviceQueue(deviceId);
@@ -238,6 +268,15 @@ public abstract class AbstractIotModbusPollScheduler {
// ========== 轮询执行 ==========
/**
* 轮询任务
*
* 默认将任务标识作为点位 ID,执行单点轮询。
*/
protected void pollTask(Long deviceId, String taskKey) {
pollPoint(deviceId, Long.valueOf(taskKey));
}
/**
* 轮询单个点位(子类实现具体的读取逻辑)
*
@@ -252,25 +291,25 @@ public abstract class AbstractIotModbusPollScheduler {
* 停止设备的轮询
*/
public void stopPolling(Long deviceId) {
Map<Long, PointTimerInfo> timers = devicePointTimers.remove(deviceId);
if (CollUtil.isEmpty(timers)) {
return;
}
for (PointTimerInfo timerInfo : timers.values()) {
vertx.cancelTimer(timerInfo.getTimerId());
Map<String, PollTimerInfo> timers = devicePollTimers.remove(deviceId);
if (CollUtil.isNotEmpty(timers)) {
for (PollTimerInfo timerInfo : timers.values()) {
vertx.cancelTimer(timerInfo.getTimerId());
}
}
// 清理请求队列
deviceRequestQueues.remove(deviceId);
deviceLastRequestTime.remove(deviceId);
deviceDelayTimerActive.remove(deviceId);
log.debug("[stopPolling][设备 {} 停止了 {} 个轮询定时器]", deviceId, timers.size());
log.debug("[stopPolling][设备 {} 停止了 {} 个轮询定时器]", deviceId,
CollUtil.isEmpty(timers) ? 0 : timers.size());
}
/**
* 停止所有轮询
*/
public void stopAll() {
for (Long deviceId : new ArrayList<>(devicePointTimers.keySet())) {
for (Long deviceId : new ArrayList<>(devicePollTimers.keySet())) {
stopPolling(deviceId);
}
}
@@ -39,11 +39,38 @@ public class IotModbusTcpClientUtils {
public static Future<int[]> read(IotModbusTcpClientConnectionManager.ModbusConnection connection,
Integer slaveId,
IotModbusPointRespDTO point) {
return read(connection, slaveId, point.getFunctionCode(),
point.getRegisterAddress(), point.getRegisterCount(), point.getIdentifier());
}
/**
* 读取 Modbus 数据
*
* @param connection Modbus 连接
* @param slaveId 从站地址
* @param functionCode 功能码
* @param registerAddress 寄存器起始地址
* @param registerCount 寄存器数量
* @return 原始值(int 数组)
*/
public static Future<int[]> read(IotModbusTcpClientConnectionManager.ModbusConnection connection,
Integer slaveId,
Integer functionCode,
Integer registerAddress,
Integer registerCount) {
return read(connection, slaveId, functionCode, registerAddress, registerCount, null);
}
private static Future<int[]> read(IotModbusTcpClientConnectionManager.ModbusConnection connection,
Integer slaveId,
Integer functionCode,
Integer registerAddress,
Integer registerCount,
String identifier) {
return connection.executeBlocking(tcpConnection -> {
try {
// 1. 创建请求
ModbusRequest request = createReadRequest(point.getFunctionCode(),
point.getRegisterAddress(), point.getRegisterCount());
ModbusRequest request = createReadRequest(functionCode, registerAddress, registerCount);
request.setUnitID(slaveId);
// 2. 执行事务(请求)
@@ -53,10 +80,10 @@ public class IotModbusTcpClientUtils {
// 3. 解析响应
ModbusResponse response = transaction.getResponse();
return extractValues(response, point.getFunctionCode());
return extractValues(response, functionCode);
} catch (Exception e) {
throw new RuntimeException(String.format("Modbus 读取失败 [slaveId=%d, identifier=%s, address=%d]",
slaveId, point.getIdentifier(), point.getRegisterAddress()), e);
throw new RuntimeException(String.format("Modbus 读取失败 [slaveId=%d, identifier=%s, functionCode=%d, address=%d, count=%d]",
slaveId, identifier, functionCode, registerAddress, registerCount), e);
}
});
}
@@ -9,8 +9,16 @@ import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbu
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusTcpClientUtils;
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.handler.upstream.IotModbusTcpClientUpstreamHandler;
import io.vertx.core.Vertx;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertList;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertMultiMap;
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.filterList;
/**
* IoT Modbus TCP Client 轮询调度器:管理点位的轮询定时器,调度读取任务并上报结果
*
@@ -35,6 +43,47 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu
// ========== 轮询执行 ==========
@Override
protected List<PollTask> buildPollTasks(IotModbusDeviceConfigRespDTO config) {
return convertList(buildReadSegments(config), segment -> new PollTask(segment.getKey(), segment.getPollInterval()));
}
/**
* 轮询读取段
*/
@Override
protected void pollTask(Long deviceId, String taskKey) {
// 1.1 从 configCache 获取最新配置
IotModbusDeviceConfigRespDTO config = configCacheService.getConfig(deviceId);
if (config == null || CollUtil.isEmpty(config.getPoints())) {
log.warn("[pollTask][设备 {} 没有配置]", deviceId);
return;
}
// 1.2 查找读取段。配置变化后,如果当前 taskKey 已不存在,直接跳过等待下一轮 updatePolling 清理 timer
ReadSegment segment = findReadSegment(config, taskKey);
if (segment == null) {
log.debug("[pollTask][设备 {} 读取段 {} 未找到,跳过陈旧轮询任务]", deviceId, taskKey);
return;
}
// 2.1 获取连接
IotModbusTcpClientConnectionManager.ModbusConnection connection = connectionManager.getConnection(deviceId);
if (connection == null) {
log.warn("[pollTask][设备 {} 没有连接]", deviceId);
return;
}
// 2.2 获取 slave ID
Integer slaveId = connectionManager.getSlaveId(deviceId);
Assert.notNull(slaveId, "设备 {} 没有配置 slaveId", deviceId);
// 3. 执行 Modbus 批量读取
IotModbusTcpClientUtils.read(connection, slaveId, segment.getFunctionCode(),
segment.getStartAddress(), segment.getRegisterCount())
.onSuccess(rawValues -> handleSegmentReadResult(config, segment, rawValues))
.onFailure(e -> log.error("[pollTask][读取点位段失败, deviceId={}, segment={}]",
deviceId, segment.getKey(), e));
}
/**
* 轮询单个点位
*/
@@ -70,4 +119,174 @@ public class IotModbusTcpClientPollScheduler extends AbstractIotModbusPollSchedu
deviceId, point.getIdentifier(), e));
}
private void handleSegmentReadResult(IotModbusDeviceConfigRespDTO config,
ReadSegment segment,
int[] rawValues) {
for (IotModbusPointRespDTO point : segment.getPoints()) {
// 批量读取返回的是整个连续地址段,需要按点位地址切片后再复用单点上报逻辑
int[] pointRawValues = extractPointRawValues(rawValues, segment, point);
if (pointRawValues == null) {
log.warn("[handleSegmentReadResult][读取段结果长度不足, deviceId={}, segment={}, identifier={}]",
config.getDeviceId(), segment.getKey(), point.getIdentifier());
continue;
}
upstreamHandler.handleReadResult(config, point, pointRawValues);
}
}
private ReadSegment findReadSegment(IotModbusDeviceConfigRespDTO config, String taskKey) {
return CollUtil.findOne(buildReadSegments(config), segment -> segment.getKey().equals(taskKey));
}
/**
* 构建批量读取段
*
* <p>只合并功能码、轮询间隔相同,且地址连续或重叠的点位;不跨功能码、不跨轮询间隔,避免改变原有轮询语义。
* 同时按 Modbus 协议限制控制单次读取长度,超过限制时拆成多个读取段。
*/
static List<ReadSegment> buildReadSegments(IotModbusDeviceConfigRespDTO config) {
if (config == null) {
return Collections.emptyList();
}
// 1. 按功能码和轮询间隔分组:两者任一不同,都不能共用同一次 Modbus 读请求
List<IotModbusPointRespDTO> validPoints = filterList(config.getPoints(), IotModbusTcpClientPollScheduler::isValidReadPoint);
if (CollUtil.isEmpty(validPoints)) {
return Collections.emptyList();
}
Map<SegmentGroupKey, List<IotModbusPointRespDTO>> pointsByGroup = convertMultiMap(validPoints,
point -> new SegmentGroupKey(point.getFunctionCode(), point.getPollInterval()));
// 2. 组内按地址排序后,合并连续或重叠区间,生成实际轮询的读取段
List<ReadSegment> segments = new ArrayList<>();
for (Map.Entry<SegmentGroupKey, List<IotModbusPointRespDTO>> entry : pointsByGroup.entrySet()) {
List<IotModbusPointRespDTO> points = entry.getValue();
points.sort(Comparator.comparing(IotModbusPointRespDTO::getRegisterAddress)
.thenComparing(IotModbusPointRespDTO::getRegisterCount)
.thenComparing(IotModbusPointRespDTO::getId));
buildReadSegments(entry.getKey(), points, segments);
}
// 3. 固定排序,保证生成的 taskKey 稳定,便于 updatePolling 做增量更新
segments.sort(Comparator.comparing(ReadSegment::getFunctionCode)
.thenComparing(ReadSegment::getPollInterval)
.thenComparing(ReadSegment::getStartAddress));
return segments;
}
private static void buildReadSegments(SegmentGroupKey groupKey,
List<IotModbusPointRespDTO> points,
List<ReadSegment> segments) {
ReadSegment current = null;
int maxRegisterCount = getMaxRegisterCount(groupKey.getFunctionCode());
// points 已按 registerAddress 排序,因此可以线性合并连续/重叠地址段
for (IotModbusPointRespDTO point : points) {
int pointStartAddress = point.getRegisterAddress();
int pointEndAddress = pointStartAddress + point.getRegisterCount();
// 1. 当前点位无法合并时,新建一个读取段
if (current == null || !canMerge(current, pointStartAddress, pointEndAddress, maxRegisterCount)) {
current = new ReadSegment(groupKey.getFunctionCode(), groupKey.getPollInterval(),
pointStartAddress, point.getRegisterCount(), new ArrayList<>());
segments.add(current);
} else {
// 2. 当前点位可合并时,扩展读取段覆盖范围
current.setRegisterCount(Math.max(current.getEndAddress(), pointEndAddress) - current.getStartAddress());
}
// 3. 记录读取段包含的点位,读取成功后按点位逐个切片上报
current.getPoints().add(point);
}
}
/**
* 判断点位是否可以合并到当前读取段
*
* <p>仅合并连续或重叠区间,不合并存在地址空洞的区间,避免额外读取无关寄存器。
*/
private static boolean canMerge(ReadSegment segment, int pointStartAddress, int pointEndAddress, int maxRegisterCount) {
if (pointStartAddress > segment.getEndAddress()) {
return false;
}
int mergedRegisterCount = Math.max(segment.getEndAddress(), pointEndAddress) - segment.getStartAddress();
return mergedRegisterCount <= maxRegisterCount;
}
/**
* 从批量读取结果中提取单个点位的原始值
*
* <p>例如读取段从地址 10 开始,点位地址为 12、数量为 2,则取 rawValues[2..4)。
*/
static int[] extractPointRawValues(int[] rawValues, ReadSegment segment, IotModbusPointRespDTO point) {
if (rawValues == null) {
return null;
}
// 1. 计算点位在批量读取结果中的相对偏移
int offset = point.getRegisterAddress() - segment.getStartAddress();
int end = offset + point.getRegisterCount();
// 2. 防御异常响应长度,避免越界影响同一读取段内其它点位
if (offset < 0 || end > rawValues.length) {
return null;
}
// 3. 返回单个点位需要的原始寄存器值
return Arrays.copyOfRange(rawValues, offset, end);
}
private static boolean isValidReadPoint(IotModbusPointRespDTO point) {
return point != null
&& point.getId() != null
&& point.getFunctionCode() != null
&& point.getRegisterAddress() != null
&& point.getRegisterCount() != null
&& point.getRegisterCount() > 0
&& point.getPollInterval() != null
&& point.getPollInterval() > 0;
}
@SuppressWarnings("EnhancedSwitchMigration")
private static int getMaxRegisterCount(Integer functionCode) {
switch (functionCode) {
case IotModbusCommonUtils.FC_READ_COILS:
case IotModbusCommonUtils.FC_READ_DISCRETE_INPUTS:
return 2000;
case IotModbusCommonUtils.FC_READ_HOLDING_REGISTERS:
case IotModbusCommonUtils.FC_READ_INPUT_REGISTERS:
return 125;
default:
return 0;
}
}
/**
* 读取段分组 Key
*/
@Data
@AllArgsConstructor
static class SegmentGroupKey {
private Integer functionCode;
private Integer pollInterval;
}
/**
* 一次 Modbus 批量读请求对应的连续地址段
*/
@Data
@AllArgsConstructor
static class ReadSegment {
private Integer functionCode;
private Integer pollInterval;
private Integer startAddress;
private Integer registerCount;
private List<IotModbusPointRespDTO> points;
String getKey() {
return functionCode + ":" + pollInterval + ":" + startAddress + ":" + registerCount;
}
int getEndAddress() {
return startAddress + registerCount;
}
}
}
@@ -21,6 +21,10 @@ rocketmq:
producer:
group: ${spring.application.name}_PRODUCER # 生产者分组
# Kafka 配置项,对应 KafkaProperties 配置类
kafka:
bootstrap-servers: 127.0.0.1:9092 # 指定 Kafka Broker 地址,可以设置多个,以逗号分隔
--- #################### IoT 网关相关配置 ####################
yudao:
@@ -0,0 +1,147 @@
package cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager;
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusPointRespDTO;
import cn.iocoder.yudao.module.iot.gateway.protocol.modbus.tcpclient.manager.IotModbusTcpClientPollScheduler.ReadSegment;
import org.junit.jupiter.api.Test;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_COILS;
import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_HOLDING_REGISTERS;
import static cn.iocoder.yudao.module.iot.gateway.protocol.modbus.common.utils.IotModbusCommonUtils.FC_READ_INPUT_REGISTERS;
import static org.junit.jupiter.api.Assertions.*;
/**
* {@link IotModbusTcpClientPollScheduler} 的单元测试
*
* @author 芋道源码
*/
public class IotModbusTcpClientPollSchedulerTest {
@Test
public void testBuildReadSegments_mergeContinuousPoints() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 1, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_HOLDING_REGISTERS, 1, 2, 1000);
IotModbusPointRespDTO point03 = randomPoint(3L, FC_READ_HOLDING_REGISTERS, 4, 1, 1000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02, point03);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(2, segments.size());
assertEquals(0, segments.get(0).getStartAddress());
assertEquals(3, segments.get(0).getRegisterCount());
assertIterableEquals(Arrays.asList(point01, point02), segments.get(0).getPoints());
assertEquals(4, segments.get(1).getStartAddress());
assertEquals(1, segments.get(1).getRegisterCount());
assertIterableEquals(Collections.singletonList(point03), segments.get(1).getPoints());
}
@Test
public void testBuildReadSegments_mergeOverlappingPoints() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 2, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_HOLDING_REGISTERS, 1, 1, 1000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(1, segments.size());
assertEquals(0, segments.get(0).getStartAddress());
assertEquals(2, segments.get(0).getRegisterCount());
assertIterableEquals(Arrays.asList(point01, point02), segments.get(0).getPoints());
}
@Test
public void testBuildReadSegments_notMergeDifferentFunctionCodeOrPollInterval() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 1, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_INPUT_REGISTERS, 1, 1, 1000);
IotModbusPointRespDTO point03 = randomPoint(3L, FC_READ_HOLDING_REGISTERS, 1, 1, 2000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02, point03);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(3, segments.size());
assertEquals(FC_READ_HOLDING_REGISTERS, segments.get(0).getFunctionCode());
assertEquals(1000, segments.get(0).getPollInterval());
assertEquals(FC_READ_HOLDING_REGISTERS, segments.get(1).getFunctionCode());
assertEquals(2000, segments.get(1).getPollInterval());
assertEquals(FC_READ_INPUT_REGISTERS, segments.get(2).getFunctionCode());
assertEquals(1000, segments.get(2).getPollInterval());
}
@Test
public void testBuildReadSegments_splitWhenExceedsMaxRegisterCount() {
// 准备参数
IotModbusPointRespDTO point01 = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 0, 100, 1000);
IotModbusPointRespDTO point02 = randomPoint(2L, FC_READ_HOLDING_REGISTERS, 100, 30, 1000);
IotModbusDeviceConfigRespDTO config = randomConfig(point01, point02);
// 调用
List<ReadSegment> segments = IotModbusTcpClientPollScheduler.buildReadSegments(config);
// 断言
assertEquals(2, segments.size());
assertEquals(0, segments.get(0).getStartAddress());
assertEquals(100, segments.get(0).getRegisterCount());
assertEquals(100, segments.get(1).getStartAddress());
assertEquals(30, segments.get(1).getRegisterCount());
}
@Test
public void testExtractPointRawValues() {
// 准备参数
IotModbusPointRespDTO point = randomPoint(1L, FC_READ_HOLDING_REGISTERS, 12, 2, 1000);
ReadSegment segment = new ReadSegment(FC_READ_HOLDING_REGISTERS, 1000, 10, 4, Collections.singletonList(point));
int[] rawValues = new int[]{100, 200, 300, 400};
// 调用
int[] pointRawValues = IotModbusTcpClientPollScheduler.extractPointRawValues(rawValues, segment, point);
// 断言
assertArrayEquals(new int[]{300, 400}, pointRawValues);
}
@Test
public void testExtractPointRawValuesForCoils() {
// 准备参数
IotModbusPointRespDTO point = randomPoint(1L, FC_READ_COILS, 3, 2, 1000);
ReadSegment segment = new ReadSegment(FC_READ_COILS, 1000, 0, 5, Collections.singletonList(point));
int[] rawValues = new int[]{1, 0, 1, 1, 0, 0, 0, 0}; // 线圈响应可能按字节补齐
// 调用
int[] pointRawValues = IotModbusTcpClientPollScheduler.extractPointRawValues(rawValues, segment, point);
// 断言
assertArrayEquals(new int[]{1, 0}, pointRawValues);
}
private static IotModbusDeviceConfigRespDTO randomConfig(IotModbusPointRespDTO... points) {
IotModbusDeviceConfigRespDTO config = new IotModbusDeviceConfigRespDTO();
config.setDeviceId(1L);
config.setPoints(Arrays.asList(points));
return config;
}
private static IotModbusPointRespDTO randomPoint(Long id, Integer functionCode, Integer registerAddress,
Integer registerCount, Integer pollInterval) {
IotModbusPointRespDTO point = new IotModbusPointRespDTO();
point.setId(id);
point.setFunctionCode(functionCode);
point.setRegisterAddress(registerAddress);
point.setRegisterCount(registerCount);
point.setPollInterval(pollInterval);
return point;
}
}
@@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.promotion.controller.admin.coupon.vo.template.CouponTemplatePageReqVO;
import cn.iocoder.yudao.module.promotion.dal.dataobject.coupon.CouponTemplateDO;
import cn.iocoder.yudao.module.promotion.enums.coupon.CouponTemplateValidityTypeEnum;
@@ -34,7 +35,7 @@ public interface CouponTemplateMapper extends BaseMapperX<CouponTemplateDO> {
.eqIfPresent(CouponTemplateDO::getDiscountType, reqVO.getDiscountType())
.betweenIfPresent(CouponTemplateDO::getCreateTime, reqVO.getCreateTime())
.eqIfPresent(CouponTemplateDO::getProductScope, reqVO.getProductScope())
.and(reqVO.getProductScopeValue() != null, w -> w.apply("FIND_IN_SET({0}, product_scope_values)",
.and(reqVO.getProductScopeValue() != null, w -> w.apply(MyBatisUtils.findInSet("product_scope_values"),
reqVO.getProductScopeValue()))
.and(canTakeConsumer != null, canTakeConsumer)
.orderByDesc(CouponTemplateDO::getId));
@@ -61,7 +62,7 @@ public interface CouponTemplateMapper extends BaseMapperX<CouponTemplateDO> {
Consumer<LambdaQueryWrapper<CouponTemplateDO>> canTakeConsumer = buildCanTakeQueryConsumer(canTakeTypes);
return selectList(new LambdaQueryWrapperX<CouponTemplateDO>()
.eqIfPresent(CouponTemplateDO::getProductScope, productScope)
.and(productScopeValue != null, w -> w.apply("FIND_IN_SET({0}, product_scope_values)",
.and(productScopeValue != null, w -> w.apply(MyBatisUtils.findInSet("product_scope_values"),
productScopeValue))
.and(canTakeConsumer != null, canTakeConsumer)
.last(" LIMIT " + count)
@@ -1,9 +1,9 @@
package cn.iocoder.yudao.module.promotion.dal.mysql.reward;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.promotion.controller.admin.reward.vo.RewardActivityPageReqVO;
import cn.iocoder.yudao.module.promotion.dal.dataobject.reward.RewardActivityDO;
import cn.iocoder.yudao.module.promotion.enums.common.PromotionProductScopeEnum;
@@ -14,6 +14,7 @@ import java.util.Collection;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* 满减送活动 Mapper
@@ -34,18 +35,18 @@ public interface RewardActivityMapper extends BaseMapperX<RewardActivityDO> {
Collection<Long> categoryIds,
Integer status) {
LocalDateTime now = LocalDateTime.now();
Function<Collection<Long>, String> productScopeValuesFindInSetFunc = ids -> ids.stream()
.map(id -> StrUtil.format("FIND_IN_SET({}, product_scope_values) ", id))
Function<Collection<Long>, String> productScopeValuesFindInSetFunc = ids -> IntStream.range(0, ids.size())
.mapToObj(index -> MyBatisUtils.findInSetWithParamIndex("product_scope_values", index))
.collect(Collectors.joining(" OR "));
return selectList(new LambdaQueryWrapperX<RewardActivityDO>()
.eq(RewardActivityDO::getStatus, status)
.lt(RewardActivityDO::getStartTime, now)
.gt(RewardActivityDO::getEndTime, now)
.and(i -> i.eq(RewardActivityDO::getProductScope, PromotionProductScopeEnum.SPU.getScope())
.and(i1 -> i1.apply(productScopeValuesFindInSetFunc.apply(spuIds)))
.and(i1 -> i1.apply(productScopeValuesFindInSetFunc.apply(spuIds), spuIds.toArray()))
.or(i1 -> i1.eq(RewardActivityDO::getProductScope, PromotionProductScopeEnum.ALL.getScope()))
.or(i1 -> i1.eq(RewardActivityDO::getProductScope, PromotionProductScopeEnum.CATEGORY.getScope())
.and(i2 -> i2.apply(productScopeValuesFindInSetFunc.apply(categoryIds)))))
.and(i2 -> i2.apply(productScopeValuesFindInSetFunc.apply(categoryIds), categoryIds.toArray()))))
.orderByDesc(RewardActivityDO::getId)
);
}
@@ -5,6 +5,7 @@ import cn.hutool.core.util.ObjectUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.promotion.controller.admin.seckill.vo.activity.SeckillActivityPageReqVO;
import cn.iocoder.yudao.module.promotion.controller.app.seckill.vo.activity.AppSeckillActivityPageReqVO;
import cn.iocoder.yudao.module.promotion.dal.dataobject.seckill.SeckillActivityDO;
@@ -27,7 +28,7 @@ public interface SeckillActivityMapper extends BaseMapperX<SeckillActivityDO> {
.likeIfPresent(SeckillActivityDO::getName, reqVO.getName())
.eqIfPresent(SeckillActivityDO::getStatus, reqVO.getStatus())
.betweenIfPresent(SeckillActivityDO::getCreateTime, reqVO.getCreateTime())
.apply(ObjectUtil.isNotNull(reqVO.getConfigId()), "FIND_IN_SET(" + reqVO.getConfigId() + ", config_ids) > 0")
.apply(ObjectUtil.isNotNull(reqVO.getConfigId()), MyBatisUtils.findInSet("config_ids"), reqVO.getConfigId())
.orderByDesc(SeckillActivityDO::getId));
}
@@ -70,7 +71,7 @@ public interface SeckillActivityMapper extends BaseMapperX<SeckillActivityDO> {
.eqIfPresent(SeckillActivityDO::getStatus, status)
.lt(SeckillActivityDO::getStartTime, dateTime)
.gt(SeckillActivityDO::getEndTime, dateTime)// 开始时间 < 指定时间 < 结束时间,也就是说获取指定时间段的活动
.apply(ObjectUtil.isNotNull(pageReqVO.getConfigId()), "FIND_IN_SET(" + pageReqVO.getConfigId() + ",config_ids) > 0"));
.apply(ObjectUtil.isNotNull(pageReqVO.getConfigId()), MyBatisUtils.findInSet("config_ids"), pageReqVO.getConfigId()));
}
default SeckillActivityDO selectBySpuIdAndStatusAndNow(Long spuId, Integer status) {
@@ -2,10 +2,10 @@ package cn.iocoder.yudao.module.member.dal.mysql.user;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.lang.Assert;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
import cn.iocoder.yudao.module.member.controller.admin.user.vo.MemberUserPageReqVO;
import cn.iocoder.yudao.module.member.dal.dataobject.user.MemberUserDO;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
@@ -13,6 +13,7 @@ import org.apache.ibatis.annotations.Mapper;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.IntStream;
/**
* 会员 User Mapper
@@ -38,10 +39,12 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
default PageResult<MemberUserDO> selectPage(MemberUserPageReqVO reqVO) {
// 处理 tagIds 过滤条件
String tagIdSql = "";
Object[] tagIdParams = new Object[0];
if (CollUtil.isNotEmpty(reqVO.getTagIds())) {
tagIdSql = reqVO.getTagIds().stream()
.map(tagId -> "FIND_IN_SET(" + tagId + ", tag_ids)")
tagIdSql = IntStream.range(0, reqVO.getTagIds().size())
.mapToObj(index -> MyBatisUtils.findInSetWithParamIndex("tag_ids", index))
.collect(Collectors.joining(" OR "));
tagIdParams = reqVO.getTagIds().toArray();
}
// 分页查询
return selectPage(reqVO, new LambdaQueryWrapperX<MemberUserDO>()
@@ -52,7 +55,7 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
.betweenIfPresent(MemberUserDO::getCreateTime, reqVO.getCreateTime())
.eqIfPresent(MemberUserDO::getLevelId, reqVO.getLevelId())
.eqIfPresent(MemberUserDO::getGroupId, reqVO.getGroupId())
.apply(StrUtil.isNotEmpty(tagIdSql), tagIdSql)
.apply(CollUtil.isNotEmpty(reqVO.getTagIds()), tagIdSql, tagIdParams)
.orderByDesc(MemberUserDO::getId));
}
@@ -66,7 +69,7 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
default Long selectCountByTagId(Long tagId) {
return selectCount(new LambdaQueryWrapperX<MemberUserDO>()
.apply("FIND_IN_SET({0}, tag_ids)", tagId));
.apply(MyBatisUtils.findInSet("tag_ids"), tagId));
}
/**
@@ -24,7 +24,7 @@ public interface MesQcTemplateMapper extends BaseMapperX<MesQcTemplateDO> {
.likeIfPresent(MesQcTemplateDO::getName, reqVO.getName())
.orderByDesc(MesQcTemplateDO::getId);
if (reqVO.getType() != null) {
query.apply(MyBatisUtils.findInSet("types", reqVO.getType()));
query.apply(MyBatisUtils.findInSet("types"), reqVO.getType());
}
query.eqIfPresent(MesQcTemplateDO::getStatus, reqVO.getStatus());
return selectPage(reqVO, query);
@@ -32,6 +32,8 @@ import static cn.iocoder.yudao.module.mes.enums.ErrorCodeConstants.*;
@Validated
public class MesMdAutoCodeRecordServiceImpl implements MesMdAutoCodeRecordService {
private static final int GENERATE_MAX_RETRY_COUNT = 10;
@Resource
private MesMdAutoCodeRecordMapper recordMapper;
@@ -63,10 +65,34 @@ public class MesMdAutoCodeRecordServiceImpl implements MesMdAutoCodeRecordServic
throw exception(AUTO_CODE_GENERATE_FAILED);
}
// 2.1 构建上下文
MesMdAutoCodeContext context = new MesMdAutoCodeContext()
.setRule(rule).setParts(parts).setInputChar(inputChar);
// 2.2 遍历分段,生成编码
// 2. 循环生成编码,跳过因 Redis 流水号落后导致的历史重复编码
for (int i = 0; i < GENERATE_MAX_RETRY_COUNT; i++) {
// 2.1 生成编码
MesMdAutoCodeContext context = new MesMdAutoCodeContext()
.setRule(rule).setParts(parts).setInputChar(inputChar);
String result = generateCode(rule, parts, context);
// 2.2 二次校验(防止重复)
MesMdAutoCodeRecordDO existRecord = recordMapper.selectByResult(result);
if (existRecord != null) {
continue;
}
// 2.3 保存生成记录
MesMdAutoCodeRecordDO record = new MesMdAutoCodeRecordDO()
.setRuleId(rule.getId()).setResult(result)
.setSerialNo(context.getSerialNo()).setInputChar(inputChar);
recordMapper.insert(record);
return result;
}
// 3. 重试多次后仍然失败,抛出异常
throw exception(AUTO_CODE_GENERATE_FAILED);
}
/**
* 生成编码
*/
private String generateCode(MesMdAutoCodeRuleDO rule, List<MesMdAutoCodePartDO> parts,
MesMdAutoCodeContext context) {
StringBuilder codeBuilder = new StringBuilder();
for (MesMdAutoCodePartDO part : parts) {
MesMdAutoCodePartStrategy strategy = strategyMap.get(part.getType());
@@ -78,22 +104,11 @@ public class MesMdAutoCodeRecordServiceImpl implements MesMdAutoCodeRecordServic
// 拼接分段编码
codeBuilder.append(partCode);
}
// 2.3 补齐处理
// 补齐处理
String result = codeBuilder.toString();
if (Boolean.TRUE.equals(rule.getPadded()) && rule.getMaxLength() != null) {
result = padCode(result, rule);
}
// 3.1 二次校验(防止重复)
MesMdAutoCodeRecordDO existRecord = recordMapper.selectByResult(result);
if (existRecord != null) {
throw exception(AUTO_CODE_GENERATE_FAILED);
}
// 3.2 保存生成记录
MesMdAutoCodeRecordDO record = new MesMdAutoCodeRecordDO()
.setRuleId(rule.getId()).setResult(result)
.setSerialNo(context.getSerialNo()).setInputChar(inputChar);
recordMapper.insert(record);
return result;
}
@@ -98,6 +98,42 @@ public class MesMdAutoCodeRecordServiceImplTest {
assertEquals(1, record.getSerialNo());
}
@Test
public void testGenerateAutoCode_retryWhenResultExists() {
// 准备参数
String ruleCode = "DV_MACHINERY_CODE";
MesMdAutoCodeRuleDO rule = new MesMdAutoCodeRuleDO().setId(5L).setCode(ruleCode).setPadded(false);
MesMdAutoCodePartDO part1 = new MesMdAutoCodePartDO().setType(MesMdAutoCodePartTypeEnum.FIXED_CHAR.getType())
.setFixCharacter("M").setLength(1).setSort(1);
MesMdAutoCodePartDO part2 = new MesMdAutoCodePartDO().setType(MesMdAutoCodePartTypeEnum.SERIAL_NUMBER.getType())
.setSerialStartNo(1).setSerialStep(1).setLength(5).setSort(2).setCycleFlag(false);
// mock
when(ruleService.getAutoCodeRuleByCode(ruleCode)).thenReturn(rule);
when(partService.getAutoCodePartListByRuleId(5L)).thenReturn(Arrays.asList(part1, part2));
when(fixedCharStrategy.generate(eq(part1), any(MesMdAutoCodeContext.class))).thenReturn("M");
long[] serialNoRef = {0L};
when(serialNumberStrategy.generate(eq(part2), any(MesMdAutoCodeContext.class))).thenAnswer(invocation -> {
MesMdAutoCodeContext context = invocation.getArgument(1);
long serialNo = ++serialNoRef[0];
context.setSerialNo(serialNo);
return String.format("%05d", serialNo);
});
when(recordMapper.selectByResult("M00001")).thenReturn(new MesMdAutoCodeRecordDO());
when(recordMapper.selectByResult("M00002")).thenReturn(new MesMdAutoCodeRecordDO());
when(recordMapper.selectByResult("M00003")).thenReturn(null);
// 调用
String result = recordService.generateAutoCode(ruleCode, null);
// 断言
assertEquals("M00003", result);
ArgumentCaptor<MesMdAutoCodeRecordDO> captor = ArgumentCaptor.forClass(MesMdAutoCodeRecordDO.class);
verify(recordMapper).insert(captor.capture());
MesMdAutoCodeRecordDO record = captor.getValue();
assertEquals(5L, record.getRuleId());
assertEquals("M00003", record.getResult());
assertEquals(3L, record.getSerialNo());
}
@Test
public void testGenerateAutoCode_withInputChar() {
// 准备参数
@@ -26,6 +26,14 @@ public interface PayTransferMapper extends BaseMapperX<PayTransferDO> {
.eq(PayTransferDO::getStatus, whereStatus));
}
default int updateChannelPackageInfoIfAbsent(Long id, String channelPackageInfo) {
return update(new PayTransferDO().setChannelPackageInfo(channelPackageInfo),
new LambdaQueryWrapper<PayTransferDO>()
.eq(PayTransferDO::getId, id)
.and(wrapper -> wrapper.isNull(PayTransferDO::getChannelPackageInfo)
.or().eq(PayTransferDO::getChannelPackageInfo, "")));
}
default PayTransferDO selectByAppIdAndMerchantOrderId(Long appId, String merchantOrderId) {
return selectOne(PayTransferDO::getAppId, appId,
PayTransferDO::getMerchantTransferId, merchantOrderId);
@@ -59,7 +67,3 @@ public interface PayTransferMapper extends BaseMapperX<PayTransferDO> {
}
}
@@ -2,6 +2,7 @@ package cn.iocoder.yudao.module.pay.service.transfer;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
@@ -162,6 +163,7 @@ public class PayTransferServiceImpl implements PayTransferService {
throw exception(PAY_TRANSFER_NOT_FOUND);
}
if (PayTransferStatusEnum.isProcessing(transfer.getStatus())) { // 如果已经是转账中,直接返回,不用重复更新
updateChannelPackageInfoIfAbsent(transfer, notify);
log.info("[notifyTransferProgressing][transfer({}) 已经是转账中状态,无需更新]", transfer.getId());
return;
}
@@ -175,11 +177,35 @@ public class PayTransferServiceImpl implements PayTransferService {
new PayTransferDO().setStatus(PayTransferStatusEnum.PROCESSING.getStatus())
.setChannelPackageInfo(notify.getChannelPackageInfo()));
if (updateCounts == 0) {
PayTransferDO latestTransfer = transferMapper.selectById(transfer.getId());
if (latestTransfer != null && PayTransferStatusEnum.isProcessing(latestTransfer.getStatus())) {
updateChannelPackageInfoIfAbsent(latestTransfer, notify);
log.info("[notifyTransferProgressing][transfer({}) 已被并发更新为转账中状态,无需重复更新]",
transfer.getId());
return;
}
throw exception(PAY_TRANSFER_NOTIFY_FAIL_STATUS_IS_NOT_WAITING);
}
log.info("[notifyTransferProgressing][transfer({}) 更新为转账进行中状态]", transfer.getId());
}
/**
* 补充渠道 package 信息:处理同步任务先更新为转账中,发起转账接口后返回 channelPackageInfo 的场景
*
* @see <a href="https://github.com/YunaiV/ruoyi-vue-pro/issues/1144">Issue #1144</a>
*/
private void updateChannelPackageInfoIfAbsent(PayTransferDO transfer, PayTransferRespDTO notify) {
if (StrUtil.isBlank(notify.getChannelPackageInfo())
|| StrUtil.isNotBlank(transfer.getChannelPackageInfo())) {
return;
}
int updateCount = transferMapper.updateChannelPackageInfoIfAbsent(transfer.getId(),
notify.getChannelPackageInfo());
if (updateCount > 0) {
log.info("[updateChannelPackageInfoIfAbsent][transfer({}) 补充渠道 package 信息]", transfer.getId());
}
}
private void notifyTransferSuccess(PayChannelDO channel, PayTransferRespDTO notify) {
// 1. 校验状态
PayTransferDO transfer = transferMapper.selectByAppIdAndNo(channel.getAppId(), notify.getOutTransferNo());
@@ -0,0 +1,91 @@
package cn.iocoder.yudao.module.pay.service.transfer;
import cn.iocoder.yudao.framework.test.core.ut.BaseDbAndRedisUnitTest;
import cn.iocoder.yudao.module.pay.dal.dataobject.channel.PayChannelDO;
import cn.iocoder.yudao.module.pay.dal.dataobject.transfer.PayTransferDO;
import cn.iocoder.yudao.module.pay.dal.mysql.transfer.PayTransferMapper;
import cn.iocoder.yudao.module.pay.dal.redis.no.PayNoRedisDAO;
import cn.iocoder.yudao.module.pay.enums.transfer.PayTransferStatusEnum;
import cn.iocoder.yudao.module.pay.framework.pay.config.PayProperties;
import cn.iocoder.yudao.module.pay.framework.pay.core.client.dto.transfer.PayTransferRespDTO;
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 org.junit.jupiter.api.Test;
import org.springframework.context.annotation.Import;
import org.springframework.test.context.bean.override.mockito.MockitoBean;
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* {@link PayTransferServiceImpl} 的单元测试类
*
* @author 芋道源码
*/
@Import({PayTransferServiceImpl.class, PayNoRedisDAO.class})
public class PayTransferServiceTest extends BaseDbAndRedisUnitTest {
@Resource
private PayTransferServiceImpl transferService;
@Resource
private PayTransferMapper transferMapper;
@MockitoBean
private PayProperties payProperties;
@MockitoBean
private PayAppService appService;
@MockitoBean
private PayChannelService channelService;
@MockitoBean
private PayNotifyService notifyService;
@Test
public void testNotifyTransferProgressing_fillChannelPackageInfoIfAbsent() {
// mock 数据(PayTransferDO):同步任务先把 WAITING 更新为 PROCESSING,但是不返回 package 信息
PayTransferDO transfer = randomPojo(PayTransferDO.class,
o -> o.setAppId(10L)
.setNo("T110")
.setStatus(PayTransferStatusEnum.PROCESSING.getStatus())
.setChannelPackageInfo(null));
transferMapper.insert(transfer);
// 准备参数
PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setAppId(10L));
PayTransferRespDTO createNotify = PayTransferRespDTO.processingOf("WX_TRANSFER_110",
"T110", "create");
createNotify.setChannelPackageInfo("package-info-110");
// 调用:模拟发起转账接口随后返回 PROCESSING,并携带微信确认收款 package 信息
transferService.notifyTransfer(channel, createNotify);
// 断言:已是 PROCESSING 时仍会补写 package 信息
PayTransferDO dbTransfer = transferMapper.selectById(transfer.getId());
assertEquals(PayTransferStatusEnum.PROCESSING.getStatus(), dbTransfer.getStatus());
assertEquals("package-info-110", dbTransfer.getChannelPackageInfo());
}
@Test
public void testNotifyTransferProgressing_notOverwriteChannelPackageInfo() {
// mock 数据(PayTransferDO):已经存在 package 信息
PayTransferDO transfer = randomPojo(PayTransferDO.class,
o -> o.setAppId(10L)
.setNo("T110")
.setStatus(PayTransferStatusEnum.PROCESSING.getStatus())
.setChannelPackageInfo("package-info-110"));
transferMapper.insert(transfer);
// 准备参数
PayChannelDO channel = randomPojo(PayChannelDO.class, o -> o.setAppId(10L));
PayTransferRespDTO syncNotify = PayTransferRespDTO.processingOf("WX_TRANSFER_110",
"T110", "sync");
// 调用:后续同步任务不返回 package 信息
transferService.notifyTransfer(channel, syncNotify);
// 断言:已有 package 信息不被空值覆盖
PayTransferDO dbTransfer = transferMapper.selectById(transfer.getId());
assertEquals("package-info-110", dbTransfer.getChannelPackageInfo());
}
}
@@ -165,12 +165,14 @@ CREATE TABLE IF NOT EXISTS `pay_transfer` (
`user_name` varchar(64) NULL DEFAULT NULL,
`status` tinyint(4) NOT NULL,
`notify_url` varchar(1024) NULL DEFAULT NULL,
`user_ip` varchar(50) NULL DEFAULT NULL,
`channel_transfer_no` varchar(64) NULL DEFAULT NULL,
`success_time` datetime(0) NULL DEFAULT NULL,
`channel_error_code` varchar(128) NULL DEFAULT NULL,
`channel_error_msg` varchar(256) NULL DEFAULT NULL,
`channel_notify_data` varchar(1024) NULL DEFAULT NULL,
`channel_extras` varchar(1024) NULL DEFAULT NULL,
`channel_package_info` varchar(1024) NULL DEFAULT NULL,
`creator` varchar(64) NULL DEFAULT '',
`create_time` datetime(0) NOT NULL DEFAULT CURRENT_TIMESTAMP,
`updater` varchar(64) NULL DEFAULT '',
@@ -21,7 +21,7 @@ public interface MailLogMapper extends BaseMapperX<MailLogDO> {
.eqIfPresent(MailLogDO::getSendStatus, reqVO.getSendStatus())
.betweenIfPresent(MailLogDO::getSendTime, reqVO.getSendTime())
.apply(StrUtil.isNotBlank(reqVO.getToMail()),
MyBatisUtils.findInSet("to_mails", reqVO.getToMail()))
MyBatisUtils.findInSet("to_mails"), reqVO.getToMail())
.orderByDesc(MailLogDO::getId));
}
@@ -29,7 +29,7 @@ public interface TenantMapper extends BaseMapperX<TenantDO> {
default List<TenantDO> selectListByWebsite(String website) {
return selectList(new LambdaQueryWrapperX<TenantDO>()
.apply(MyBatisUtils.findInSet("websites", website)));
.apply(MyBatisUtils.findInSet("websites"), website));
}
default Long selectCountByPackageId(Long packageId) {
@@ -160,8 +160,8 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserId(2L)));
// 测试 userType 不匹配
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setUserType(UserTypeEnum.MEMBER.getValue())));
// 测试 toMails 不匹配(特殊:find_in_set 无法单测)
// mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setToMails(Lists.newArrayList("788@qq.com"))));
// 测试 toMails 不匹配
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setToMails(Lists.newArrayList("788@qq.com"))));
// 测试 accountId 不匹配
mailLogMapper.insert(cloneIgnoreId(dbMailLog, o -> o.setAccountId(11L)));
// 测试 templateId 不匹配
@@ -174,7 +174,7 @@ public class MailLogServiceImplTest extends BaseDbUnitTest {
MailLogPageReqVO reqVO = new MailLogPageReqVO();
reqVO.setUserId(1L);
reqVO.setUserType(UserTypeEnum.ADMIN.getValue());
// reqVO.setToMail("768@qq.com");
reqVO.setToMail("768@qq.com");
reqVO.setAccountId(10L);
reqVO.setTemplateId(100L);
reqVO.setSendStatus(MailSendStatusEnum.INIT.getStatus());
@@ -21,7 +21,6 @@ import cn.iocoder.yudao.module.system.service.tenant.handler.TenantInfoHandler;
import cn.iocoder.yudao.module.system.service.tenant.handler.TenantMenuHandler;
import cn.iocoder.yudao.module.system.service.user.AdminUserService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Disabled;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.context.annotation.Import;
@@ -334,7 +333,6 @@ public class TenantServiceImplTest extends BaseDbUnitTest {
}
@Test
@Disabled // H2 不支持 find_in_set 函数
public void testGetTenantByWebsite() {
// mock 数据
TenantDO dbTenant = randomPojo(TenantDO.class, o -> o.setWebsites(singletonList("https://www.iocoder.cn")));