# 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:
YunaiV
2026-05-31 19:35:10 +08:00
84 changed files with 2451 additions and 200 deletions

View File

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

View File

@ -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);
}

View File

@ -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);
}

View File

@ -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\"}";
}

View File

@ -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);
}
}

View File

@ -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(),

View File

@ -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, "表定义已经存在");

View File

@ -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();
}
}

View File

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

View File

@ -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);
}

View File

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

View File

@ -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() {
// 准备参数

View File

@ -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));
}
}
}

View File

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

View File

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

View File

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

View File

@ -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 告警记录不存在");

View File

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

View File

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

View File

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

View File

@ -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);
}
}

View File

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

View File

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

View File

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

View File

@ -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, "标识符不匹配,期望: " +

View File

@ -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, "消息中不包含属性: " +

View File

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

View File

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

View File

@ -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);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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);
}
}
}

View File

@ -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);
}
}
}
}

View File

@ -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);
}
}
}

View File

@ -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;
}
/**
* 获取设备配置
*/

View File

@ -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);
}
}
/**
* 发送数据到设备
*

View File

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

View File

@ -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);
}
});
}
}

View File

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

View File

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

View File

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

View File

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

View File

@ -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 = falseSKU 售价 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 = trueSKU 固定佣金为 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());
}
}

View File

@ -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 '佣金提现';

View File

@ -35,6 +35,10 @@ public class MemberUserRespDTO {
* 手机
*/
private String mobile;
/**
* 邮箱
*/
private String email;
/**
* 创建时间(注册时间)
*/

View File

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

View File

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

View File

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

View File

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

View File

@ -45,6 +45,10 @@ public class MemberUserDO extends TenantBaseDO {
* 手机
*/
private String mobile;
/**
* 邮箱
*/
private String email;
/**
* 加密后的密码
*

View File

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

View File

@ -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, "登录失败,账号密码不正确");

View File

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

View File

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

View File

@ -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()));
});
}
}

View File

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

View File

@ -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')")

View File

@ -36,6 +36,14 @@ public interface MesQcDefectRecordService {
*/
void deleteDefectRecord(Long id);
/**
* 获得质检缺陷记录
*
* @param id 编号
* @return 质检缺陷记录
*/
MesQcDefectRecordDO getDefectRecord(Long id);
/**
* 获得质检缺陷记录分页
*

View File

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

View File

@ -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 客户端

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

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

View File

@ -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));
}

View File

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

View File

@ -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')")

View File

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

View File

@ -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')")

View File

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

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -69,6 +69,14 @@ public interface MailTemplateService {
*/
List<MailTemplateDO> getMailTemplateList();
/**
* 获取指定状态的邮件模版数组
*
* @param status 状态
* @return 邮件模版数组
*/
List<MailTemplateDO> getMailTemplateListByStatus(Integer status);
/**
* 从缓存中获取邮件模版
*

View File

@ -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);
}
}
}

View File

@ -69,6 +69,14 @@ public interface NotifyTemplateService {
*/
PageResult<NotifyTemplateDO> getNotifyTemplatePage(NotifyTemplatePageReqVO pageReqVO);
/**
* 获得指定状态的站内信模版列表
*
* @param status 状态
* @return 站内信模版列表
*/
List<NotifyTemplateDO> getNotifyTemplateListByStatus(Integer status);
/**
* 格式化站内信内容
*

View File

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

View File

@ -70,6 +70,14 @@ public interface SmsTemplateService {
*/
PageResult<SmsTemplateDO> getSmsTemplatePage(SmsTemplatePageReqVO pageReqVO);
/**
* 获得指定状态的短信模板列表
*
* @param status 状态
* @return 短信模板列表
*/
List<SmsTemplateDO> getSmsTemplateListByStatus(Integer status);
/**
* 获得指定短信渠道下的短信模板数量
*

View File

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

View File

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

View File

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

View File

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