diff --git a/app/src/main/java/com/tinyengine/it/TinyEngineApplication.java b/app/src/main/java/com/tinyengine/it/TinyEngineApplication.java index 78c56cd8..bc2b5ed5 100644 --- a/app/src/main/java/com/tinyengine/it/TinyEngineApplication.java +++ b/app/src/main/java/com/tinyengine/it/TinyEngineApplication.java @@ -15,6 +15,7 @@ import org.mybatis.spring.annotation.MapperScan; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; import org.springframework.context.annotation.EnableAspectJAutoProxy; /** @@ -25,6 +26,7 @@ @SpringBootApplication @EnableAspectJAutoProxy @MapperScan({"com.tinyengine.it.mapper","com.tinyengine.it.dynamic.dao"}) +@EntityScan("com.tinyengine.it.modeldata.entity") public class TinyEngineApplication { /** * The entry point of application. diff --git a/app/src/main/resources/application-alpha.yml b/app/src/main/resources/application-alpha.yml index 3ef129e3..17395748 100644 --- a/app/src/main/resources/application-alpha.yml +++ b/app/src/main/resources/application-alpha.yml @@ -28,6 +28,28 @@ spring: min-evictable-idle-time-millis: 300000 # 连接在池中保持空闲的最小时间(单位:毫秒)。如果空闲时间超过这个值,连接将被回收,默认值为 1800000。 pool-prepared-statements: true # 是否缓存 PreparedStatement 对象,默认值为 true。 max-open-prepared-statements: 20 # 最大缓存的 PreparedStatement 数量,默认值为 -1,表示无限制。如果 `pool-prepared-statements` 设置为 true,设置此值以限制缓存数量。 + + jpa: + hibernate: + ddl-auto: update + show-sql: true + + data: + redis: + host: tiny-engine-redis + port: 6379 + database: 0 # 默认库,Nonce 建议单独使用一个库(如 1),避免业务数据干扰 + # 连接超时配置(非常重要) + connect-timeout: 5000ms # 建立连接超时,5秒 + timeout: 5000ms # 读取数据超时,5秒 + jedis: # Jedis 连接池配置 + pool: + max-active: 20 + max-idle: 10 + min-idle: 5 + max-wait: 2000ms + + # 清空任务配置 cleanup: enabled: false diff --git a/app/src/main/resources/sql/mysql/10_create_apikey_table_ddl_2026_0630.sql b/app/src/main/resources/sql/mysql/10_create_apikey_table_ddl_2026_0630.sql new file mode 100644 index 00000000..97fb2343 --- /dev/null +++ b/app/src/main/resources/sql/mysql/10_create_apikey_table_ddl_2026_0630.sql @@ -0,0 +1,14 @@ +drop table if exists `t_api_key`; + +create table `t_api_key` +( + `id` int not null auto_increment comment '主键id', + `api_key` varchar(255) not null comment 'api_key', + `api_secret` varchar(255) comment '秘钥', + `expire_time` timestamp not null comment '过期时间', + `tenant_id` varchar(60) comment '租户id', + `status` int comment '业务租户id', + primary key (`id`) using btree, + unique index `u_idx_api_key` (`api_key`,`api_secret`) using btree +) engine = innodb comment = 'api_key表'; + diff --git a/app/src/main/resources/sql/mysql/11_create_modeldata_table_ddl_2026_0630.sql b/app/src/main/resources/sql/mysql/11_create_modeldata_table_ddl_2026_0630.sql new file mode 100644 index 00000000..f4e03b74 --- /dev/null +++ b/app/src/main/resources/sql/mysql/11_create_modeldata_table_ddl_2026_0630.sql @@ -0,0 +1,19 @@ +drop table if exists `t_model_data`; + +create table `t_model_data` +( + `id` int not null auto_increment comment '主键id', + `model_id` varchar(255) not null comment '模型id', + `data_json` json NOT NULL COMMENT '模型数据', + `version` varchar(255) comment '版本', + `created_by` varchar(60) not null comment '创建人', + `created_time` timestamp not null default current_timestamp comment '创建时间', + `last_updated_by` varchar(60) not null comment '最后修改人', + `last_updated_time` timestamp not null default current_timestamp comment '更新时间', + `tenant_id` varchar(60) comment '租户id', + `renter_id` varchar(60) comment '业务租户id', + `site_id` varchar(60) comment '站点id,设计预留字段', + primary key (`id`) using btree, + unique index `u_idx_model_data` (`id`,`model_id`) using btree +) engine = innodb comment = '模型数据表'; + diff --git a/app/src/main/resources/sql/mysql/init_data_2026_0630.sql b/app/src/main/resources/sql/mysql/init_data_2026_0630.sql new file mode 100644 index 00000000..b269c18c --- /dev/null +++ b/app/src/main/resources/sql/mysql/init_data_2026_0630.sql @@ -0,0 +1 @@ +INSERT INTO `t_api_key` (`id`, `api_key`, `api_secret`, `expire_time`, `tenant_id`, `status`) VALUES (1, '', '', '2032-11-01 11:38:23', NULL, 1); diff --git a/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java b/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java index 76f8375e..9cb3fd59 100644 --- a/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java +++ b/base/src/main/java/com/tinyengine/it/common/exception/ExceptionEnum.java @@ -352,7 +352,11 @@ public enum ExceptionEnum implements IBaseError { /** * Cm 345 exception enum. */ - CM345("CM345", "用户名不存在,请重新输入"),; + CM345("CM345", "用户名不存在,请重新输入"), + /** + * Cm 345 exception enum. + */ + CM346("CM346", "缺少请求头"); /** * 错误码 */ diff --git a/base/src/main/java/com/tinyengine/it/config/RedisConfig.java b/base/src/main/java/com/tinyengine/it/config/RedisConfig.java new file mode 100644 index 00000000..5155d23f --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/config/RedisConfig.java @@ -0,0 +1,48 @@ +package com.tinyengine.it.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisPassword; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + /** + * 专门用于 Nonce 存储的 RedisTemplate(Key 和 Value 都存字符串) + * 避免乱码,方便 redis-cli 命令行调试查看 + */ + @Bean + public StringRedisTemplate stringRedisTemplate(RedisConnectionFactory connectionFactory) { + StringRedisTemplate template = new StringRedisTemplate(); + template.setConnectionFactory(connectionFactory); + // StringRedisTemplate 默认使用 StringRedisSerializer,无需额外配置 + return template; + } + + /** + * (可选)如果你的业务还需要存对象,可以配置通用的 RedisTemplate + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // 使用 StringRedisSerializer 序列化 Key + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + + // Value 使用 Jackson 序列化(如果存对象) + // 这里不重复贴 Jackson 代码,保持配置简洁 + template.afterPropertiesSet(); + return template; + } + +} diff --git a/base/src/main/java/com/tinyengine/it/dynamic/controller/ModelDataController.java b/base/src/main/java/com/tinyengine/it/dynamic/controller/ModelDataController.java index 64195144..71f58c35 100644 --- a/base/src/main/java/com/tinyengine/it/dynamic/controller/ModelDataController.java +++ b/base/src/main/java/com/tinyengine/it/dynamic/controller/ModelDataController.java @@ -68,7 +68,7 @@ public Result > insert(@RequestBody @Valid DynamicInsert dto return Result.success(dynamicService.insert(dto)); } catch (Exception e) { log.error("insert failed for table: {}", dto.getNameEn(), e); - return Result.failed("insert operation failed"); + return Result.failed("insert operation failed:"+e.getMessage()); } } @@ -90,7 +90,7 @@ public Result > update(@RequestBody @Valid DynamicUpdate dto return Result.success(dynamicService.update(dto)); } catch (Exception e) { log.error("updateApi failed for table: {}", dto.getNameEn(), e); - return Result.failed("update operation failed"); + return Result.failed("update operation failed:"+e.getMessage()); } } @@ -111,7 +111,7 @@ public Result > delete(@RequestBody @Valid DynamicDelete dto return Result.success(dynamicService.delete(dto)); } catch (Exception e) { log.error("deleteApi failed for table: {}", dto.getNameEn(), e); - return Result.failed("delete operation failed"); + return Result.failed("delete operation failed:"+e.getMessage()); } } } diff --git a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java index 3e7cdb94..8f87fdc1 100644 --- a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java +++ b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicModelService.java @@ -606,18 +606,18 @@ public Map createData(DynamicInsert dataDto) { String tableName = getTableName(dataDto.getNameEn()); - Map record = new HashMap<>(dataDto.getParams()); - for (String col : record.keySet()) { + Map map = new HashMap<>(dataDto.getParams()); + for (String col : map.keySet()) { SqlIdentifierValidator.validate(col); } String userId = loginUserContext.getLoginUserId(); // 添加系统字段 - record.put("created_by",userId); - record.put("updated_by", userId); + map.put("created_by",userId); + map.put("updated_by", userId); // 构建SQL - String columns = String.join(", ", record.keySet()); - String placeholders = record.keySet().stream() + String columns = String.join(", ", map.keySet()); + String placeholders = map.keySet().stream() .map(k -> "?") .collect(Collectors.joining(", ")); @@ -634,7 +634,7 @@ public PreparedStatement createPreparedStatement(Connection con) throws SQLExcep PreparedStatement ps = con.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); int index = 1; - for (Object value : record.values()) { + for (Object value : map.values()) { ps.setObject(index++, value); } @@ -645,10 +645,10 @@ public PreparedStatement createPreparedStatement(Connection con) throws SQLExcep Long generatedId = keyHolder.getKey() != null ? keyHolder.getKey().longValue() : null; if (generatedId != null) { - record.put("id", generatedId); + map.put("id", generatedId); } - return record; + return map; } diff --git a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java index f33cb184..1e805614 100644 --- a/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java +++ b/base/src/main/java/com/tinyengine/it/dynamic/service/DynamicService.java @@ -5,16 +5,20 @@ import com.tinyengine.it.common.utils.SqlIdentifierValidator; import com.tinyengine.it.dynamic.dao.ModelDataDao; import com.tinyengine.it.dynamic.dto.*; +import com.tinyengine.it.login.config.context.DefaultLoginUserContext; import com.tinyengine.it.model.dto.ParametersDto; import com.tinyengine.it.model.entity.Model; +import com.tinyengine.it.modeldata.service.ModelDataCacheService; import com.tinyengine.it.service.material.ModelService; import jakarta.transaction.Transactional; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import java.math.BigInteger; import java.util.*; - +@Slf4j @Service public class DynamicService { @Autowired @@ -23,6 +27,8 @@ public class DynamicService { private ModelService modelService; @Autowired private LoginUserContext loginUserContext; + @Autowired + private ModelDataCacheService modelDataCacheService; private static final Set SYSTEM_FIELDS = Set.of( "id", "created_at", "updated_at", "deleted_at", "created_by", "updated_by" @@ -41,6 +47,30 @@ public List query(DynamicQuery dto) { return dynamicDao.select(params); } + public Page> queryPage(DynamicQuery dto) { + String tableName = getTableName(dto.getNameEn()); + Map params = new HashMap<>(); + params.put("tableName", tableName); + params.put("fields", dto.getFields()); + params.put("conditions", dto.getParams()); + params.put("pageNum", dto.getCurrentPage()); + params.put("pageSize", dto.getPageSize()); + params.put("orderBy", dto.getOrderBy()); + params.put("orderType", dto.getOrderType()); + + String tenantId = DefaultLoginUserContext.getCurrentTenant(); + Integer modelId=null; + List modelList = modelService.getModelByEnName(dto.getNameEn()); + if (modelList.isEmpty()) { + throw new IllegalArgumentException("模型不存在: " + dto.getNameEn()); + } else { + modelId = modelList.get(0).getId(); + } + log.info("Querying modelId: {}, tenantId: {}, params: {}", modelId, tenantId, params); + Page> query = modelDataCacheService.query(modelId, tenantId, dto); + log.info("Queried query: {}, result size: {}", query, query.getContent().size()); + return query; + } public Long count(String tableName, Map conditions) { Map params = new HashMap<>(); @@ -65,9 +95,10 @@ public Map queryWithPage(DynamicQuery dto) { validateTableExists(dto.getNameEn()); validateConditionKeys(dto.getParams()); validateQueryFields(dto); - List list = query(dto); - String tableName = getTableName(dto.getNameEn()); - Long total = count(tableName, dto.getParams()); + Page> queryPage = queryPage(dto); + List list= queryPage.map(JSONObject::new).toList(); + long total = queryPage.getTotalElements(); + Map result = new HashMap<>(); result.put("list", list); @@ -80,7 +111,7 @@ public Map queryWithPage(DynamicQuery dto) { } @Transactional - public Map insert(DynamicInsert dto) { + public Map insert(DynamicInsert dto) throws Exception { if (dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { throw new IllegalArgumentException("插入操作必须指定模型名称"); } @@ -94,29 +125,33 @@ public Map insert(DynamicInsert dto) { params.put("tableName", tableName); params.put("data", dto.getParams()); - String userId = loginUserContext.getLoginUserId(); - if (userId == null || userId.trim().isEmpty()) { - List modelList = modelService.getModelByEnName(dto.getNameEn()); - if (modelList.isEmpty()) { - throw new IllegalArgumentException("模型不存在: " + dto.getNameEn()); - } else { - userId = modelList.get(0).getCreatedBy(); - } + String tenantId = DefaultLoginUserContext.getCurrentTenant(); + Integer modelId; + String userId; + List modelList = modelService.getModelByEnName(dto.getNameEn()); + if (modelList.isEmpty()) { + throw new IllegalArgumentException("模型不存在: " + dto.getNameEn()); + } else { + userId = modelList.get(0).getCreatedBy(); + modelId = modelList.get(0).getId(); } dto.getParams().put("created_by", userId); - dto.getParams().put("updated_by", userId); + dto.getParams().put("tenantId", tenantId); Map result = new HashMap<>(); - Long insertRow = dynamicDao.insert(params); - BigInteger id = (BigInteger) params.get("id"); + log.info("Adding to modelId: {}, params: {}", modelId, dto.getParams()); + log.info("modelId: {}, tenantId: {}, userId: {}", modelId, tenantId, userId); + Map add = modelDataCacheService.add(modelId, tenantId, dto.getParams(), userId); + Integer id = (Integer) add.get("_id"); + Integer insertRow = (Integer) add.get("_row"); result.put("insert", insertRow); - result.put("id", id.longValue()); + result.put("id", id); return result; } @Transactional - public Map update(DynamicUpdate dto) { + public Map update(DynamicUpdate dto) throws Exception { if (dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { throw new IllegalArgumentException("更新操作必须指定模型名称"); } @@ -126,6 +161,9 @@ public Map update(DynamicUpdate dto) { if (dto.getData() == null || dto.getData().isEmpty()) { throw new IllegalArgumentException("更新数据不能为空"); } + if(!dto.getParams().containsKey("id")) { + throw new IllegalArgumentException("更新操作必须指定id"); + } validateTableExists(dto.getNameEn()); validateTableAndData(dto.getNameEn(), dto.getData()); validateTableAndData(dto.getNameEn(), dto.getParams()); @@ -135,13 +173,26 @@ public Map update(DynamicUpdate dto) { params.put("data", dto.getData()); params.put("conditions", dto.getParams()); Map result = new HashMap<>(); - Integer update = dynamicDao.update(params); - result.put("update", update); + String userId = loginUserContext.getLoginUserId(); + String tenantId = DefaultLoginUserContext.getCurrentTenant(); + Integer modelId=null; + List modelList = modelService.getModelByEnName(dto.getNameEn()); + if (modelList.isEmpty()) { + throw new IllegalArgumentException("模型不存在: " + dto.getNameEn()); + } else { + userId = modelList.get(0).getCreatedBy(); + modelId = modelList.get(0).getId(); + } + log.info("modelId: {}, tenantId: {}, userId: {}", modelId, tenantId, userId); + log.info("Updating table: {}, with params: {}, data: {}", tableName, dto.getParams(), dto.getData()); + dto.getData().put("updated_by", userId); + Map update = modelDataCacheService.update(modelId,tenantId,Integer.parseInt(dto.getParams().get("id").toString()), dto.getData(), userId); + result.put("update", update.get("_row")); return result; } @Transactional - public Map delete(DynamicDelete dto) { + public Map delete(DynamicDelete dto) throws Exception { if (dto.getNameEn() == null || dto.getNameEn().trim().isEmpty()) { throw new IllegalArgumentException("删除操作必须指定模型名称"); } @@ -156,9 +207,18 @@ public Map delete(DynamicDelete dto) { conditions.put("id", dto.getId()); params.put("tableName", tableName); params.put("conditions", conditions); + String tenantId = DefaultLoginUserContext.getCurrentTenant(); + Integer modelId=null; + List modelList = modelService.getModelByEnName(dto.getNameEn()); + if (modelList.isEmpty()) { + throw new IllegalArgumentException("模型不存在: " + dto.getNameEn()); + } else { + modelId = modelList.get(0).getId(); + } + log.info("Deleting from table: {}, with params: {}", tableName, params); Map result = new HashMap<>(); - Integer delete = dynamicDao.delete(params); - result.put("delete", delete); + Map objectMap = modelDataCacheService.delete(modelId,tenantId,Long.valueOf(dto.getId())); + result.put("delete", objectMap.get("_row")); return result; } diff --git a/base/src/main/java/com/tinyengine/it/login/config/LoginConfig.java b/base/src/main/java/com/tinyengine/it/login/config/LoginConfig.java index 0d78e04e..4020666a 100644 --- a/base/src/main/java/com/tinyengine/it/login/config/LoginConfig.java +++ b/base/src/main/java/com/tinyengine/it/login/config/LoginConfig.java @@ -44,7 +44,9 @@ public void addInterceptors(InterceptorRegistry registry) { "/app-center/api/ai/chat", "/app-center/api/chat/completions", // 图片文件资源下载 - "/material-center/api/resource/download/*" + "/material-center/api/resource/download/*", + "/platform-center/api/model-data/*" + ); } } diff --git a/base/src/main/java/com/tinyengine/it/login/config/context/DefaultLoginUserContext.java b/base/src/main/java/com/tinyengine/it/login/config/context/DefaultLoginUserContext.java index 0f552c3a..a0786668 100644 --- a/base/src/main/java/com/tinyengine/it/login/config/context/DefaultLoginUserContext.java +++ b/base/src/main/java/com/tinyengine/it/login/config/context/DefaultLoginUserContext.java @@ -15,6 +15,9 @@ public class DefaultLoginUserContext implements LoginUserContext { private static final ThreadLocal CURRENT_USER = new ThreadLocal<>(); + private static final ThreadLocal CURRENT_TENANT = new ThreadLocal<>(); + + private static final int DEFAULT_PLATFORM = 1; private static final String DEFAULT_TENANT = "1"; @@ -98,6 +101,19 @@ public static UserInfo getCurrentUser() { public static void clear() { CURRENT_USER.remove(); + + } + + public static void setCurrentTenant(String tenantId) { + CURRENT_TENANT.set(tenantId); + } + + public static String getCurrentTenant() { + return CURRENT_TENANT.get(); + } + + public static void clearCurrentTenant() { + CURRENT_TENANT.remove(); } diff --git a/base/src/main/java/com/tinyengine/it/login/utils/SignatureUtil.java b/base/src/main/java/com/tinyengine/it/login/utils/SignatureUtil.java new file mode 100644 index 00000000..16f3d7ea --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/login/utils/SignatureUtil.java @@ -0,0 +1,10 @@ +package com.tinyengine.it.login.utils; + +import org.apache.commons.codec.digest.HmacAlgorithms; +import org.apache.commons.codec.digest.HmacUtils; + +public class SignatureUtil { + public static String hmacSha256(String secret, String data) { + return new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secret).hmacHex(data); + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/dao/ApiKeyRepository.java b/base/src/main/java/com/tinyengine/it/modeldata/dao/ApiKeyRepository.java new file mode 100644 index 00000000..47a88170 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/dao/ApiKeyRepository.java @@ -0,0 +1,12 @@ +package com.tinyengine.it.modeldata.dao; + +import com.tinyengine.it.modeldata.entity.ApiKeyEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; +@Repository +public interface ApiKeyRepository extends JpaRepository { + Optional findByApiKeyAndStatus(String apiKey, Integer status); + +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/dao/ModelDataRepository.java b/base/src/main/java/com/tinyengine/it/modeldata/dao/ModelDataRepository.java new file mode 100644 index 00000000..a4724a0c --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/dao/ModelDataRepository.java @@ -0,0 +1,36 @@ +package com.tinyengine.it.modeldata.dao; + +import com.tinyengine.it.modeldata.entity.ModelData; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.stereotype.Repository; + +import java.util.List; +@Repository +public interface ModelDataRepository extends JpaRepository, JpaSpecificationExecutor { + /** + * 根据模型ID和租户ID分页查询(最常用) + */ + Page findByModelIdAndTenantId(Integer modelId, String tenantId, Pageable pageable); + + /** + * 根据模型ID查询所有数据(不带租户过滤,谨慎使用) + */ + List findByModelId(Integer modelId); + + /** + * 根据模型ID和租户ID查询所有数据 + */ + List findByModelIdAndTenantId(Integer modelId, String tenantId); + + ModelData findByModelIdAndTenantIdAndId(Integer modelId, String tenantId, Integer id); + + + /** + * 统计某模型下的数据条数 + */ + long countByModelIdAndTenantId(Integer modelId, String tenantId); +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/entity/ApiKeyEntity.java b/base/src/main/java/com/tinyengine/it/modeldata/entity/ApiKeyEntity.java new file mode 100644 index 00000000..960cce10 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/entity/ApiKeyEntity.java @@ -0,0 +1,30 @@ +package com.tinyengine.it.modeldata.entity; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.Data; + +import java.time.LocalDateTime; +@Data +@Entity +@Table(name = "t_api_key") +public class ApiKeyEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Integer id; + @Column(name = "api_key") + @Schema(name = "apiKey", description = "站点ID") + private String apiKey; + @Schema(name = "apiSecret", description = "秘钥") + private String apiSecret; + @Schema(name = "tenantId", description = "租户id") + private String tenantId; + @Schema(name = "status", description = "状态,0-禁用,1-启用") + private Integer status; + @Schema(name = "expireTime", description = "过期时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonProperty("expire_time") + private LocalDateTime expireTime; +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/entity/ModelData.java b/base/src/main/java/com/tinyengine/it/modeldata/entity/ModelData.java new file mode 100644 index 00000000..91d28cff --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/entity/ModelData.java @@ -0,0 +1,75 @@ +package com.tinyengine.it.modeldata.entity; + +import com.baomidou.mybatisplus.annotation.*; + + +import com.baomidou.mybatisplus.annotation.Version; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.Setter; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.LocalDateTime; +import java.util.Map; + +@Getter +@Setter +@TableName("t_model_data") +@Entity(name = "t_model_data") +@Schema(name = "ModelData", description = "模型数据表") +public class ModelData { + @Schema(name = "modelId", description = "模型id") + private Integer modelId; + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "json") + @Schema(name = "dataJson", description = "存储数据") + private Map dataJson; + @Id + @Schema(name = "id", description = "主键id") + @GeneratedValue(strategy = GenerationType.IDENTITY) // 新增这一行! + @TableId(value = "id", type = IdType.AUTO) + private Integer id; + + + @Version + @Schema(name = "version", description = "版本号") + private String version; // 这就是 _version 的数据来源 + + @TableField(fill = FieldFill.INSERT) + @Schema(name = "createdBy", description = "创建人") + private String createdBy; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @Schema(name = "lastUpdatedBy", description = "最后修改人") + private String lastUpdatedBy; + + @TableField(fill = FieldFill.INSERT) + @Schema(name = "createdTime", description = "创建时间") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @JsonProperty("created_at") + private LocalDateTime createdTime; + + @TableField(fill = FieldFill.INSERT_UPDATE) + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + @Schema(name = "lastUpdatedTime", description = "更新时间") + @JsonProperty("updated_at") + private LocalDateTime lastUpdatedTime; + + @TableField(fill = FieldFill.INSERT) + @Schema(name = "tenantId", description = "租户ID") + private String tenantId; + + @TableField(fill = FieldFill.INSERT) + @Schema(name = "renterId", description = "业务租户ID") + private String renterId; + + @Schema(name = "siteId", description = "站点ID") + private String siteId; + + + +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/exception/BusinessException.java b/base/src/main/java/com/tinyengine/it/modeldata/exception/BusinessException.java new file mode 100644 index 00000000..228441e7 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/exception/BusinessException.java @@ -0,0 +1,12 @@ +package com.tinyengine.it.modeldata.exception; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class BusinessException extends RuntimeException { + public BusinessException(String message) { + super(message); + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/filter/ApiKeyAuthFilter.java b/base/src/main/java/com/tinyengine/it/modeldata/filter/ApiKeyAuthFilter.java new file mode 100644 index 00000000..81020128 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/filter/ApiKeyAuthFilter.java @@ -0,0 +1,126 @@ +package com.tinyengine.it.modeldata.filter; + +import com.tinyengine.it.login.config.context.DefaultLoginUserContext; +import com.tinyengine.it.login.utils.SignatureUtil; +import com.tinyengine.it.modeldata.entity.ApiKeyEntity; +import com.tinyengine.it.modeldata.service.ApiKeyService; +import com.tinyengine.it.modeldata.util.CachedBodyHttpServletRequest; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.annotation.Order; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.time.Duration; + +@Component +@Order(1) // 确保在所有业务过滤器之前 +public class ApiKeyAuthFilter extends OncePerRequestFilter { + + @Autowired + private ApiKeyService apiKeyService; + @Autowired + private StringRedisTemplate stringRedisTemplate; + + /** + * @param request + * @param response + * @param filterChain + * @throws ServletException + * @throws IOException + */ + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String requestURI = request.getRequestURI(); + if (!requestURI.startsWith("/platform-center/api/model-data")) { + filterChain.doFilter(request, response); + return; + } + String org = request.getHeader("X-Lowcode-Org"); + if(org == null || org.isEmpty()) { + response.setStatus(401); + response.getWriter().write("Missing required header: X-Lowcode-Org"); + return; + } + // 1. 包装请求以支持多次读取 Body + CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request); + + // 2. 提取认证头 + String apiKey = request.getHeader("X-API-Key"); + String timestamp = request.getHeader("X-Timestamp"); + String nonce = request.getHeader("X-Nonce"); + String signature = request.getHeader("X-Signature"); + + if (apiKey == null || timestamp == null || nonce == null || signature == null) { + response.setStatus(401); + response.getWriter().write("Missing required headers: X-API-Key, X-Timestamp, X-Nonce, X-Signature"); + return; + } + + // 3. 时间戳有效期校验(5分钟) + long now = System.currentTimeMillis(); + long reqTime; + try { + reqTime = Long.parseLong(timestamp); + } catch (NumberFormatException e) { + response.setStatus(401); + response.getWriter().write("Invalid timestamp"); + return; + } + if (Math.abs(now - reqTime) > 300_000) { + response.setStatus(401); + response.getWriter().write("Request expired (timestamp out of 5min window)"); + return; + } + + // 4. Nonce 防重放(Redis 存储,有效期5分钟) + String nonceKey = "nonce:" + nonce; + Boolean isNew = stringRedisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(5)); + if (Boolean.FALSE.equals(isNew)) { + response.setStatus(401); + response.getWriter().write("Nonce already used"); + return; + } + + // 5. 根据 API Key 获取密钥和租户信息 + ApiKeyEntity keyEntity = apiKeyService.getValidKey(apiKey); + if (keyEntity == null) { + response.setStatus(401); + response.getWriter().write("Invalid API Key"); + return; + } + + + + // 7. 构建待签名字符串:method\npath\ntimestamp\nnonce\nbody + String method = request.getMethod(); + String path = request.getRequestURI(); + String body = wrappedRequest.getBodyString(); // 使用缓存中的 body + String stringToSign = String.format("%s\n%s\n%s\n%s\n%s", method, path, timestamp, nonce, body); + + // 8. 计算服务端签名并比对 + String expectedSignature = SignatureUtil.hmacSha256(keyEntity.getApiSecret(), stringToSign); + if (!expectedSignature.equals(signature)) { + response.setStatus(401); + response.getWriter().write("Invalid signature"); + return; + } + + // 9. 认证通过,将租户信息存入线程上下文 + DefaultLoginUserContext.setCurrentTenant(org); + try { + filterChain.doFilter(wrappedRequest, response); + } finally { + DefaultLoginUserContext.clearCurrentTenant(); + } + + + } + + +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/service/ApiKeyService.java b/base/src/main/java/com/tinyengine/it/modeldata/service/ApiKeyService.java new file mode 100644 index 00000000..6cfec22d --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/service/ApiKeyService.java @@ -0,0 +1,40 @@ +package com.tinyengine.it.modeldata.service; + +import com.tinyengine.it.modeldata.dao.ApiKeyRepository; +import com.tinyengine.it.modeldata.entity.ApiKeyEntity; +import com.tinyengine.it.modeldata.util.AESUtil; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +import java.time.LocalDateTime; +import java.util.Optional; +@Service +public class ApiKeyService { + @Autowired + private ApiKeyRepository repository; + private static final String MODEL_DATA_SECRET_ENCRYPT_KEY = "MODEL_DATA_SECRET_ENCRYPT_KEY"; + + + public ApiKeyEntity getValidKey(String key) { + Optional opt = repository.findByApiKeyAndStatus(key, 1); + if (opt.isEmpty()) return null; + ApiKeyEntity entity = opt.get(); + String encryptKey = System.getenv(MODEL_DATA_SECRET_ENCRYPT_KEY); + if(encryptKey == null || encryptKey.isEmpty()) { + throw new RuntimeException("Missing environment variable: " + MODEL_DATA_SECRET_ENCRYPT_KEY); + } + + // 解密 Secret + String plainSecret = null; + try { + plainSecret = AESUtil.decrypt(entity.getApiSecret(), encryptKey); + } catch (Exception e) { + throw new RuntimeException(e); + } + entity.setApiSecret(plainSecret); // 此时内存中为明文,用于 HMAC 计算 + if (entity.getExpireTime() != null && entity.getExpireTime().isBefore(LocalDateTime.now())) { + return null; + } + return entity; + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/service/DynamicModelDataService.java b/base/src/main/java/com/tinyengine/it/modeldata/service/DynamicModelDataService.java new file mode 100644 index 00000000..815560a4 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/service/DynamicModelDataService.java @@ -0,0 +1,142 @@ +package com.tinyengine.it.modeldata.service; + +import com.alibaba.fastjson.TypeReference; +import com.tinyengine.it.model.dto.ParametersDto; +import com.tinyengine.it.model.entity.Model; +import com.tinyengine.it.modeldata.dao.ModelDataRepository; +import com.tinyengine.it.modeldata.entity.ModelData; +import com.tinyengine.it.modeldata.exception.BusinessException; +import com.tinyengine.it.service.material.ModelService; +import jakarta.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import com.alibaba.fastjson.JSON; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@Service +public class DynamicModelDataService { + + @Autowired + private ModelService modelService; + @Autowired + private ModelDataRepository modelDataRepo; + @Autowired + private ValidationService validationService; + @Autowired + private JdbcTemplate jdbcTemplate; // 用于复杂查询 + + // 创建数据实例 + @Transactional + public ModelData create(Integer modelId, Map data, String tenantId, String userId) throws Exception { + Model model = modelService.queryModelById(modelId); + if (model == null) { + throw new BusinessException("模型不存在"); + } + List fieldDefs = model.getParameters(); + if (!fieldDefs.isEmpty() && !(fieldDefs.get(0) instanceof ParametersDto)) { + String json = JSON.toJSONString(fieldDefs); + fieldDefs = JSON.parseObject(json, new TypeReference>() {}); + model.setParameters(fieldDefs); + } + + fieldDefs = model.getParameters(); + validationService.validate(data, fieldDefs); + + ModelData entity = new ModelData(); + entity.setModelId(modelId); + entity.setVersion(model.getVersion()); + entity.setTenantId(tenantId); + entity.setDataJson(data); + entity.setCreatedBy(userId); + entity.setLastUpdatedBy(userId); + entity.setCreatedTime(LocalDateTime.now()); + entity.setLastUpdatedTime(LocalDateTime.now()); + entity.setLastUpdatedBy(userId); + return modelDataRepo.save(entity); + } + + // 更新数据实例 + @Transactional + public ModelData update(Integer dataId, Map data, String userId) throws Exception { + ModelData existing = modelDataRepo.findById(Long.valueOf(dataId)) + .orElseThrow(() -> new BusinessException("数据不存在")); + Model model = modelService.queryModelById(existing.getModelId()); + if (model == null) { + throw new BusinessException("模型不存在"); + } + + List fieldDefs = model.getParameters(); + if (!fieldDefs.isEmpty() && !(fieldDefs.get(0) instanceof ParametersDto)) { + String json = JSON.toJSONString(fieldDefs); + fieldDefs = JSON.parseObject(json, new TypeReference>() {}); + // 将转换后的列表设置回 model(可选,以便后续使用) + model.setParameters(fieldDefs); + } + + // 合并数据 + Map merged = new HashMap<>(existing.getDataJson()); + merged.putAll(data); + validationService.validate(merged, fieldDefs); + + existing.setDataJson(merged); + existing.setLastUpdatedBy(userId); + existing.setLastUpdatedTime(LocalDateTime.now()); + return modelDataRepo.save(existing); + } + + // 删除 + @Transactional + public void delete(Long dataId) { + modelDataRepo.deleteById(dataId); + } + + // 按 ID 查询 + public ModelData get(Long dataId) { + return modelDataRepo.findById(dataId).orElse(null); + } + + // 分页查询(支持动态过滤) + public Page> query(Integer modelId, Map filters, String tenantId, Pageable pageable) { + // 使用 JdbcTemplate 实现动态 JSON 查询 + String tableName = "t_model_data"; + StringBuilder where = new StringBuilder("WHERE model_id = ? AND tenant_id = ?"); + List args = new ArrayList<>(); + args.add(modelId); + args.add(tenantId); + + for (Map.Entry entry : filters.entrySet()) { + String field = entry.getKey(); + String value = entry.getValue(); + where.append(" AND JSON_EXTRACT(data_json, '$.\"").append(field).append("\"') = ?"); + args.add(value); + } + + // 分页查询 + String countSql = "SELECT COUNT(*) FROM " + tableName + " " + where; + long total = jdbcTemplate.queryForObject(countSql, Long.class, args.toArray()); + + String selectSql = "SELECT id, data_json, created_time FROM " + tableName + " " + where + + " ORDER BY created_time DESC LIMIT ? OFFSET ?"; + args.add(pageable.getPageSize()); + args.add(pageable.getOffset()); + + List> list = jdbcTemplate.query(selectSql, args.toArray(), (rs, rowNum) -> { + Map row = new HashMap<>(); + row.put("id", rs.getLong("id")); + row.put("data", rs.getString("data_json")); // 可转为 Map + row.put("createdTime", rs.getTimestamp("created_time")); + return row; + }); + + return new PageImpl<>(list, pageable, total); + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/service/ModelDataCacheService.java b/base/src/main/java/com/tinyengine/it/modeldata/service/ModelDataCacheService.java new file mode 100644 index 00000000..fb6f0bd1 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/service/ModelDataCacheService.java @@ -0,0 +1,238 @@ +package com.tinyengine.it.modeldata.service; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.TypeReference; +import com.tinyengine.it.dynamic.dto.DynamicQuery; +import com.tinyengine.it.modeldata.dao.ModelDataRepository; +import com.tinyengine.it.modeldata.entity.ModelData; + +import com.tinyengine.it.modeldata.exception.BusinessException; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.*; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Service; + +import java.time.Duration; +import java.util.*; +import java.util.stream.Collectors; +import java.util.stream.Stream; +@Service +public class ModelDataCacheService { + + @Autowired + private ModelDataRepository modelDataRepo; + + @Autowired + private DynamicModelDataService dynamicModelDataService; // 原有的业务服务(含校验) + + @Autowired + private StringRedisTemplate redisTemplate; + + private static final String CACHE_KEY_PREFIX = "model:data:"; + // TTL 设置为 10 分钟,可根据业务调整 + private static final Duration CACHE_TTL = Duration.ofMinutes(10); + + private String cacheKey(Integer modelId, String tenantId) { + return CACHE_KEY_PREFIX + modelId + ":" + tenantId; + } + + /** + * 加载数据列表(缓存优先) + */ + public List> loadDataList(Integer modelId, String tenantId) { + String key = cacheKey(modelId, tenantId); + String cachedJson = redisTemplate.opsForValue().get(key); + if (cachedJson != null) { + return JSON.parseObject(cachedJson, new TypeReference>>() {}); + } + List entities = modelDataRepo.findByModelIdAndTenantId(modelId, tenantId); + List> dataList = entities.stream() + .map(entity -> { + Map item = new HashMap<>(entity.getDataJson()); + item.put("_id", entity.getId()); + item.put("_version", entity.getVersion()); + return item; + }) + .collect(Collectors.toList()); + redisTemplate.opsForValue().set(key, JSON.toJSONString(dataList), CACHE_TTL); + return dataList; + } + + private void invalidateCache(Integer modelId, String tenantId) { + redisTemplate.delete(cacheKey(modelId, tenantId)); + } + + // ========== 核心查询方法 ========== + + /** + * 根据 DynamicQuery 进行动态查询(缓存 + 内存过滤) + */ + public Page> query(Integer modelId, String tenantId, DynamicQuery query) { + List> allData = loadDataList(modelId, tenantId); + + Stream> stream = allData.stream(); + + // 1. 条件过滤(params) + if (query.getParams() != null && !query.getParams().isEmpty()) { + for (Map.Entry entry : query.getParams().entrySet()) { + String field = entry.getKey(); + Object rawValue = entry.getValue(); + if (rawValue == null) continue; + String strValue = rawValue.toString(); + String operator = "eq"; + String value = strValue; + if (strValue.contains(":")) { + String[] parts = strValue.split(":", 2); + operator = parts[0]; + value = parts[1]; + }else{ + // 如果字段是 name(不区分大小写)且未指定操作符,默认使用模糊查询 + if ("name".equalsIgnoreCase(field)) { + operator = "like"; + } + } + final String op = operator; + final String val = value; + stream = stream.filter(item -> { + Object fieldValue = item.get(field); + if (fieldValue == null) return false; + switch (op) { + case "eq": return fieldValue.toString().equals(val); + case "ne": return !fieldValue.toString().equals(val); + case "gt": return compareNumber(fieldValue, val) > 0; + case "lt": return compareNumber(fieldValue, val) < 0; + case "gte": return compareNumber(fieldValue, val) >= 0; + case "lte": return compareNumber(fieldValue, val) <= 0; + case "like": return fieldValue.toString().contains(val); + case "startswith": return fieldValue.toString().startsWith(val); + case "endswith": return fieldValue.toString().endsWith(val); + case "in": { + String[] values = val.split(","); + for (String v : values) { + if (fieldValue.toString().equals(v.trim())) return true; + } + return false; + } + default: return true; + } + }); + } + } + + // 2. 字段选择(fields) + List fields = query.getFields(); + boolean selectFields = (fields != null && !fields.isEmpty()); + + // 先收集过滤后的完整数据(用于分页和排序) + List> filteredFull = stream.collect(Collectors.toList()); + + // 3. 排序 + if (query.getOrderBy() != null && !query.getOrderBy().isEmpty()) { + String sortField = query.getOrderBy(); + boolean ascending = "ASC".equalsIgnoreCase(query.getOrderType()); + filteredFull.sort((a, b) -> { + Object va = a.get(sortField); + Object vb = b.get(sortField); + if (va == null && vb == null) return 0; + if (va == null) return ascending ? -1 : 1; + if (vb == null) return ascending ? 1 : -1; + int cmp = compare(va, vb); + return ascending ? cmp : -cmp; + }); + } + + // 4. 分页 + int total = filteredFull.size(); + int page = Math.max(query.getCurrentPage(), 1); + int size = query.getPageSize() != null ? query.getPageSize() : 10; + int start = (page - 1) * size; + int end = Math.min(start + size, total); + List> pageData = (start < total) + ? filteredFull.subList(start, end) + : Collections.emptyList(); + + // 5. 字段裁剪(只返回指定字段) + List> resultList; + if (selectFields) { + resultList = pageData.stream() + .map(item -> { + Map filteredItem = new LinkedHashMap<>(); + // 始终返回 id + filteredItem.put("id", item.get("_id")); + for (String field : fields) { + if (item.containsKey(field)) { + filteredItem.put(field, item.get(field)); + } + } + return filteredItem; + }) + .collect(Collectors.toList()); + } else { + resultList = pageData; + } + + return new PageImpl<>(resultList, PageRequest.of(page - 1, size), total); + } + + // 辅助比较 + private int compare(Object a, Object b) { + if (a instanceof Comparable && b instanceof Comparable) { + return ((Comparable) a).compareTo(b); + } + return a.toString().compareTo(b.toString()); + } + + private int compareNumber(Object fieldValue, String val) { + try { + double d1 = Double.parseDouble(fieldValue.toString()); + double d2 = Double.parseDouble(val); + return Double.compare(d1, d2); + } catch (NumberFormatException e) { + return fieldValue.toString().compareTo(val); + } + } + + // ========== 增删改 ========== + + public Map add(Integer modelId, String tenantId, Map data, String userId) throws Exception { + ModelData saved = dynamicModelDataService.create(modelId, data, tenantId, userId); + invalidateCache(modelId, tenantId); + Map result = new HashMap<>(saved.getDataJson()); + result.put("_id", saved.getId()); + result.put("_version", saved.getVersion()); + result.put("_row", 1); + + return result; + } + + public Map update(Integer modelId,String tenantId,Integer dataId, Map newData, String userId) throws Exception { + ModelData entity = modelDataRepo.findByModelIdAndTenantIdAndId(modelId, tenantId, dataId); + if (entity == null) { + throw new BusinessException("数据不存在"); + } + ModelData updated = dynamicModelDataService.update(dataId, newData, userId); + invalidateCache(updated.getModelId(), updated.getTenantId()); + Map result = new HashMap<>(updated.getDataJson()); + result.put("_id", updated.getId()); + result.put("_version", updated.getVersion()); + result.put("_row", 1); + + return result; + } + + + public Map delete(Integer modelId,String tenantId,Long dataId)throws Exception { + ModelData entity = modelDataRepo.findByModelIdAndTenantIdAndId(modelId, tenantId, Math.toIntExact(dataId)); + if (entity == null) { + throw new BusinessException("数据不存在"); + } + modelDataRepo.deleteById(dataId); + invalidateCache(entity.getModelId(), entity.getTenantId()); + Map result = new HashMap<>(); + result.put("_id", entity.getId()); + result.put("_version", entity.getVersion()); + result.put("_row", 1); + return result; + } + +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/service/ValidationService.java b/base/src/main/java/com/tinyengine/it/modeldata/service/ValidationService.java new file mode 100644 index 00000000..55f19d5d --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/service/ValidationService.java @@ -0,0 +1,105 @@ +package com.tinyengine.it.modeldata.service; + +import com.tinyengine.it.model.dto.ParametersDto; +import com.tinyengine.it.modeldata.exception.BusinessException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +@Service +public class ValidationService { + public void validate(Map data, List fieldDefs) throws Exception{ + if(fieldDefs == null || fieldDefs.isEmpty()) { + throw new BusinessException("字段定义不能为空" ); + } + if(data == null || data.isEmpty()) { + throw new BusinessException("数据不能为空" ); + } + // 1. 必填校验 + Set requiredProps = fieldDefs.stream() + .filter(ParametersDto::getRequired) + .map(ParametersDto::getProp) + .collect(Collectors.toSet()); + for (String required : requiredProps) { + // 跳过 id 字段(通常由数据库自动生成,不需要客户端传入) + if ("id".equalsIgnoreCase(required)) { + continue; + } + if (!data.containsKey(required) || data.get(required) == null) { + throw new BusinessException("缺少必填字段: " + required); + } + } + + // 2. 类型校验 + for (ParametersDto def : fieldDefs) { + Object value = data.get(def.getProp()); + if (value != null) { + checkType(value, def.getType(), def.getProp()); + } + } + + // 3. 不允许出现未定义字段(可选) + Set definedProps = fieldDefs.stream().map(ParametersDto::getProp).collect(Collectors.toSet()); + // 定义系统字段忽略列表(这些字段允许在 data 中出现,即使不在模型定义中) + Set ignoredFields = Set.of("created_by", "tenantId","updated_by"); + for (String prop : data.keySet()) { + if (ignoredFields.contains(prop)) { + continue; // 跳过系统字段,不校验 + } + if (!definedProps.contains(prop)) { + throw new BusinessException("未定义的字段: " + prop); + } + } + } + + private void checkType(Object value, String expectedType, String fieldName) { + System.out.println("Validating field: " + fieldName + ", Expected type: " + expectedType + ", Actual value: " + value); + switch (expectedType) { + case "String": + if (!(value instanceof String)) throw new BusinessException(fieldName + " 应为字符串类型"); + break; + case "Number": + if (!(value instanceof Number)) throw new BusinessException(fieldName + " 应为数字类型"); + break; + case "Boolean": + if (!(value instanceof Boolean)) throw new BusinessException(fieldName + " 应为布尔类型"); + break; + case "Date": + if (!(value instanceof String)) throw new BusinessException(fieldName + " 应为日期字符串"); + + // 校验格式 yyyy-MM-dd + if (!((String) value).matches("\\d{4}-\\d{2}-\\d{2}")) { + throw new BusinessException(fieldName + " 格式应为 yyyy-MM-dd"); + } + break; + case "DateTime" : + if (!(value instanceof String)) { + throw new BusinessException(fieldName + " 应为日期时间字符串"); + } + // 校验格式 yyyy-MM-dd HH:mm:ss + if (!((String) value).matches("\\d{4}-\\d{2}-\\d{2} \\d{2}:\\d{2}:\\d{2}")) { + throw new BusinessException(fieldName + " 格式应为 yyyy-MM-dd HH:mm:ss"); + } + break; + // 新增:枚举类型校验 + case "Enum" : + + if (!(value instanceof String enumStr)) { + throw new BusinessException(fieldName + " 应传入数字字符串"); + } + + // 使用正则校验:仅包含数字(至少一位) + if (!enumStr.matches("\\d+")) { + throw new BusinessException(fieldName + " 应为非负整数(纯数字字符串)"); + } + break; + + // 可扩展 Date, Array, Object 等 + default: + // 忽略自定义类型 + } + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/util/AESUtil.java b/base/src/main/java/com/tinyengine/it/modeldata/util/AESUtil.java new file mode 100644 index 00000000..8d834d50 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/util/AESUtil.java @@ -0,0 +1,122 @@ +package com.tinyengine.it.modeldata.util; + +import javax.crypto.Cipher; +import javax.crypto.KeyGenerator; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.util.Base64; + +public class AESUtil { + private static final String ALGORITHM = "AES"; + private static final String TRANSFORMATION = "AES/GCM/NoPadding"; + private static final int GCM_TAG_LENGTH = 128; // 认证标签长度(位) + private static final int GCM_IV_LENGTH = 12; // 推荐 12 字节(96 位) + private static final int AES_KEY_SIZE = 256; // 密钥长度 + + private AESUtil() { + // 工具类私有构造 + } + + /** + * 加密数据 + * + * @param plainText 明文(UTF-8 字符串) + * @param keyBase64 Base64 编码的密钥(256 位) + * @return Base64 编码的密文(格式:IV + 密文 + 认证标签) + * @throws Exception 加密失败 + */ + public static String encrypt(String plainText, String keyBase64) throws Exception { + if (plainText == null || keyBase64 == null) { + throw new IllegalArgumentException("明文和密钥不能为空"); + } + + byte[] keyBytes = Base64.getDecoder().decode(keyBase64); + SecretKey secretKey = new SecretKeySpec(keyBytes, ALGORITHM); + + // 生成随机 IV + byte[] iv = new byte[GCM_IV_LENGTH]; + SecureRandom secureRandom = SecureRandom.getInstanceStrong(); + secureRandom.nextBytes(iv); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec); + + byte[] cipherText = cipher.doFinal(plainText.getBytes(StandardCharsets.UTF_8)); + + // 合并 IV + 密文 + byte[] combined = new byte[iv.length + cipherText.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(cipherText, 0, combined, iv.length, cipherText.length); + + return Base64.getEncoder().encodeToString(combined); + } + + /** + * 解密数据 + * + * @param encryptedBase64 Base64 编码的密文(IV + 密文 + 认证标签) + * @param keyBase64 Base64 编码的密钥(必须与加密时一致) + * @return 明文字符串 + * @throws Exception 解密失败(数据被篡改、密钥错误等) + */ + public static String decrypt(String encryptedBase64, String keyBase64) throws Exception { + if (encryptedBase64 == null || keyBase64 == null) { + throw new IllegalArgumentException("密文和密钥不能为空"); + } + + byte[] combined = Base64.getDecoder().decode(encryptedBase64); + if (combined.length < GCM_IV_LENGTH) { + throw new IllegalArgumentException("密文数据长度不足"); + } + + // 分离 IV 和密文 + byte[] iv = new byte[GCM_IV_LENGTH]; + byte[] cipherText = new byte[combined.length - GCM_IV_LENGTH]; + System.arraycopy(combined, 0, iv, 0, GCM_IV_LENGTH); + System.arraycopy(combined, GCM_IV_LENGTH, cipherText, 0, cipherText.length); + + byte[] keyBytes = Base64.getDecoder().decode(keyBase64); + SecretKey secretKey = new SecretKeySpec(keyBytes, ALGORITHM); + + Cipher cipher = Cipher.getInstance(TRANSFORMATION); + GCMParameterSpec spec = new GCMParameterSpec(GCM_TAG_LENGTH, iv); + cipher.init(Cipher.DECRYPT_MODE, secretKey, spec); + + byte[] plainBytes = cipher.doFinal(cipherText); + return new String(plainBytes, StandardCharsets.UTF_8); + } + + /** + * 生成一个 Base64 编码的 AES-256 密钥(用于初始化) + */ + public static String generateKey() throws NoSuchAlgorithmException { + KeyGenerator keyGen = KeyGenerator.getInstance(ALGORITHM); + keyGen.init(AES_KEY_SIZE, SecureRandom.getInstanceStrong()); + SecretKey secretKey = keyGen.generateKey(); + return Base64.getEncoder().encodeToString(secretKey.getEncoded()); + } + + // 测试入口 + public static void main(String[] args) throws Exception { + // 生成密钥(实际使用时从环境变量读取) + String key = generateKey(); + System.out.println("AES Key (Base64): " + key); + + // 加密 + String plain = "sk_123456"; + String encrypted = encrypt(plain, key); + System.out.println("加密后: " + encrypted); + + // 解密 + String decrypted = decrypt(encrypted, key); + System.out.println("解密后: " + decrypted); + + // 篡改测试(会抛出异常) + // System.out.println(decrypt(encrypted.substring(0, encrypted.length()-2), key)); + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/util/CachedBodyHttpServletRequest.java b/base/src/main/java/com/tinyengine/it/modeldata/util/CachedBodyHttpServletRequest.java new file mode 100644 index 00000000..90bf78f5 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/util/CachedBodyHttpServletRequest.java @@ -0,0 +1,37 @@ +package com.tinyengine.it.modeldata.util; + +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletRequestWrapper; +import org.springframework.util.StreamUtils; + +import java.io.BufferedReader; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; + +public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper { + private final byte[] cachedBody; + + public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException { + super(request); + this.cachedBody = StreamUtils.copyToByteArray(request.getInputStream()); + } + + + @Override + public ServletInputStream getInputStream() { + return new CachedBodyServletInputStream(this.cachedBody); + } + @Override + public BufferedReader getReader() { + return new BufferedReader(new InputStreamReader( + new ByteArrayInputStream(this.cachedBody), StandardCharsets.UTF_8 + )); + } + + public String getBodyString() { + return new String(this.cachedBody, StandardCharsets.UTF_8); + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/util/CachedBodyServletInputStream.java b/base/src/main/java/com/tinyengine/it/modeldata/util/CachedBodyServletInputStream.java new file mode 100644 index 00000000..fe64e380 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/util/CachedBodyServletInputStream.java @@ -0,0 +1,29 @@ +package com.tinyengine.it.modeldata.util; + +public class CachedBodyServletInputStream extends jakarta.servlet.ServletInputStream { + private final java.io.ByteArrayInputStream inputStream; + + public CachedBodyServletInputStream(byte[] cachedBody) { + this.inputStream = new java.io.ByteArrayInputStream(cachedBody); + } + + @Override + public int read() { + return inputStream.read(); + } + + @Override + public boolean isFinished() { + return inputStream.available() == 0; + } + + @Override + public boolean isReady() { + return true; + } + + @Override + public void setReadListener(jakarta.servlet.ReadListener readListener) { + throw new UnsupportedOperationException(); + } +} diff --git a/base/src/main/java/com/tinyengine/it/modeldata/util/ModelParser.java b/base/src/main/java/com/tinyengine/it/modeldata/util/ModelParser.java new file mode 100644 index 00000000..d46d13f2 --- /dev/null +++ b/base/src/main/java/com/tinyengine/it/modeldata/util/ModelParser.java @@ -0,0 +1,17 @@ +package com.tinyengine.it.modeldata.util; + +import com.alibaba.fastjson.JSON; +import com.alibaba.fastjson.TypeReference; +import com.tinyengine.it.model.dto.ParametersDto; + +import java.util.Collections; +import java.util.List; + +public class ModelParser { + public static List parseParameters(String parametersJson) { + if (parametersJson == null || parametersJson.trim().isEmpty()) { + return Collections.emptyList(); + } + return JSON.parseObject(parametersJson, new TypeReference>() {}); + } +} diff --git a/base/src/main/java/com/tinyengine/it/service/material/impl/ResourceServiceImpl.java b/base/src/main/java/com/tinyengine/it/service/material/impl/ResourceServiceImpl.java index 63507530..1c276b13 100644 --- a/base/src/main/java/com/tinyengine/it/service/material/impl/ResourceServiceImpl.java +++ b/base/src/main/java/com/tinyengine/it/service/material/impl/ResourceServiceImpl.java @@ -202,6 +202,7 @@ public Resource resourceUpload(Resource resource) { } QueryWrapper queryWrapper = new QueryWrapper<>(); queryWrapper.eq("hash", resource.getHash()); + queryWrapper.eq("tenant_id", resource.getTenantId()); // 接入租户系统需添加租户id查询 Resource resourceResult = this.baseMapper.selectOne(queryWrapper); if (resourceResult != null) { diff --git a/base/src/test/java/com/tinyengine/it/modeldata/service/ApiKeyServiceTest.java b/base/src/test/java/com/tinyengine/it/modeldata/service/ApiKeyServiceTest.java new file mode 100644 index 00000000..fd229451 --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/modeldata/service/ApiKeyServiceTest.java @@ -0,0 +1,81 @@ +package com.tinyengine.it.modeldata.service; + +import com.tinyengine.it.modeldata.dao.ApiKeyRepository; +import com.tinyengine.it.modeldata.entity.ApiKeyEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ApiKeyServiceTest { + @Mock + private ApiKeyRepository repository; + + @InjectMocks + private ApiKeyService apiKeyService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testGetValidKey_ValidKey() { + // Arrange + String key = "validApiKey"; + ApiKeyEntity entity = new ApiKeyEntity(); + + entity.setApiKey(key); + entity.setStatus(1); + entity.setExpireTime(LocalDateTime.now().plusDays(1)); + when(repository.findByApiKeyAndStatus(key, 1)).thenReturn(Optional.of(entity)); + + // Act + ApiKeyEntity result = apiKeyService.getValidKey(key); + + // Assert + assertNotNull(result); + assertEquals(key, result.getApiKey()); + verify(repository, times(1)).findByApiKeyAndStatus(key, 1); + } + + @Test + void testGetValidKey_ExpiredKey() { + // Arrange + String key = "expiredApiKey"; + ApiKeyEntity entity = new ApiKeyEntity(); + entity.setApiKey(key); + entity.setStatus(1); + entity.setExpireTime(LocalDateTime.now().minusDays(1)); + when(repository.findByApiKeyAndStatus(key, 1)).thenReturn(Optional.of(entity)); + + // Act + ApiKeyEntity result = apiKeyService.getValidKey(key); + + // Assert + assertNull(result); + verify(repository, times(1)).findByApiKeyAndStatus(key, 1); + } + + @Test + void testGetValidKey_NonExistentKey() { + // Arrange + String key = "nonExistentKey"; + when(repository.findByApiKeyAndStatus(key, 1)).thenReturn(Optional.empty()); + + // Act + ApiKeyEntity result = apiKeyService.getValidKey(key); + + // Assert + assertNull(result); + verify(repository, times(1)).findByApiKeyAndStatus(key, 1); + } + +} \ No newline at end of file diff --git a/base/src/test/java/com/tinyengine/it/modeldata/service/DynamicModelDataServiceTest.java b/base/src/test/java/com/tinyengine/it/modeldata/service/DynamicModelDataServiceTest.java new file mode 100644 index 00000000..79e7d8a8 --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/modeldata/service/DynamicModelDataServiceTest.java @@ -0,0 +1,194 @@ +package com.tinyengine.it.modeldata.service; + +import com.tinyengine.it.model.dto.ParametersDto; +import com.tinyengine.it.model.entity.Model; +import com.tinyengine.it.modeldata.dao.ModelDataRepository; +import com.tinyengine.it.modeldata.entity.ModelData; +import com.tinyengine.it.modeldata.exception.BusinessException; +import com.tinyengine.it.service.material.ModelService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class DynamicModelDataServiceTest { + @Mock + private ModelService modelService; + + @Mock + private ModelDataRepository modelDataRepo; + + @Mock + private ValidationService validationService; + + @InjectMocks + private DynamicModelDataService dynamicModelDataService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + } + + @Test + void testCreate_Success() throws Exception { + // Arrange + Integer modelId = 1; + String tenantId = "tenant1"; + String userId = "user1"; + Map data = new HashMap<>(); + data.put("field1", "value1"); + + Model model = new Model(); + model.setId(modelId); + model.setVersion("0.01"); + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setDescription("Test field"); + parametersDto.setRequired(true); + parametersDto.setType("String"); + parametersDto.setProp("field1"); + model.setParameters(Collections.singletonList(parametersDto)); + + when(modelService.queryModelById(modelId)).thenReturn(model); + doNothing().when(validationService).validate(data, model.getParameters()); + + ModelData savedEntity = new ModelData(); + savedEntity.setId(1); + when(modelDataRepo.save(any(ModelData.class))).thenReturn(savedEntity); + + // Act + ModelData result = dynamicModelDataService.create(modelId, data, tenantId, userId); + + // Assert + assertNotNull(result); + assertEquals(1, result.getId()); + verify(modelService, times(1)).queryModelById(modelId); + verify(validationService, times(1)).validate(data, model.getParameters()); + verify(modelDataRepo, times(1)).save(any(ModelData.class)); + } + + @Test + void testCreate_ModelNotFound() { + // Arrange + Integer modelId = 1; + when(modelService.queryModelById(modelId)).thenReturn(null); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + dynamicModelDataService.create(modelId, new HashMap<>(), "tenant1", "user1") + ); + assertEquals("模型不存在", exception.getMessage()); + verify(modelService, times(1)).queryModelById(modelId); + } + + @Test + void testUpdate_Success() throws Exception { + // Arrange + Integer dataId = 1; + String userId = "user1"; + Map data = new HashMap<>(); + data.put("field1", "newValue"); + + ModelData existing = new ModelData(); + existing.setId(1); + existing.setModelId(1); + existing.setDataJson(new HashMap<>()); + + Model model = new Model(); + model.setId(1); + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setDescription("Test field"); + parametersDto.setRequired(true); + parametersDto.setType("String"); + parametersDto.setProp("field1"); + model.setParameters(Collections.singletonList(parametersDto)); + + when(modelDataRepo.findById(1L)).thenReturn(Optional.of(existing)); + when(modelService.queryModelById(1)).thenReturn(model); + doNothing().when(validationService).validate(anyMap(), anyList()); + when(modelDataRepo.save(any(ModelData.class))).thenReturn(existing); + + // Act + ModelData result = dynamicModelDataService.update(dataId, data, userId); + + // Assert + assertNotNull(result); + verify(modelDataRepo, times(1)).findById(1L); + verify(modelService, times(1)).queryModelById(1); + verify(validationService, times(1)).validate(anyMap(), anyList()); + verify(modelDataRepo, times(1)).save(any(ModelData.class)); + } + + @Test + void testDelete_Success() { + // Arrange + Long dataId = 1L; + + // Act + dynamicModelDataService.delete(dataId); + + // Assert + verify(modelDataRepo, times(1)).deleteById(dataId); + } + + @Test + void testGet_Success() { + // Arrange + Integer dataId = 1; + ModelData entity = new ModelData(); + entity.setId(dataId); + when(modelDataRepo.findById(Long.valueOf(dataId))).thenReturn(Optional.of(entity)); + + // Act + ModelData result = dynamicModelDataService.get(Long.valueOf(dataId)); + + // Assert + assertNotNull(result); + assertEquals(dataId, result.getId()); + verify(modelDataRepo, times(1)).findById(Long.valueOf(dataId)); + } + + @Test + void testGet_NotFound() { + // Arrange + Long dataId = 1L; + when(modelDataRepo.findById(dataId)).thenReturn(Optional.empty()); + + // Act + ModelData result = dynamicModelDataService.get(dataId); + + // Assert + assertNull(result); + verify(modelDataRepo, times(1)).findById(dataId); + } + + @Test + void testCreate_Failure() throws Exception { + // Arrange + Integer modelId = 1; + String tenantId = "tenant1"; + String userId = "user1"; + Map data = new HashMap<>(); + data.put("field1", "value1"); + + when(modelService.queryModelById(modelId)).thenThrow(new BusinessException("Model service failure")); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + dynamicModelDataService.create(modelId, data, tenantId, userId) + ); + assertEquals("Model service failure", exception.getMessage()); + verify(modelService, times(1)).queryModelById(modelId); + verify(validationService, never()).validate(anyMap(), anyList()); + verify(modelDataRepo, never()).save(any(ModelData.class)); + } + +} \ No newline at end of file diff --git a/base/src/test/java/com/tinyengine/it/modeldata/service/ModelDataCacheServiceTest.java b/base/src/test/java/com/tinyengine/it/modeldata/service/ModelDataCacheServiceTest.java new file mode 100644 index 00000000..63215b7a --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/modeldata/service/ModelDataCacheServiceTest.java @@ -0,0 +1,149 @@ +package com.tinyengine.it.modeldata.service; + +import com.tinyengine.it.modeldata.dao.ModelDataRepository; +import com.tinyengine.it.modeldata.entity.ModelData; +import com.tinyengine.it.modeldata.exception.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.*; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +class ModelDataCacheServiceTest { + + @Mock + private DynamicModelDataService dynamicModelDataService; + + @Mock + private StringRedisTemplate redisTemplate; + + @Mock + private ModelDataRepository modelDataRepo; + + @InjectMocks + private ModelDataCacheService modelDataCacheService; + + @BeforeEach + void setUp() { + MockitoAnnotations.openMocks(this); + when(redisTemplate.delete(anyString())).thenReturn(true); + // Mock ValueOperations + ValueOperations valueOperations = mock(ValueOperations.class); + when(redisTemplate.opsForValue()).thenReturn(valueOperations); + + // Configure behavior for redisTemplate.delete() + when(redisTemplate.delete(anyString())).thenReturn(true); + + // Configure behavior for ValueOperations.get() + when(valueOperations.get(anyString())).thenReturn(null); + + } + + + + @Test + void testAdd_Success() throws Exception { + // Arrange + Integer modelId = 1; + String tenantId = "tenant1"; + String userId = "user1"; + Map data = new HashMap<>(); + data.put("field1", "value1"); + + ModelData mockModelData = new ModelData(); + mockModelData.setId(1); + mockModelData.setVersion("0.01"); + mockModelData.setDataJson(data); + + when(dynamicModelDataService.create(modelId, data, tenantId, userId)).thenReturn(mockModelData); + + // Act + Map result = modelDataCacheService.add(modelId, tenantId, data, userId); + + // Assert + assertNotNull(result); + assertEquals(1, result.get("_id")); + verify(dynamicModelDataService, times(1)).create(modelId, data, tenantId, userId); + verify(redisTemplate, times(1)).delete(anyString()); + } + + @Test + void testUpdate_Success() throws Exception { + // Arrange + Integer dataId = 1; + Integer modelId = 1; + String tenantId = "1"; + String userId = "user1"; + Map newData = new HashMap<>(); + newData.put("field1", "newValue"); + + ModelData mockModelData = new ModelData(); + mockModelData.setId(1); + mockModelData.setVersion("0.0.2"); + mockModelData.setModelId(1); + mockModelData.setDataJson(newData); + mockModelData.setTenantId("1"); + + when(modelDataRepo.findByModelIdAndTenantIdAndId(modelId, tenantId, dataId)).thenReturn(mockModelData); + when(dynamicModelDataService.update(dataId, newData, userId)).thenReturn(mockModelData); + + // Act + Map result = modelDataCacheService.update(modelId,tenantId,dataId, newData, userId); + + // Assert + assertNotNull(result); + assertEquals(1, result.get("_id")); + verify(dynamicModelDataService, times(1)).update(dataId, newData, userId); + verify(redisTemplate, times(1)).delete(anyString()); + } + + @Test + void testDelete_Success() throws Exception { + // Arrange + Integer dataId = 1; + Integer modelId = 1; + String tenantId = "1"; + ModelData mockModelData = new ModelData(); + mockModelData.setId(Math.toIntExact(dataId)); + mockModelData.setModelId(1); + mockModelData.setTenantId("1"); + when(modelDataRepo.findByModelIdAndTenantIdAndId(modelId, tenantId, Math.toIntExact(dataId))).thenReturn(mockModelData); + + // Act + Map result = modelDataCacheService.delete(modelId,tenantId,Long.valueOf(dataId)); + + // Assert + assertNotNull(result); + assertEquals(dataId, result.get("_id")); + verify(modelDataRepo, times(1)).deleteById(Long.valueOf(dataId)); + verify(redisTemplate, times(1)).delete(anyString()); + } + @Test + void testAdd_Failure() throws Exception { + // Arrange + Integer modelId = 1; + String tenantId = "tenant1"; + String userId = "user1"; + Map data = new HashMap<>(); + data.put("field1", "value1"); + + when(dynamicModelDataService.create(modelId, data, tenantId, userId)) + .thenThrow(new BusinessException("Failed to create model data")); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + modelDataCacheService.add(modelId, tenantId, data, userId) + ); + assertEquals("Failed to create model data", exception.getMessage()); + verify(dynamicModelDataService, times(1)).create(modelId, data, tenantId, userId); + verify(redisTemplate, never()).delete(anyString()); + } + +} \ No newline at end of file diff --git a/base/src/test/java/com/tinyengine/it/modeldata/service/ValidationServiceTest.java b/base/src/test/java/com/tinyengine/it/modeldata/service/ValidationServiceTest.java new file mode 100644 index 00000000..06e0b9ee --- /dev/null +++ b/base/src/test/java/com/tinyengine/it/modeldata/service/ValidationServiceTest.java @@ -0,0 +1,222 @@ +package com.tinyengine.it.modeldata.service; + +import com.tinyengine.it.model.dto.ParametersDto; +import com.tinyengine.it.modeldata.exception.BusinessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ValidationServiceTest { + + private ValidationService validationService; + + @BeforeEach + void setUp() { + validationService = new ValidationService(); + } + + @Test + void testValidate_Success() throws Exception { + // Arrange + Map data = new HashMap<>(); + data.put("field1", "value1"); + data.put("field2", 123); + data.put("field3", true); + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setProp("field1"); + parametersDto.setType("String"); + parametersDto.setRequired(true); + + ParametersDto parametersDto2 = new ParametersDto(); + parametersDto2.setProp("field2"); + parametersDto2.setType("Number"); + parametersDto2.setRequired(true); + + + ParametersDto parametersDto3 = new ParametersDto(); + parametersDto3.setProp("field3"); + parametersDto3.setType("Boolean"); + parametersDto3.setRequired(true); + + List fieldDefs = List.of( + parametersDto,parametersDto2,parametersDto3 + ); + + // Act & Assert + assertDoesNotThrow(() -> validationService.validate(data, fieldDefs)); + } + + @Test + void testValidate_MissingRequiredField() { + // Arrange + Map data = new HashMap<>(); + data.put("field1", "value1"); + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setProp("field1"); + parametersDto.setType("String"); + parametersDto.setRequired(true); + + ParametersDto parametersDto2 = new ParametersDto(); + parametersDto2.setProp("field2"); + parametersDto2.setType("Number"); + parametersDto2.setRequired(true); + List fieldDefs = List.of( + parametersDto, + parametersDto2 + ); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + validationService.validate(data, fieldDefs) + ); + assertEquals("缺少必填字段: field2", exception.getMessage()); + } + + @Test + void testValidate_InvalidFieldType() { + // Arrange + Map data = new HashMap<>(); + data.put("field1", 123); // Invalid type, should be String + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setProp("field1"); + parametersDto.setType("String"); + parametersDto.setRequired(true); + List fieldDefs = List.of( + parametersDto + ); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + validationService.validate(data, fieldDefs) + ); + assertEquals("field1 应为字符串类型", exception.getMessage()); + } + + @Test + void testValidate_UndefinedField() { + // Arrange + Map data = new HashMap<>(); + data.put("field1", "value1"); + data.put("undefinedField", "value"); + + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setProp("field1"); + parametersDto.setType("String"); + parametersDto.setRequired(true); + + List fieldDefs = List.of( + parametersDto + ); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + validationService.validate(data, fieldDefs) + ); + assertEquals("未定义的字段: undefinedField", exception.getMessage()); + } + @Test + void testValidate_EmptyData() { + // Arrange + Map data = new HashMap<>(); + + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setProp("field1"); + parametersDto.setType("String"); + parametersDto.setRequired(true); + + List fieldDefs = List.of( + parametersDto + ); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + validationService.validate(data, fieldDefs) + ); + assertEquals("数据不能为空", exception.getMessage()); + } + + @Test + void testValidate_EmptyFieldDefinitions() { + // Arrange + Map data = new HashMap<>(); + data.put("field1", "value1"); + + List fieldDefs = Collections.emptyList(); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + validationService.validate(data, fieldDefs) + ); + assertEquals("字段定义不能为空", exception.getMessage()); + } + + @Test + void testValidate_NullData() { + // Arrange + Map data = null; + + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setProp("field1"); + parametersDto.setType("String"); + parametersDto.setRequired(true); + + List fieldDefs = List.of( + parametersDto + ); + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + validationService.validate(data, fieldDefs) + ); + assertEquals("数据不能为空", exception.getMessage()); + } + + @Test + void testValidate_NullFieldDefinitions() { + // Arrange + Map data = new HashMap<>(); + data.put("field1", "value1"); + + List fieldDefs = null; + + // Act & Assert + BusinessException exception = assertThrows(BusinessException.class, () -> + validationService.validate(data, fieldDefs) + ); + assertEquals("字段定义不能为空", exception.getMessage()); + } + + @Test + void testValidate_OptionalField() { + // Arrange + Map data = new HashMap<>(); + data.put("field1", "value1"); + data.put("field2", 18); + + + ParametersDto parametersDto = new ParametersDto(); + parametersDto.setProp("field1"); + parametersDto.setType("String"); + parametersDto.setRequired(true); + + ParametersDto parametersDto2 = new ParametersDto(); + parametersDto2.setProp("field2"); + parametersDto2.setType("Number"); + parametersDto2.setRequired(true); + + List fieldDefs = List.of( + parametersDto,parametersDto2 + ); + + + + // Act & Assert + assertDoesNotThrow(() -> validationService.validate(data, fieldDefs)); + } +} \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index 27a0025d..8d9a8e30 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -28,6 +28,25 @@ services: - PERSIST_DIRECTORY=/data - ANONYMIZED_TELEMETRY=FALSE + tiny-engine-redis: + image: redis:7-alpine + container_name: tiny-engine-redis + restart: always + ports: + - "6379:6379" + environment: + TZ: Asia/Shanghai + volumes: + - ./docker-deploy-data/redis/data/:/data + - ./docker-deploy-data/redis/conf/:/usr/local/etc/redis/ + - ./docker-deploy-data/redis/logs/:/logs/ + command: > + redis-server + --appendonly yes + --maxmemory 256mb + --maxmemory-policy allkeys-lru + --logfile /logs/redis.log + tiny-engine-back: image: tiny-engine-back container_name: tiny-engine-back @@ -39,11 +58,13 @@ services: depends_on: - tiny-engine-data - tiny-engine-rag + - tiny-engine-redis environment: - CHROMA_BASE_URL=http://tiny-engine-rag:8000 - MODEL_PATH=/app/all-MiniLM-L6-v2/model.onnx - TOKENIZER_PATH=/app/all-MiniLM-L6-v2/tokenizer.json + tiny-engine: image: tiny-engine container_name: tiny-engine diff --git a/pom.xml b/pom.xml index c651a73c..dd1ccead 100644 --- a/pom.xml +++ b/pom.xml @@ -10,7 +10,7 @@ org.springframework.boot spring-boot-starter-parent - 3.1.9 + 3.2.4 @@ -28,7 +28,6 @@ 3.5.7 3.3.3 2.3.32 - 2.5.4 2.5.0 4.11.0 6.1.0 @@ -38,7 +37,6 @@ 0.12.3 1.5.0 1.5.0-beta11 - 1.79 1.0-SNAPSHOT @@ -56,6 +54,31 @@ org.springframework.boot spring-boot-starter-data-jpa + + mysql + mysql-connector-java + 8.0.23 + + + + org.springframework.boot + spring-boot-starter-data-redis + + + io.lettuce + lettuce-core + + + + + redis.clients + jedis + + + + org.apache.commons + commons-pool2 + org.bouncycastle @@ -65,7 +88,7 @@ com.alibaba - druid-spring-boot-starter + druid-spring-boot-3-starter ${druid-spring-boot-starter.version} @@ -78,7 +101,7 @@ com.baomidou - mybatis-plus-boot-starter + mybatis-plus-spring-boot3-starter ${mybatis-plus.version} @@ -175,6 +198,15 @@ spring-boot-starter-test test + + + org.hibernate + hibernate-core + 6.3.2.Final + + + + org.mockito @@ -189,6 +221,7 @@ ${netty-buffer.version} + io.jsonwebtoken jjwt @@ -221,6 +254,7 @@ ${tiny-engine.version} +