mirror of
https://gitee.com/zhijiantianya/ruoyi-vue-pro.git
synced 2026-06-05 18:25:41 +08:00
Merge branch 'master-jdk17' of https://gitee.com/zhijiantianya/ruoyi-vue-pro
# Conflicts: # yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/service/thingmodel/IotThingModelServiceImpl.java # yudao-module-mall/yudao-module-trade/src/test/java/cn/iocoder/yudao/module/trade/service/brokerage/BrokerageRecordServiceImplTest.java # yudao-module-member/src/main/java/cn/iocoder/yudao/module/member/controller/admin/user/vo/MemberUserBaseVO.java
This commit is contained in:
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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\"}";
|
||||
|
||||
}
|
||||
@ -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 lombok.Data;
|
||||
@ -27,10 +27,7 @@ public class FileUploadReqVO {
|
||||
}
|
||||
|
||||
public static boolean isDirectoryValid(String directory) {
|
||||
// 1. 不能包含 .. 防止目录穿越
|
||||
// 2. 不能以 / 或 \ 开头,防止上传到根目录
|
||||
return !StrUtil.contains(directory, "..")
|
||||
&& !StrUtil.startWithAny(directory, "/", "\\");
|
||||
return FilePathUtils.isDirectoryValid(directory);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -37,7 +37,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(),
|
||||
|
||||
@ -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, "表定义已经存在");
|
||||
|
||||
@ -3,8 +3,12 @@ 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 static cn.iocoder.yudao.framework.common.exception.util.ServiceExceptionUtil.exception;
|
||||
import static cn.iocoder.yudao.module.infra.enums.ErrorCodeConstants.FILE_PATH_INVALID;
|
||||
|
||||
/**
|
||||
* 本地文件客户端
|
||||
@ -50,7 +54,13 @@ public class LocalFileClient extends AbstractFileClient<LocalFileClientConfig> {
|
||||
}
|
||||
|
||||
private String getFilePath(String path) {
|
||||
return config.getBasePath() + File.separator + path;
|
||||
FilePathUtils.validatePath(path);
|
||||
Path basePath = Path.of(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.Path;
|
||||
|
||||
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 (Path.of(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 lombok.SneakyThrows;
|
||||
@ -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() {
|
||||
// 准备参数
|
||||
|
||||
@ -94,6 +94,9 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi {
|
||||
// 2. 组装返回结果
|
||||
Set<Long> deviceIds = convertSet(configList, IotDeviceModbusConfigDO::getDeviceId);
|
||||
Map<Long, IotDeviceDO> deviceMap = deviceService.getDeviceMap(deviceIds);
|
||||
if (CollUtil.isEmpty(deviceMap)) {
|
||||
return success(new ArrayList<>());
|
||||
}
|
||||
Map<Long, List<IotDeviceModbusPointDO>> pointMap = modbusPointService.getEnabledDeviceModbusPointMapByDeviceIds(deviceIds);
|
||||
Map<Long, IotProductDO> productMap = productService.getProductMap(convertSet(deviceMap.values(), IotDeviceDO::getProductId));
|
||||
List<IotModbusDeviceConfigRespDTO> result = new ArrayList<>(configList.size());
|
||||
@ -139,4 +142,4 @@ public class IoTDeviceApiImpl implements IotDeviceCommonApi {
|
||||
return success(deviceService.registerSubDevices(reqDTO));
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 告警记录不存在");
|
||||
|
||||
@ -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 org.springframework.context.annotation.Lazy;
|
||||
@ -16,7 +19,10 @@ import javax.annotation.Resource;
|
||||
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 实现类
|
||||
@ -42,6 +48,7 @@ public class IotAlertConfigServiceImpl implements IotAlertConfigService {
|
||||
// 校验关联数据是否存在
|
||||
sceneRuleService.validateSceneRuleList(createReqVO.getSceneRuleIds());
|
||||
adminUserApi.validateUserList(createReqVO.getReceiveUserIds());
|
||||
validateReceiveTemplates(createReqVO);
|
||||
|
||||
// 插入
|
||||
IotAlertConfigDO alertConfig = BeanUtils.toBean(createReqVO, IotAlertConfigDO.class);
|
||||
@ -56,6 +63,7 @@ public class IotAlertConfigServiceImpl implements IotAlertConfigService {
|
||||
// 校验关联数据是否存在
|
||||
sceneRuleService.validateSceneRuleList(updateReqVO.getSceneRuleIds());
|
||||
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));
|
||||
.setTemplateCode(templateCode).setTemplateParams(templateParams));
|
||||
break;
|
||||
}
|
||||
} catch (Exception ex) {
|
||||
@ -111,6 +120,15 @@ public class IotAlertTriggerSceneRuleAction implements IotSceneRuleAction {
|
||||
}
|
||||
}
|
||||
|
||||
private String resolveTemplateCode(IotAlertConfigDO config, IotAlertReceiveTypeEnum typeEnum) {
|
||||
String templateCode = switch (typeEnum) {
|
||||
case SMS -> config.getSmsTemplateCode();
|
||||
case MAIL -> config.getMailTemplateCode();
|
||||
case NOTIFY -> config.getNotifyTemplateCode();
|
||||
};
|
||||
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,11 +18,17 @@ 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;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.annotation.CacheEvict;
|
||||
import org.springframework.cache.annotation.Cacheable;
|
||||
@ -25,8 +37,15 @@ import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.validation.annotation.Validated;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -0,0 +1,104 @@
|
||||
package cn.iocoder.yudao.module.iot.core.messagebus.core.rabbitmq;
|
||||
|
||||
import cn.hutool.core.util.TypeUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageBus;
|
||||
import cn.iocoder.yudao.module.iot.core.messagebus.core.IotMessageSubscriber;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.amqp.core.AcknowledgeMode;
|
||||
import org.springframework.amqp.core.Binding;
|
||||
import org.springframework.amqp.core.BindingBuilder;
|
||||
import org.springframework.amqp.core.MessageBuilder;
|
||||
import org.springframework.amqp.core.Queue;
|
||||
import org.springframework.amqp.core.TopicExchange;
|
||||
import org.springframework.amqp.rabbit.core.RabbitAdmin;
|
||||
import org.springframework.amqp.rabbit.core.RabbitTemplate;
|
||||
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
|
||||
import org.springframework.amqp.rabbit.listener.api.ChannelAwareMessageListener;
|
||||
|
||||
import java.lang.reflect.Type;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 基于 RabbitMQ 的 {@link IotMessageBus} 实现类
|
||||
*
|
||||
* @author ywc
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class IotRabbitMQMessageBus implements IotMessageBus {
|
||||
|
||||
private static final String ROUTING_KEY = "#";
|
||||
|
||||
private final RabbitTemplate rabbitTemplate;
|
||||
|
||||
private final RabbitAdmin rabbitAdmin;
|
||||
|
||||
@Getter
|
||||
private final List<IotMessageSubscriber<?>> subscribers = new ArrayList<>();
|
||||
|
||||
private final List<SimpleMessageListenerContainer> containers = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public void post(String topic, Object message) {
|
||||
rabbitTemplate.send(topic, ROUTING_KEY, MessageBuilder.withBody(JsonUtils.toJsonByte(message)).build());
|
||||
log.info("[post][topic({}) 发送消息({})]", topic, message);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("DataFlowIssue")
|
||||
public void register(IotMessageSubscriber<?> subscriber) {
|
||||
Type type = TypeUtil.getTypeArgument(subscriber.getClass(), 0);
|
||||
if (type == null) {
|
||||
throw new IllegalStateException(String.format("类型(%s) 需要设置消息类型", getClass().getName()));
|
||||
}
|
||||
|
||||
// 1.1 声明交换机、队列和绑定关系
|
||||
Queue queue = new Queue(subscriber.getGroup(), true, false, false);
|
||||
rabbitAdmin.declareQueue(queue);
|
||||
TopicExchange exchange = new TopicExchange(subscriber.getTopic());
|
||||
rabbitAdmin.declareExchange(exchange);
|
||||
Binding binding = BindingBuilder.bind(queue).to(exchange).with(ROUTING_KEY);
|
||||
rabbitAdmin.declareBinding(binding);
|
||||
|
||||
// 1.2 创建消费容器
|
||||
SimpleMessageListenerContainer container = new SimpleMessageListenerContainer(rabbitTemplate.getConnectionFactory());
|
||||
container.setQueues(queue);
|
||||
container.setConcurrentConsumers(1);
|
||||
container.setMaxConcurrentConsumers(10);
|
||||
container.setAcknowledgeMode(AcknowledgeMode.MANUAL);
|
||||
container.setMessageListener((ChannelAwareMessageListener) (message, channel) -> {
|
||||
try {
|
||||
subscriber.onMessage(JsonUtils.parseObject(message.getBody(), type));
|
||||
channel.basicAck(message.getMessageProperties().getDeliveryTag(), false);
|
||||
} catch (Exception ex) {
|
||||
log.error("[onMessage][topic({}/{}) message({}) 消费者({}) 处理异常]",
|
||||
subscriber.getTopic(), subscriber.getGroup(), message, subscriber.getClass().getName(), ex);
|
||||
channel.basicNack(message.getMessageProperties().getDeliveryTag(), false, false);
|
||||
}
|
||||
});
|
||||
container.start();
|
||||
|
||||
// 2. 保存消费者引用
|
||||
containers.add(container);
|
||||
subscribers.add(subscriber);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void destroy() {
|
||||
for (SimpleMessageListenerContainer container : containers) {
|
||||
try {
|
||||
container.stop();
|
||||
container.destroy();
|
||||
log.info("[destroy][关闭 RabbitMQ 消费者容器成功]");
|
||||
} catch (Exception e) {
|
||||
log.error("[destroy][关闭 RabbitMQ 消费者容器异常]", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -12,11 +12,14 @@ 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送数据到设备
|
||||
*
|
||||
|
||||
@ -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) {
|
||||
@ -129,7 +128,7 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
||||
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) {
|
||||
@ -166,7 +175,7 @@ public class CombinationRecordServiceImpl implements CombinationRecordService {
|
||||
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);
|
||||
|
||||
@ -0,0 +1,325 @@
|
||||
package cn.iocoder.yudao.module.promotion.service.combination;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.core.KeyValue;
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.member.api.user.MemberUserApi;
|
||||
import cn.iocoder.yudao.module.member.api.user.dto.MemberUserRespDTO;
|
||||
import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi;
|
||||
import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO;
|
||||
import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
|
||||
import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
|
||||
import cn.iocoder.yudao.module.promotion.api.combination.dto.CombinationRecordCreateReqDTO;
|
||||
import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationActivityDO;
|
||||
import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationProductDO;
|
||||
import cn.iocoder.yudao.module.promotion.dal.dataobject.combination.CombinationRecordDO;
|
||||
import cn.iocoder.yudao.module.promotion.dal.mysql.combination.CombinationRecordMapper;
|
||||
import cn.iocoder.yudao.module.promotion.enums.combination.CombinationRecordStatusEnum;
|
||||
import cn.iocoder.yudao.module.system.api.social.SocialClientApi;
|
||||
import cn.iocoder.yudao.module.trade.api.order.TradeOrderApi;
|
||||
import cn.iocoder.yudao.module.trade.enums.order.TradeOrderCancelTypeEnum;
|
||||
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.List;
|
||||
import java.util.function.Consumer;
|
||||
|
||||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertServiceException;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
|
||||
import static cn.iocoder.yudao.module.promotion.enums.ErrorCodeConstants.*;
|
||||
import static org.junit.jupiter.api.Assertions.*;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
/**
|
||||
* {@link CombinationRecordServiceImpl} 的单元测试类
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Import(CombinationRecordServiceImpl.class)
|
||||
public class CombinationRecordServiceImplTest extends BaseDbUnitTest {
|
||||
|
||||
private static final Long USER_ID = 100L;
|
||||
private static final Long ACTIVITY_ID = 200L;
|
||||
private static final Long SPU_ID = 300L;
|
||||
private static final Long SKU_ID = 400L;
|
||||
private static final Long HEAD_ID = 500L;
|
||||
private static final Long ORDER_ID = 600L;
|
||||
|
||||
@Resource
|
||||
private CombinationRecordServiceImpl combinationRecordService;
|
||||
@Resource
|
||||
private CombinationRecordMapper combinationRecordMapper;
|
||||
|
||||
@MockitoBean
|
||||
private CombinationActivityService combinationActivityService;
|
||||
@MockitoBean
|
||||
private MemberUserApi memberUserApi;
|
||||
@MockitoBean
|
||||
private ProductSpuApi productSpuApi;
|
||||
@MockitoBean
|
||||
private ProductSkuApi productSkuApi;
|
||||
@MockitoBean
|
||||
private TradeOrderApi tradeOrderApi;
|
||||
@MockitoBean
|
||||
private SocialClientApi socialClientApi;
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_headIdNullAsNewGroup() {
|
||||
// mock 数据
|
||||
CombinationActivityDO activity = mockValidateContext(10);
|
||||
|
||||
// 调用
|
||||
KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
|
||||
.validateCombinationRecord(USER_ID, ACTIVITY_ID, null, SKU_ID, 1);
|
||||
|
||||
// 断言
|
||||
assertSame(activity, result.getKey());
|
||||
assertEquals(SKU_ID, result.getValue().getSkuId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_headIdGroupAsNewGroup() {
|
||||
// mock 数据:headId 为 0 时,也代表新开团
|
||||
CombinationActivityDO activity = mockValidateContext(10);
|
||||
|
||||
// 调用
|
||||
KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
|
||||
.validateCombinationRecord(USER_ID, ACTIVITY_ID, CombinationRecordDO.HEAD_ID_GROUP, SKU_ID, 1);
|
||||
|
||||
// 断言
|
||||
assertSame(activity, result.getKey());
|
||||
assertEquals(SKU_ID, result.getValue().getSkuId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_realHeadIdAsJoinGroup() {
|
||||
// mock 数据:真实 headId 时,按参团校验父拼团
|
||||
CombinationActivityDO activity = mockValidateContext(10);
|
||||
combinationRecordMapper.insert(randomRecord(o -> {
|
||||
o.setId(HEAD_ID);
|
||||
o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
|
||||
o.setUserId(101L);
|
||||
o.setOrderId(601L);
|
||||
}));
|
||||
|
||||
// 调用
|
||||
KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
|
||||
.validateCombinationRecord(USER_ID, ACTIVITY_ID, HEAD_ID, SKU_ID, 1);
|
||||
|
||||
// 断言
|
||||
assertSame(activity, result.getKey());
|
||||
assertEquals(SKU_ID, result.getValue().getSkuId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_realHeadIdNotExists() {
|
||||
// mock 数据:拼团活动正常,但父拼团不存在
|
||||
mockActivity();
|
||||
|
||||
// 调用,并断言
|
||||
assertServiceException(() -> combinationRecordService.validateCombinationRecord(
|
||||
USER_ID, ACTIVITY_ID, HEAD_ID, SKU_ID, 1), COMBINATION_RECORD_HEAD_NOT_EXISTS);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_countEqualsStock() {
|
||||
// mock 数据:库存刚好等于购买数量
|
||||
mockValidateContext(10);
|
||||
|
||||
// 调用
|
||||
KeyValue<CombinationActivityDO, CombinationProductDO> result = combinationRecordService
|
||||
.validateCombinationRecord(USER_ID, ACTIVITY_ID, null, SKU_ID, 10);
|
||||
|
||||
// 断言
|
||||
assertNotNull(result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_countGreaterThanStock() {
|
||||
// mock 数据:库存不足
|
||||
mockValidateContext(10);
|
||||
|
||||
// 调用,并断言
|
||||
assertServiceException(() -> combinationRecordService.validateCombinationRecord(
|
||||
USER_ID, ACTIVITY_ID, null, SKU_ID, 11), COMBINATION_ACTIVITY_UPDATE_STOCK_FAIL);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_haveJoined() {
|
||||
// mock 数据:用户存在进行中的拼团记录
|
||||
mockValidateContext(10);
|
||||
combinationRecordMapper.insert(randomRecord(o -> {
|
||||
o.setStatus(CombinationRecordStatusEnum.IN_PROGRESS.getStatus());
|
||||
o.setCount(1);
|
||||
}));
|
||||
|
||||
// 调用,并断言
|
||||
assertServiceException(() -> combinationRecordService.validateCombinationRecord(
|
||||
USER_ID, ACTIVITY_ID, null, SKU_ID, 1), COMBINATION_RECORD_FAILED_HAVE_JOINED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testValidateCombinationRecord_totalLimitCountExceed() {
|
||||
// mock 数据:用户历史购买数量加本次数量超过总限购
|
||||
CombinationActivityDO activity = mockValidateContext(10);
|
||||
activity.setTotalLimitCount(5);
|
||||
combinationRecordMapper.insert(randomRecord(o -> {
|
||||
o.setStatus(CombinationRecordStatusEnum.SUCCESS.getStatus());
|
||||
o.setCount(3);
|
||||
}));
|
||||
|
||||
// 调用,并断言
|
||||
assertServiceException(() -> combinationRecordService.validateCombinationRecord(
|
||||
USER_ID, ACTIVITY_ID, null, SKU_ID, 3), COMBINATION_RECORD_FAILED_TOTAL_LIMIT_COUNT_EXCEED);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCreateCombinationRecord_headIdGroupAsNewGroup() {
|
||||
// mock 数据:开团参数中 headId 为 0
|
||||
mockValidateContext(10);
|
||||
mockCreateContext();
|
||||
CombinationRecordCreateReqDTO reqDTO = randomCreateReqDTO(CombinationRecordDO.HEAD_ID_GROUP);
|
||||
|
||||
// 调用
|
||||
CombinationRecordDO record = combinationRecordService.createCombinationRecord(reqDTO);
|
||||
|
||||
// 断言:仍然按开团处理,不更新其它团记录
|
||||
assertNotNull(record.getId());
|
||||
assertEquals(CombinationRecordDO.HEAD_ID_GROUP, record.getHeadId());
|
||||
assertEquals(CombinationRecordStatusEnum.IN_PROGRESS.getStatus(), record.getStatus());
|
||||
assertEquals(1, record.getUserCount());
|
||||
assertFalse(record.getVirtualGroup());
|
||||
assertNotNull(record.getStartTime());
|
||||
assertNotNull(record.getExpireTime());
|
||||
assertEquals(1L, combinationRecordMapper.selectCount());
|
||||
assertEquals(CombinationRecordDO.HEAD_ID_GROUP,
|
||||
combinationRecordMapper.selectById(record.getId()).getHeadId());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testHandleExpireRecord_cancelOrders() {
|
||||
// mock 数据:过期团长和一个团员
|
||||
CombinationRecordDO headRecord = randomRecord(o -> {
|
||||
o.setId(HEAD_ID);
|
||||
o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
|
||||
o.setUserId(USER_ID);
|
||||
o.setOrderId(ORDER_ID);
|
||||
});
|
||||
CombinationRecordDO memberRecord = randomRecord(o -> {
|
||||
o.setId(501L);
|
||||
o.setHeadId(HEAD_ID);
|
||||
o.setUserId(101L);
|
||||
o.setOrderId(601L);
|
||||
});
|
||||
combinationRecordMapper.insert(headRecord);
|
||||
combinationRecordMapper.insert(memberRecord);
|
||||
|
||||
// 调用
|
||||
combinationRecordService.handleExpireRecord(headRecord);
|
||||
|
||||
// 断言:整团记录标记为失败,并取消已支付订单
|
||||
List<CombinationRecordDO> records = combinationRecordMapper.selectList();
|
||||
assertEquals(2, records.size());
|
||||
assertTrue(records.stream().allMatch(item ->
|
||||
CombinationRecordStatusEnum.FAILED.getStatus().equals(item.getStatus())));
|
||||
assertTrue(records.stream().allMatch(item -> item.getEndTime() != null));
|
||||
verify(tradeOrderApi).cancelPaidOrder(USER_ID, ORDER_ID, TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType());
|
||||
verify(tradeOrderApi).cancelPaidOrder(101L, 601L, TradeOrderCancelTypeEnum.COMBINATION_CLOSE.getType());
|
||||
}
|
||||
|
||||
private CombinationActivityDO mockValidateContext(Integer stock) {
|
||||
CombinationActivityDO activity = mockActivity();
|
||||
CombinationProductDO product = randomPojo(CombinationProductDO.class, o -> {
|
||||
o.setActivityId(ACTIVITY_ID);
|
||||
o.setSpuId(SPU_ID);
|
||||
o.setSkuId(SKU_ID);
|
||||
o.setCombinationPrice(100);
|
||||
});
|
||||
ProductSkuRespDTO sku = randomPojo(ProductSkuRespDTO.class, o -> {
|
||||
o.setId(SKU_ID);
|
||||
o.setSpuId(SPU_ID);
|
||||
o.setStock(stock);
|
||||
o.setPicUrl("https://www.iocoder.cn/sku.png");
|
||||
});
|
||||
when(combinationActivityService.selectByActivityIdAndSkuId(ACTIVITY_ID, SKU_ID)).thenReturn(product);
|
||||
when(productSkuApi.getSku(SKU_ID)).thenReturn(sku);
|
||||
return activity;
|
||||
}
|
||||
|
||||
private CombinationActivityDO mockActivity() {
|
||||
CombinationActivityDO activity = randomPojo(CombinationActivityDO.class, o -> {
|
||||
o.setId(ACTIVITY_ID);
|
||||
o.setStatus(CommonStatusEnum.ENABLE.getStatus());
|
||||
o.setStartTime(LocalDateTime.now().minusHours(1));
|
||||
o.setEndTime(LocalDateTime.now().plusHours(1));
|
||||
o.setSingleLimitCount(100);
|
||||
o.setTotalLimitCount(100);
|
||||
o.setUserSize(3);
|
||||
o.setVirtualGroup(false);
|
||||
o.setLimitDuration(24);
|
||||
});
|
||||
when(combinationActivityService.validateCombinationActivityExists(ACTIVITY_ID)).thenReturn(activity);
|
||||
return activity;
|
||||
}
|
||||
|
||||
private void mockCreateContext() {
|
||||
MemberUserRespDTO user = randomPojo(MemberUserRespDTO.class, o -> {
|
||||
o.setId(USER_ID);
|
||||
o.setNickname("芋道源码");
|
||||
o.setAvatar("https://www.iocoder.cn/avatar.png");
|
||||
});
|
||||
when(memberUserApi.getUser(USER_ID)).thenReturn(user);
|
||||
ProductSpuRespDTO spu = randomPojo(ProductSpuRespDTO.class, o -> {
|
||||
o.setId(SPU_ID);
|
||||
o.setName("测试商品");
|
||||
o.setPicUrl("https://www.iocoder.cn/spu.png");
|
||||
});
|
||||
when(productSpuApi.getSpu(SPU_ID)).thenReturn(spu);
|
||||
}
|
||||
|
||||
private static CombinationRecordCreateReqDTO randomCreateReqDTO(Long headId) {
|
||||
return randomPojo(CombinationRecordCreateReqDTO.class, o -> {
|
||||
o.setActivityId(ACTIVITY_ID);
|
||||
o.setSpuId(SPU_ID);
|
||||
o.setSkuId(SKU_ID);
|
||||
o.setCount(1);
|
||||
o.setOrderId(ORDER_ID);
|
||||
o.setUserId(USER_ID);
|
||||
o.setHeadId(headId);
|
||||
o.setCombinationPrice(100);
|
||||
});
|
||||
}
|
||||
|
||||
@SafeVarargs
|
||||
private static CombinationRecordDO randomRecord(Consumer<CombinationRecordDO>... consumers) {
|
||||
return randomPojo(CombinationRecordDO.class, o -> {
|
||||
o.setActivityId(ACTIVITY_ID);
|
||||
o.setCombinationPrice(100);
|
||||
o.setSpuId(SPU_ID);
|
||||
o.setSpuName("测试商品");
|
||||
o.setPicUrl("https://www.iocoder.cn/spu.png");
|
||||
o.setSkuId(SKU_ID);
|
||||
o.setCount(1);
|
||||
o.setUserId(USER_ID);
|
||||
o.setNickname("芋道源码");
|
||||
o.setAvatar("https://www.iocoder.cn/avatar.png");
|
||||
o.setHeadId(CombinationRecordDO.HEAD_ID_GROUP);
|
||||
o.setStatus(CombinationRecordStatusEnum.IN_PROGRESS.getStatus());
|
||||
o.setOrderId(ORDER_ID);
|
||||
o.setUserSize(3);
|
||||
o.setUserCount(1);
|
||||
o.setVirtualGroup(false);
|
||||
o.setStartTime(LocalDateTime.now().minusMinutes(10));
|
||||
o.setExpireTime(LocalDateTime.now().plusHours(1));
|
||||
o.setEndTime(null);
|
||||
for (Consumer<CombinationRecordDO> consumer : consumers) {
|
||||
consumer.accept(o);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@ -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,7 +328,7 @@ 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;
|
||||
}
|
||||
@ -339,22 +338,24 @@ public class BrokerageRecordServiceImpl implements BrokerageRecordService {
|
||||
return respVO;
|
||||
}
|
||||
|
||||
// 3.1 商品单独分佣模式
|
||||
Integer fixedMinPrice = 0;
|
||||
Integer fixedMaxPrice = 0;
|
||||
Integer spuMinPrice = 0;
|
||||
Integer spuMaxPrice = 0;
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,36 +1,43 @@
|
||||
package cn.iocoder.yudao.module.trade.service.brokerage;
|
||||
|
||||
import cn.hutool.core.collection.ListUtil;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.test.core.ut.BaseDbUnitTest;
|
||||
import cn.iocoder.yudao.module.product.api.sku.ProductSkuApi;
|
||||
import cn.iocoder.yudao.module.product.api.sku.dto.ProductSkuRespDTO;
|
||||
import cn.iocoder.yudao.module.product.api.spu.ProductSpuApi;
|
||||
import cn.iocoder.yudao.module.product.api.spu.dto.ProductSpuRespDTO;
|
||||
import cn.iocoder.yudao.module.trade.controller.admin.brokerage.vo.record.BrokerageRecordPageReqVO;
|
||||
import cn.iocoder.yudao.module.trade.controller.app.brokerage.vo.record.AppBrokerageProductPriceRespVO;
|
||||
import cn.iocoder.yudao.module.trade.dal.dataobject.brokerage.BrokerageRecordDO;
|
||||
import cn.iocoder.yudao.module.trade.dal.dataobject.config.TradeConfigDO;
|
||||
import cn.iocoder.yudao.module.trade.dal.mysql.brokerage.BrokerageRecordMapper;
|
||||
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordBizTypeEnum;
|
||||
import cn.iocoder.yudao.module.trade.enums.brokerage.BrokerageRecordStatusEnum;
|
||||
import cn.iocoder.yudao.module.trade.service.config.TradeConfigService;
|
||||
import org.junit.jupiter.api.Disabled;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.context.annotation.Import;
|
||||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.math.RoundingMode;
|
||||
import java.util.List;
|
||||
|
||||
import static cn.hutool.core.util.RandomUtil.randomEle;
|
||||
import static cn.hutool.core.util.RandomUtil.randomInt;
|
||||
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildBetweenTime;
|
||||
import static cn.iocoder.yudao.framework.common.util.date.LocalDateTimeUtils.buildTime;
|
||||
import static cn.iocoder.yudao.framework.common.util.object.ObjectUtils.cloneIgnoreId;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.AssertUtils.assertPojoEquals;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomInteger;
|
||||
import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
// TODO @芋艿:单测后续看看
|
||||
/**
|
||||
* {@link BrokerageRecordServiceImpl} 的单元测试类
|
||||
*
|
||||
* @author owen
|
||||
*/
|
||||
@Disabled // TODO 芋艿:后续 fix 补充的单测
|
||||
@Import(BrokerageRecordServiceImpl.class)
|
||||
public class BrokerageRecordServiceImplTest extends BaseDbUnitTest {
|
||||
|
||||
@ -39,35 +46,47 @@ public class BrokerageRecordServiceImplTest extends BaseDbUnitTest {
|
||||
@Resource
|
||||
private BrokerageRecordMapper brokerageRecordMapper;
|
||||
|
||||
@MockBean
|
||||
@MockitoBean
|
||||
private TradeConfigService tradeConfigService;
|
||||
@MockBean
|
||||
@MockitoBean
|
||||
private BrokerageUserService brokerageUserService;
|
||||
@MockitoBean
|
||||
private ProductSpuApi productSpuApi;
|
||||
@MockitoBean
|
||||
private ProductSkuApi productSkuApi;
|
||||
|
||||
@Test
|
||||
@Disabled // TODO 请修改 null 为需要的值,然后删除 @Disabled 注解
|
||||
public void testGetBrokerageRecordPage() {
|
||||
// mock 数据
|
||||
BrokerageRecordDO dbBrokerageRecord = randomPojo(BrokerageRecordDO.class, o -> { // 等会查询到
|
||||
o.setUserId(null);
|
||||
o.setBizType(null);
|
||||
o.setStatus(null);
|
||||
o.setCreateTime(null);
|
||||
o.setUserId(1L);
|
||||
o.setBizType(BrokerageRecordBizTypeEnum.ORDER.getType());
|
||||
o.setStatus(BrokerageRecordStatusEnum.SETTLEMENT.getStatus());
|
||||
o.setSourceUserLevel(1);
|
||||
o.setSourceUserId(100L);
|
||||
o.setCreateTime(buildTime(2023, 2, 10));
|
||||
o.setDeleted(false);
|
||||
});
|
||||
brokerageRecordMapper.insert(dbBrokerageRecord);
|
||||
// 测试 userId 不匹配
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord, o -> o.setUserId(null)));
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord, o -> o.setUserId(2L)));
|
||||
// 测试 bizType 不匹配
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord, o -> o.setBizType(null)));
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord,
|
||||
o -> o.setBizType(BrokerageRecordBizTypeEnum.WITHDRAW.getType())));
|
||||
// 测试 status 不匹配
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord, o -> o.setStatus(null)));
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord,
|
||||
o -> o.setStatus(BrokerageRecordStatusEnum.CANCEL.getStatus())));
|
||||
// 测试 sourceUserLevel 不匹配
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord, o -> o.setSourceUserLevel(2)));
|
||||
// 测试 createTime 不匹配
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord, o -> o.setCreateTime(null)));
|
||||
brokerageRecordMapper.insert(cloneIgnoreId(dbBrokerageRecord,
|
||||
o -> o.setCreateTime(buildTime(2023, 3, 1))));
|
||||
// 准备参数
|
||||
BrokerageRecordPageReqVO reqVO = new BrokerageRecordPageReqVO();
|
||||
reqVO.setUserId(null);
|
||||
reqVO.setBizType(null);
|
||||
reqVO.setStatus(null);
|
||||
reqVO.setUserId(1L);
|
||||
reqVO.setBizType(BrokerageRecordBizTypeEnum.ORDER.getType());
|
||||
reqVO.setStatus(BrokerageRecordStatusEnum.SETTLEMENT.getStatus());
|
||||
reqVO.setSourceUserLevel(1);
|
||||
reqVO.setCreateTime(buildBetweenTime(2023, 2, 1, 2023, 2, 28));
|
||||
|
||||
// 调用
|
||||
@ -81,37 +100,159 @@ public class BrokerageRecordServiceImplTest extends BaseDbUnitTest {
|
||||
@Test
|
||||
public void testCalculatePrice_useFixedPrice() {
|
||||
// mock 数据
|
||||
Integer payPrice = randomInteger();
|
||||
Integer percent = randomInt(1, 101);
|
||||
Integer fixedPrice = randomInt();
|
||||
Integer payPrice = 1000;
|
||||
Integer percent = 10;
|
||||
Integer fixedPrice = 88;
|
||||
// 调用
|
||||
int brokerage = brokerageRecordService.calculatePrice(payPrice, percent, fixedPrice);
|
||||
// 断言
|
||||
assertEquals(brokerage, fixedPrice);
|
||||
assertEquals(fixedPrice, brokerage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCalculatePrice_usePercent() {
|
||||
// mock 数据
|
||||
Integer payPrice = randomInteger();
|
||||
Integer percent = randomInt(1, 101);
|
||||
Integer fixedPrice = randomEle(new Integer[]{0, null});
|
||||
System.out.println("fixedPrice=" + fixedPrice);
|
||||
// mock 数据:fixedPrice 为 null 时,按比例计算佣金
|
||||
Integer payPrice = 1000;
|
||||
Integer percent = 10;
|
||||
Integer fixedPrice = null;
|
||||
// 调用
|
||||
int brokerage = brokerageRecordService.calculatePrice(payPrice, percent, fixedPrice);
|
||||
// 断言
|
||||
assertEquals(brokerage, NumberUtil.div(NumberUtil.mul(payPrice, percent), 100, 0, RoundingMode.DOWN).intValue());
|
||||
assertEquals(NumberUtil.div(NumberUtil.mul(payPrice, percent), 100, 0,
|
||||
RoundingMode.DOWN).intValue(), brokerage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCalculatePrice_fixedPriceIsZero() {
|
||||
// mock 数据:fixedPrice 为 0 时,仍使用固定佣金,不回退到比例计算
|
||||
Integer payPrice = 1000;
|
||||
Integer percent = 10;
|
||||
Integer fixedPrice = 0;
|
||||
// 调用
|
||||
int brokerage = brokerageRecordService.calculatePrice(payPrice, percent, fixedPrice);
|
||||
// 断言
|
||||
assertEquals(0, brokerage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCalculatePrice_equalsZero() {
|
||||
// mock 数据
|
||||
// mock 数据:三个参数均无效时,返回 0
|
||||
Integer payPrice = null;
|
||||
Integer percent = null;
|
||||
Integer fixedPrice = null;
|
||||
// 调用
|
||||
int brokerage = brokerageRecordService.calculatePrice(payPrice, percent, fixedPrice);
|
||||
// 断言
|
||||
assertEquals(brokerage, 0);
|
||||
assertEquals(0, brokerage);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCalculateProductBrokeragePrice_globalPercent() {
|
||||
// mock:分销功能已开启,比例 10%,当前用户有分销资格
|
||||
TradeConfigDO tradeConfig = new TradeConfigDO();
|
||||
tradeConfig.setBrokerageEnabled(true);
|
||||
tradeConfig.setBrokerageFirstPercent(10);
|
||||
when(tradeConfigService.getTradeConfig()).thenReturn(tradeConfig);
|
||||
when(brokerageUserService.getUserBrokerageEnabled(100L)).thenReturn(true);
|
||||
|
||||
// mock:商品未开启独立分销(subCommissionType = false),SKU 售价 1000 分
|
||||
ProductSpuRespDTO spu = new ProductSpuRespDTO();
|
||||
spu.setSubCommissionType(false);
|
||||
when(productSpuApi.getSpu(1L)).thenReturn(spu);
|
||||
|
||||
ProductSkuRespDTO sku = new ProductSkuRespDTO();
|
||||
sku.setPrice(1000);
|
||||
when(productSkuApi.getSkuListBySpuId(ListUtil.of(1L))).thenReturn(List.of(sku));
|
||||
|
||||
// 调用
|
||||
AppBrokerageProductPriceRespVO result = brokerageRecordService.calculateProductBrokeragePrice(100L, 1L);
|
||||
|
||||
// 断言:按 10% 比例计算,1000 * 10% = 100 分(向下取整)
|
||||
assertTrue(result.getEnabled());
|
||||
assertEquals(100, result.getBrokerageMinPrice());
|
||||
assertEquals(100, result.getBrokerageMaxPrice());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCalculateProductBrokeragePrice_subCommissionZeroFixed() {
|
||||
// mock:分销功能已开启,比例 10%,当前用户有分销资格
|
||||
TradeConfigDO tradeConfig = new TradeConfigDO();
|
||||
tradeConfig.setBrokerageEnabled(true);
|
||||
tradeConfig.setBrokerageFirstPercent(10);
|
||||
when(tradeConfigService.getTradeConfig()).thenReturn(tradeConfig);
|
||||
when(brokerageUserService.getUserBrokerageEnabled(100L)).thenReturn(true);
|
||||
|
||||
// mock:商品开启独立分销(subCommissionType = true),SKU 固定佣金为 0(商家主动设置)
|
||||
ProductSpuRespDTO spu = new ProductSpuRespDTO();
|
||||
spu.setSubCommissionType(true);
|
||||
when(productSpuApi.getSpu(2L)).thenReturn(spu);
|
||||
|
||||
ProductSkuRespDTO sku = new ProductSkuRespDTO();
|
||||
sku.setPrice(1000);
|
||||
sku.setFirstBrokeragePrice(0);
|
||||
when(productSkuApi.getSkuListBySpuId(ListUtil.of(2L))).thenReturn(List.of(sku));
|
||||
|
||||
// 调用
|
||||
AppBrokerageProductPriceRespVO result = brokerageRecordService.calculateProductBrokeragePrice(100L, 2L);
|
||||
|
||||
// 断言:独立分销固定佣金为 0,应返回 0,不得回退到全局比例
|
||||
assertTrue(result.getEnabled());
|
||||
assertEquals(0, result.getBrokerageMinPrice());
|
||||
assertEquals(0, result.getBrokerageMaxPrice());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCalculateProductBrokeragePrice_subCommissionNullFixed() {
|
||||
// mock:分销功能已开启,比例 10%,当前用户有分销资格
|
||||
TradeConfigDO tradeConfig = new TradeConfigDO();
|
||||
tradeConfig.setBrokerageEnabled(true);
|
||||
tradeConfig.setBrokerageFirstPercent(10);
|
||||
when(tradeConfigService.getTradeConfig()).thenReturn(tradeConfig);
|
||||
when(brokerageUserService.getUserBrokerageEnabled(100L)).thenReturn(true);
|
||||
|
||||
// mock:商品开启独立分销,其中一个 SKU 固定佣金未配置
|
||||
ProductSpuRespDTO spu = new ProductSpuRespDTO();
|
||||
spu.setSubCommissionType(true);
|
||||
when(productSpuApi.getSpu(3L)).thenReturn(spu);
|
||||
|
||||
ProductSkuRespDTO nullBrokerageSku = new ProductSkuRespDTO();
|
||||
nullBrokerageSku.setPrice(1000);
|
||||
nullBrokerageSku.setFirstBrokeragePrice(null);
|
||||
ProductSkuRespDTO fixedBrokerageSku = new ProductSkuRespDTO();
|
||||
fixedBrokerageSku.setPrice(2000);
|
||||
fixedBrokerageSku.setFirstBrokeragePrice(200);
|
||||
when(productSkuApi.getSkuListBySpuId(ListUtil.of(3L))).thenReturn(List.of(nullBrokerageSku, fixedBrokerageSku));
|
||||
|
||||
// 调用
|
||||
AppBrokerageProductPriceRespVO result = brokerageRecordService.calculateProductBrokeragePrice(100L, 3L);
|
||||
|
||||
// 断言:独立分销固定佣金为空时按 0 处理,避免比较最小/最大佣金时报错
|
||||
assertTrue(result.getEnabled());
|
||||
assertEquals(0, result.getBrokerageMinPrice());
|
||||
assertEquals(200, result.getBrokerageMaxPrice());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testCalculateProductBrokeragePrice_subCommissionEmptySkuList() {
|
||||
// mock:分销功能已开启,比例 10%,当前用户有分销资格
|
||||
TradeConfigDO tradeConfig = new TradeConfigDO();
|
||||
tradeConfig.setBrokerageEnabled(true);
|
||||
tradeConfig.setBrokerageFirstPercent(10);
|
||||
when(tradeConfigService.getTradeConfig()).thenReturn(tradeConfig);
|
||||
when(brokerageUserService.getUserBrokerageEnabled(100L)).thenReturn(true);
|
||||
|
||||
// mock:商品开启独立分销,但查询不到 SKU 固定佣金
|
||||
ProductSpuRespDTO spu = new ProductSpuRespDTO();
|
||||
spu.setSubCommissionType(true);
|
||||
when(productSpuApi.getSpu(4L)).thenReturn(spu);
|
||||
when(productSkuApi.getSkuListBySpuId(ListUtil.of(4L))).thenReturn(List.of());
|
||||
|
||||
// 调用
|
||||
AppBrokerageProductPriceRespVO result = brokerageRecordService.calculateProductBrokeragePrice(100L, 4L);
|
||||
|
||||
// 断言:独立分销没有固定佣金时,不回退到全局比例
|
||||
assertTrue(result.getEnabled());
|
||||
assertEquals(0, result.getBrokerageMinPrice());
|
||||
assertEquals(0, result.getBrokerageMaxPrice());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 '佣金提现';
|
||||
|
||||
@ -35,6 +35,10 @@ public class MemberUserRespDTO {
|
||||
* 手机
|
||||
*/
|
||||
private String mobile;
|
||||
/**
|
||||
* 邮箱
|
||||
*/
|
||||
private String email;
|
||||
/**
|
||||
* 创建时间(注册时间)
|
||||
*/
|
||||
|
||||
@ -5,7 +5,9 @@ import lombok.Data;
|
||||
import org.hibernate.validator.constraints.URL;
|
||||
import org.springframework.format.annotation.DateTimeFormat;
|
||||
|
||||
import javax.validation.constraints.NotNull;
|
||||
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())
|
||||
|
||||
@ -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, "登录失败,账号密码不正确");
|
||||
|
||||
@ -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);
|
||||
}
|
||||
@ -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;
|
||||
|
||||
|
||||
@ -53,6 +53,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')")
|
||||
|
||||
@ -36,6 +36,14 @@ public interface MesQcDefectRecordService {
|
||||
*/
|
||||
void deleteDefectRecord(Long id);
|
||||
|
||||
/**
|
||||
* 获得质检缺陷记录
|
||||
*
|
||||
* @param id 编号
|
||||
* @return 质检缺陷记录
|
||||
*/
|
||||
MesQcDefectRecordDO getDefectRecord(Long id);
|
||||
|
||||
/**
|
||||
* 获得质检缺陷记录分页
|
||||
*
|
||||
|
||||
@ -106,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,6 +1,11 @@
|
||||
package cn.iocoder.yudao.module.system.api.permission;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.collection.CollectionUtils;
|
||||
import cn.iocoder.yudao.module.system.api.permission.dto.RoleRespDTO;
|
||||
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 角色 API 接口
|
||||
@ -18,4 +23,31 @@ public interface RoleApi {
|
||||
*/
|
||||
void validRoleList(Collection<Long> ids);
|
||||
|
||||
/**
|
||||
* 获得角色信息
|
||||
*
|
||||
* @param id 角色编号
|
||||
* @return 角色信息
|
||||
*/
|
||||
RoleRespDTO getRole(Long id);
|
||||
|
||||
/**
|
||||
* 获得角色信息数组
|
||||
*
|
||||
* @param ids 角色编号数组
|
||||
* @return 角色信息数组
|
||||
*/
|
||||
List<RoleRespDTO> getRoleList(Collection<Long> ids);
|
||||
|
||||
/**
|
||||
* 获得指定编号的角色 Map
|
||||
*
|
||||
* @param ids 角色编号数组
|
||||
* @return 角色 Map
|
||||
*/
|
||||
default Map<Long, RoleRespDTO> getRoleMap(Collection<Long> ids) {
|
||||
List<RoleRespDTO> list = getRoleList(ids);
|
||||
return CollectionUtils.convertMap(list, RoleRespDTO::getId);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,10 +1,14 @@
|
||||
package cn.iocoder.yudao.module.system.api.permission;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.object.BeanUtils;
|
||||
import cn.iocoder.yudao.module.system.api.permission.dto.RoleRespDTO;
|
||||
import cn.iocoder.yudao.module.system.dal.dataobject.permission.RoleDO;
|
||||
import cn.iocoder.yudao.module.system.service.permission.RoleService;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.Collection;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 角色 API 实现类
|
||||
@ -21,4 +25,16 @@ public class RoleApiImpl implements RoleApi {
|
||||
public void validRoleList(Collection<Long> ids) {
|
||||
roleService.validateRoleList(ids);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RoleRespDTO getRole(Long id) {
|
||||
RoleDO role = roleService.getRole(id);
|
||||
return BeanUtils.toBean(role, RoleRespDTO.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<RoleRespDTO> getRoleList(Collection<Long> ids) {
|
||||
List<RoleDO> list = roleService.getRoleList(ids);
|
||||
return BeanUtils.toBean(list, RoleRespDTO.class);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,37 @@
|
||||
package cn.iocoder.yudao.module.system.api.permission.dto;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.enums.CommonStatusEnum;
|
||||
import lombok.Data;
|
||||
|
||||
/**
|
||||
* 角色 Response DTO
|
||||
*
|
||||
* @author 芋道源码
|
||||
*/
|
||||
@Data
|
||||
public class RoleRespDTO {
|
||||
|
||||
/**
|
||||
* 角色编号
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 角色名称
|
||||
*/
|
||||
private String name;
|
||||
/**
|
||||
* 角色编码
|
||||
*/
|
||||
private String code;
|
||||
/**
|
||||
* 显示顺序
|
||||
*/
|
||||
private Integer sort;
|
||||
/**
|
||||
* 角色状态
|
||||
*
|
||||
* 枚举 {@link CommonStatusEnum}
|
||||
*/
|
||||
private Integer status;
|
||||
|
||||
}
|
||||
@ -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 javax.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 数据
|
||||
|
||||
Reference in New Issue
Block a user