mirror of
https://gitee.com/yudaocode/yudao-boot-mini.git
synced 2026-10-06 14:13:14 +08:00
Merge branch 'master-jdk17' of https://gitee.com/zhijiantianya/ruoyi-vue-pro
# 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:
+51
@@ -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());
|
||||
// 先移除;再添加
|
||||
|
||||
+30
@@ -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() {
|
||||
// 准备参数
|
||||
|
||||
+4
-3
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+11
-5
@@ -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,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);
|
||||
}
|
||||
|
||||
+12
-12
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
+37
-5
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+67
@@ -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());
|
||||
|
||||
+48
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据节点,获取出口连线
|
||||
*
|
||||
|
||||
+113
-93
@@ -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
-2
@@ -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);
|
||||
|
||||
+2
-1
@@ -3,6 +3,7 @@ package cn.iocoder.yudao.module.im.dal.mysql.message;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.mapper.BaseMapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.query.LambdaQueryWrapperX;
|
||||
import cn.iocoder.yudao.framework.mybatis.core.util.MyBatisUtils;
|
||||
import cn.iocoder.yudao.module.im.controller.admin.manager.message.vo.channel.ImChannelMessagePageReqVO;
|
||||
import cn.iocoder.yudao.module.im.dal.dataobject.message.ImChannelMessageDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
@@ -32,7 +33,7 @@ public interface ImChannelMessageMapper extends BaseMapperX<ImChannelMessageDO>
|
||||
.gt(ImChannelMessageDO::getId, minId)
|
||||
.and(w -> w.isNull(ImChannelMessageDO::getReceiverUserIds)
|
||||
.or().eq(ImChannelMessageDO::getReceiverUserIds, "")
|
||||
.or().apply("FIND_IN_SET({0}, receiver_user_ids)", userId))
|
||||
.or().apply(MyBatisUtils.findInSet("receiver_user_ids"), userId))
|
||||
.orderByAsc(ImChannelMessageDO::getId)
|
||||
.last("LIMIT " + size));
|
||||
}
|
||||
|
||||
+12
-10
@@ -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")
|
||||
|
||||
+7
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+2
-1
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
-3
@@ -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
|
||||
|
||||
+25
-2
@@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否是图片
|
||||
*
|
||||
|
||||
+9
@@ -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);
|
||||
|
||||
}
|
||||
|
||||
+5
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+8
-9
@@ -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)
|
||||
|
||||
+11
-3
@@ -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>
|
||||
|
||||
+5
-4
@@ -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)
|
||||
|
||||
+65
-75
@@ -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>
|
||||
|
||||
|
||||
+15
@@ -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() {
|
||||
// 准备参数
|
||||
|
||||
+57
-1
@@ -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 {
|
||||
|
||||
+32
@@ -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());
|
||||
}
|
||||
|
||||
}
|
||||
+34
@@ -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");
|
||||
}
|
||||
|
||||
}
|
||||
+17
@@ -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"
|
||||
} ]
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
// TODO 待办:请将下面的错误码复制到 yudao-module-infra 模块的 ErrorCodeConstants 类中。注意,请给“TODO 补充编号”设置一个错误码编号!!!
|
||||
// ========== 学生 TODO 补充编号 ==========
|
||||
ErrorCode STUDENT_NOT_EXISTS = new ErrorCode(TODO 补充编号, "学生不存在");
|
||||
+104
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+68
@@ -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;
|
||||
|
||||
|
||||
}
|
||||
+30
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
+32
@@ -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;
|
||||
|
||||
}
|
||||
+59
@@ -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;
|
||||
|
||||
}
|
||||
+49
@@ -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;
|
||||
|
||||
}
|
||||
+62
@@ -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);
|
||||
|
||||
}
|
||||
+85
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
+145
@@ -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>
|
||||
+12
@@ -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>
|
||||
+3
@@ -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;
|
||||
|
||||
|
||||
+2
-2
@@ -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));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
+2
-2
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+22
-2
@@ -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) {
|
||||
|
||||
+2
-2
@@ -19,9 +19,9 @@ public class IotMessageBusProperties {
|
||||
/**
|
||||
* 消息总线类型
|
||||
*
|
||||
* 可选值:local、redis、rocketmq、rabbitmq
|
||||
* 可选值:local、redis、rocketmq、kafka、rabbitmq
|
||||
*/
|
||||
@NotNull(message = "IoT 消息总线类型不能为空")
|
||||
private String type = "local";
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
+127
@@ -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>
|
||||
|
||||
|
||||
+88
-49
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+32
-5
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
+219
@@ -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:
|
||||
|
||||
+147
@@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
+3
-2
@@ -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)
|
||||
|
||||
+6
-5
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
+3
-2
@@ -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) {
|
||||
|
||||
+8
-5
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+1
-1
@@ -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);
|
||||
|
||||
+31
-16
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
+36
@@ -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() {
|
||||
// 准备参数
|
||||
|
||||
+8
-4
@@ -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> {
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+26
@@ -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());
|
||||
|
||||
+91
@@ -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 '',
|
||||
|
||||
+1
-1
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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) {
|
||||
|
||||
+3
-3
@@ -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());
|
||||
|
||||
-2
@@ -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")));
|
||||
|
||||
Reference in New Issue
Block a user