【同步】BOOT 和 CLOUD 的功能
parent
1b060dc93f
commit
6e492e1e6b
|
|
@ -77,6 +77,9 @@ def load_and_clean(sql_file: str) -> str:
|
|||
|
||||
|
||||
class Convertor(ABC):
|
||||
# 不同数据库的关键字不完全一致;子类按需声明需要转义的列名。
|
||||
reserved_column_names = set()
|
||||
|
||||
def __init__(self, src: str, db_type) -> None:
|
||||
self.src = src
|
||||
self.db_type = db_type
|
||||
|
|
@ -179,6 +182,31 @@ class Convertor(ABC):
|
|||
"""
|
||||
return ""
|
||||
|
||||
def escape_column_name(self, name: str) -> str:
|
||||
"""转义目标库保留字列名,例如 Oracle / Kingbase 的 level。"""
|
||||
|
||||
column_name = name.lower()
|
||||
if column_name in self.reserved_column_names:
|
||||
return f'"{column_name}"'
|
||||
return column_name
|
||||
|
||||
def escape_insert_columns(self, insert_script: str) -> str:
|
||||
"""INSERT 显式列清单需要和 CREATE / COMMENT 使用同一套列名转义。"""
|
||||
|
||||
match = re.match(
|
||||
r"(INSERT INTO\s+\S+\s*\()([^)]+)(\)\s+VALUES\s+[\s\S]*)",
|
||||
insert_script,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if not match:
|
||||
return insert_script
|
||||
|
||||
columns = [
|
||||
self.escape_column_name(column.strip())
|
||||
for column in match.group(2).split(",")
|
||||
]
|
||||
return f"{match.group(1)}{', '.join(columns)}{match.group(3)}"
|
||||
|
||||
@staticmethod
|
||||
def inserts(table_name: str, script_content: str) -> Generator:
|
||||
PREFIX = f"INSERT INTO `{table_name}`"
|
||||
|
|
@ -204,18 +232,55 @@ class Convertor(ABC):
|
|||
Generator[str]: create index 语句
|
||||
"""
|
||||
|
||||
def generate_columns(columns):
|
||||
keys = [
|
||||
f"{col['name'].lower()}{' ' + col['order'].lower() if col['order'] != 'ASC' else ''}"
|
||||
for col in columns[0]
|
||||
]
|
||||
return ", ".join(keys)
|
||||
|
||||
for no, index in enumerate(ddl["index"], 1):
|
||||
columns = generate_columns(index["columns"])
|
||||
for no, index in enumerate(ddl.get("index", []), 1):
|
||||
columns = ", ".join(Convertor.index_columns(index.get("columns", [])))
|
||||
if not columns:
|
||||
continue
|
||||
table_name = ddl["table_name"].lower()
|
||||
yield f"CREATE INDEX idx_{table_name}_{no:02d} ON {table_name} ({columns})"
|
||||
|
||||
@staticmethod
|
||||
def index_columns(columns) -> list:
|
||||
"""兼容 simple-ddl-parser 不同版本的索引列结构。"""
|
||||
|
||||
keys = []
|
||||
|
||||
def append(name, order="ASC"):
|
||||
if not name:
|
||||
return
|
||||
column_name = str(name).strip("`").lower()
|
||||
column_order = str(order or "ASC").upper()
|
||||
if column_order == "DESC":
|
||||
keys.append(f"{column_name} desc")
|
||||
else:
|
||||
keys.append(column_name)
|
||||
|
||||
def visit(value):
|
||||
# 普通索引常见结构:[[{'name': 'user_id', 'order': 'ASC'}]]
|
||||
if isinstance(value, (list, tuple)):
|
||||
for item in value:
|
||||
visit(item)
|
||||
return
|
||||
if isinstance(value, dict):
|
||||
name = value.get("name")
|
||||
if isinstance(name, (dict, list, tuple)):
|
||||
visit(name)
|
||||
return
|
||||
append(name, value.get("order", "ASC"))
|
||||
return
|
||||
# 唯一索引在部分版本中会被解析成 ['mobile', 'ASC', 'tenant_id', 'ASC']。
|
||||
if isinstance(value, str):
|
||||
token = value.strip("`")
|
||||
order = token.upper()
|
||||
if order in ("ASC", "DESC"):
|
||||
if order == "DESC" and keys and not keys[-1].endswith(" desc"):
|
||||
keys[-1] = f"{keys[-1]} desc"
|
||||
return
|
||||
append(token)
|
||||
|
||||
visit(columns)
|
||||
return keys
|
||||
|
||||
@staticmethod
|
||||
def unique_index(ddl: Dict) -> Generator:
|
||||
if "constraints" in ddl and "uniques" in ddl["constraints"]:
|
||||
|
|
@ -223,7 +288,9 @@ class Convertor(ABC):
|
|||
for uk in uk_list:
|
||||
table_name = ddl["table_name"]
|
||||
uk_name = uk["constraint_name"]
|
||||
uk_columns = uk["columns"]
|
||||
uk_columns = Convertor.index_columns(uk["columns"])
|
||||
if not uk_columns:
|
||||
continue
|
||||
yield table_name, uk_name, uk_columns
|
||||
|
||||
@staticmethod
|
||||
|
|
@ -381,7 +448,7 @@ class PostgreSQLConvertor(Convertor):
|
|||
)
|
||||
nullable = "NULL" if col["nullable"] else "NOT NULL"
|
||||
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
|
||||
return f"{name} {full_type} {nullable} {default}"
|
||||
return f"{self.escape_column_name(name)} {full_type} {nullable} {default}"
|
||||
|
||||
table_name = ddl["table_name"].lower()
|
||||
columns = [f"{_generate_column(col).strip()}" for col in ddl["columns"]]
|
||||
|
|
@ -406,7 +473,7 @@ CREATE TABLE {table_name} (
|
|||
for column in table_ddl["columns"]:
|
||||
table_comment = column["comment"]
|
||||
script += (
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{column['name']} IS '{table_comment}';"
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{self.escape_column_name(column['name'])} IS '{table_comment}';"
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
|
@ -435,6 +502,7 @@ CREATE TABLE {table_name} (
|
|||
"""生成 insert 语句,以及根据最后的 insert id+1 生成 Sequence"""
|
||||
|
||||
inserts = list(Convertor.inserts(table_name, self.content))
|
||||
inserts = [self.escape_insert_columns(s) for s in inserts]
|
||||
# 转换 MySQL 字符串转义为 PostgreSQL 格式:\\ -> \,\' -> ''
|
||||
inserts = [re.sub(r"\\\\|\\'", lambda m: "\\" if m.group() == "\\\\" else "''", s) for s in inserts]
|
||||
## 生成 insert 脚本
|
||||
|
|
@ -482,6 +550,8 @@ INSERT INTO dual VALUES (1);
|
|||
|
||||
|
||||
class OracleConvertor(Convertor):
|
||||
reserved_column_names = {"level", "size"}
|
||||
|
||||
def __init__(self, src):
|
||||
super().__init__(src, "Oracle")
|
||||
|
||||
|
|
@ -526,10 +596,8 @@ class OracleConvertor(Convertor):
|
|||
# Oracle的 INSERT '' 不能通过NOT NULL校验,因此对文字类型字段覆写为 NULL
|
||||
nullable = "NULL" if type in ("varchar", "text", "longtext") else nullable
|
||||
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
|
||||
# Oracle 中 size 不能作为字段名
|
||||
field_name = '"size"' if name == "size" else name
|
||||
# Oracle DEFAULT 定义在 NULLABLE 之前
|
||||
return f"{field_name} {full_type} {default} {nullable}"
|
||||
return f"{self.escape_column_name(name)} {full_type} {default} {nullable}"
|
||||
|
||||
table_name = ddl["table_name"].lower()
|
||||
columns = [f"{generate_column(col).strip()}" for col in ddl["columns"]]
|
||||
|
|
@ -554,7 +622,7 @@ CREATE TABLE {table_name} (
|
|||
for column in table_ddl["columns"]:
|
||||
table_comment = column["comment"]
|
||||
script += (
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{column['name']} IS '{table_comment}';"
|
||||
f"COMMENT ON COLUMN {table_ddl['table_name']}.{self.escape_column_name(column['name'])} IS '{table_comment}';"
|
||||
+ "\n"
|
||||
)
|
||||
|
||||
|
|
@ -586,6 +654,7 @@ CREATE TABLE {table_name} (
|
|||
"""拷贝 INSERT 语句"""
|
||||
inserts = []
|
||||
for insert_script in Convertor.inserts(table_name, self.content):
|
||||
insert_script = self.escape_insert_columns(insert_script)
|
||||
# 对日期数据添加 TO_DATE 转换
|
||||
insert_script = re.sub(
|
||||
r"('\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}')",
|
||||
|
|
@ -907,6 +976,8 @@ SET IDENTITY_INSERT {table_name.lower()} OFF;
|
|||
|
||||
|
||||
class KingbaseConvertor(PostgreSQLConvertor):
|
||||
reserved_column_names = {"level"}
|
||||
|
||||
def __init__(self, src):
|
||||
super().__init__(src)
|
||||
self.db_type = "Kingbase"
|
||||
|
|
@ -925,7 +996,7 @@ class KingbaseConvertor(PostgreSQLConvertor):
|
|||
if full_type == "text":
|
||||
nullable = "NULL"
|
||||
default = f"DEFAULT {col['default']}" if col["default"] is not None else ""
|
||||
return f"{name} {full_type} {nullable} {default}"
|
||||
return f"{self.escape_column_name(name)} {full_type} {nullable} {default}"
|
||||
|
||||
table_name = ddl["table_name"].lower()
|
||||
columns = [f"{_generate_column(col).strip()}" for col in ddl["columns"]]
|
||||
|
|
@ -945,6 +1016,8 @@ CREATE TABLE {table_name} (
|
|||
|
||||
|
||||
class OpengaussConvertor(KingbaseConvertor):
|
||||
reserved_column_names = set()
|
||||
|
||||
def __init__(self, src):
|
||||
super().__init__(src)
|
||||
self.db_type = "OpenGauss"
|
||||
|
|
|
|||
|
|
@ -235,6 +235,14 @@ public class JsonUtils {
|
|||
}
|
||||
}
|
||||
|
||||
public static String getText(JsonNode node, String fieldName) {
|
||||
if (node == null) {
|
||||
return null;
|
||||
}
|
||||
JsonNode value = node.get(fieldName);
|
||||
return value != null && !value.isNull() ? value.asText() : null;
|
||||
}
|
||||
|
||||
public static boolean isJson(String text) {
|
||||
return JSONUtil.isTypeJSON(text);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -102,13 +102,13 @@ public class QueryWrapperX<T> extends QueryWrapper<T> {
|
|||
}
|
||||
|
||||
public QueryWrapperX<T> betweenIfPresent(String column, Object[] values) {
|
||||
if (values!= null && values.length != 0 && values[0] != null && values[1] != null) {
|
||||
if (values != null && values.length != 0 && values[0] != null && values[1] != null) {
|
||||
return (QueryWrapperX<T>) super.between(column, values[0], values[1]);
|
||||
}
|
||||
if (values!= null && values.length != 0 && values[0] != null) {
|
||||
if (values != null && values.length != 0 && values[0] != null) {
|
||||
return (QueryWrapperX<T>) ge(column, values[0]);
|
||||
}
|
||||
if (values!= null && values.length != 0 && values[1] != null) {
|
||||
if (values != null && values.length != 0 && values[1] != null) {
|
||||
return (QueryWrapperX<T>) le(column, values[1]);
|
||||
}
|
||||
return this;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.form.BpmFormFi
|
|||
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
|
||||
import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum;
|
||||
import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import lombok.SneakyThrows;
|
||||
import org.flowable.common.engine.api.delegate.Expression;
|
||||
import org.flowable.common.engine.api.variable.VariableContainer;
|
||||
|
|
@ -27,6 +28,7 @@ import org.flowable.engine.runtime.ProcessInstance;
|
|||
import org.flowable.task.api.TaskInfo;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
|
@ -245,10 +247,10 @@ public class FlowableUtils {
|
|||
}
|
||||
|
||||
// 解析表单配置
|
||||
Map<String, BpmFormFieldVO> formFieldsMap = new HashMap<>();
|
||||
Map<String, BpmFormFieldVO> formFieldsMap = new LinkedHashMap<>();
|
||||
processDefinitionInfo.getFormFields().forEach(formFieldStr -> {
|
||||
BpmFormFieldVO formField = JsonUtils.parseObject(formFieldStr, BpmFormFieldVO.class);
|
||||
parseFormField(formField, formFieldsMap);
|
||||
JsonNode formFieldNode = JsonUtils.parseObject(formFieldStr, JsonNode.class);
|
||||
parseFormField(formFieldNode, formFieldsMap);
|
||||
});
|
||||
|
||||
// 情况一:当自定义了摘要
|
||||
|
|
@ -275,18 +277,32 @@ public class FlowableUtils {
|
|||
/**
|
||||
* 递归解析表单字段
|
||||
*/
|
||||
private static void parseFormField(BpmFormFieldVO formField, Map<String, BpmFormFieldVO> formFieldsMap) {
|
||||
if (formField == null) {
|
||||
private static void parseFormField(JsonNode formFieldNode, Map<String, BpmFormFieldVO> formFieldsMap) {
|
||||
if (formFieldNode == null || !formFieldNode.isObject()) {
|
||||
return;
|
||||
}
|
||||
// 如果存在 children -> 说明是布局组件
|
||||
if (formField.getChildren() != null && !formField.getChildren().isEmpty()) {
|
||||
for (BpmFormFieldVO child : formField.getChildren()) {
|
||||
|
||||
// 如果 children 里存在对象节点,说明是布局组件;字符串节点是分割线、标签、文字等展示组件内容,直接跳过。
|
||||
JsonNode children = formFieldNode.get("children");
|
||||
if (children != null && children.isArray() && children.size() > 0) {
|
||||
boolean hasObjectChild = false;
|
||||
for (JsonNode child : children) {
|
||||
if (!child.isObject()) {
|
||||
continue;
|
||||
}
|
||||
hasObjectChild = true;
|
||||
parseFormField(child, formFieldsMap);
|
||||
}
|
||||
return;
|
||||
if (hasObjectChild) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 真实字段才加入 map
|
||||
BpmFormFieldVO formField = new BpmFormFieldVO()
|
||||
.setType(JsonUtils.getText(formFieldNode, "type"))
|
||||
.setField(JsonUtils.getText(formFieldNode, "field"))
|
||||
.setTitle(JsonUtils.getText(formFieldNode, "title"));
|
||||
if (StrUtil.isNotBlank(formField.getField())) {
|
||||
formFieldsMap.put(formField.getField(), formField);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
|
|
@ -64,7 +65,7 @@ public class BpmTaskCandidateInvokerTest extends BaseMockitoUnitTest {
|
|||
public void setUp() {
|
||||
userStrategy = new BpmTaskCandidateUserStrategy(); // 创建 strategy 实例
|
||||
when(emptyStrategy.getStrategy()).thenReturn(BpmTaskCandidateStrategyEnum.ASSIGN_EMPTY);
|
||||
strategyList = List.of(userStrategy, emptyStrategy); // 创建 strategyList
|
||||
strategyList = ListUtil.of(userStrategy, emptyStrategy); // 创建 strategyList
|
||||
taskCandidateInvoker = new BpmTaskCandidateInvoker(strategyList, adminUserApi);
|
||||
}
|
||||
|
||||
|
|
@ -223,7 +224,7 @@ public class BpmTaskCandidateInvokerTest extends BaseMockitoUnitTest {
|
|||
when(adminUserApi.getUserMap(eq(asSet(1L, 2L)))).thenReturn(userMap);
|
||||
// mock 方法(empty)
|
||||
when(emptyStrategy.calculateUsersByActivity(same(bpmnModel), eq(activityId),
|
||||
eq(param), same(startUserId), same(processDefinitionId), same(processVariables)))
|
||||
eq(param), same(startUserId), same(processDefinitionId), same(processVariables)))
|
||||
.thenReturn(Sets.newSet(2L));
|
||||
|
||||
// 调用
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package cn.iocoder.yudao.module.bpm.framework.flowable.core.candidate.strategy.dept;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.yudao.module.bpm.framework.flowable.core.enums.BpmnVariableConstants;
|
||||
|
|
@ -12,7 +13,6 @@ import org.mockito.InjectMocks;
|
|||
import org.mockito.Mock;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
|
|
@ -41,7 +41,7 @@ public class BpmTaskCandidateStartUserSelectStrategyTest extends BaseMockitoUnit
|
|||
// mock 方法(FlowableUtils)
|
||||
Map<String, Object> processVariables = new HashMap<>();
|
||||
processVariables.put(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_SELECT_ASSIGNEES,
|
||||
MapUtil.of("activity_001", List.of(1L, 2L)));
|
||||
MapUtil.of("activity_001", ListUtil.of(1L, 2L)));
|
||||
when(processInstance.getProcessVariables()).thenReturn(processVariables);
|
||||
|
||||
// 调用
|
||||
|
|
@ -56,7 +56,7 @@ public class BpmTaskCandidateStartUserSelectStrategyTest extends BaseMockitoUnit
|
|||
String activityId = "activity_001";
|
||||
Map<String, Object> processVariables = new HashMap<>();
|
||||
processVariables.put(BpmnVariableConstants.PROCESS_INSTANCE_VARIABLE_START_USER_SELECT_ASSIGNEES,
|
||||
MapUtil.of("activity_001", List.of(1L, 2L)));
|
||||
MapUtil.of("activity_001", ListUtil.of(1L, 2L)));
|
||||
|
||||
// 调用
|
||||
Set<Long> userIds = strategy.calculateUsersByActivity(null, activityId, null,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,167 @@
|
|||
package cn.iocoder.yudao.module.bpm.framework.flowable.core.util;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.KeyValue;
|
||||
import cn.iocoder.yudao.module.bpm.controller.admin.definition.vo.model.BpmModelMetaInfoVO;
|
||||
import cn.iocoder.yudao.module.bpm.dal.dataobject.definition.BpmProcessDefinitionInfoDO;
|
||||
import cn.iocoder.yudao.module.bpm.enums.definition.BpmModelFormTypeEnum;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.Collections;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
|
||||
/**
|
||||
* {@link FlowableUtils} 的单元测试。
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
class FlowableUtilsTest {
|
||||
|
||||
@Test
|
||||
public void testGetSummary_customSummary_parseDbFormFields() {
|
||||
// 准备参数:模拟 DB 中 form_fields 字段,列表里每个元素都是一个 form-create 字段 JSON。
|
||||
BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionInfo(dbFormFields(),
|
||||
summarySetting(true, "reason", "days", "notExists", "startTime"));
|
||||
Map<String, Object> processVariables = processVariables();
|
||||
|
||||
// 调用
|
||||
List<KeyValue<String, String>> summary = FlowableUtils.getSummary(processDefinitionInfo, processVariables);
|
||||
|
||||
// 断言
|
||||
assertEquals(Arrays.asList(
|
||||
new KeyValue<>("请假原因", "事假"),
|
||||
new KeyValue<>("请假天数", "3"),
|
||||
new KeyValue<>("开始时间", "2026-05-31 09:00:00")),
|
||||
summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSummary_defaultSummary_parseFirstThreeFieldsByFormOrder() {
|
||||
// 准备参数:未开启自定义摘要时,默认取表单配置顺序里的前三个真实字段。
|
||||
BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionInfo(dbFormFields(), null);
|
||||
Map<String, Object> processVariables = processVariables();
|
||||
|
||||
// 调用
|
||||
List<KeyValue<String, String>> summary = FlowableUtils.getSummary(processDefinitionInfo, processVariables);
|
||||
|
||||
// 断言
|
||||
assertEquals(Arrays.asList(
|
||||
new KeyValue<>("请假原因", "事假"),
|
||||
new KeyValue<>("开始时间", "2026-05-31 09:00:00"),
|
||||
new KeyValue<>("请假天数", "3")),
|
||||
summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSummary_summaryDisabled_useDefaultSummary() {
|
||||
// 准备参数:摘要设置存在但未启用时,仍走默认摘要逻辑。
|
||||
BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionInfo(dbFormFields(),
|
||||
summarySetting(false, "remark"));
|
||||
Map<String, Object> processVariables = processVariables();
|
||||
|
||||
// 调用
|
||||
List<KeyValue<String, String>> summary = FlowableUtils.getSummary(processDefinitionInfo, processVariables);
|
||||
|
||||
// 断言
|
||||
assertEquals(Arrays.asList(
|
||||
new KeyValue<>("请假原因", "事假"),
|
||||
new KeyValue<>("开始时间", "2026-05-31 09:00:00"),
|
||||
new KeyValue<>("请假天数", "3")),
|
||||
summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSummary_displayComponentsOnly_returnEmpty() {
|
||||
// 准备参数:分割线、标签、文字等展示组件的 children 是字符串数组,不是表单字段对象。
|
||||
BpmProcessDefinitionInfoDO processDefinitionInfo = processDefinitionInfo(Arrays.asList(
|
||||
DIVIDER_FIELD,
|
||||
TEXT_FIELD,
|
||||
TAG_FIELD), null);
|
||||
|
||||
// 调用
|
||||
List<KeyValue<String, String>> summary = FlowableUtils.getSummary(processDefinitionInfo,
|
||||
Collections.emptyMap());
|
||||
|
||||
// 断言
|
||||
assertEquals(Collections.emptyList(), summary);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSummary_notNormalForm_returnNull() {
|
||||
// 准备参数
|
||||
BpmProcessDefinitionInfoDO processDefinitionInfo = BpmProcessDefinitionInfoDO.builder()
|
||||
.formType(BpmModelFormTypeEnum.CUSTOM.getType())
|
||||
.build();
|
||||
|
||||
// 调用 & 断言
|
||||
assertNull(FlowableUtils.getSummary(null, Collections.emptyMap()));
|
||||
assertNull(FlowableUtils.getSummary(processDefinitionInfo, Collections.emptyMap()));
|
||||
}
|
||||
|
||||
private static BpmProcessDefinitionInfoDO processDefinitionInfo(List<String> formFields,
|
||||
BpmModelMetaInfoVO.SummarySetting summarySetting) {
|
||||
return BpmProcessDefinitionInfoDO.builder()
|
||||
.formType(BpmModelFormTypeEnum.NORMAL.getType())
|
||||
.formFields(formFields)
|
||||
.summarySetting(summarySetting)
|
||||
.build();
|
||||
}
|
||||
|
||||
private static BpmModelMetaInfoVO.SummarySetting summarySetting(Boolean enable, String... fields) {
|
||||
BpmModelMetaInfoVO.SummarySetting summarySetting = new BpmModelMetaInfoVO.SummarySetting();
|
||||
summarySetting.setEnable(enable);
|
||||
summarySetting.setSummary(Arrays.asList(fields));
|
||||
return summarySetting;
|
||||
}
|
||||
|
||||
private static List<String> dbFormFields() {
|
||||
return Arrays.asList(
|
||||
DIVIDER_FIELD,
|
||||
"{\"type\":\"input\",\"field\":\"reason\",\"title\":\"请假原因\",\"value\":\"\","
|
||||
+ "\"props\":{\"type\":\"textarea\",\"placeholder\":\"请输入请假原因\"},"
|
||||
+ "\"$required\":\"请输入请假原因\",\"_fc_id\":\"id_F1\",\"_fc_drag_tag\":\"input\","
|
||||
+ "\"hidden\":false,\"display\":true}",
|
||||
TEXT_FIELD,
|
||||
"{\"type\":\"elRow\",\"title\":\"栅格布局\",\"children\":["
|
||||
+ "{\"type\":\"elCol\",\"props\":{\"span\":12},\"children\":["
|
||||
+ "{\"type\":\"DatePicker\",\"field\":\"startTime\",\"title\":\"开始时间\","
|
||||
+ "\"props\":{\"type\":\"datetime\",\"placeholder\":\"请选择开始时间\"},"
|
||||
+ "\"_fc_id\":\"id_F2\",\"_fc_drag_tag\":\"datePicker\"}]},"
|
||||
+ "\"字段说明\","
|
||||
+ "{\"type\":\"elCol\",\"props\":{\"span\":12},\"children\":["
|
||||
+ "{\"type\":\"inputNumber\",\"field\":\"days\",\"title\":\"请假天数\","
|
||||
+ "\"props\":{\"min\":0,\"precision\":1},\"_fc_id\":\"id_F3\","
|
||||
+ "\"_fc_drag_tag\":\"inputNumber\"}]}],\"_fc_id\":\"id_LAYOUT\","
|
||||
+ "\"_fc_drag_tag\":\"row\"}",
|
||||
TAG_FIELD,
|
||||
"{\"type\":\"input\",\"field\":\"remark\",\"title\":\"备注\",\"value\":\"\","
|
||||
+ "\"props\":{\"placeholder\":\"请输入备注\"},\"_fc_id\":\"id_F4\","
|
||||
+ "\"_fc_drag_tag\":\"input\",\"hidden\":false,\"display\":true}");
|
||||
}
|
||||
|
||||
private static Map<String, Object> processVariables() {
|
||||
Map<String, Object> processVariables = new HashMap<>();
|
||||
processVariables.put("reason", "事假");
|
||||
processVariables.put("startTime", "2026-05-31 09:00:00");
|
||||
processVariables.put("days", 3);
|
||||
processVariables.put("remark", "下午到家");
|
||||
return processVariables;
|
||||
}
|
||||
|
||||
private static final String DIVIDER_FIELD = "{\"type\":\"elDivider\",\"children\":[\"基础信息\"],"
|
||||
+ "\"props\":{\"contentPosition\":\"left\"},\"_fc_id\":\"id_DIVIDER\","
|
||||
+ "\"_fc_drag_tag\":\"elDivider\"}";
|
||||
|
||||
private static final String TEXT_FIELD = "{\"type\":\"div\",\"children\":[\"请按实际情况填写\"],"
|
||||
+ "\"props\":{\"style\":{\"color\":\"#909399\"}},\"_fc_id\":\"id_TEXT\","
|
||||
+ "\"_fc_drag_tag\":\"text\"}";
|
||||
|
||||
private static final String TAG_FIELD = "{\"type\":\"elTag\",\"children\":[\"重要\"],"
|
||||
+ "\"props\":{\"type\":\"warning\"},\"_fc_id\":\"id_TAG\",\"_fc_drag_tag\":\"elTag\"}";
|
||||
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@ public interface ErrorCodeConstants {
|
|||
ErrorCode FILE_PATH_EXISTS = new ErrorCode(1_001_003_000, "文件路径已存在");
|
||||
ErrorCode FILE_NOT_EXISTS = new ErrorCode(1_001_003_001, "文件不存在");
|
||||
ErrorCode FILE_IS_EMPTY = new ErrorCode(1_001_003_002, "文件为空");
|
||||
ErrorCode FILE_PATH_INVALID = new ErrorCode(1_001_003_003, "文件路径不正确");
|
||||
|
||||
// ========== 代码生成器 1-001-004-000 ==========
|
||||
ErrorCode CODEGEN_TABLE_EXISTS = new ErrorCode(1_001_004_002, "表定义已经存在");
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
package cn.iocoder.yudao.module.infra.controller.admin.file.vo.file;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils;
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.AssertTrue;
|
||||
|
|
@ -26,10 +26,7 @@ public class FileUploadReqVO {
|
|||
}
|
||||
|
||||
public static boolean isDirectoryValid(String directory) {
|
||||
// 1. 不能包含 .. 防止目录穿越
|
||||
// 2. 不能以 / 或 \ 开头,防止上传到根目录
|
||||
return !StrUtil.contains(directory, "..")
|
||||
&& !StrUtil.startWithAny(directory, "/", "\\");
|
||||
return FilePathUtils.isDirectoryValid(directory);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ public class AppFileController {
|
|||
@Parameter(name = "file", description = "文件附件", required = true,
|
||||
schema = @Schema(type = "string", format = "binary"))
|
||||
@PermitAll
|
||||
public CommonResult<String> uploadFile(AppFileUploadReqVO uploadReqVO) throws Exception {
|
||||
public CommonResult<String> uploadFile(@Valid AppFileUploadReqVO uploadReqVO) throws Exception {
|
||||
MultipartFile file = uploadReqVO.getFile();
|
||||
byte[] content = IoUtil.readBytes(file.getInputStream());
|
||||
return success(fileService.createFile(content, file.getOriginalFilename(),
|
||||
|
|
|
|||
|
|
@ -3,8 +3,13 @@ package cn.iocoder.yudao.module.infra.framework.file.core.client.local;
|
|||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.io.IORuntimeException;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.AbstractFileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID;
|
||||
|
||||
/**
|
||||
* 本地文件客户端
|
||||
|
|
@ -50,7 +55,13 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
|
|||
}
|
||||
|
||||
private String getFilePath(String path) {
|
||||
return config.getBasePath() + File.separator + path;
|
||||
FilePathUtils.validatePath(path);
|
||||
Path basePath = Paths.get(config.getBasePath()).toAbsolutePath().normalize();
|
||||
Path filePath = basePath.resolve(path).normalize();
|
||||
if (!filePath.startsWith(basePath)) {
|
||||
throw exception(FILE_PATH_INVALID);
|
||||
}
|
||||
return filePath.toString();
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
package cn.iocoder.yudao.module.infra.framework.file.core.utils;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
|
||||
import java.nio.file.InvalidPathException;
|
||||
import java.nio.file.Paths;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID;
|
||||
|
||||
/**
|
||||
* 文件路径工具类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
public class FilePathUtils {
|
||||
|
||||
private FilePathUtils() {
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件名是否合法,禁止携带目录路径。
|
||||
*
|
||||
* @param name 文件名
|
||||
* @return 文件名
|
||||
*/
|
||||
public static String validateFileName(String name) {
|
||||
if (StrUtil.isEmpty(name)) {
|
||||
return name;
|
||||
}
|
||||
if (!isPathValid(name) || StrUtil.contains(name, StrUtil.SLASH) || !StrUtil.equals(name, FileUtil.getName(name))) {
|
||||
throw exception(FILE_PATH_INVALID);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件目录是否合法。
|
||||
*
|
||||
* @param directory 文件目录,允许为空
|
||||
* @return 是否合法
|
||||
*/
|
||||
public static boolean isDirectoryValid(String directory) {
|
||||
return StrUtil.isEmpty(directory) || isPathValid(directory);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件目录是否合法,不合法时抛出业务异常。
|
||||
*
|
||||
* @param directory 文件目录,允许为空
|
||||
*/
|
||||
public static void validateDirectory(String directory) {
|
||||
if (!isDirectoryValid(directory)) {
|
||||
throw exception(FILE_PATH_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验文件相对路径是否合法,不合法时抛出业务异常。
|
||||
*
|
||||
* @param path 文件相对路径
|
||||
*/
|
||||
public static void validatePath(String path) {
|
||||
if (StrUtil.isEmpty(path) || !isPathValid(path)) {
|
||||
throw exception(FILE_PATH_INVALID);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验路径是否为安全的相对路径,禁止绝对路径、Windows 盘符、反斜杠、空路径段和目录穿越。
|
||||
*
|
||||
* @param path 路径
|
||||
* @return 是否合法
|
||||
*/
|
||||
private static boolean isPathValid(String path) {
|
||||
// 不能以 / 或 \ 开头,避免传入绝对路径
|
||||
if (StrUtil.startWithAny(path, StrUtil.SLASH, "\\")) {
|
||||
return false;
|
||||
}
|
||||
// 不能包含反斜杠或空字符,避免绕过不同系统的路径解析
|
||||
if (StrUtil.contains(path, "\\") || path.indexOf('\0') >= 0) {
|
||||
return false;
|
||||
}
|
||||
// 不能是 Windows 盘符路径,例如 C:/test.jpg
|
||||
if (path.length() >= 2 && Character.isLetter(path.charAt(0)) && path.charAt(1) == ':') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
// 使用 JDK Path 再兜底判断一次绝对路径
|
||||
if (Paths.get(path).isAbsolute()) {
|
||||
return false;
|
||||
}
|
||||
} catch (InvalidPathException ex) {
|
||||
return false;
|
||||
}
|
||||
// 不能包含空路径段、当前目录或上级目录,避免目录穿越
|
||||
for (String segment : path.split(StrUtil.SLASH, -1)) {
|
||||
if (StrUtil.isEmpty(segment) || ".".equals(segment) || "..".equals(segment)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePresigned
|
|||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.FileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FilePathUtils;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.utils.FileTypeUtils;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import jakarta.annotation.Resource;
|
||||
|
|
@ -70,11 +71,14 @@ public class FileServiceImpl implements FileService {
|
|||
@Override
|
||||
@SneakyThrows
|
||||
public String createFile(byte[] content, String name, String directory, String type) {
|
||||
// 1.1 处理 type 为空的情况
|
||||
// 1.1 处理 name 的合法性,禁止携带目录路径
|
||||
name = FilePathUtils.validateFileName(name);
|
||||
|
||||
// 1.2.1 处理 type 为空的情况
|
||||
if (StrUtil.isEmpty(type)) {
|
||||
type = FileTypeUtils.getMineType(content, name);
|
||||
}
|
||||
// 1.2 处理 name 为空的情况
|
||||
// 1.2.2 处理 name 为空的情况
|
||||
if (StrUtil.isEmpty(name)) {
|
||||
name = DigestUtil.sha256Hex(content);
|
||||
}
|
||||
|
|
@ -102,7 +106,11 @@ public class FileServiceImpl implements FileService {
|
|||
|
||||
@VisibleForTesting
|
||||
String generateUploadPath(String name, String directory) {
|
||||
// 1. 生成前缀、后缀
|
||||
// 1.1 处理 name 和 directory 的合法性
|
||||
name = FilePathUtils.validateFileName(name);
|
||||
FilePathUtils.validatePath(name);
|
||||
FilePathUtils.validateDirectory(directory);
|
||||
// 1.2 生成前缀、后缀
|
||||
String prefix = null;
|
||||
if (PATH_PREFIX_DATE_ENABLE) {
|
||||
prefix = LocalDateTimeUtil.format(LocalDateTimeUtil.now(), PURE_DATE_PATTERN);
|
||||
|
|
@ -159,7 +167,13 @@ public class FileServiceImpl implements FileService {
|
|||
|
||||
@Override
|
||||
public Long createFile(FileCreateReqVO createReqVO) {
|
||||
// 1.1 校验参数的合法性
|
||||
FilePathUtils.validatePath(createReqVO.getPath());
|
||||
createReqVO.setName(FilePathUtils.validateFileName(createReqVO.getName()));
|
||||
// 1.2 处理 URL 的合法性,移除 URL 中的查询参数(例如签名参数),保证 URL 的唯一性
|
||||
createReqVO.setUrl(HttpUtils.removeUrlQuery(createReqVO.getUrl())); // 目的:移除私有桶情况下,URL 的签名参数
|
||||
|
||||
// 2. 保存到数据库
|
||||
FileDO file = BeanUtils.toBean(createReqVO, FileDO.class);
|
||||
fileMapper.insert(file);
|
||||
return file.getId();
|
||||
|
|
@ -172,15 +186,17 @@ public class FileServiceImpl implements FileService {
|
|||
|
||||
@Override
|
||||
public void deleteFile(Long id) throws Exception {
|
||||
// 校验存在
|
||||
// 1.1 校验存在
|
||||
FileDO file = validateFileExists(id);
|
||||
// 1.2 校验路径合法性,避免误删文件存储器中的其他文件
|
||||
FilePathUtils.validatePath(file.getPath());
|
||||
|
||||
// 从文件存储器中删除
|
||||
// 2.1 从文件存储器中删除
|
||||
FileClient client = fileConfigService.getFileClient(file.getConfigId());
|
||||
Assert.notNull(client, "客户端({}) 不能为空", file.getConfigId());
|
||||
client.delete(file.getPath());
|
||||
|
||||
// 删除记录
|
||||
// 2.2 删除记录
|
||||
fileMapper.deleteById(id);
|
||||
}
|
||||
|
||||
|
|
@ -190,6 +206,7 @@ public class FileServiceImpl implements FileService {
|
|||
// 删除文件
|
||||
List<FileDO> files = fileMapper.selectByIds(ids);
|
||||
for (FileDO file : files) {
|
||||
FilePathUtils.validatePath(file.getPath());
|
||||
// 获取客户端
|
||||
FileClient client = fileConfigService.getFileClient(file.getConfigId());
|
||||
Assert.notNull(client, "客户端({}) 不能为空", file.getPath());
|
||||
|
|
@ -211,8 +228,13 @@ public class FileServiceImpl implements FileService {
|
|||
|
||||
@Override
|
||||
public byte[] getFileContent(Long configId, String path) throws Exception {
|
||||
// 1. 校验路径合法性
|
||||
FilePathUtils.validatePath(path);
|
||||
|
||||
// 2.1 获取客户端
|
||||
FileClient client = fileConfigService.getFileClient(configId);
|
||||
Assert.notNull(client, "客户端({}) 不能为空", configId);
|
||||
// 2.2 获取文件内容
|
||||
return client.getContent(path);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,16 +1,57 @@
|
|||
package cn.iocoder.yudao.module.infra.framework.file.core.local;
|
||||
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.io.resource.ResourceUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.iocoder.yudao.framework.common.exception.ServiceException;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClient;
|
||||
import cn.iocoder.yudao.module.infra.framework.file.core.client.local.LocalFileClientConfig;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomString;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
public class LocalFileClientTest {
|
||||
|
||||
@TempDir
|
||||
public File tempDir;
|
||||
|
||||
@Test
|
||||
public void testUpload_success() {
|
||||
// 准备参数
|
||||
LocalFileClient client = createClient();
|
||||
byte[] content = "test".getBytes(StandardCharsets.UTF_8);
|
||||
String path = "avatar/test.txt";
|
||||
|
||||
// 调用
|
||||
String url = client.upload(content, path, "text/plain");
|
||||
|
||||
// 断言
|
||||
assertEquals("http://127.0.0.1:48080/admin-api/infra/file/0/get/avatar/test.txt", url);
|
||||
assertArrayEquals(content, FileUtil.readBytes(new File(tempDir, path)));
|
||||
assertArrayEquals(content, client.getContent(path));
|
||||
|
||||
// 删除
|
||||
client.delete(path);
|
||||
assertFalse(FileUtil.exist(new File(tempDir, path)));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testUpload_pathInvalid() {
|
||||
// 准备参数
|
||||
LocalFileClient client = createClient();
|
||||
byte[] content = "test".getBytes(StandardCharsets.UTF_8);
|
||||
|
||||
// 调用,并断言异常
|
||||
assertThrows(ServiceException.class, () -> client.upload(content, "../test.txt", "text/plain"));
|
||||
assertFalse(FileUtil.exist(new File(tempDir.getParentFile(), "test.txt")));
|
||||
}
|
||||
|
||||
@Test
|
||||
@Disabled
|
||||
public void test() {
|
||||
|
|
@ -42,4 +83,13 @@ public class LocalFileClientTest {
|
|||
System.out.println();
|
||||
}
|
||||
|
||||
private LocalFileClient createClient() {
|
||||
LocalFileClientConfig config = new LocalFileClientConfig();
|
||||
config.setDomain("http://127.0.0.1:48080");
|
||||
config.setBasePath(tempDir.getAbsolutePath());
|
||||
LocalFileClient client = new LocalFileClient(0L, config);
|
||||
client.init();
|
||||
return client;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
|||
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.framework.test.core.util.AssertUtils;
|
||||
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FileCreateReqVO;
|
||||
import cn.iocoder.yudao.module.infra.controller.admin.file.vo.file.FilePageReqVO;
|
||||
import cn.iocoder.yudao.module.infra.dal.dataobject.file.FileDO;
|
||||
import cn.iocoder.yudao.module.infra.dal.mysql.file.FileMapper;
|
||||
|
|
@ -22,6 +23,7 @@ import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.bui
|
|||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.*;
|
||||
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_NOT_EXISTS;
|
||||
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.*;
|
||||
|
|
@ -172,6 +174,16 @@ public class FileServiceImplTest extends BaseDbUnitTest {
|
|||
assertServiceException(() -> fileService.deleteFile(id), FILE_NOT_EXISTS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testDeleteFile_pathInvalid() {
|
||||
// mock 数据
|
||||
FileDO dbFile = randomPojo(FileDO.class, o -> o.setConfigId(10L).setPath("../tudou.jpg"));
|
||||
fileMapper.insert(dbFile);
|
||||
|
||||
// 调用,并断言异常
|
||||
assertServiceException(() -> fileService.deleteFile(dbFile.getId()), FILE_PATH_INVALID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetFileContent() throws Exception {
|
||||
// 准备参数
|
||||
|
|
@ -189,6 +201,59 @@ public class FileServiceImplTest extends BaseDbUnitTest {
|
|||
assertSame(result, content);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetFileContent_pathInvalid() {
|
||||
// 准备参数
|
||||
Long configId = 10L;
|
||||
String path = "../tudou.jpg";
|
||||
|
||||
// 调用,并断言异常
|
||||
assertServiceException(() -> fileService.getFileContent(configId, path), FILE_PATH_INVALID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateFileByPresignedPath_success() {
|
||||
// 准备参数
|
||||
FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> {
|
||||
o.setPath("avatar/test.jpg");
|
||||
o.setName("test.jpg");
|
||||
o.setUrl("https://www.iocoder.cn/test.jpg?token=123");
|
||||
});
|
||||
|
||||
// 调用
|
||||
Long fileId = fileService.createFile(reqVO);
|
||||
|
||||
// 断言
|
||||
FileDO file = fileMapper.selectById(fileId);
|
||||
assertEquals("avatar/test.jpg", file.getPath());
|
||||
assertEquals("test.jpg", file.getName());
|
||||
assertEquals("https://www.iocoder.cn/test.jpg", file.getUrl());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateFileByPresignedPath_nameInvalid() {
|
||||
// 准备参数
|
||||
FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> {
|
||||
o.setPath("avatar/test.jpg");
|
||||
o.setName("../test.jpg");
|
||||
});
|
||||
|
||||
// 调用,并断言异常
|
||||
assertServiceException(() -> fileService.createFile(reqVO), FILE_PATH_INVALID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateFileByPresignedPath_pathInvalid() {
|
||||
// 准备参数
|
||||
FileCreateReqVO reqVO = randomPojo(FileCreateReqVO.class, o -> {
|
||||
o.setPath("../test.jpg");
|
||||
o.setName("test.jpg");
|
||||
});
|
||||
|
||||
// 调用,并断言异常
|
||||
assertServiceException(() -> fileService.createFile(reqVO), FILE_PATH_INVALID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateUploadPath_AllEnabled() {
|
||||
// 准备参数
|
||||
|
|
@ -342,6 +407,28 @@ public class FileServiceImplTest extends BaseDbUnitTest {
|
|||
assertTrue(path.matches(directory + "/\\d{8}/test_\\d+"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateUploadPath_FileNameInvalid() {
|
||||
// 准备参数
|
||||
String name = "../test.jpg";
|
||||
String directory = "avatar";
|
||||
FileServiceImpl.PATH_PREFIX_DATE_ENABLE = false;
|
||||
FileServiceImpl.PATH_SUFFIX_TIMESTAMP_ENABLE = false;
|
||||
|
||||
// 调用,并断言异常
|
||||
assertServiceException(() -> fileService.generateUploadPath(name, directory), FILE_PATH_INVALID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateUploadPath_DirectoryInvalid() {
|
||||
// 准备参数
|
||||
String name = "test.jpg";
|
||||
String directory = "../avatar";
|
||||
|
||||
// 调用,并断言异常
|
||||
assertServiceException(() -> fileService.generateUploadPath(name, directory), FILE_PATH_INVALID);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGenerateUploadPath_DirectoryEmpty() {
|
||||
// 准备参数
|
||||
|
|
|
|||
|
|
@ -98,6 +98,9 @@ public interface ErrorCodeConstants {
|
|||
|
||||
// ========== IoT 告警配置 1-050-013-000 ==========
|
||||
ErrorCode ALERT_CONFIG_NOT_EXISTS = new ErrorCode(1_050_013_000, "IoT 告警配置不存在");
|
||||
ErrorCode ALERT_CONFIG_SMS_TEMPLATE_REQUIRED = new ErrorCode(1_050_013_001, "已选择短信接收方式,短信模板不能为空");
|
||||
ErrorCode ALERT_CONFIG_MAIL_TEMPLATE_REQUIRED = new ErrorCode(1_050_013_002, "已选择邮件接收方式,邮件模板不能为空");
|
||||
ErrorCode ALERT_CONFIG_NOTIFY_TEMPLATE_REQUIRED = new ErrorCode(1_050_013_003, "已选择站内信接收方式,站内信模板不能为空");
|
||||
|
||||
// ========== IoT 告警记录 1-050-014-000 ==========
|
||||
ErrorCode ALERT_RECORD_NOT_EXISTS = new ErrorCode(1_050_014_000, "IoT 告警记录不存在");
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import cn.iocoder.yudao.framework.mq.redis.core.stream.AbstractRedisStreamMessag
|
|||
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.local.IotLocalMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.rabbitmq.IotRabbitMQMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.redis.IotRedisMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.rocketmq.IotRocketMQMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.producer.IotDeviceMessageProducer;
|
||||
|
|
@ -14,8 +15,11 @@ import lombok.extern.slf4j.Slf4j;
|
|||
import org.apache.rocketmq.spring.autoconfigure.RocketMQProperties;
|
||||
import org.apache.rocketmq.spring.core.RocketMQTemplate;
|
||||
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.condition.ConditionalOnClass;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
|
@ -126,4 +130,25 @@ public class IotMessageBusAutoConfiguration {
|
|||
|
||||
}
|
||||
|
||||
}
|
||||
// ==================== RabbitMQ 实现 ====================
|
||||
|
||||
@Configuration
|
||||
@ConditionalOnProperty(prefix = "yudao.iot.message-bus", name = "type", havingValue = "rabbitmq")
|
||||
@ConditionalOnClass(RabbitTemplate.class)
|
||||
public static class IotRabbitMQMessageBusConfiguration {
|
||||
|
||||
@Bean
|
||||
@ConditionalOnMissingBean
|
||||
public RabbitAdmin rabbitAdmin(RabbitTemplate rabbitTemplate) {
|
||||
return new RabbitAdmin(rabbitTemplate);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public IotRabbitMQMessageBus iotRabbitMQMessageBus(RabbitTemplate rabbitTemplate, RabbitAdmin rabbitAdmin) {
|
||||
log.info("[iotRabbitMQMessageBus][创建 IoT RabbitMQ 消息总线]");
|
||||
return new IotRabbitMQMessageBus(rabbitTemplate, rabbitAdmin);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -326,9 +326,27 @@ public class IotModbusTcpServerProtocol implements IotProtocol {
|
|||
log.error("[refreshConfig][处理设备配置失败, deviceId={}]", config.getDeviceId(), e);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 清理本轮不再返回配置的已连接设备,避免继续轮询已删除设备的旧点位
|
||||
Set<Long> missingDeviceIds = configCacheService.cleanupMissingConfigs(connectedDeviceIds, configs);
|
||||
for (Long deviceId : missingDeviceIds) {
|
||||
cleanupMissingDevice(deviceId);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("[refreshConfig][刷新配置失败]", e);
|
||||
}
|
||||
}
|
||||
|
||||
private void cleanupMissingDevice(Long deviceId) {
|
||||
try {
|
||||
pollScheduler.stopPolling(deviceId);
|
||||
pendingRequestManager.removeDevice(deviceId);
|
||||
configCacheService.removeConfig(deviceId);
|
||||
connectionManager.closeConnection(deviceId);
|
||||
log.info("[cleanupMissingDevice][设备 {} 配置已失效,已停止轮询并清理连接]", deviceId);
|
||||
} catch (Exception e) {
|
||||
log.error("[cleanupMissingDevice][清理设备失败, deviceId={}]", deviceId, e);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,17 +6,20 @@ import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
|||
import cn.iocoder.yudao.module.iot.core.biz.IotDeviceCommonApi;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigListReqDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.biz.dto.IotModbusDeviceConfigRespDTO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.modbus.IotModbusModeEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotProtocolTypeEnum;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.convertSet;
|
||||
|
||||
/**
|
||||
* IoT Modbus TCP Server 配置缓存:认证时按需加载,断连时清理,定时刷新已连接设备
|
||||
*
|
||||
|
|
@ -96,6 +99,27 @@ public class IotModbusTcpServerConfigCacheService {
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 清理本轮刷新后不再有效的设备配置
|
||||
*
|
||||
* @param refreshedDeviceIds 本轮参与刷新的设备编号
|
||||
* @param currentConfigs 本轮远端返回的有效配置
|
||||
* @return 本轮已不再有效的设备编号
|
||||
*/
|
||||
public Set<Long> cleanupMissingConfigs(Set<Long> refreshedDeviceIds,
|
||||
List<IotModbusDeviceConfigRespDTO> currentConfigs) {
|
||||
if (CollUtil.isEmpty(refreshedDeviceIds)) {
|
||||
return Collections.emptySet();
|
||||
}
|
||||
Set<Long> currentDeviceIds = convertSet(currentConfigs, IotModbusDeviceConfigRespDTO::getDeviceId);
|
||||
Set<Long> missingDeviceIds = new HashSet<>(refreshedDeviceIds);
|
||||
missingDeviceIds.removeAll(currentDeviceIds);
|
||||
for (Long deviceId : missingDeviceIds) {
|
||||
configCache.remove(deviceId);
|
||||
}
|
||||
return missingDeviceIds;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取设备配置
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import lombok.experimental.Accessors;
|
|||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
|
@ -109,7 +110,7 @@ public class IotModbusTcpServerConnectionManager {
|
|||
* 获取所有已连接设备的 ID 集合
|
||||
*/
|
||||
public Set<Long> getConnectedDeviceIds() {
|
||||
return deviceSocketMap.keySet();
|
||||
return new HashSet<>(deviceSocketMap.keySet());
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -130,6 +131,24 @@ public class IotModbusTcpServerConnectionManager {
|
|||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定设备连接,并先移除映射,避免 closeHandler 再按正常断连发送下线消息
|
||||
*/
|
||||
public void closeConnection(Long deviceId) {
|
||||
NetSocket socket = deviceSocketMap.remove(deviceId);
|
||||
if (socket == null) {
|
||||
return;
|
||||
}
|
||||
connectionMap.remove(socket);
|
||||
try {
|
||||
socket.close();
|
||||
log.info("[closeConnection][设备 {} 连接已关闭]", deviceId);
|
||||
} catch (Exception e) {
|
||||
log.warn("[closeConnection][关闭设备连接失败, deviceId={}, remoteAddress={}]",
|
||||
deviceId, socket.remoteAddress(), e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送数据到设备
|
||||
*
|
||||
|
|
|
|||
|
|
@ -37,6 +37,15 @@ public class IotAlertConfigRespVO {
|
|||
@Schema(description = "接收的类型数组", requiredMode = Schema.RequiredMode.REQUIRED, example = "1,2,3")
|
||||
private List<Integer> receiveTypes;
|
||||
|
||||
@Schema(description = "短信模板编号", example = "iot_alert_sms")
|
||||
private String smsTemplateCode;
|
||||
|
||||
@Schema(description = "邮件模板编号", example = "iot_alert_mail")
|
||||
private String mailTemplateCode;
|
||||
|
||||
@Schema(description = "站内信模板编号", example = "iot_alert_notify")
|
||||
private String notifyTemplateCode;
|
||||
|
||||
@Schema(description = "创建时间", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime createTime;
|
||||
|
||||
|
|
|
|||
|
|
@ -44,4 +44,12 @@ public class IotAlertConfigSaveReqVO {
|
|||
@NotEmpty(message = "接收的类型数组不能为空")
|
||||
private List<Integer> receiveTypes;
|
||||
|
||||
@Schema(description = "短信模板编号", example = "iot_alert_sms")
|
||||
private String smsTemplateCode;
|
||||
|
||||
@Schema(description = "邮件模板编号", example = "iot_alert_mail")
|
||||
private String mailTemplateCode;
|
||||
|
||||
@Schema(description = "站内信模板编号", example = "iot_alert_notify")
|
||||
private String notifyTemplateCode;
|
||||
}
|
||||
|
|
@ -81,4 +81,22 @@ public class IotAlertConfigDO extends BaseDO {
|
|||
@TableField(typeHandler = IntegerListTypeHandler.class)
|
||||
private List<Integer> receiveTypes;
|
||||
|
||||
/**
|
||||
* 短信模板编号
|
||||
*
|
||||
* 关联 SmsTemplateDO 的 code 属性
|
||||
*/
|
||||
private String smsTemplateCode;
|
||||
/**
|
||||
* 邮件模板编号
|
||||
*
|
||||
* 关联 MailTemplateDO 的 code 属性
|
||||
*/
|
||||
private String mailTemplateCode;
|
||||
/**
|
||||
* 站内信模板编号
|
||||
*
|
||||
* 关联 NotifyTemplateDO 的 code 属性
|
||||
*/
|
||||
private String notifyTemplateCode;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,14 @@
|
|||
package cn.iocoder.yudao.module.iot.service.alert;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigPageReqVO;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.config.IotAlertConfigSaveReqVO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertConfigDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.mysql.alert.IotAlertConfigMapper;
|
||||
import cn.iocoder.yudao.module.iot.enums.alert.IotAlertReceiveTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.IotSceneRuleService;
|
||||
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
|
||||
import jakarta.annotation.Resource;
|
||||
|
|
@ -16,7 +19,10 @@ import org.springframework.validation.annotation.Validated;
|
|||
import java.util.List;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.ALERT_CONFIG_MAIL_TEMPLATE_REQUIRED;
|
||||
import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.ALERT_CONFIG_NOT_EXISTS;
|
||||
import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.ALERT_CONFIG_NOTIFY_TEMPLATE_REQUIRED;
|
||||
import static cn.iocoder.yudao.module.iot.enums.ErrorCodeConstants.ALERT_CONFIG_SMS_TEMPLATE_REQUIRED;
|
||||
|
||||
/**
|
||||
* IoT 告警配置 Service 实现类
|
||||
|
|
@ -41,7 +47,8 @@ public class IotAlertConfigServiceImpl implements IotAlertConfigService {
|
|||
public Long createAlertConfig(IotAlertConfigSaveReqVO createReqVO) {
|
||||
// 校验关联数据是否存在
|
||||
sceneRuleService.validateSceneRuleList(createReqVO.getSceneRuleIds());
|
||||
adminUserApi.validateUserList(createReqVO.getReceiveUserIds()).checkError();
|
||||
adminUserApi.validateUserList(createReqVO.getReceiveUserIds());
|
||||
validateReceiveTemplates(createReqVO);
|
||||
|
||||
// 插入
|
||||
IotAlertConfigDO alertConfig = BeanUtils.toBean(createReqVO, IotAlertConfigDO.class);
|
||||
|
|
@ -55,7 +62,8 @@ public class IotAlertConfigServiceImpl implements IotAlertConfigService {
|
|||
validateAlertConfigExists(updateReqVO.getId());
|
||||
// 校验关联数据是否存在
|
||||
sceneRuleService.validateSceneRuleList(updateReqVO.getSceneRuleIds());
|
||||
adminUserApi.validateUserList(updateReqVO.getReceiveUserIds()).checkError();
|
||||
adminUserApi.validateUserList(updateReqVO.getReceiveUserIds());
|
||||
validateReceiveTemplates(updateReqVO);
|
||||
|
||||
// 更新
|
||||
IotAlertConfigDO updateObj = BeanUtils.toBean(updateReqVO, IotAlertConfigDO.class);
|
||||
|
|
@ -76,6 +84,24 @@ public class IotAlertConfigServiceImpl implements IotAlertConfigService {
|
|||
}
|
||||
}
|
||||
|
||||
private void validateReceiveTemplates(IotAlertConfigSaveReqVO reqVO) {
|
||||
if (CollUtil.isEmpty(reqVO.getReceiveTypes())) {
|
||||
return;
|
||||
}
|
||||
if (reqVO.getReceiveTypes().contains(IotAlertReceiveTypeEnum.SMS.getType())
|
||||
&& StrUtil.isBlank(reqVO.getSmsTemplateCode())) {
|
||||
throw exception(ALERT_CONFIG_SMS_TEMPLATE_REQUIRED);
|
||||
}
|
||||
if (reqVO.getReceiveTypes().contains(IotAlertReceiveTypeEnum.MAIL.getType())
|
||||
&& StrUtil.isBlank(reqVO.getMailTemplateCode())) {
|
||||
throw exception(ALERT_CONFIG_MAIL_TEMPLATE_REQUIRED);
|
||||
}
|
||||
if (reqVO.getReceiveTypes().contains(IotAlertReceiveTypeEnum.NOTIFY.getType())
|
||||
&& StrUtil.isBlank(reqVO.getNotifyTemplateCode())) {
|
||||
throw exception(ALERT_CONFIG_NOTIFY_TEMPLATE_REQUIRED);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public IotAlertConfigDO getAlertConfig(Long id) {
|
||||
return alertConfigMapper.selectById(id);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package cn.iocoder.yudao.module.iot.service.device.property;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.core.util.ObjUtil;
|
||||
|
|
@ -148,8 +147,16 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
|
|||
// 1. 根据物模型,拼接合法的属性
|
||||
// TODO @芋艿:【待定 004】赋能后,属性到底以 thingModel 为准(ik),还是 db 的表结构为准(tl)?
|
||||
List<IotThingModelDO> thingModels = thingModelService.getThingModelListByProductIdFromCache(device.getProductId());
|
||||
Map<String, Object> properties = new HashMap<>();
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
params.forEach((key, value) -> {
|
||||
if (!(key instanceof CharSequence)) {
|
||||
log.error("[saveDeviceProperty][消息({}) 的属性 key({}) 类型不正确]", message, key);
|
||||
return;
|
||||
}
|
||||
if (value == null) {
|
||||
log.warn("[saveDeviceProperty][消息({}) 的属性({}) 值为空,跳过]", message, key);
|
||||
return;
|
||||
}
|
||||
// 忽略大小写匹配物模型,避免设备上报的 key 与 identifier 大小写不一致导致丢失
|
||||
IotThingModelDO thingModel = CollUtil.findOne(thingModels,
|
||||
o -> StrUtil.equalsIgnoreCase(o.getIdentifier(), (CharSequence) key));
|
||||
|
|
@ -158,21 +165,9 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
|
|||
return;
|
||||
}
|
||||
String identifier = thingModel.getIdentifier(); // 统一以物模型 identifier 作为 key,避免大小写问题
|
||||
String dataType = thingModel.getProperty().getDataType();
|
||||
if (ObjectUtils.equalsAny(dataType,
|
||||
IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) {
|
||||
// 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,有没对应数据类型,只能通过 JSON 来存储
|
||||
properties.put(identifier, JsonUtils.toJsonString(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.INT.getDataType().equals(dataType)) {
|
||||
properties.put(identifier, Convert.toInt(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(dataType)) {
|
||||
properties.put(identifier, Convert.toFloat(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(dataType)) {
|
||||
properties.put(identifier, Convert.toDouble(value));
|
||||
} else if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(dataType)) {
|
||||
properties.put(identifier, Convert.toBool(value, false) ? (byte) 1 : (byte) 0);
|
||||
} else {
|
||||
properties.put(identifier, value);
|
||||
Object convertedValue = convertPropertyValue(message, thingModel, value);
|
||||
if (convertedValue != null) {
|
||||
properties.put(identifier, convertedValue);
|
||||
}
|
||||
});
|
||||
if (CollUtil.isEmpty(properties)) {
|
||||
|
|
@ -194,6 +189,23 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
|
|||
extractAndUpdateDeviceLocation(device, (Map<?, ?>) message.getParams());
|
||||
}
|
||||
|
||||
private Object convertPropertyValue(IotDeviceMessage message, IotThingModelDO thingModel, Object value) {
|
||||
String identifier = thingModel.getIdentifier();
|
||||
String dataType = thingModel.getProperty().getDataType();
|
||||
try {
|
||||
Object convertedValue = thingModelService.convertThingModelPropertyValue(thingModel, value);
|
||||
if (convertedValue == null) {
|
||||
log.warn("[saveDeviceProperty][消息({}) 的属性({}) 值({}) 无法转换为类型({}),跳过]",
|
||||
message, identifier, value, dataType);
|
||||
}
|
||||
return convertedValue;
|
||||
} catch (Exception e) {
|
||||
log.error("[saveDeviceProperty][消息({}) 的属性({}) 值({}) 转换为类型({}) 异常,跳过]",
|
||||
message, identifier, value, dataType, e);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, IotDevicePropertyDO> getLatestDeviceProperties(Long deviceId) {
|
||||
return deviceDataRedisDAO.get(deviceId);
|
||||
|
|
@ -310,4 +322,4 @@ public class IotDevicePropertyServiceImpl implements IotDevicePropertyService {
|
|||
return new BigDecimal[]{longitude, latitude};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package cn.iocoder.yudao.module.iot.service.rule.scene.action;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
|
|
@ -79,30 +80,38 @@ public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction {
|
|||
}
|
||||
Map<String, Object> templateParams = buildTemplateParams(config, deviceMessage, device);
|
||||
config.getReceiveUserIds().forEach(userId ->
|
||||
config.getReceiveTypes().forEach(receiveType -> sendAlertMessageToUser(userId, receiveType, templateParams)));
|
||||
config.getReceiveTypes().forEach(receiveType ->
|
||||
sendAlertMessageToUser(userId, receiveType, config, templateParams)));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按指定接收方式,给单个用户发送告警消息
|
||||
*/
|
||||
private void sendAlertMessageToUser(Long userId, Integer receiveType, Map<String, Object> templateParams) {
|
||||
private void sendAlertMessageToUser(Long userId, Integer receiveType, IotAlertConfigDO config,
|
||||
Map<String, Object> templateParams) {
|
||||
IotAlertReceiveTypeEnum typeEnum = IotAlertReceiveTypeEnum.of(receiveType);
|
||||
if (typeEnum == null) {
|
||||
return;
|
||||
}
|
||||
String templateCode = resolveTemplateCode(config, typeEnum);
|
||||
if (StrUtil.isBlank(templateCode)) {//为了兼容老的结构
|
||||
templateCode=typeEnum.getTemplateCode();
|
||||
log.warn("[sendAlertMessageToUser][配置({}) 用户({}) 接收方式({}) 未配置模板,使用默认模板({})]",
|
||||
config.getId(), userId, typeEnum,templateCode);
|
||||
}
|
||||
try {
|
||||
switch (typeEnum) {
|
||||
case SMS:
|
||||
smsSendApi.sendSingleSmsToAdmin(new SmsSendSingleToUserReqDTO().setUserId(userId)
|
||||
.setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams));
|
||||
.setTemplateCode(templateCode).setTemplateParams(templateParams));
|
||||
break;
|
||||
case MAIL:
|
||||
mailSendApi.sendSingleMailToAdmin(new MailSendSingleToUserReqDTO().setUserId(userId)
|
||||
.setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams));
|
||||
.setTemplateCode(templateCode).setTemplateParams(templateParams));
|
||||
break;
|
||||
case NOTIFY:
|
||||
notifyMessageSendApi.sendSingleMessageToAdmin(new NotifySendSingleToUserReqDTO().setUserId(userId)
|
||||
.setTemplateCode(typeEnum.getTemplateCode()).setTemplateParams(templateParams)).checkError();
|
||||
.setTemplateCode(templateCode).setTemplateParams(templateParams));
|
||||
break;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
|
|
@ -111,6 +120,24 @@ public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction {
|
|||
}
|
||||
}
|
||||
|
||||
private String resolveTemplateCode(IotAlertConfigDO config, IotAlertReceiveTypeEnum typeEnum) {
|
||||
String templateCode = null;
|
||||
switch (typeEnum) {
|
||||
case SMS:
|
||||
templateCode = config.getSmsTemplateCode();
|
||||
break;
|
||||
case MAIL:
|
||||
templateCode = config.getMailTemplateCode();
|
||||
break;
|
||||
case NOTIFY:
|
||||
templateCode = config.getNotifyTemplateCode();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
return StrUtil.blankToDefault(templateCode, typeEnum.getTemplateCode());
|
||||
}
|
||||
|
||||
private Map<String, Object> buildTemplateParams(IotAlertConfigDO config,
|
||||
@Nullable IotDeviceMessage deviceMessage,
|
||||
@Nullable IotDeviceDO device) {
|
||||
|
|
|
|||
|
|
@ -2,13 +2,17 @@ package cn.iocoder.yudao.module.iot.service.rule.scene.matcher;
|
|||
|
||||
import cn.hutool.core.text.CharPool;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.number.NumberUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.spring.SpringExpressionUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.spring.SpringUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleConditionOperatorEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.util.HashMap;
|
||||
|
|
@ -248,4 +252,26 @@ public final class IotSceneRuleMatcherHelper {
|
|||
return StrUtil.isNotBlank(expectedIdentifier) && expectedIdentifier.equals(actualIdentifier);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验匹配器中的产品和设备是否一致
|
||||
*
|
||||
* @param message 消息
|
||||
* @param productId 产品编号
|
||||
* @param deviceId 设备编号
|
||||
* @return 校验结果
|
||||
*/
|
||||
public static boolean productAndDeviceNotMatched(IotDeviceMessage message, Long productId, Long deviceId) {
|
||||
if (message == null || message.getDeviceId() == null) {
|
||||
return false;
|
||||
}
|
||||
if (deviceId != null && !IotDeviceDO.DEVICE_ID_ALL.equals(deviceId)) {
|
||||
return ObjectUtil.notEqual(message.getDeviceId(), deviceId);
|
||||
}
|
||||
if (productId == null) {
|
||||
return false;
|
||||
}
|
||||
IotDeviceDO device = SpringUtils.getBean(IotDeviceService.class).getDeviceFromCache(message.getDeviceId());
|
||||
return device == null || ObjectUtil.notEqual(device.getProductId(), productId);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,13 +31,19 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc
|
|||
return false;
|
||||
}
|
||||
|
||||
// 1.2 检查操作符和参数是否有效
|
||||
// 1.2 修复条件匹配中忽略了产品和设备的一致性验证,2025.05.25 by panda
|
||||
if (IotSceneRuleMatcherHelper.productAndDeviceNotMatched(message, condition.getProductId(),condition.getDeviceId())){
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message,condition,"条件匹配器中产品或设备不匹配");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.3 检查操作符和参数是否有效
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "操作符或参数无效");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.3 验证操作符是否为支持的时间操作符
|
||||
// 1.4 验证操作符是否为支持的时间操作符
|
||||
String operator = condition.getOperator();
|
||||
IotSceneRuleConditionOperatorEnum operatorEnum = IotSceneRuleConditionOperatorEnum.operatorOf(operator);
|
||||
if (operatorEnum == null) {
|
||||
|
|
@ -45,6 +51,7 @@ public class IotCurrentTimeConditionMatcher implements IotSceneRuleConditionMatc
|
|||
return false;
|
||||
}
|
||||
|
||||
// 1.5 验证操作符是否为时间相关的操作符
|
||||
if (IotSceneRuleTimeHelper.isTimeOperator(operatorEnum)) {
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "不支持的时间操作符: " + operator);
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -30,14 +30,20 @@ public class IotDevicePropertyConditionMatcher implements IotSceneRuleConditionM
|
|||
return false;
|
||||
}
|
||||
|
||||
// 1.2 检查消息中是否包含条件指定的属性标识符
|
||||
// 1.2 修复条件匹配中忽略了产品和设备的一致性验证,2025.05.25 by panda
|
||||
if (IotSceneRuleMatcherHelper.productAndDeviceNotMatched(message, condition.getProductId(),condition.getDeviceId())){
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message,condition,"条件匹配器中产品或设备不匹配");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.3 检查消息中是否包含条件指定的属性标识符
|
||||
// 注意:属性上报可能同时上报多个属性,所以需要判断 condition.getIdentifier() 是否在 message 的 params 中
|
||||
if (IotDeviceMessageUtils.notContainsIdentifier(message, condition.getIdentifier())) {
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "消息中不包含属性: " + condition.getIdentifier());
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.3 检查操作符和参数是否有效
|
||||
// 1.4 检查操作符和参数是否有效
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "操作符或参数无效");
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -27,8 +27,13 @@ public class IotDeviceStateConditionMatcher implements IotSceneRuleConditionMatc
|
|||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "条件基础参数无效");
|
||||
return false;
|
||||
}
|
||||
// 1.2 修复条件匹配中忽略了产品和设备的一致性验证,2025.05.25 by panda
|
||||
if (IotSceneRuleMatcherHelper.productAndDeviceNotMatched(message, condition.getProductId(),condition.getDeviceId())){
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message,condition,"条件匹配器中产品或设备不匹配");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.2 检查操作符和参数是否有效
|
||||
// 1.3 检查操作符和参数是否有效
|
||||
if (!IotSceneRuleMatcherHelper.isConditionOperatorAndParamValid(condition)) {
|
||||
IotSceneRuleMatcherHelper.logConditionMatchFailure(message, condition, "操作符或参数无效");
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -43,7 +43,13 @@ public class IotDeviceEventPostTriggerMatcher implements IotSceneRuleTriggerMatc
|
|||
return false;
|
||||
}
|
||||
|
||||
// 1.3 检查标识符是否匹配
|
||||
// 1.3 修复触发器中忽略了产品和设备的一致性验证,2025.05.25 by panda
|
||||
if (IotSceneRuleMatcherHelper.productAndDeviceNotMatched(message, trigger.getProductId(),trigger.getDeviceId())){
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message,trigger,"触发器中产品或设备不匹配");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.4 检查标识符是否匹配
|
||||
String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message);
|
||||
if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " +
|
||||
|
|
|
|||
|
|
@ -36,7 +36,13 @@ public class IotDevicePropertyPostTriggerMatcher implements IotSceneRuleTriggerM
|
|||
return false;
|
||||
}
|
||||
|
||||
// 1.3 检查消息中是否包含触发器指定的属性标识符
|
||||
// 1.3 修复触发器中忽略了产品和设备的一致性验证,2025.05.25 by panda
|
||||
if (IotSceneRuleMatcherHelper.productAndDeviceNotMatched(message, trigger.getProductId(),trigger.getDeviceId())){
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message,trigger,"触发器中产品或设备不匹配");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.4 检查消息中是否包含触发器指定的属性标识符
|
||||
// 注意:属性上报可能同时上报多个属性,所以需要判断 trigger.getIdentifier() 是否在 message 的 params 中
|
||||
if (IotDeviceMessageUtils.notContainsIdentifier(message, trigger.getIdentifier())) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息中不包含属性: " +
|
||||
|
|
|
|||
|
|
@ -7,7 +7,9 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
|||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.Map;
|
||||
|
|
@ -20,6 +22,9 @@ import java.util.Map;
|
|||
@Component
|
||||
public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTriggerMatcher {
|
||||
|
||||
@Resource
|
||||
private IotDeviceService iotDeviceService;
|
||||
|
||||
@Override
|
||||
public IotSceneRuleTriggerTypeEnum getSupportedTriggerType() {
|
||||
return IotSceneRuleTriggerTypeEnum.DEVICE_SERVICE_INVOKE;
|
||||
|
|
@ -37,7 +42,12 @@ public class IotDeviceServiceInvokeTriggerMatcher implements IotSceneRuleTrigger
|
|||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "消息方法不匹配,期望: " + IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod() + ", 实际: " + message.getMethod());
|
||||
return false;
|
||||
}
|
||||
// 1.3 检查标识符是否匹配
|
||||
// 1.3 修复触发器中忽略了产品和设备的一致性验证,2025.05.25 by panda
|
||||
if (IotSceneRuleMatcherHelper.productAndDeviceNotMatched(message, trigger.getProductId(),trigger.getDeviceId())){
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message,trigger,"触发器中产品或设备不匹配");
|
||||
return false;
|
||||
}
|
||||
// 1.4 检查标识符是否匹配
|
||||
String messageIdentifier = IotDeviceMessageUtils.getIdentifier(message);
|
||||
if (!IotSceneRuleMatcherHelper.isIdentifierMatched(trigger.getIdentifier(), messageIdentifier)) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "标识符不匹配,期望: " + trigger.getIdentifier() + ", 实际: " + messageIdentifier);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
|||
import cn.iocoder.yudao.module.iot.core.util.IotDeviceMessageUtils;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.rule.IotSceneRuleDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.rule.IotSceneRuleTriggerTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.rule.scene.matcher.IotSceneRuleMatcherHelper;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
|
|
@ -16,6 +18,9 @@ import org.springframework.stereotype.Component;
|
|||
@Component
|
||||
public class IotDeviceStateUpdateTriggerMatcher implements IotSceneRuleTriggerMatcher {
|
||||
|
||||
@Resource
|
||||
private IotDeviceService iotDeviceService;
|
||||
|
||||
@Override
|
||||
public IotSceneRuleTriggerTypeEnum getSupportedTriggerType() {
|
||||
return IotSceneRuleTriggerTypeEnum.DEVICE_STATE_UPDATE;
|
||||
|
|
@ -36,7 +41,13 @@ public class IotDeviceStateUpdateTriggerMatcher implements IotSceneRuleTriggerMa
|
|||
return false;
|
||||
}
|
||||
|
||||
// 1.3 检查操作符和值是否有效
|
||||
// 1.3 修复触发器中忽略了产品和设备的一致性验证,2025.05.25 by panda
|
||||
if (IotSceneRuleMatcherHelper.productAndDeviceNotMatched(message, trigger.getProductId(),trigger.getDeviceId())){
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message,trigger,"触发器中产品或设备不匹配");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 1.4 检查操作符和值是否有效
|
||||
if (!IotSceneRuleMatcherHelper.isTriggerOperatorAndValueValid(trigger)) {
|
||||
IotSceneRuleMatcherHelper.logTriggerMatchFailure(message, trigger, "操作符或值无效");
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -108,4 +108,13 @@ public interface IotThingModelService {
|
|||
*/
|
||||
void validateThingModelListExists(Long productId, Set<String> identifiers);
|
||||
|
||||
}
|
||||
/**
|
||||
* 按物模型属性的数据类型转换设备上报值
|
||||
*
|
||||
* @param thingModel 物模型
|
||||
* @param value 设备上报值
|
||||
* @return 转换后的值;无法转换时返回 null
|
||||
*/
|
||||
Object convertThingModelPropertyValue(IotThingModelDO thingModel, Object value);
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,16 @@
|
|||
package cn.iocoder.yudao.module.iot.service.thingmodel;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.convert.Convert;
|
||||
import cn.hutool.core.date.DatePattern;
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
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.date.LocalDateTimeUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.common.util.object.ObjectUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.aop.TenantIgnore;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.thingmodel.vo.IotThingModelListReqVO;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.thingmodel.vo.IotThingModelPageReqVO;
|
||||
|
|
@ -12,9 +18,14 @@ import cn.iocoder.yudao.module.iot.controller.admin.thingmodel.vo.IotThingModelS
|
|||
import cn.iocoder.yudao.module.iot.convert.thingmodel.IotThingModelConvert;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelBoolOrEnumDataSpecs;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDataSpecs;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDateOrTextDataSpecs;
|
||||
import cn.iocoder.yudao.module.iot.dal.mysql.thingmodel.IotThingModelMapper;
|
||||
import cn.iocoder.yudao.module.iot.dal.redis.RedisKeyConstants;
|
||||
import cn.iocoder.yudao.module.iot.enums.product.IotProductStatusEnum;
|
||||
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.framework.tdengine.core.TDengineTableField;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import jakarta.annotation.Resource;
|
||||
|
|
@ -26,7 +37,15 @@ import org.springframework.stereotype.Service;
|
|||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.OffsetDateTime;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.Collection;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
|
|
@ -166,6 +185,239 @@ public class IotThingModelServiceImpl implements IotThingModelService {
|
|||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Object convertThingModelPropertyValue(IotThingModelDO thingModel, Object value) {
|
||||
if (thingModel == null || thingModel.getProperty() == null || value == null) {
|
||||
return null;
|
||||
}
|
||||
String dataType = thingModel.getProperty().getDataType();
|
||||
if (ObjectUtils.equalsAny(dataType,
|
||||
IotDataSpecsDataTypeEnum.STRUCT.getDataType(), IotDataSpecsDataTypeEnum.ARRAY.getDataType())) {
|
||||
// 特殊:STRUCT 和 ARRAY 类型,在 TDengine 里,没有对应数据类型,只能通过 JSON 来存储
|
||||
return convertToVarchar(JsonUtils.toJsonString(value), TDengineTableField.LENGTH_VARCHAR);
|
||||
}
|
||||
if (IotDataSpecsDataTypeEnum.INT.getDataType().equals(dataType)) {
|
||||
return convertToInt(value);
|
||||
}
|
||||
if (IotDataSpecsDataTypeEnum.FLOAT.getDataType().equals(dataType)) {
|
||||
return convertToFloat(value);
|
||||
}
|
||||
if (IotDataSpecsDataTypeEnum.DOUBLE.getDataType().equals(dataType)) {
|
||||
return convertToDouble(value);
|
||||
}
|
||||
if (IotDataSpecsDataTypeEnum.ENUM.getDataType().equals(dataType)) {
|
||||
return convertEnumToTinyInt(thingModel, value);
|
||||
}
|
||||
if (IotDataSpecsDataTypeEnum.BOOL.getDataType().equals(dataType)) {
|
||||
return convertBoolToTinyInt(value);
|
||||
}
|
||||
if (IotDataSpecsDataTypeEnum.TEXT.getDataType().equals(dataType)) {
|
||||
return convertToVarchar(Convert.toStr(value), getTextLength(thingModel));
|
||||
}
|
||||
if (IotDataSpecsDataTypeEnum.DATE.getDataType().equals(dataType)) {
|
||||
return convertToTimestamp(value);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Integer getTextLength(IotThingModelDO thingModel) {
|
||||
ThingModelDataSpecs dataSpecs = thingModel.getProperty().getDataSpecs();
|
||||
if (!(dataSpecs instanceof ThingModelDateOrTextDataSpecs)) {
|
||||
return null;
|
||||
}
|
||||
return ((ThingModelDateOrTextDataSpecs) dataSpecs).getLength();
|
||||
}
|
||||
|
||||
private String convertToVarchar(String value, Integer length) {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (length != null && value.getBytes(StandardCharsets.UTF_8).length > length) {
|
||||
return null;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
private Integer convertToInt(Object value) {
|
||||
BigDecimal decimal = convertToBigDecimal(value);
|
||||
if (decimal == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return decimal.intValueExact();
|
||||
} catch (ArithmeticException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Float convertToFloat(Object value) {
|
||||
BigDecimal decimal = convertToBigDecimal(value);
|
||||
if (decimal == null) {
|
||||
return null;
|
||||
}
|
||||
float result = decimal.floatValue();
|
||||
return Float.isFinite(result) ? result : null;
|
||||
}
|
||||
|
||||
private Double convertToDouble(Object value) {
|
||||
BigDecimal decimal = convertToBigDecimal(value);
|
||||
if (decimal == null) {
|
||||
return null;
|
||||
}
|
||||
double result = decimal.doubleValue();
|
||||
return Double.isFinite(result) ? result : null;
|
||||
}
|
||||
|
||||
private BigDecimal convertToBigDecimal(Object value) {
|
||||
if (value instanceof Boolean) {
|
||||
return null;
|
||||
}
|
||||
String text = Convert.toStr(value);
|
||||
if (StrUtil.isBlank(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return new BigDecimal(text);
|
||||
} catch (NumberFormatException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Byte convertEnumToTinyInt(IotThingModelDO thingModel, Object value) {
|
||||
Integer intValue = convertToInt(value);
|
||||
if (intValue == null && value instanceof CharSequence) {
|
||||
intValue = getEnumValueByName(thingModel, value.toString());
|
||||
}
|
||||
if (intValue == null || !isTinyInt(intValue)) {
|
||||
return null;
|
||||
}
|
||||
if (CollUtil.isNotEmpty(thingModel.getProperty().getDataSpecsList())
|
||||
&& getEnumDataSpecsByValue(thingModel, intValue) == null) {
|
||||
return null;
|
||||
}
|
||||
return intValue.byteValue();
|
||||
}
|
||||
|
||||
private Integer getEnumValueByName(IotThingModelDO thingModel, String name) {
|
||||
ThingModelBoolOrEnumDataSpecs dataSpecs = getEnumDataSpecsByName(thingModel, name);
|
||||
return dataSpecs != null ? dataSpecs.getValue() : null;
|
||||
}
|
||||
|
||||
private ThingModelBoolOrEnumDataSpecs getEnumDataSpecsByName(IotThingModelDO thingModel, String name) {
|
||||
if (CollUtil.isEmpty(thingModel.getProperty().getDataSpecsList())) {
|
||||
return null;
|
||||
}
|
||||
for (ThingModelDataSpecs dataSpecs : thingModel.getProperty().getDataSpecsList()) {
|
||||
if (!(dataSpecs instanceof ThingModelBoolOrEnumDataSpecs)) {
|
||||
continue;
|
||||
}
|
||||
ThingModelBoolOrEnumDataSpecs enumDataSpecs = (ThingModelBoolOrEnumDataSpecs) dataSpecs;
|
||||
if (StrUtil.equals(enumDataSpecs.getName(), name)) {
|
||||
return enumDataSpecs;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private ThingModelBoolOrEnumDataSpecs getEnumDataSpecsByValue(IotThingModelDO thingModel, Integer value) {
|
||||
if (CollUtil.isEmpty(thingModel.getProperty().getDataSpecsList())) {
|
||||
return null;
|
||||
}
|
||||
for (ThingModelDataSpecs dataSpecs : thingModel.getProperty().getDataSpecsList()) {
|
||||
if (!(dataSpecs instanceof ThingModelBoolOrEnumDataSpecs)) {
|
||||
continue;
|
||||
}
|
||||
ThingModelBoolOrEnumDataSpecs enumDataSpecs = (ThingModelBoolOrEnumDataSpecs) dataSpecs;
|
||||
if (Objects.equals(enumDataSpecs.getValue(), value)) {
|
||||
return enumDataSpecs;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private Byte convertBoolToTinyInt(Object value) {
|
||||
if (value instanceof Boolean) {
|
||||
return (Boolean) value ? (byte) 1 : (byte) 0;
|
||||
}
|
||||
if (value instanceof CharSequence) {
|
||||
String text = StrUtil.trim(value.toString());
|
||||
if (StrUtil.equalsIgnoreCase(text, "true")) {
|
||||
return (byte) 1;
|
||||
}
|
||||
if (StrUtil.equalsIgnoreCase(text, "false")) {
|
||||
return (byte) 0;
|
||||
}
|
||||
}
|
||||
Integer intValue = convertToInt(value);
|
||||
if (intValue == null || (intValue != 0 && intValue != 1)) {
|
||||
return null;
|
||||
}
|
||||
return intValue.byteValue();
|
||||
}
|
||||
|
||||
private boolean isTinyInt(Integer value) {
|
||||
return value >= Byte.MIN_VALUE && value <= Byte.MAX_VALUE;
|
||||
}
|
||||
|
||||
private Long convertToTimestamp(Object value) {
|
||||
if (value instanceof LocalDateTime) {
|
||||
return LocalDateTimeUtil.toEpochMilli((LocalDateTime) value);
|
||||
}
|
||||
if (value instanceof LocalDate) {
|
||||
return LocalDateTimeUtil.toEpochMilli(((LocalDate) value).atStartOfDay());
|
||||
}
|
||||
if (value instanceof Instant) {
|
||||
return ((Instant) value).toEpochMilli();
|
||||
}
|
||||
if (value instanceof OffsetDateTime) {
|
||||
return ((OffsetDateTime) value).toInstant().toEpochMilli();
|
||||
}
|
||||
if (value instanceof ZonedDateTime) {
|
||||
return ((ZonedDateTime) value).toInstant().toEpochMilli();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return ((Date) value).getTime();
|
||||
}
|
||||
Long timestamp = convertToLong(value);
|
||||
if (timestamp != null) {
|
||||
return timestamp;
|
||||
}
|
||||
if (!(value instanceof CharSequence)) {
|
||||
return null;
|
||||
}
|
||||
String text = StrUtil.trim(value.toString());
|
||||
if (StrUtil.isBlank(text)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return OffsetDateTime.parse(text).toInstant().toEpochMilli();
|
||||
} catch (Exception ignored) {
|
||||
// 尝试本地时间格式,例如 yyyy-MM-dd HH:mm:ss
|
||||
}
|
||||
try {
|
||||
return LocalDateTimeUtil.toEpochMilli(LocalDateTimeUtil.parse(text, DatePattern.NORM_DATETIME_PATTERN));
|
||||
} catch (Exception ignored) {
|
||||
// 尝试其它 Hutool 支持的本地时间格式
|
||||
}
|
||||
try {
|
||||
return LocalDateTimeUtil.toEpochMilli(LocalDateTimeUtils.parse(text));
|
||||
} catch (Exception ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Long convertToLong(Object value) {
|
||||
BigDecimal decimal = convertToBigDecimal(value);
|
||||
if (decimal == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
return decimal.longValueExact();
|
||||
} catch (ArithmeticException e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private void validateProductThingModelMapperExists(Long id) {
|
||||
if (thingModelMapper.selectById(id) == null) {
|
||||
throw exception(THING_MODEL_NOT_EXISTS);
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
|
|||
// mock 行为
|
||||
when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
|
||||
.thenReturn(singletonList(thingModel));
|
||||
when(thingModelService.convertThingModelPropertyValue(thingModel, 100)).thenReturn(100);
|
||||
|
||||
// 调用
|
||||
service.saveDeviceProperty(device, message);
|
||||
|
|
@ -91,64 +92,62 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_boolFromBooleanTrue() {
|
||||
// 准备参数:物模型为 BOOL,设备上报原生 boolean true
|
||||
assertBoolValueConvertedToByte(true, (byte) 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_boolFromBooleanFalse() {
|
||||
// 准备参数:物模型为 BOOL,设备上报原生 boolean false
|
||||
assertBoolValueConvertedToByte(false, (byte) 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_boolFromStringTrue() {
|
||||
// 准备参数:物模型为 BOOL,设备上报字符串 "true"
|
||||
assertBoolValueConvertedToByte("true", (byte) 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_boolFromStringFalse() {
|
||||
// 准备参数:物模型为 BOOL,设备上报字符串 "false"
|
||||
assertBoolValueConvertedToByte("false", (byte) 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_boolFromNumberOne() {
|
||||
// 准备参数:物模型为 BOOL,设备上报数字 1
|
||||
assertBoolValueConvertedToByte(1, (byte) 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_boolFromNumberZero() {
|
||||
// 准备参数:物模型为 BOOL,设备上报数字 0
|
||||
assertBoolValueConvertedToByte(0, (byte) 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验 BOOL 类型属性上报后,最终落到 properties Map 的值类型与数值
|
||||
*/
|
||||
private void assertBoolValueConvertedToByte(Object reportedValue, byte expected) {
|
||||
// 准备参数
|
||||
public void testSaveDeviceProperty_convertValueFailed() {
|
||||
// 准备参数:物模型存在,但是属性值无法按物模型转换
|
||||
IotDeviceDO device = buildDevice();
|
||||
IotThingModelDO thingModel = buildThingModel("PowerSwitch", IotDataSpecsDataTypeEnum.BOOL.getDataType());
|
||||
IotThingModelDO temperature = buildThingModel("Temperature", IotDataSpecsDataTypeEnum.INT.getDataType());
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("PowerSwitch", reportedValue);
|
||||
params.put("Temperature", "abc");
|
||||
IotDeviceMessage message = buildMessage(params);
|
||||
|
||||
when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
|
||||
.thenReturn(singletonList(temperature));
|
||||
when(thingModelService.convertThingModelPropertyValue(temperature, "abc")).thenReturn(null);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
|
||||
|
||||
verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong());
|
||||
verify(deviceDataRedisDAO, never()).putAll(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_skipNullValue() {
|
||||
// 准备参数:属性值为空,不能写入 TDengine 与 Redis
|
||||
IotDeviceDO device = buildDevice();
|
||||
IotThingModelDO thingModel = buildThingModel("Temperature", IotDataSpecsDataTypeEnum.INT.getDataType());
|
||||
Map<String, Object> params = new HashMap<>();
|
||||
params.put("Temperature", null);
|
||||
IotDeviceMessage message = buildMessage(params);
|
||||
|
||||
// mock 行为
|
||||
when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
|
||||
.thenReturn(singletonList(thingModel));
|
||||
|
||||
// 调用:不能抛异常
|
||||
assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
|
||||
|
||||
// 断言:写入的 value 是 byte 类型,且值匹配
|
||||
verify(thingModelService, never()).convertThingModelPropertyValue(any(), any());
|
||||
verify(devicePropertyMapper, never()).insert(any(), any(), anyLong(), anyLong());
|
||||
verify(deviceDataRedisDAO, never()).putAll(anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testSaveDeviceProperty_skipInvalidKeyType() {
|
||||
// 准备参数:Map 中包含非字符串 key,不能因为强转失败影响其它合法属性
|
||||
IotDeviceDO device = buildDevice();
|
||||
IotThingModelDO thingModel = buildThingModel("PowerSwitch", IotDataSpecsDataTypeEnum.BOOL.getDataType());
|
||||
Map<Object, Object> params = new HashMap<>();
|
||||
params.put(123, 1);
|
||||
params.put("PowerSwitch", true);
|
||||
IotDeviceMessage message = buildMessage(params);
|
||||
|
||||
when(thingModelService.getThingModelListByProductIdFromCache(device.getProductId()))
|
||||
.thenReturn(singletonList(thingModel));
|
||||
when(thingModelService.convertThingModelPropertyValue(thingModel, true)).thenReturn((byte) 1);
|
||||
|
||||
assertDoesNotThrow(() -> service.saveDeviceProperty(device, message));
|
||||
|
||||
Map<String, Object> dbProperties = captureMapperInsertProperties();
|
||||
Object actual = dbProperties.get("PowerSwitch");
|
||||
assertTrue(actual instanceof Byte, "BOOL 属性应被转为 Byte 类型,实际为 " + (actual == null ? "null" : actual.getClass()));
|
||||
assertEquals(expected, actual);
|
||||
assertEquals(1, dbProperties.size());
|
||||
assertEquals((byte) 1, dbProperties.get("PowerSwitch"));
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
|
@ -173,7 +172,7 @@ public class IotDevicePropertyServiceImplTest extends BaseMockitoUnitTest {
|
|||
/**
|
||||
* 构造一条属性上报消息
|
||||
*/
|
||||
private IotDeviceMessage buildMessage(Map<String, Object> params) {
|
||||
private IotDeviceMessage buildMessage(Map<?, ?> params) {
|
||||
IotDeviceMessage message = new IotDeviceMessage();
|
||||
message.setMethod(IotDeviceMessageMethodEnum.PROPERTY_POST.getMethod());
|
||||
message.setParams(params);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
package cn.iocoder.yudao.module.iot.service.rule.scene;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.util.spring.SpringExpressionUtils;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
|
|
@ -21,6 +23,9 @@ import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
|||
import org.junit.jupiter.api.*;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
import java.lang.reflect.Field;
|
||||
import java.util.*;
|
||||
|
|
@ -43,8 +48,23 @@ import static org.mockito.Mockito.*;
|
|||
*
|
||||
* @author HUIHUI
|
||||
*/
|
||||
@SpringJUnitConfig(classes = IotSceneRuleTimerConditionIntegrationTest.TestConfig.class)
|
||||
public class IotSceneRuleTimerConditionIntegrationTest extends BaseMockitoUnitTest {
|
||||
|
||||
/**
|
||||
* 注入一下 SpringUtil,解析 EL 表达式时需要
|
||||
* {@link SpringExpressionUtils#parseExpression}
|
||||
*/
|
||||
@Configuration
|
||||
static class TestConfig {
|
||||
|
||||
@Bean
|
||||
public SpringUtil springUtil() {
|
||||
return new SpringUtil();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@InjectMocks
|
||||
private IotSceneRuleServiceImpl sceneRuleService;
|
||||
|
||||
|
|
|
|||
|
|
@ -106,6 +106,9 @@ public class IotAlertTriggerSceneRuleActionTest extends BaseMockitoUnitTest {
|
|||
IotAlertReceiveTypeEnum.SMS.getType(),
|
||||
IotAlertReceiveTypeEnum.MAIL.getType(),
|
||||
IotAlertReceiveTypeEnum.NOTIFY.getType()));
|
||||
c.setSmsTemplateCode("custom_sms");
|
||||
c.setMailTemplateCode("custom_mail");
|
||||
c.setNotifyTemplateCode("custom_notify");
|
||||
});
|
||||
IotDeviceDO device = randomPojo(IotDeviceDO.class);
|
||||
|
||||
|
|
@ -130,13 +133,13 @@ public class IotAlertTriggerSceneRuleActionTest extends BaseMockitoUnitTest {
|
|||
ArgumentCaptor<SmsSendSingleToUserReqDTO> smsCaptor = ArgumentCaptor.forClass(SmsSendSingleToUserReqDTO.class);
|
||||
verify(smsSendApi, times(1)).sendSingleSmsToAdmin(smsCaptor.capture());
|
||||
assertEquals(userId, smsCaptor.getValue().getUserId());
|
||||
assertEquals(IotAlertReceiveTypeEnum.SMS.getTemplateCode(), smsCaptor.getValue().getTemplateCode());
|
||||
assertEquals("custom_sms", smsCaptor.getValue().getTemplateCode());
|
||||
ArgumentCaptor<MailSendSingleToUserReqDTO> mailCaptor = ArgumentCaptor.forClass(MailSendSingleToUserReqDTO.class);
|
||||
verify(mailSendApi, times(1)).sendSingleMailToAdmin(mailCaptor.capture());
|
||||
assertEquals(IotAlertReceiveTypeEnum.MAIL.getTemplateCode(), mailCaptor.getValue().getTemplateCode());
|
||||
assertEquals("custom_mail", mailCaptor.getValue().getTemplateCode());
|
||||
ArgumentCaptor<NotifySendSingleToUserReqDTO> notifyCaptor = ArgumentCaptor.forClass(NotifySendSingleToUserReqDTO.class);
|
||||
verify(notifyMessageSendApi, times(1)).sendSingleMessageToAdmin(notifyCaptor.capture());
|
||||
assertEquals(IotAlertReceiveTypeEnum.NOTIFY.getTemplateCode(), notifyCaptor.getValue().getTemplateCode());
|
||||
assertEquals("custom_notify", notifyCaptor.getValue().getTemplateCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
|
|
|||
|
|
@ -0,0 +1,201 @@
|
|||
package cn.iocoder.yudao.module.iot.service.thingmodel;
|
||||
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.ThingModelProperty;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelBoolOrEnumDataSpecs;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.model.dataType.ThingModelDateOrTextDataSpecs;
|
||||
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotDataSpecsDataTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceModbusPointService;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
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 java.time.LocalDateTime;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
|
||||
/**
|
||||
* {@link IotThingModelServiceImpl} 的单元测试
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Import(IotThingModelServiceImpl.class)
|
||||
public class IotThingModelServiceImplTest extends BaseDbUnitTest {
|
||||
|
||||
@Resource
|
||||
private IotThingModelServiceImpl thingModelService;
|
||||
|
||||
@MockitoBean
|
||||
private IotProductService productService;
|
||||
@MockitoBean
|
||||
private IotDeviceModbusPointService deviceModbusPointService;
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_boolFromBooleanTrue() {
|
||||
assertBoolValueConvertedToByte(true, (byte) 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_boolFromBooleanFalse() {
|
||||
assertBoolValueConvertedToByte(false, (byte) 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_boolFromStringTrue() {
|
||||
assertBoolValueConvertedToByte("true", (byte) 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_boolFromStringFalse() {
|
||||
assertBoolValueConvertedToByte("false", (byte) 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_boolFromNumberOne() {
|
||||
assertBoolValueConvertedToByte(1, (byte) 1);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_boolFromNumberZero() {
|
||||
assertBoolValueConvertedToByte(0, (byte) 0);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_boolInvalid() {
|
||||
IotThingModelDO thingModel = buildThingModel("PowerSwitch", IotDataSpecsDataTypeEnum.BOOL.getDataType());
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, "yes");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_enumFromStringNumber() {
|
||||
IotThingModelDO thingModel = buildEnumThingModel("WorkMode", enumSpec("Auto", 1), enumSpec("Manual", 2));
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, "1");
|
||||
|
||||
assertEquals((byte) 1, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_enumFromSpecName() {
|
||||
IotThingModelDO thingModel = buildEnumThingModel("WorkMode", enumSpec("Auto", 1), enumSpec("Manual", 2));
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, "Manual");
|
||||
|
||||
assertEquals((byte) 2, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_enumInvalidSpecValue() {
|
||||
IotThingModelDO thingModel = buildEnumThingModel("WorkMode", enumSpec("Auto", 1), enumSpec("Manual", 2));
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, 3);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_enumOutOfTinyIntRange() {
|
||||
IotThingModelDO thingModel = buildThingModel("WorkMode", IotDataSpecsDataTypeEnum.ENUM.getDataType());
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, 128);
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_dateFromLocalDateTime() {
|
||||
IotThingModelDO thingModel = buildThingModel("CollectTime", IotDataSpecsDataTypeEnum.DATE.getDataType());
|
||||
LocalDateTime collectTime = LocalDateTime.of(2025, 1, 2, 3, 4, 5);
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, collectTime);
|
||||
|
||||
assertEquals(LocalDateTimeUtil.toEpochMilli(collectTime), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_dateFromString() {
|
||||
IotThingModelDO thingModel = buildThingModel("CollectTime", IotDataSpecsDataTypeEnum.DATE.getDataType());
|
||||
LocalDateTime collectTime = LocalDateTime.of(2025, 1, 2, 3, 4, 5);
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, "2025-01-02 03:04:05");
|
||||
|
||||
assertEquals(LocalDateTimeUtil.toEpochMilli(collectTime), result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_intInvalid() {
|
||||
IotThingModelDO thingModel = buildThingModel("Temperature", IotDataSpecsDataTypeEnum.INT.getDataType());
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, "abc");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_textOverLength() {
|
||||
IotThingModelDO thingModel = buildTextThingModel("Remark", 3);
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, "abcd");
|
||||
|
||||
assertNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testConvertThingModelPropertyValue_structToJson() {
|
||||
IotThingModelDO thingModel = buildThingModel("GeoLocation", IotDataSpecsDataTypeEnum.STRUCT.getDataType());
|
||||
Map<String, Object> value = new HashMap<>();
|
||||
value.put("Longitude", 120.1D);
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, value);
|
||||
|
||||
assertEquals(JsonUtils.toJsonString(value), result);
|
||||
}
|
||||
|
||||
private void assertBoolValueConvertedToByte(Object reportedValue, byte expected) {
|
||||
IotThingModelDO thingModel = buildThingModel("PowerSwitch", IotDataSpecsDataTypeEnum.BOOL.getDataType());
|
||||
|
||||
Object result = thingModelService.convertThingModelPropertyValue(thingModel, reportedValue);
|
||||
|
||||
assertEquals(expected, result);
|
||||
}
|
||||
|
||||
private IotThingModelDO buildThingModel(String identifier, String dataType) {
|
||||
ThingModelProperty property = new ThingModelProperty();
|
||||
property.setIdentifier(identifier);
|
||||
property.setDataType(dataType);
|
||||
return IotThingModelDO.builder().identifier(identifier).property(property).build();
|
||||
}
|
||||
|
||||
private IotThingModelDO buildEnumThingModel(String identifier, ThingModelBoolOrEnumDataSpecs... dataSpecsList) {
|
||||
IotThingModelDO thingModel = buildThingModel(identifier, IotDataSpecsDataTypeEnum.ENUM.getDataType());
|
||||
thingModel.getProperty().setDataSpecsList(Arrays.asList(dataSpecsList));
|
||||
return thingModel;
|
||||
}
|
||||
|
||||
private ThingModelBoolOrEnumDataSpecs enumSpec(String name, Integer value) {
|
||||
ThingModelBoolOrEnumDataSpecs dataSpecs = new ThingModelBoolOrEnumDataSpecs();
|
||||
dataSpecs.setName(name);
|
||||
dataSpecs.setValue(value);
|
||||
return dataSpecs;
|
||||
}
|
||||
|
||||
private IotThingModelDO buildTextThingModel(String identifier, Integer length) {
|
||||
IotThingModelDO thingModel = buildThingModel(identifier, IotDataSpecsDataTypeEnum.TEXT.getDataType());
|
||||
ThingModelDateOrTextDataSpecs dataSpecs = new ThingModelDateOrTextDataSpecs();
|
||||
dataSpecs.setLength(length);
|
||||
thingModel.getProperty().setDataSpecs(dataSpecs);
|
||||
return thingModel;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -77,7 +77,6 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
|||
@Resource
|
||||
public SocialClientApi socialClientApi;
|
||||
|
||||
// TODO @芋艿:在详细预览下;
|
||||
@Override
|
||||
public KeyValue<CombinationActivityDO, CombinationProductDO> validateCombinationRecord(
|
||||
Long userId, Long activityId, Long headId, Long skuId, Integer count) {
|
||||
|
|
@ -97,7 +96,7 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
|||
}
|
||||
|
||||
// 2. 父拼团是否存在,是否已经满了
|
||||
if (headId != null) {
|
||||
if (isJoinCombination(headId)) {
|
||||
// 2.1. 查询进行中的父拼团
|
||||
CombinationRecordDO record = combinationRecordMapper.selectByHeadId(headId, CombinationRecordStatusEnum.IN_PROGRESS.getStatus());
|
||||
if (record == null) {
|
||||
|
|
@ -124,12 +123,12 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
|||
throw exception(COMBINATION_JOIN_ACTIVITY_PRODUCT_NOT_EXISTS);
|
||||
}
|
||||
// 4.2 校验 sku 是否存在
|
||||
ProductSkuRespDTO sku = productSkuApi.getSku(skuId).getCheckedData();
|
||||
ProductSkuRespDTO sku = productSkuApi.getSku(skuId);
|
||||
if (sku == null) {
|
||||
throw exception(COMBINATION_JOIN_ACTIVITY_PRODUCT_NOT_EXISTS);
|
||||
}
|
||||
// 4.3 校验库存是否充足
|
||||
if (count >= sku.getStock()) {
|
||||
if (count > sku.getStock()) {
|
||||
throw exception(COMBINATION_ACTIVITY_UPDATE_STOCK_FAIL);
|
||||
}
|
||||
|
||||
|
|
@ -153,6 +152,16 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
|||
return new KeyValue<>(activity, product);
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否加入已有拼团。
|
||||
*
|
||||
* 前端开团时可能不传 headId,也可能传 {@link CombinationRecordDO#HEAD_ID_GROUP},都应视为新开团;
|
||||
* 只有传入真实团长记录编号时,才需要按参团校验父拼团。
|
||||
*/
|
||||
private static boolean isJoinCombination(Long headId) {
|
||||
return headId != null && ObjUtil.notEqual(headId, CombinationRecordDO.HEAD_ID_GROUP);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public CombinationRecordDO createCombinationRecord(CombinationRecordCreateReqDTO reqDTO) {
|
||||
|
|
@ -161,12 +170,12 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
|||
reqDTO.getActivityId(), reqDTO.getHeadId(), reqDTO.getSkuId(), reqDTO.getCount());
|
||||
|
||||
// 2. 组合数据创建拼团记录
|
||||
MemberUserRespDTO user = memberUserApi.getUser(reqDTO.getUserId()).getCheckedData();
|
||||
ProductSpuRespDTO spu = productSpuApi.getSpu(reqDTO.getSpuId()).getCheckedData();
|
||||
ProductSkuRespDTO sku = productSkuApi.getSku(reqDTO.getSkuId()).getCheckedData();
|
||||
MemberUserRespDTO user = memberUserApi.getUser(reqDTO.getUserId());
|
||||
ProductSpuRespDTO spu = productSpuApi.getSpu(reqDTO.getSpuId());
|
||||
ProductSkuRespDTO sku = productSkuApi.getSku(reqDTO.getSkuId());
|
||||
CombinationRecordDO record = CombinationActivityConvert.INSTANCE.convert(reqDTO, keyValue.getKey(), user, spu, sku);
|
||||
// 2.1. 如果是团长需要设置 headId 为 CombinationRecordDO#HEAD_ID_GROUP
|
||||
if (record.getHeadId() == null) {
|
||||
if (!isJoinCombination(record.getHeadId())) {
|
||||
record.setStartTime(LocalDateTime.now())
|
||||
.setExpireTime(LocalDateTime.now().plusHours(keyValue.getKey().getLimitDuration()))
|
||||
.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
|
||||
|
|
@ -229,7 +238,7 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
|||
.setTemplateTitle(COMBINATION_SUCCESS)
|
||||
.setPage("pages/order/detail?id=" + record.getOrderId()) // 订单详情页
|
||||
.addMessage("thing1", "商品拼团活动") // 活动标题
|
||||
.addMessage("thing2", "恭喜您拼团成功!我们将尽快为您发货。")).checkError(); // 温馨提示
|
||||
.addMessage("thing2", "恭喜您拼团成功!我们将尽快为您发货。")); // 温馨提示
|
||||
}
|
||||
|
||||
@Override
|
||||
|
|
@ -336,7 +345,7 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
|||
CombinationRecordStatusEnum.FAILED);
|
||||
// 2. 订单取消
|
||||
headAndRecords.forEach(item -> tradeOrderApi.cancelPaidOrder(item.getUserId(), item.getOrderId(),
|
||||
TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType()).checkError());
|
||||
TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -5,8 +5,9 @@ DELETE FROM "promotion_reward_activity";
|
|||
DELETE FROM "promotion_discount_activity";
|
||||
DELETE FROM "promotion_discount_product";
|
||||
DELETE FROM "promotion_seckill_config";
|
||||
DELETE FROM "promotion_combination_record";
|
||||
DELETE FROM "promotion_combination_activity";
|
||||
DELETE FROM "promotion_article_category";
|
||||
DELETE FROM "promotion_article";
|
||||
DELETE FROM "promotion_diy_template";
|
||||
DELETE FROM "promotion_diy_page";
|
||||
DELETE FROM "promotion_diy_page";
|
||||
|
|
|
|||
|
|
@ -203,6 +203,36 @@ CREATE TABLE IF NOT EXISTS "promotion_combination_activity"
|
|||
PRIMARY KEY ("id")
|
||||
) COMMENT '拼团活动';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "promotion_combination_record"
|
||||
(
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"activity_id" bigint NOT NULL,
|
||||
"combination_price" int NOT NULL,
|
||||
"spu_id" bigint NOT NULL,
|
||||
"spu_name" varchar NOT NULL,
|
||||
"pic_url" varchar,
|
||||
"sku_id" bigint NOT NULL,
|
||||
"count" int NOT NULL,
|
||||
"user_id" bigint NOT NULL,
|
||||
"nickname" varchar,
|
||||
"avatar" varchar,
|
||||
"head_id" bigint NOT NULL,
|
||||
"status" int NOT NULL,
|
||||
"order_id" bigint NOT NULL,
|
||||
"user_size" int NOT NULL,
|
||||
"user_count" int NOT NULL,
|
||||
"virtual_group" bit NOT NULL,
|
||||
"expire_time" datetime,
|
||||
"start_time" datetime,
|
||||
"end_time" datetime,
|
||||
"creator" varchar DEFAULT '',
|
||||
"create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar DEFAULT '',
|
||||
"update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
"deleted" bit NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT '拼团记录';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "promotion_article_category"
|
||||
(
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package cn.iocoder.yudao.module.trade.convert.order;
|
||||
|
||||
import cn.hutool.core.util.BooleanUtil;
|
||||
import cn.hutool.core.util.ObjectUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
|
|
@ -268,8 +269,8 @@ public interface TradeOrderConvert {
|
|||
.setTitle(StrUtil.format("{}成功购买{}", user.getNickname(), item.getSpuName()));
|
||||
if (BooleanUtil.isTrue(spu.getSubCommissionType())) {
|
||||
// 特殊:单独设置的佣金需要乘以购买数量。关联 https://gitee.com/yudaocode/yudao-mall-uniapp/issues/ICY7SJ
|
||||
bo.setFirstFixedPrice(sku.getFirstBrokeragePrice() * item.getCount())
|
||||
.setSecondFixedPrice(sku.getSecondBrokeragePrice() * item.getCount());
|
||||
bo.setFirstFixedPrice(ObjectUtil.defaultIfNull(sku.getFirstBrokeragePrice(), 0) * item.getCount())
|
||||
.setSecondFixedPrice(ObjectUtil.defaultIfNull(sku.getSecondBrokeragePrice(), 0) * item.getCount());
|
||||
}
|
||||
return bo;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,7 +38,6 @@ import java.util.*;
|
|||
import static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getMaxValue;
|
||||
import static cn.iocoder.yudao.framework.common.util.collection.CollectionUtils.getMinValue;
|
||||
import static cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils.getLoginUserId;
|
||||
import static cn.iocoder.yudao.module.trade.enums.ErrorCodeConstants.BROKERAGE_WITHDRAW_USER_BALANCE_NOT_ENOUGH;
|
||||
|
||||
/**
|
||||
|
|
@ -143,7 +142,7 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService {
|
|||
int calculatePrice(Integer basePrice, Integer percent, Integer fixedPrice) {
|
||||
// 1. 优先使用固定佣金
|
||||
if (fixedPrice != null && fixedPrice >= 0) {
|
||||
return ObjectUtil.defaultIfNull(fixedPrice, 0);
|
||||
return fixedPrice;
|
||||
}
|
||||
// 2. 根据比例计算佣金
|
||||
if (basePrice != null && basePrice > 0 && percent != null && percent > 0) {
|
||||
|
|
@ -329,32 +328,34 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService {
|
|||
return respVO;
|
||||
}
|
||||
// 2.2 校验用户是否有分销资格
|
||||
respVO.setEnabled(brokerageUserService.getUserBrokerageEnabled(getLoginUserId()));
|
||||
respVO.setEnabled(brokerageUserService.getUserBrokerageEnabled(userId));
|
||||
if (BooleanUtil.isFalse(respVO.getEnabled())) {
|
||||
return respVO;
|
||||
}
|
||||
// 2.3 校验商品是否存在
|
||||
ProductSpuRespDTO spu = productSpuApi.getSpu(spuId).getCheckedData();
|
||||
ProductSpuRespDTO spu = productSpuApi.getSpu(spuId);
|
||||
if (spu == null) {
|
||||
return respVO;
|
||||
}
|
||||
|
||||
// 3.1 商品单独分佣模式
|
||||
Integer fixedMinPrice = 0;
|
||||
Integer fixedMaxPrice = 0;
|
||||
Integer spuMinPrice = 0;
|
||||
Integer spuMaxPrice = 0;
|
||||
List<ProductSkuRespDTO> skuList = productSkuApi.getSkuListBySpuId(ListUtil.of(spuId)).getCheckedData();
|
||||
// 3.1 获取商品 SKU 列表
|
||||
List<ProductSkuRespDTO> skuList = productSkuApi.getSkuListBySpuId(ListUtil.of(spuId));
|
||||
if (BooleanUtil.isTrue(spu.getSubCommissionType())) {
|
||||
fixedMinPrice = getMinValue(skuList, ProductSkuRespDTO::getFirstBrokeragePrice);
|
||||
fixedMaxPrice = getMaxValue(skuList, ProductSkuRespDTO::getFirstBrokeragePrice);
|
||||
// 3.2 全局分佣模式(根据商品价格比例计算)
|
||||
// 3.2.1 商品独立分销模式:直接取 SKU 固定佣金
|
||||
// 注意:固定佣金允许为 0,表示商家主动设为零佣金;为空时,也按 0 处理
|
||||
Integer fixedMinPrice = getMinValue(skuList,
|
||||
sku -> ObjectUtil.defaultIfNull(sku.getFirstBrokeragePrice(), 0));
|
||||
Integer fixedMaxPrice = getMaxValue(skuList,
|
||||
sku -> ObjectUtil.defaultIfNull(sku.getFirstBrokeragePrice(), 0));
|
||||
respVO.setBrokerageMinPrice(calculatePrice(null, tradeConfig.getBrokerageFirstPercent(), fixedMinPrice))
|
||||
.setBrokerageMaxPrice(calculatePrice(null, tradeConfig.getBrokerageFirstPercent(), fixedMaxPrice));
|
||||
} else {
|
||||
spuMinPrice = getMinValue(skuList, ProductSkuRespDTO::getPrice);
|
||||
spuMaxPrice = getMaxValue(skuList, ProductSkuRespDTO::getPrice);
|
||||
// 3.2.2 全局比例模式:固定佣金传 null,避免被默认值 0 提前拦截
|
||||
Integer spuMinPrice = getMinValue(skuList, ProductSkuRespDTO::getPrice);
|
||||
Integer spuMaxPrice = getMaxValue(skuList, ProductSkuRespDTO::getPrice);
|
||||
respVO.setBrokerageMinPrice(calculatePrice(spuMinPrice, tradeConfig.getBrokerageFirstPercent(), null))
|
||||
.setBrokerageMaxPrice(calculatePrice(spuMaxPrice, tradeConfig.getBrokerageFirstPercent(), null));
|
||||
}
|
||||
respVO.setBrokerageMinPrice(calculatePrice(spuMinPrice, tradeConfig.getBrokerageFirstPercent(), fixedMinPrice));
|
||||
respVO.setBrokerageMaxPrice(calculatePrice(spuMaxPrice, tradeConfig.getBrokerageFirstPercent(), fixedMaxPrice));
|
||||
return respVO;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ CREATE TABLE IF NOT EXISTS "trade_brokerage_user"
|
|||
) COMMENT '分销用户';
|
||||
CREATE TABLE IF NOT EXISTS "trade_brokerage_record"
|
||||
(
|
||||
"id" int NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"id" bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
|
||||
"user_id" bigint NOT NULL,
|
||||
"biz_id" varchar NOT NULL,
|
||||
"biz_type" varchar NOT NULL,
|
||||
|
|
@ -184,6 +184,8 @@ CREATE TABLE IF NOT EXISTS "trade_brokerage_record"
|
|||
"status" varchar NOT NULL,
|
||||
"frozen_days" int NOT NULL,
|
||||
"unfreeze_time" varchar,
|
||||
"source_user_level" int,
|
||||
"source_user_id" bigint,
|
||||
"creator" varchar DEFAULT '',
|
||||
"create_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updater" varchar DEFAULT '',
|
||||
|
|
@ -232,4 +234,4 @@ CREATE TABLE IF NOT EXISTS "trade_delivery_express"
|
|||
"update_time" datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
"deleted" bit NOT NULL DEFAULT FALSE,
|
||||
PRIMARY KEY ("id")
|
||||
) COMMENT '佣金提现';
|
||||
) COMMENT '佣金提现';
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ public interface ErrorCodeConstants {
|
|||
ErrorCode USER_MOBILE_NOT_EXISTS = new ErrorCode(1_004_001_001, "手机号未注册用户");
|
||||
ErrorCode USER_MOBILE_USED = new ErrorCode(1_004_001_002, "修改手机失败,该手机号({})已经被使用");
|
||||
ErrorCode USER_POINT_NOT_ENOUGH = new ErrorCode(1_004_001_003, "用户积分余额不足");
|
||||
ErrorCode USER_EMAIL_USED = new ErrorCode(1_004_001_004, "修改邮箱失败,该邮箱({})已经被使用");
|
||||
|
||||
// ========== AUTH 模块 1-004-003-000 ==========
|
||||
ErrorCode AUTH_LOGIN_BAD_CREDENTIALS = new ErrorCode(1_004_003_000, "登录失败,账号密码不正确");
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import lombok.Data;
|
|||
import org.hibernate.validator.constraints.URL;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
|
|
@ -26,6 +28,11 @@ public class MemberUserBaseVO {
|
|||
@NotNull(message = "状态不能为空")
|
||||
private Byte status;
|
||||
|
||||
@Schema(description = "邮箱", example = "member@iocoder.cn")
|
||||
@Email(message = "邮箱格式不正确")
|
||||
@Size(max = 50, message = "邮箱长度不能超过 50 个字符")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "用户昵称", requiredMode = Schema.RequiredMode.REQUIRED, example = "李四")
|
||||
@NotNull(message = "用户昵称不能为空")
|
||||
private String nickname;
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ public class MemberUserPageReqVO extends PageParam {
|
|||
@Schema(description = "手机号", example = "15601691300")
|
||||
private String mobile;
|
||||
|
||||
@Schema(description = "邮箱", example = "member@iocoder.cn")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "用户昵称", example = "李四")
|
||||
private String nickname;
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,9 @@ public class AppMemberUserInfoRespVO {
|
|||
@Schema(description = "用户手机号", requiredMode = Schema.RequiredMode.REQUIRED, example = "15601691300")
|
||||
private String mobile;
|
||||
|
||||
@Schema(description = "邮箱", example = "member@iocoder.cn")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "用户性别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer sex;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
package cn.iocoder.yudao.module.member.controller.app.user.vo;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.validation.InEnum;
|
||||
import cn.iocoder.yudao.module.system.enums.common.SexEnum;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.Data;
|
||||
import org.hibernate.validator.constraints.URL;
|
||||
|
||||
|
|
@ -17,6 +17,11 @@ public class AppMemberUserUpdateReqVO {
|
|||
@URL(message = "头像必须是 URL 格式")
|
||||
private String avatar;
|
||||
|
||||
@Schema(description = "邮箱", example = "member@iocoder.cn")
|
||||
@Email(message = "邮箱格式不正确")
|
||||
@Size(max = 50, message = "邮箱长度不能超过 50 个字符")
|
||||
private String email;
|
||||
|
||||
@Schema(description = "性别", requiredMode = Schema.RequiredMode.REQUIRED, example = "1")
|
||||
private Integer sex;
|
||||
|
||||
|
|
|
|||
|
|
@ -45,6 +45,10 @@ public class MemberUserDO extends TenantBaseDO {
|
|||
* 手机
|
||||
*/
|
||||
private String mobile;
|
||||
/**
|
||||
* 邮箱
|
||||
*/
|
||||
private String email;
|
||||
/**
|
||||
* 加密后的密码
|
||||
*
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
|
|||
return selectOne(MemberUserDO::getMobile, mobile);
|
||||
}
|
||||
|
||||
default MemberUserDO selectByEmail(String email) {
|
||||
return selectOne(MemberUserDO::getEmail, email);
|
||||
}
|
||||
|
||||
default List<MemberUserDO> selectListByNicknameLike(String nickname) {
|
||||
return selectList(new LambdaQueryWrapperX<MemberUserDO>()
|
||||
.likeIfPresent(MemberUserDO::getNickname, nickname));
|
||||
|
|
@ -42,6 +46,7 @@ public interface MemberUserMapper extends BaseMapperX<MemberUserDO> {
|
|||
// 分页查询
|
||||
return selectPage(reqVO, new LambdaQueryWrapperX<MemberUserDO>()
|
||||
.likeIfPresent(MemberUserDO::getMobile, reqVO.getMobile())
|
||||
.likeIfPresent(MemberUserDO::getEmail, reqVO.getEmail())
|
||||
.betweenIfPresent(MemberUserDO::getLoginDate, reqVO.getLoginDate())
|
||||
.likeIfPresent(MemberUserDO::getNickname, reqVO.getNickname())
|
||||
.betweenIfPresent(MemberUserDO::getCreateTime, reqVO.getCreateTime())
|
||||
|
|
|
|||
|
|
@ -142,6 +142,12 @@ public class MemberUserServiceImpl implements MemberUserService {
|
|||
|
||||
@Override
|
||||
public void updateUser(Long userId, AppMemberUserUpdateReqVO reqVO) {
|
||||
// 1.1 检测用户是否存在
|
||||
validateUserExists(userId);
|
||||
// 1.2 校验手机是否已经被绑定
|
||||
validateEmailUnique(userId, reqVO.getEmail());
|
||||
|
||||
// 2. 更新用户
|
||||
MemberUserDO updateObj = BeanUtils.toBean(reqVO, MemberUserDO.class).setId(userId);
|
||||
memberUserMapper.updateById(updateObj);
|
||||
}
|
||||
|
|
@ -158,11 +164,11 @@ public class MemberUserServiceImpl implements MemberUserService {
|
|||
// 补充说明:从安全性来说,老手机也校验 oldCode 验证码会更安全。但是由于 uni-app 商城界面暂时没做,所以这里不强制校验
|
||||
if (StrUtil.isNotEmpty(reqVO.getOldCode())) {
|
||||
smsCodeApi.useSmsCode(new SmsCodeUseReqDTO().setMobile(user.getMobile()).setCode(reqVO.getOldCode())
|
||||
.setScene(SmsSceneEnum.MEMBER_UPDATE_MOBILE.getScene()).setUsedIp(getClientIP())).checkError();
|
||||
.setScene(SmsSceneEnum.MEMBER_UPDATE_MOBILE.getScene()).setUsedIp(getClientIP()));
|
||||
}
|
||||
// 2.2 使用新验证码
|
||||
smsCodeApi.useSmsCode(new SmsCodeUseReqDTO().setMobile(reqVO.getMobile()).setCode(reqVO.getCode())
|
||||
.setScene(SmsSceneEnum.MEMBER_UPDATE_MOBILE.getScene()).setUsedIp(getClientIP())).checkError();
|
||||
.setScene(SmsSceneEnum.MEMBER_UPDATE_MOBILE.getScene()).setUsedIp(getClientIP()));
|
||||
|
||||
// 3. 更新用户手机
|
||||
memberUserMapper.updateById(MemberUserDO.builder().id(userId).mobile(reqVO.getMobile()).build());
|
||||
|
|
@ -172,7 +178,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
|||
public void updateUserMobileByWeixin(Long userId, AppMemberUserUpdateMobileByWeixinReqVO reqVO) {
|
||||
// 1.1 获得对应的手机号信息
|
||||
SocialWxPhoneNumberInfoRespDTO phoneNumberInfo = socialClientApi.getWxMaPhoneNumberInfo(
|
||||
UserTypeEnum.MEMBER.getValue(), reqVO.getCode()).getCheckedData();
|
||||
UserTypeEnum.MEMBER.getValue(), reqVO.getCode());
|
||||
Assert.notNull(phoneNumberInfo, "获得手机信息失败,结果为空");
|
||||
// 1.2 校验新手机是否已经被绑定
|
||||
validateMobileUnique(userId, phoneNumberInfo.getPhoneNumber());
|
||||
|
|
@ -187,7 +193,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
|||
MemberUserDO user = validateUserExists(userId);
|
||||
// 校验验证码
|
||||
smsCodeApi.useSmsCode(new SmsCodeUseReqDTO().setMobile(user.getMobile()).setCode(reqVO.getCode())
|
||||
.setScene(SmsSceneEnum.MEMBER_UPDATE_PASSWORD.getScene()).setUsedIp(getClientIP())).checkError();
|
||||
.setScene(SmsSceneEnum.MEMBER_UPDATE_PASSWORD.getScene()).setUsedIp(getClientIP()));
|
||||
|
||||
// 更新用户密码
|
||||
memberUserMapper.updateById(MemberUserDO.builder().id(userId)
|
||||
|
|
@ -201,7 +207,7 @@ public class MemberUserServiceImpl implements MemberUserService {
|
|||
|
||||
// 使用验证码
|
||||
smsCodeApi.useSmsCode(AuthConvert.INSTANCE.convert(reqVO, SmsSceneEnum.MEMBER_RESET_PASSWORD,
|
||||
getClientIP())).checkError();
|
||||
getClientIP()));
|
||||
|
||||
// 更新密码
|
||||
memberUserMapper.updateById(MemberUserDO.builder().id(user.getId())
|
||||
|
|
@ -238,6 +244,8 @@ public class MemberUserServiceImpl implements MemberUserService {
|
|||
validateUserExists(updateReqVO.getId());
|
||||
// 校验手机唯一
|
||||
validateMobileUnique(updateReqVO.getId(), updateReqVO.getMobile());
|
||||
// 校验邮箱唯一
|
||||
validateEmailUnique(updateReqVO.getId(), updateReqVO.getEmail());
|
||||
|
||||
// 更新
|
||||
MemberUserDO updateObj = MemberUserConvert.INSTANCE.convert(updateReqVO);
|
||||
|
|
@ -274,6 +282,24 @@ public class MemberUserServiceImpl implements MemberUserService {
|
|||
}
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void validateEmailUnique(Long id, String email) {
|
||||
if (StrUtil.isBlank(email)) {
|
||||
return;
|
||||
}
|
||||
MemberUserDO user = memberUserMapper.selectByEmail(email);
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
// 如果 id 为空,说明不用比较是否为相同 id 的用户
|
||||
if (id == null) {
|
||||
throw exception(USER_EMAIL_USED, email);
|
||||
}
|
||||
if (!user.getId().equals(id)) {
|
||||
throw exception(USER_EMAIL_USED, email);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<MemberUserDO> getUserPage(MemberUserPageReqVO pageReqVO) {
|
||||
return memberUserMapper.selectPage(pageReqVO);
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ CREATE TABLE IF NOT EXISTS "member_user"
|
|||
"avatar" varchar(255) NOT NULL DEFAULT '' COMMENT '头像',
|
||||
"status" tinyint NOT NULL COMMENT '状态',
|
||||
"mobile" varchar(11) NOT NULL COMMENT '手机号',
|
||||
"email" varchar(50) NULL COMMENT '邮箱',
|
||||
"password" varchar(100) NOT NULL DEFAULT '' COMMENT '密码',
|
||||
"register_ip" varchar(32) NOT NULL COMMENT '注册 IP',
|
||||
"login_ip" varchar(50) NULL DEFAULT '' COMMENT '最后登录IP',
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import cn.iocoder.yudao.module.mes.controller.admin.pro.andon.vo.config.MesProAn
|
|||
import cn.iocoder.yudao.module.mes.controller.admin.pro.andon.vo.config.MesProAndonConfigSaveReqVO;
|
||||
import cn.iocoder.yudao.module.mes.dal.dataobject.pro.andon.MesProAndonConfigDO;
|
||||
import cn.iocoder.yudao.module.mes.service.pro.andon.MesProAndonConfigService;
|
||||
import cn.iocoder.yudao.module.system.api.permission.RoleApi;
|
||||
import cn.iocoder.yudao.module.system.api.permission.dto.RoleRespDTO;
|
||||
import cn.iocoder.yudao.module.system.api.user.AdminUserApi;
|
||||
import cn.iocoder.yudao.module.system.api.user.dto.AdminUserRespDTO;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
|
|
@ -40,6 +42,8 @@ public class MesProAndonConfigController {
|
|||
|
||||
@Resource
|
||||
private AdminUserApi adminUserApi;
|
||||
@Resource
|
||||
private RoleApi roleApi;
|
||||
|
||||
@PostMapping("/create")
|
||||
@Operation(summary = "创建安灯呼叫配置")
|
||||
|
|
@ -102,10 +106,15 @@ public class MesProAndonConfigController {
|
|||
// 1. 批量获取关联数据
|
||||
Map<Long, AdminUserRespDTO> userMap = adminUserApi.getUserMap(
|
||||
convertSet(list, MesProAndonConfigDO::getHandlerUserId));
|
||||
Map<Long, RoleRespDTO> roleMap = roleApi.getRoleMap(
|
||||
convertSet(list, MesProAndonConfigDO::getHandlerRoleId));
|
||||
// 2. 拼接 VO
|
||||
return BeanUtils.toBean(list, MesProAndonConfigRespVO.class, vo ->
|
||||
MapUtils.findAndThen(userMap, vo.getHandlerUserId(),
|
||||
user -> vo.setHandlerUserNickname(user.getNickname())));
|
||||
return BeanUtils.toBean(list, MesProAndonConfigRespVO.class, vo -> {
|
||||
MapUtils.findAndThen(userMap, vo.getHandlerUserId(),
|
||||
user -> vo.setHandlerUserNickname(user.getNickname()));
|
||||
MapUtils.findAndThen(roleMap, vo.getHandlerRoleId(),
|
||||
role -> vo.setHandlerRoleName(role.getName()));
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,9 @@ public class MesProAndonConfigRespVO {
|
|||
@Schema(description = "处置人角色编号", example = "10")
|
||||
private Long handlerRoleId;
|
||||
|
||||
@Schema(description = "处置人角色名称", example = "生产经理")
|
||||
private String handlerRoleName;
|
||||
|
||||
@Schema(description = "处置人编号", example = "100")
|
||||
private Long handlerUserId;
|
||||
|
||||
|
|
|
|||
|
|
@ -52,6 +52,15 @@ public class MesQcDefectRecordController {
|
|||
return success(true);
|
||||
}
|
||||
|
||||
@GetMapping("/get")
|
||||
@Operation(summary = "获得质检缺陷记录")
|
||||
@Parameter(name = "id", description = "编号", required = true, example = "1024")
|
||||
@PreAuthorize("@ss.hasPermission('mes:qc-defect:query')")
|
||||
public CommonResult<MesQcDefectRecordRespVO> getDefectRecord(@RequestParam("id") Long id) {
|
||||
MesQcDefectRecordDO record = defectRecordService.getDefectRecord(id);
|
||||
return success(BeanUtils.toBean(record, MesQcDefectRecordRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/page")
|
||||
@Operation(summary = "获得质检缺陷记录分页")
|
||||
@PreAuthorize("@ss.hasPermission('mes:qc-defect:query')")
|
||||
|
|
|
|||
|
|
@ -35,6 +35,14 @@ public interface MesQcDefectRecordService {
|
|||
*/
|
||||
void deleteDefectRecord(Long id);
|
||||
|
||||
/**
|
||||
* 获得质检缺陷记录
|
||||
*
|
||||
* @param id 编号
|
||||
* @return 质检缺陷记录
|
||||
*/
|
||||
MesQcDefectRecordDO getDefectRecord(Long id);
|
||||
|
||||
/**
|
||||
* 获得质检缺陷记录分页
|
||||
*
|
||||
|
|
|
|||
|
|
@ -45,21 +45,18 @@ public class MesQcDefectRecordServiceImpl implements MesQcDefectRecordService {
|
|||
@Resource
|
||||
@Lazy
|
||||
private MesQcIqcLineService iqcLineService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private MesQcIpqcService ipqcService;
|
||||
@Resource
|
||||
@Lazy
|
||||
private MesQcIpqcLineService ipqcLineService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private MesQcOqcService oqcService;
|
||||
@Resource
|
||||
@Lazy
|
||||
private MesQcOqcLineService oqcLineService;
|
||||
|
||||
@Resource
|
||||
@Lazy
|
||||
private MesQcRqcService rqcService;
|
||||
|
|
@ -109,6 +106,11 @@ public class MesQcDefectRecordServiceImpl implements MesQcDefectRecordService {
|
|||
recalculateDefectStats(record.getQcType(), record.getQcId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public MesQcDefectRecordDO getDefectRecord(Long id) {
|
||||
return defectRecordMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<MesQcDefectRecordDO> getDefectRecordPage(MesQcDefectRecordPageReqVO pageReqVO) {
|
||||
return defectRecordMapper.selectPage(pageReqVO);
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@ public abstract class AbstractWxPayClient extends AbstractPayClient<WxPayClientC
|
|||
}
|
||||
// 特殊:强制使用微信公钥模式,避免灰度期间的问题!!!
|
||||
payConfig.setStrictlyNeedWechatPaySerial(true);
|
||||
// 特殊:weixin-java-pay 只有配置 publicKeyPath 后,再开启 fullPublicKeyModel,才会使用 PublicCertificateVerifier
|
||||
// 对应 https://t.zsxq.com/5Q9lO 帖子
|
||||
payConfig.setFullPublicKeyModel(true);
|
||||
}
|
||||
|
||||
// 创建 client 客户端
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package cn.iocoder.yudao.module.system.controller.admin.mail;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
|
|
@ -80,10 +81,11 @@ public class MailTemplateController {
|
|||
return success(BeanUtils.toBean(pageResult, MailTemplateRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping({"/list-all-simple", "simple-list"})
|
||||
@Operation(summary = "获得邮件模版精简列表")
|
||||
public CommonResult<List<MailTemplateSimpleRespVO>> getSimpleTemplateList() {
|
||||
List<MailTemplateDO> list = mailTempleService.getMailTemplateList();
|
||||
@GetMapping({"/list-all-simple", "/simple-list"})
|
||||
@Operation(summary = "获得邮件模版精简列表", description = "只包含被开启的邮件模版,主要用于前端的下拉选项")
|
||||
public CommonResult<List<MailTemplateSimpleRespVO>> getSimpleMailTemplateList() {
|
||||
List<MailTemplateDO> list = mailTempleService.getMailTemplateListByStatus(
|
||||
CommonStatusEnum.ENABLE.getStatus());
|
||||
return success(BeanUtils.toBean(list, MailTemplateSimpleRespVO.class));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -13,4 +13,7 @@ public class MailTemplateSimpleRespVO {
|
|||
@Schema(description = "模版名字", requiredMode = Schema.RequiredMode.REQUIRED, example = "哒哒哒")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "模版编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "mail_001")
|
||||
private String code;
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
package cn.iocoder.yudao.module.system.controller.admin.notify;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.enums.UserTypeEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
|
|
@ -8,6 +9,7 @@ import cn.iocoder.yudao.module.system.controller.admin.notify.vo.template.Notify
|
|||
import cn.iocoder.yudao.module.system.controller.admin.notify.vo.template.NotifyTemplateRespVO;
|
||||
import cn.iocoder.yudao.module.system.controller.admin.notify.vo.template.NotifyTemplateSaveReqVO;
|
||||
import cn.iocoder.yudao.module.system.controller.admin.notify.vo.template.NotifyTemplateSendReqVO;
|
||||
import cn.iocoder.yudao.module.system.controller.admin.notify.vo.template.NotifyTemplateSimpleRespVO;
|
||||
import cn.iocoder.yudao.module.system.dal.dataobject.notify.NotifyTemplateDO;
|
||||
import cn.iocoder.yudao.module.system.service.notify.NotifySendService;
|
||||
import cn.iocoder.yudao.module.system.service.notify.NotifyTemplateService;
|
||||
|
|
@ -86,6 +88,14 @@ public class NotifyTemplateController {
|
|||
return success(BeanUtils.toBean(pageResult, NotifyTemplateRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping({"/list-all-simple", "/simple-list"})
|
||||
@Operation(summary = "获得站内信模版精简列表", description = "只包含被开启的站内信模版,主要用于前端的下拉选项")
|
||||
public CommonResult<List<NotifyTemplateSimpleRespVO>> getSimpleNotifyTemplateList() {
|
||||
List<NotifyTemplateDO> list = notifyTemplateService.getNotifyTemplateListByStatus(
|
||||
CommonStatusEnum.ENABLE.getStatus());
|
||||
return success(BeanUtils.toBean(list, NotifyTemplateSimpleRespVO.class));
|
||||
}
|
||||
|
||||
@PostMapping("/send-notify")
|
||||
@Operation(summary = "发送站内信")
|
||||
@PreAuthorize("@ss.hasPermission('system:notify-template:send-notify')")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package cn.iocoder.yudao.module.system.controller.admin.notify.vo.template;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 站内信模版的精简 Response VO")
|
||||
@Data
|
||||
public class NotifyTemplateSimpleRespVO {
|
||||
|
||||
@Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "模版名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "系统通知")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "模版编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "notify_001")
|
||||
private String code;
|
||||
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
package cn.iocoder.yudao.module.system.controller.admin.sms;
|
||||
|
||||
import cn.iocoder.yudao.framework.apilog.core.annotation.ApiAccessLog;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.common.pojo.CommonResult;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageParam;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
|
|
@ -10,6 +11,7 @@ import cn.iocoder.yudao.module.system.controller.admin.sms.vo.template.SmsTempla
|
|||
import cn.iocoder.yudao.module.system.controller.admin.sms.vo.template.SmsTemplateRespVO;
|
||||
import cn.iocoder.yudao.module.system.controller.admin.sms.vo.template.SmsTemplateSaveReqVO;
|
||||
import cn.iocoder.yudao.module.system.controller.admin.sms.vo.template.SmsTemplateSendReqVO;
|
||||
import cn.iocoder.yudao.module.system.controller.admin.sms.vo.template.SmsTemplateSimpleRespVO;
|
||||
import cn.iocoder.yudao.module.system.dal.dataobject.sms.SmsTemplateDO;
|
||||
import cn.iocoder.yudao.module.system.service.sms.SmsSendService;
|
||||
import cn.iocoder.yudao.module.system.service.sms.SmsTemplateService;
|
||||
|
|
@ -88,6 +90,14 @@ public class SmsTemplateController {
|
|||
return success(BeanUtils.toBean(pageResult, SmsTemplateRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping({"/list-all-simple", "/simple-list"})
|
||||
@Operation(summary = "获得短信模板精简列表", description = "只包含被开启的短信模板,主要用于前端的下拉选项")
|
||||
public CommonResult<List<SmsTemplateSimpleRespVO>> getSimpleSmsTemplateList() {
|
||||
List<SmsTemplateDO> list = smsTemplateService.getSmsTemplateListByStatus(
|
||||
CommonStatusEnum.ENABLE.getStatus());
|
||||
return success(BeanUtils.toBean(list, SmsTemplateSimpleRespVO.class));
|
||||
}
|
||||
|
||||
@GetMapping("/export-excel")
|
||||
@Operation(summary = "导出短信模板 Excel")
|
||||
@PreAuthorize("@ss.hasPermission('system:sms-template:export')")
|
||||
|
|
|
|||
|
|
@ -0,0 +1,19 @@
|
|||
package cn.iocoder.yudao.module.system.controller.admin.sms.vo.template;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
|
||||
@Schema(description = "管理后台 - 短信模板的精简 Response VO")
|
||||
@Data
|
||||
public class SmsTemplateSimpleRespVO {
|
||||
|
||||
@Schema(description = "编号", requiredMode = Schema.RequiredMode.REQUIRED, example = "1024")
|
||||
private Long id;
|
||||
|
||||
@Schema(description = "模板名称", requiredMode = Schema.RequiredMode.REQUIRED, example = "验证码")
|
||||
private String name;
|
||||
|
||||
@Schema(description = "模板编码", requiredMode = Schema.RequiredMode.REQUIRED, example = "sms_001")
|
||||
private String code;
|
||||
|
||||
}
|
||||
|
|
@ -7,6 +7,8 @@ import cn.iocoder.yudao.module.system.controller.admin.mail.vo.template.MailTemp
|
|||
import cn.iocoder.yudao.module.system.dal.dataobject.mail.MailTemplateDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface MailTemplateMapper extends BaseMapperX<MailTemplateDO> {
|
||||
|
||||
|
|
@ -28,4 +30,8 @@ public interface MailTemplateMapper extends BaseMapperX<MailTemplateDO> {
|
|||
return selectOne(MailTemplateDO::getCode, code);
|
||||
}
|
||||
|
||||
default List<MailTemplateDO> selectListByStatus(Integer status) {
|
||||
return selectList(MailTemplateDO::getStatus, status);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import cn.iocoder.yudao.module.system.controller.admin.notify.vo.template.Notify
|
|||
import cn.iocoder.yudao.module.system.dal.dataobject.notify.NotifyTemplateDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface NotifyTemplateMapper extends BaseMapperX<NotifyTemplateDO> {
|
||||
|
||||
|
|
@ -23,4 +25,8 @@ public interface NotifyTemplateMapper extends BaseMapperX<NotifyTemplateDO> {
|
|||
.orderByDesc(NotifyTemplateDO::getId));
|
||||
}
|
||||
|
||||
default List<NotifyTemplateDO> selectListByStatus(Integer status) {
|
||||
return selectList(NotifyTemplateDO::getStatus, status);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ import cn.iocoder.yudao.module.system.controller.admin.sms.vo.template.SmsTempla
|
|||
import cn.iocoder.yudao.module.system.dal.dataobject.sms.SmsTemplateDO;
|
||||
import org.apache.ibatis.annotations.Mapper;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Mapper
|
||||
public interface SmsTemplateMapper extends BaseMapperX<SmsTemplateDO> {
|
||||
|
||||
|
|
@ -30,4 +32,8 @@ public interface SmsTemplateMapper extends BaseMapperX<SmsTemplateDO> {
|
|||
return selectCount(SmsTemplateDO::getChannelId, channelId);
|
||||
}
|
||||
|
||||
default List<SmsTemplateDO> selectListByStatus(Integer status) {
|
||||
return selectList(SmsTemplateDO::getStatus, status);
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ public interface MailTemplateService {
|
|||
*/
|
||||
List<MailTemplateDO> getMailTemplateList();
|
||||
|
||||
/**
|
||||
* 获取指定状态的邮件模版数组
|
||||
*
|
||||
* @param status 状态
|
||||
* @return 邮件模版数组
|
||||
*/
|
||||
List<MailTemplateDO> getMailTemplateListByStatus(Integer status);
|
||||
|
||||
/**
|
||||
* 从缓存中获取邮件模版
|
||||
*
|
||||
|
|
|
|||
|
|
@ -113,7 +113,9 @@ public class MailTemplateServiceImpl implements MailTemplateService {
|
|||
}
|
||||
|
||||
@Override
|
||||
public MailTemplateDO getMailTemplate(Long id) {return mailTemplateMapper.selectById(id);}
|
||||
public MailTemplateDO getMailTemplate(Long id) {
|
||||
return mailTemplateMapper.selectById(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
@Cacheable(value = RedisKeyConstants.MAIL_TEMPLATE, key = "#code", unless = "#result == null")
|
||||
|
|
@ -127,7 +129,14 @@ public class MailTemplateServiceImpl implements MailTemplateService {
|
|||
}
|
||||
|
||||
@Override
|
||||
public List<MailTemplateDO> getMailTemplateList() {return mailTemplateMapper.selectList();}
|
||||
public List<MailTemplateDO> getMailTemplateList() {
|
||||
return mailTemplateMapper.selectList();
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<MailTemplateDO> getMailTemplateListByStatus(Integer status) {
|
||||
return mailTemplateMapper.selectListByStatus(status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String formatMailTemplateContent(String content, Map<String, Object> params) {
|
||||
|
|
@ -236,4 +245,4 @@ public class MailTemplateServiceImpl implements MailTemplateService {
|
|||
return ReUtil.findAllGroup1(PATTERN_PARAMS, content);
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -69,6 +69,14 @@ public interface NotifyTemplateService {
|
|||
*/
|
||||
PageResult<NotifyTemplateDO> getNotifyTemplatePage(NotifyTemplatePageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 获得指定状态的站内信模版列表
|
||||
*
|
||||
* @param status 状态
|
||||
* @return 站内信模版列表
|
||||
*/
|
||||
List<NotifyTemplateDO> getNotifyTemplateListByStatus(Integer status);
|
||||
|
||||
/**
|
||||
* 格式化站内信内容
|
||||
*
|
||||
|
|
|
|||
|
|
@ -115,6 +115,11 @@ public class NotifyTemplateServiceImpl implements NotifyTemplateService {
|
|||
return notifyTemplateMapper.selectPage(pageReqVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<NotifyTemplateDO> getNotifyTemplateListByStatus(Integer status) {
|
||||
return notifyTemplateMapper.selectListByStatus(status);
|
||||
}
|
||||
|
||||
@VisibleForTesting
|
||||
void validateNotifyTemplateCodeDuplicate(Long id, String code) {
|
||||
NotifyTemplateDO template = notifyTemplateMapper.selectByCode(code);
|
||||
|
|
|
|||
|
|
@ -70,6 +70,14 @@ public interface SmsTemplateService {
|
|||
*/
|
||||
PageResult<SmsTemplateDO> getSmsTemplatePage(SmsTemplatePageReqVO pageReqVO);
|
||||
|
||||
/**
|
||||
* 获得指定状态的短信模板列表
|
||||
*
|
||||
* @param status 状态
|
||||
* @return 短信模板列表
|
||||
*/
|
||||
List<SmsTemplateDO> getSmsTemplateListByStatus(Integer status);
|
||||
|
||||
/**
|
||||
* 获得指定短信渠道下的短信模板数量
|
||||
*
|
||||
|
|
|
|||
|
|
@ -130,6 +130,11 @@ public class SmsTemplateServiceImpl implements SmsTemplateService {
|
|||
return smsTemplateMapper.selectPage(pageReqVO);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<SmsTemplateDO> getSmsTemplateListByStatus(Integer status) {
|
||||
return smsTemplateMapper.selectListByStatus(status);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Long getSmsTemplateCountByChannelId(Long channelId) {
|
||||
return smsTemplateMapper.selectCountByChannelId(channelId);
|
||||
|
|
|
|||
|
|
@ -156,6 +156,23 @@ public class MailTemplateServiceImplTest extends BaseDbUnitTest {
|
|||
assertEquals(dbMailTemplate02, list.get(1));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetMailTemplateListByStatus() {
|
||||
// mock 数据
|
||||
MailTemplateDO dbMailTemplate = randomPojo(MailTemplateDO.class,
|
||||
o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()));
|
||||
mailTemplateMapper.insert(dbMailTemplate);
|
||||
mailTemplateMapper.insert(cloneIgnoreId(dbMailTemplate,
|
||||
o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())));
|
||||
|
||||
// 调用
|
||||
List<MailTemplateDO> list = mailTemplateService.getMailTemplateListByStatus(
|
||||
CommonStatusEnum.ENABLE.getStatus());
|
||||
// 断言
|
||||
assertEquals(1, list.size());
|
||||
assertPojoEquals(dbMailTemplate, list.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetMailTemplate() {
|
||||
// mock 数据
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ import org.springframework.context.annotation.Import;
|
|||
|
||||
import jakarta.annotation.Resource;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime;
|
||||
|
|
@ -136,6 +137,23 @@ public class NotifyTemplateServiceImplTest extends BaseDbUnitTest {
|
|||
assertPojoEquals(dbNotifyTemplate, pageResult.getList().get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetNotifyTemplateListByStatus() {
|
||||
// mock 数据
|
||||
NotifyTemplateDO dbNotifyTemplate = randomPojo(NotifyTemplateDO.class,
|
||||
o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()));
|
||||
notifyTemplateMapper.insert(dbNotifyTemplate);
|
||||
notifyTemplateMapper.insert(cloneIgnoreId(dbNotifyTemplate,
|
||||
o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())));
|
||||
|
||||
// 调用
|
||||
List<NotifyTemplateDO> list = notifyTemplateService.getNotifyTemplateListByStatus(
|
||||
CommonStatusEnum.ENABLE.getStatus());
|
||||
// 断言
|
||||
assertEquals(1, list.size());
|
||||
assertPojoEquals(dbNotifyTemplate, list.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetNotifyTemplate() {
|
||||
// mock 数据
|
||||
|
|
|
|||
|
|
@ -244,6 +244,22 @@ public class SmsTemplateServiceImplTest extends BaseDbUnitTest {
|
|||
assertPojoEquals(dbSmsTemplate, pageResult.getList().get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSmsTemplateListByStatus() {
|
||||
// mock 数据
|
||||
SmsTemplateDO dbSmsTemplate = randomSmsTemplateDO(o -> o.setStatus(CommonStatusEnum.ENABLE.getStatus()));
|
||||
smsTemplateMapper.insert(dbSmsTemplate);
|
||||
smsTemplateMapper.insert(ObjectUtils.cloneIgnoreId(dbSmsTemplate,
|
||||
o -> o.setStatus(CommonStatusEnum.DISABLE.getStatus())));
|
||||
|
||||
// 调用
|
||||
List<SmsTemplateDO> list = smsTemplateService.getSmsTemplateListByStatus(
|
||||
CommonStatusEnum.ENABLE.getStatus());
|
||||
// 断言
|
||||
assertEquals(1, list.size());
|
||||
assertPojoEquals(dbSmsTemplate, list.get(0));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testGetSmsTemplateCountByChannelId() {
|
||||
// mock 数据
|
||||
|
|
|
|||
Loading…
Reference in New Issue