diff --git a/src/main/java/com/ycwl/basic/mapper/SourceMapper.java b/src/main/java/com/ycwl/basic/mapper/SourceMapper.java index 70f556aa..55fe26b7 100644 --- a/src/main/java/com/ycwl/basic/mapper/SourceMapper.java +++ b/src/main/java/com/ycwl/basic/mapper/SourceMapper.java @@ -107,4 +107,28 @@ public interface SourceMapper { int addFromZTSource(SourceEntity source); SourceEntity getBySampleIdAndType(Long faceSampleId, Integer type); + + /** + * 统计faceId关联的不同设备数量 + * @param faceId 人脸ID + * @return 设备数量 + */ + Integer countDistinctDevicesByFaceId(Long faceId); + + /** + * 根据faceId和设备索引获取source + * @param faceId 人脸ID + * @param deviceIndex 设备索引(从0开始) + * @param type 素材类型(1-视频,2-图片) + * @param sortStrategy 排序策略 + * @return source实体 + */ + SourceEntity getSourceByFaceAndDeviceIndex(Long faceId, Integer deviceIndex, Integer type, String sortStrategy); + + /** + * 获取faceId关联的所有设备ID列表 + * @param faceId 人脸ID + * @return 设备ID列表 + */ + List getDeviceIdsByFaceId(Long faceId); } diff --git a/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleFillRuleController.java b/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleFillRuleController.java new file mode 100644 index 00000000..64db3e8a --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/controller/PuzzleFillRuleController.java @@ -0,0 +1,117 @@ +package com.ycwl.basic.puzzle.controller; + +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO; +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest; +import com.ycwl.basic.puzzle.service.IPuzzleFillRuleService; +import com.ycwl.basic.utils.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 拼图填充规则管理Controller + * + * @author Claude + * @since 2025-01-19 + */ +@Slf4j +@RestController +@RequestMapping("/api/puzzle/admin/fill-rule") +@RequiredArgsConstructor +public class PuzzleFillRuleController { + + private final IPuzzleFillRuleService fillRuleService; + + /** + * 创建填充规则 + * + * @param request 保存请求(包含主规则+明细列表) + * @return 规则ID + */ + @PostMapping + public ApiResponse create(@RequestBody PuzzleFillRuleSaveRequest request) { + log.info("创建填充规则, ruleName={}, itemCount={}", + request.getRuleName(), + request.getItems() != null ? request.getItems().size() : 0); + + Long ruleId = fillRuleService.create(request); + return ApiResponse.success(ruleId); + } + + /** + * 更新填充规则 + * + * @param id 规则ID + * @param request 保存请求 + * @return 是否成功 + */ + @PutMapping("/{id}") + public ApiResponse update(@PathVariable Long id, + @RequestBody PuzzleFillRuleSaveRequest request) { + log.info("更新填充规则, ruleId={}, ruleName={}", id, request.getRuleName()); + + request.setId(id); + Boolean success = fillRuleService.update(request); + return ApiResponse.success(success); + } + + /** + * 删除填充规则 + * + * @param id 规则ID + * @return 是否成功 + */ + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + log.info("删除填充规则, ruleId={}", id); + + Boolean success = fillRuleService.delete(id); + return ApiResponse.success(success); + } + + /** + * 查询单条规则(含明细) + * + * @param id 规则ID + * @return 规则DTO + */ + @GetMapping("/{id}") + public ApiResponse getById(@PathVariable Long id) { + log.info("查询填充规则, ruleId={}", id); + + PuzzleFillRuleDTO rule = fillRuleService.getById(id); + return ApiResponse.success(rule); + } + + /** + * 查询模板的所有规则(含明细) + * + * @param templateId 模板ID + * @return 规则列表 + */ + @GetMapping("/template/{templateId}") + public ApiResponse> listByTemplateId(@PathVariable Long templateId) { + log.info("查询模板的所有填充规则, templateId={}", templateId); + + List rules = fillRuleService.listByTemplateId(templateId); + return ApiResponse.success(rules); + } + + /** + * 启用/禁用规则 + * + * @param id 规则ID + * @param enabled 是否启用(0-禁用 1-启用) + * @return 是否成功 + */ + @PostMapping("/{id}/toggle") + public ApiResponse toggleEnabled(@PathVariable Long id, + @RequestParam Integer enabled) { + log.info("切换规则启用状态, ruleId={}, enabled={}", id, enabled); + + Boolean success = fillRuleService.toggleEnabled(id, enabled); + return ApiResponse.success(success); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleDTO.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleDTO.java new file mode 100644 index 00000000..383ac2d0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleDTO.java @@ -0,0 +1,73 @@ +package com.ycwl.basic.puzzle.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 拼图填充规则DTO + */ +@Data +public class PuzzleFillRuleDTO { + + /** + * 规则ID + */ + private Long id; + + /** + * 关联的模板ID + */ + private Long templateId; + + /** + * 规则名称 + */ + private String ruleName; + + /** + * 条件类型 + */ + private String conditionType; + + /** + * 条件值(JSON字符串) + */ + private String conditionValue; + + /** + * 优先级 + */ + private Integer priority; + + /** + * 是否启用 + */ + private Integer enabled; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 规则描述 + */ + private String description; + + /** + * 明细列表 + */ + private List items; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleItemDTO.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleItemDTO.java new file mode 100644 index 00000000..7f7065bd --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleItemDTO.java @@ -0,0 +1,45 @@ +package com.ycwl.basic.puzzle.dto; + +import lombok.Data; + +/** + * 拼图填充规则明细DTO + */ +@Data +public class PuzzleFillRuleItemDTO { + + /** + * 明细ID + */ + private Long id; + + /** + * 目标元素标识 + */ + private String elementKey; + + /** + * 数据源类型 + */ + private String dataSource; + + /** + * 数据过滤条件(JSON字符串) + */ + private String sourceFilter; + + /** + * 排序策略 + */ + private String sortStrategy; + + /** + * 降级默认值 + */ + private String fallbackValue; + + /** + * 明细排序 + */ + private Integer itemOrder; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleSaveRequest.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleSaveRequest.java new file mode 100644 index 00000000..18b78e0e --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleFillRuleSaveRequest.java @@ -0,0 +1,63 @@ +package com.ycwl.basic.puzzle.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 拼图填充规则保存请求 + * 包含主规则+明细列表 + */ +@Data +public class PuzzleFillRuleSaveRequest { + + /** + * 规则ID(更新时传入) + */ + private Long id; + + /** + * 关联的模板ID + */ + private Long templateId; + + /** + * 规则名称 + */ + private String ruleName; + + /** + * 条件类型 + */ + private String conditionType; + + /** + * 条件值(JSON字符串) + */ + private String conditionValue; + + /** + * 优先级 + */ + private Integer priority; + + /** + * 是否启用 + */ + private Integer enabled; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 规则描述 + */ + private String description; + + /** + * 明细列表(主从一起保存) + */ + private List items; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateRequest.java b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateRequest.java index 7337c767..2f9328d3 100644 --- a/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateRequest.java +++ b/src/main/java/com/ycwl/basic/puzzle/dto/PuzzleGenerateRequest.java @@ -38,9 +38,15 @@ public class PuzzleGenerateRequest { */ private Long scenicId; + /** + * 人脸ID(可选,用于触发自动填充规则) + */ + private Long faceId; + /** * 动态数据(key为元素的elementKey,value为实际值) * 例如:{"userAvatar": "https://...", "userName": "张三", "orderNumber": "ORDER123"} + * 注意:手动传入的dynamicData优先级高于自动填充的数据 */ private Map dynamicData; diff --git a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleFillRuleEntity.java b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleFillRuleEntity.java new file mode 100644 index 00000000..bbcf0874 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleFillRuleEntity.java @@ -0,0 +1,79 @@ +package com.ycwl.basic.puzzle.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableLogic; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 拼图自动填充规则实体 + */ +@Data +@TableName("puzzle_fill_rule") +public class PuzzleFillRuleEntity { + + /** + * 规则ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 关联的模板ID + */ + private Long templateId; + + /** + * 规则名称 + */ + private String ruleName; + + /** + * 条件类型: DEVICE_COUNT-机位数量 + */ + private String conditionType; + + /** + * 条件值(JSON格式) + */ + private String conditionValue; + + /** + * 优先级(数值越大越优先) + */ + private Integer priority; + + /** + * 是否启用(0-否 1-是) + */ + private Integer enabled; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 规则描述 + */ + private String description; + + /** + * 删除标记(0-未删除 1-已删除) + */ + @TableLogic + private Integer deleted; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleFillRuleItemEntity.java b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleFillRuleItemEntity.java new file mode 100644 index 00000000..54225d78 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/entity/PuzzleFillRuleItemEntity.java @@ -0,0 +1,67 @@ +package com.ycwl.basic.puzzle.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.time.LocalDateTime; + +/** + * 拼图自动填充规则明细实体 + */ +@Data +@TableName("puzzle_fill_rule_item") +public class PuzzleFillRuleItemEntity { + + /** + * 明细ID + */ + @TableId(type = IdType.AUTO) + private Long id; + + /** + * 关联的规则ID + */ + private Long ruleId; + + /** + * 目标元素标识 + */ + private String elementKey; + + /** + * 数据源类型: FACE_URL, SOURCE_IMAGE, DEVICE_IMAGE等 + */ + private String dataSource; + + /** + * 数据过滤条件(JSON格式) + */ + private String sourceFilter; + + /** + * 排序策略: LATEST-最新, SCORE_DESC-分数降序 + */ + private String sortStrategy; + + /** + * 降级默认值 + */ + private String fallbackValue; + + /** + * 明细排序 + */ + private Integer itemOrder; + + /** + * 创建时间 + */ + private LocalDateTime createTime; + + /** + * 更新时间 + */ + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngine.java b/src/main/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngine.java new file mode 100644 index 00000000..136a9fa3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngine.java @@ -0,0 +1,137 @@ +package com.ycwl.basic.puzzle.fill; + +import com.ycwl.basic.mapper.SourceMapper; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity; +import com.ycwl.basic.puzzle.fill.condition.ConditionContext; +import com.ycwl.basic.puzzle.fill.condition.ConditionEvaluator; +import com.ycwl.basic.puzzle.fill.datasource.DataSourceContext; +import com.ycwl.basic.puzzle.fill.datasource.DataSourceResolver; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * 拼图元素自动填充引擎 + * 核心业务逻辑: + * 1. 查询模板的所有规则(按priority DESC) + * 2. 遍历规则,评估条件是否匹配 + * 3. 匹配成功后,批量填充该规则的所有明细项 + * 4. 匹配第一条后停止(优先级逻辑) + */ +@Slf4j +@Component +public class PuzzleElementFillEngine { + + @Autowired + private PuzzleFillRuleMapper ruleMapper; + + @Autowired + private PuzzleFillRuleItemMapper itemMapper; + + @Autowired + private SourceMapper sourceMapper; + + @Autowired + private ConditionEvaluator conditionEvaluator; + + @Autowired + private DataSourceResolver dataSourceResolver; + + /** + * 执行填充规则 + * + * @param templateId 模板ID + * @param faceId 人脸ID + * @param scenicId 景区ID + * @return 填充后的dynamicData + */ + public Map execute(Long templateId, Long faceId, Long scenicId) { + Map dynamicData = new HashMap<>(); + + try { + // 1. 查询模板的所有启用规则(按priority DESC排序) + List rules = ruleMapper.listByTemplateAndScenic(templateId, scenicId); + if (rules == null || rules.isEmpty()) { + log.debug("模板[{}]没有配置自动填充规则", templateId); + return dynamicData; + } + + log.info("模板[{}]共有{}条填充规则,开始执行...", templateId, rules.size()); + + // 2. 统计机位数量和获取机位列表(缓存,避免重复查询) + Integer deviceCount = sourceMapper.countDistinctDevicesByFaceId(faceId); + List deviceIds = sourceMapper.getDeviceIdsByFaceId(faceId); + log.debug("faceId[{}]关联机位数量: {}, 机位列表: {}", faceId, deviceCount, deviceIds); + + // 3. 构建条件评估上下文 + ConditionContext conditionContext = ConditionContext.builder() + .faceId(faceId) + .scenicId(scenicId) + .deviceCount(deviceCount) + .deviceIds(deviceIds) + .build(); + + // 4. 遍历规则,匹配第一条后停止 + for (PuzzleFillRuleEntity rule : rules) { + // 评估条件是否匹配 + boolean matched = conditionEvaluator.evaluate(rule, conditionContext); + + if (!matched) { + log.debug("规则[{}]条件不匹配,跳过", rule.getRuleName()); + continue; + } + + // 条件匹配!查询该规则的所有明细 + log.info("规则[{}]匹配成功,开始执行填充...", rule.getRuleName()); + List items = itemMapper.listByRuleId(rule.getId()); + + if (items == null || items.isEmpty()) { + log.warn("规则[{}]没有配置明细项", rule.getRuleName()); + break; + } + + // 5. 批量填充dynamicData + DataSourceContext dataSourceContext = DataSourceContext.builder() + .faceId(faceId) + .scenicId(scenicId) + .build(); + + int successCount = 0; + for (PuzzleFillRuleItemEntity item : items) { + String value = dataSourceResolver.resolve( + item.getDataSource(), + item.getSourceFilter(), + item.getSortStrategy(), + item.getFallbackValue(), + dataSourceContext + ); + + if (value != null && !value.isEmpty()) { + dynamicData.put(item.getElementKey(), value); + successCount++; + log.debug("填充成功: {} -> {}", item.getElementKey(), value); + } else { + log.debug("填充失败(值为空): {}", item.getElementKey()); + } + } + + log.info("规则[{}]执行完成,成功填充{}/{}个元素", rule.getRuleName(), successCount, items.size()); + + // 6. 匹配第一条后停止 + break; + } + + } catch (Exception e) { + log.error("自动填充引擎执行异常, templateId={}, faceId={}", templateId, faceId, e); + } + + return dynamicData; + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/condition/AlwaysConditionStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/condition/AlwaysConditionStrategy.java new file mode 100644 index 00000000..5212c361 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/condition/AlwaysConditionStrategy.java @@ -0,0 +1,22 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.puzzle.fill.enums.ConditionType; +import org.springframework.stereotype.Component; + +/** + * 总是匹配策略(兜底规则) + */ +@Component +public class AlwaysConditionStrategy implements ConditionStrategy { + + @Override + public boolean evaluate(JsonNode conditionValue, ConditionContext context) { + return true; + } + + @Override + public String getSupportedType() { + return ConditionType.ALWAYS.getCode(); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionContext.java b/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionContext.java new file mode 100644 index 00000000..059eeb8e --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionContext.java @@ -0,0 +1,40 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import lombok.Builder; +import lombok.Data; + +import java.util.List; + +/** + * 条件评估上下文 + * 包含评估所需的各种运行时数据 + */ +@Data +@Builder +public class ConditionContext { + + /** + * 人脸ID + */ + private Long faceId; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 机位数量(缓存值,避免重复查询) + */ + private Integer deviceCount; + + /** + * 机位ID列表(用于精确匹配指定的机位) + */ + private List deviceIds; + + /** + * 可扩展的其他上下文数据 + */ + private Object extra; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluator.java b/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluator.java new file mode 100644 index 00000000..f17f9fdc --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluator.java @@ -0,0 +1,64 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 条件评估器 + * 使用策略模式,根据conditionType动态选择评估策略 + */ +@Slf4j +@Component +public class ConditionEvaluator { + + private final Map strategyMap; + private final ObjectMapper objectMapper; + + @Autowired + public ConditionEvaluator(List strategies, ObjectMapper objectMapper) { + this.strategyMap = strategies.stream() + .collect(Collectors.toMap( + ConditionStrategy::getSupportedType, + Function.identity() + )); + this.objectMapper = objectMapper; + log.info("初始化条件评估器,已注册{}个策略: {}", strategyMap.size(), strategyMap.keySet()); + } + + /** + * 评估规则条件是否匹配 + * + * @param rule 规则实体 + * @param context 评估上下文 + * @return true-匹配, false-不匹配 + */ + public boolean evaluate(PuzzleFillRuleEntity rule, ConditionContext context) { + String conditionType = rule.getConditionType(); + ConditionStrategy strategy = strategyMap.get(conditionType); + + if (strategy == null) { + log.warn("未找到条件类型[{}]对应的评估策略,规则ID:{}", conditionType, rule.getId()); + return false; + } + + try { + JsonNode conditionValue = objectMapper.readTree(rule.getConditionValue()); + boolean result = strategy.evaluate(conditionValue, context); + log.debug("规则[{}]条件评估结果: {}, 条件类型: {}, 条件值: {}", + rule.getRuleName(), result, conditionType, rule.getConditionValue()); + return result; + } catch (Exception e) { + log.error("规则[{}]条件评估异常,规则ID:{}", rule.getRuleName(), rule.getId(), e); + return false; + } + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionStrategy.java new file mode 100644 index 00000000..9f3fef6b --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/condition/ConditionStrategy.java @@ -0,0 +1,24 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * 条件评估策略接口 + * 使用策略模式,每种条件类型实现独立的评估逻辑,方便测试和扩展 + */ +public interface ConditionStrategy { + + /** + * 评估条件是否匹配 + * + * @param conditionValue 条件值(JSON) + * @param context 评估上下文 + * @return true-匹配, false-不匹配 + */ + boolean evaluate(JsonNode conditionValue, ConditionContext context); + + /** + * 获取支持的条件类型 + */ + String getSupportedType(); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountConditionStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountConditionStrategy.java new file mode 100644 index 00000000..918a0905 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountConditionStrategy.java @@ -0,0 +1,33 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.puzzle.fill.enums.ConditionType; +import org.springframework.stereotype.Component; + +/** + * 机位数量精确匹配策略 + */ +@Component +public class DeviceCountConditionStrategy implements ConditionStrategy { + + @Override + public boolean evaluate(JsonNode conditionValue, ConditionContext context) { + if (conditionValue == null || !conditionValue.has("deviceCount")) { + return false; + } + + int expectedCount = conditionValue.get("deviceCount").asInt(); + Integer actualCount = context.getDeviceCount(); + + if (actualCount == null) { + return false; + } + + return actualCount == expectedCount; + } + + @Override + public String getSupportedType() { + return ConditionType.DEVICE_COUNT.getCode(); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountRangeConditionStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountRangeConditionStrategy.java new file mode 100644 index 00000000..c770b1dc --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountRangeConditionStrategy.java @@ -0,0 +1,42 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.puzzle.fill.enums.ConditionType; +import org.springframework.stereotype.Component; + +/** + * 机位数量范围匹配策略 + */ +@Component +public class DeviceCountRangeConditionStrategy implements ConditionStrategy { + + @Override + public boolean evaluate(JsonNode conditionValue, ConditionContext context) { + if (conditionValue == null) { + return false; + } + + Integer actualCount = context.getDeviceCount(); + if (actualCount == null) { + return false; + } + + Integer minCount = conditionValue.has("minCount") ? conditionValue.get("minCount").asInt() : null; + Integer maxCount = conditionValue.has("maxCount") ? conditionValue.get("maxCount").asInt() : null; + + if (minCount != null && actualCount < minCount) { + return false; + } + + if (maxCount != null && actualCount > maxCount) { + return false; + } + + return true; + } + + @Override + public String getSupportedType() { + return ConditionType.DEVICE_COUNT_RANGE.getCode(); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceIdMatchConditionStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceIdMatchConditionStrategy.java new file mode 100644 index 00000000..bd873dac --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/condition/DeviceIdMatchConditionStrategy.java @@ -0,0 +1,88 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * 机位ID匹配条件策略 + * 判断指定的机位ID是否在当前上下文的机位列表中 + * + * 支持两种匹配模式: + * 1. 单个机位匹配: {"deviceId": 123} + * 2. 多个机位匹配: {"deviceIds": [123, 456], "matchMode": "ALL"/"ANY"} + * - ALL: 所有指定的机位都必须存在 + * - ANY: 至少存在一个指定的机位(默认) + */ +@Slf4j +@Component +public class DeviceIdMatchConditionStrategy implements ConditionStrategy { + + @Override + public boolean evaluate(JsonNode conditionValue, ConditionContext context) { + if (conditionValue == null) { + log.warn("DEVICE_ID_MATCH条件值为null"); + return false; + } + + List contextDeviceIds = context.getDeviceIds(); + if (contextDeviceIds == null || contextDeviceIds.isEmpty()) { + log.debug("上下文中没有机位ID列表"); + return false; + } + + // 单个机位匹配模式 + if (conditionValue.has("deviceId")) { + Long requiredDeviceId = conditionValue.get("deviceId").asLong(); + boolean matched = contextDeviceIds.contains(requiredDeviceId); + log.debug("单机位匹配: deviceId={}, matched={}", requiredDeviceId, matched); + return matched; + } + + // 多个机位匹配模式 + if (conditionValue.has("deviceIds")) { + JsonNode deviceIdsNode = conditionValue.get("deviceIds"); + if (!deviceIdsNode.isArray()) { + log.warn("deviceIds字段必须是数组"); + return false; + } + + List requiredDeviceIds = new ArrayList<>(); + deviceIdsNode.forEach(node -> requiredDeviceIds.add(node.asLong())); + + if (requiredDeviceIds.isEmpty()) { + log.warn("deviceIds数组为空"); + return false; + } + + // 获取匹配模式,默认为ANY + String matchMode = conditionValue.has("matchMode") + ? conditionValue.get("matchMode").asText() + : "ANY"; + + boolean matched; + if ("ALL".equalsIgnoreCase(matchMode)) { + // ALL模式: 所有指定的机位都必须存在 + matched = contextDeviceIds.containsAll(requiredDeviceIds); + log.debug("多机位ALL匹配: requiredDeviceIds={}, matched={}", requiredDeviceIds, matched); + } else { + // ANY模式: 至少存在一个指定的机位 + matched = requiredDeviceIds.stream().anyMatch(contextDeviceIds::contains); + log.debug("多机位ANY匹配: requiredDeviceIds={}, matched={}", requiredDeviceIds, matched); + } + + return matched; + } + + log.warn("DEVICE_ID_MATCH条件缺少deviceId或deviceIds字段"); + return false; + } + + @Override + public String getSupportedType() { + return "DEVICE_ID_MATCH"; + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceContext.java b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceContext.java new file mode 100644 index 00000000..aeffe286 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceContext.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import lombok.Builder; +import lombok.Data; + +/** + * 数据源解析上下文 + */ +@Data +@Builder +public class DataSourceContext { + + /** + * 人脸ID + */ + private Long faceId; + + /** + * 景区ID + */ + private Long scenicId; + + /** + * 可扩展的其他上下文数据 + */ + private Object extra; +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceResolver.java b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceResolver.java new file mode 100644 index 00000000..791cf548 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceResolver.java @@ -0,0 +1,77 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +/** + * 数据源解析器 + * 使用策略模式,根据dataSource类型动态选择解析策略 + */ +@Slf4j +@Component +public class DataSourceResolver { + + private final Map strategyMap; + private final ObjectMapper objectMapper; + + @Autowired + public DataSourceResolver(List strategies, ObjectMapper objectMapper) { + this.strategyMap = strategies.stream() + .collect(Collectors.toMap( + DataSourceStrategy::getSupportedType, + Function.identity() + )); + this.objectMapper = objectMapper; + log.info("初始化数据源解析器,已注册{}个策略: {}", strategyMap.size(), strategyMap.keySet()); + } + + /** + * 解析数据源,返回填充值 + * + * @param dataSource 数据源类型 + * @param sourceFilterJson 过滤条件(JSON字符串) + * @param sortStrategy 排序策略 + * @param fallbackValue 降级默认值 + * @param context 解析上下文 + * @return 填充值 + */ + public String resolve(String dataSource, + String sourceFilterJson, + String sortStrategy, + String fallbackValue, + DataSourceContext context) { + DataSourceStrategy strategy = strategyMap.get(dataSource); + + if (strategy == null) { + log.warn("未找到数据源类型[{}]对应的解析策略", dataSource); + return fallbackValue; + } + + try { + JsonNode sourceFilter = null; + if (sourceFilterJson != null && !sourceFilterJson.isEmpty()) { + sourceFilter = objectMapper.readTree(sourceFilterJson); + } + + String value = strategy.resolve(sourceFilter, sortStrategy, context); + + if (value == null || value.isEmpty()) { + log.debug("数据源[{}]解析结果为空,使用降级值: {}", dataSource, fallbackValue); + return fallbackValue; + } + + return value; + } catch (Exception e) { + log.error("数据源[{}]解析异常,使用降级值: {}", dataSource, fallbackValue, e); + return fallbackValue; + } + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceStrategy.java new file mode 100644 index 00000000..348d552f --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DataSourceStrategy.java @@ -0,0 +1,25 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; + +/** + * 数据源解析策略接口 + * 使用策略模式,每种数据源类型实现独立的解析逻辑 + */ +public interface DataSourceStrategy { + + /** + * 解析数据源,返回填充值 + * + * @param sourceFilter 数据源过滤条件(JSON) + * @param sortStrategy 排序策略 + * @param context 解析上下文 + * @return 填充值(通常是URL) + */ + String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context); + + /** + * 获取支持的数据源类型 + */ + String getSupportedType(); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DeviceImageDataSourceStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DeviceImageDataSourceStrategy.java new file mode 100644 index 00000000..86cd7560 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/DeviceImageDataSourceStrategy.java @@ -0,0 +1,65 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.mapper.SourceMapper; +import com.ycwl.basic.model.pc.source.entity.SourceEntity; +import com.ycwl.basic.puzzle.fill.enums.DataSourceType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 设备图片数据源策略 + * 根据deviceIndex指定第N个设备的图片 + */ +@Slf4j +@Component +public class DeviceImageDataSourceStrategy implements DataSourceStrategy { + + @Autowired + private SourceMapper sourceMapper; + + @Override + public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) { + try { + // 默认type=2(图片) + Integer type = 2; + if (sourceFilter != null && sourceFilter.has("type")) { + type = sourceFilter.get("type").asInt(); + } + + // 获取deviceIndex + Integer deviceIndex = 0; + if (sourceFilter != null && sourceFilter.has("deviceIndex")) { + deviceIndex = sourceFilter.get("deviceIndex").asInt(); + } + + // 使用默认策略 + if (sortStrategy == null || sortStrategy.isEmpty()) { + sortStrategy = "LATEST"; + } + + SourceEntity source = sourceMapper.getSourceByFaceAndDeviceIndex( + context.getFaceId(), + deviceIndex, + type, + sortStrategy + ); + + if (source != null) { + String url = type == 1 ? source.getVideoUrl() : source.getUrl(); + log.debug("解析DEVICE_IMAGE成功, faceId={}, deviceIndex={}, type={}, url={}", + context.getFaceId(), deviceIndex, type, url); + return url; + } + } catch (Exception e) { + log.error("解析DEVICE_IMAGE异常, faceId={}", context.getFaceId(), e); + } + return null; + } + + @Override + public String getSupportedType() { + return DataSourceType.DEVICE_IMAGE.getCode(); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/FaceUrlDataSourceStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/FaceUrlDataSourceStrategy.java new file mode 100644 index 00000000..1a76c0de --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/FaceUrlDataSourceStrategy.java @@ -0,0 +1,39 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.mapper.FaceMapper; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import com.ycwl.basic.puzzle.fill.enums.DataSourceType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +/** + * 人脸URL数据源策略 + */ +@Slf4j +@Component +public class FaceUrlDataSourceStrategy implements DataSourceStrategy { + + @Autowired + private FaceMapper faceMapper; + + @Override + public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) { + try { + FaceEntity face = faceMapper.get(context.getFaceId()); + if (face != null && face.getFaceUrl() != null) { + log.debug("解析FACE_URL成功, faceId={}, url={}", context.getFaceId(), face.getFaceUrl()); + return face.getFaceUrl(); + } + } catch (Exception e) { + log.error("解析FACE_URL异常, faceId={}", context.getFaceId(), e); + } + return null; + } + + @Override + public String getSupportedType() { + return DataSourceType.FACE_URL.getCode(); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategy.java new file mode 100644 index 00000000..c25090e9 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategy.java @@ -0,0 +1,30 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.puzzle.fill.enums.DataSourceType; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 静态值数据源策略 + * 直接返回配置的静态值 + */ +@Slf4j +@Component +public class StaticValueDataSourceStrategy implements DataSourceStrategy { + + @Override + public String resolve(JsonNode sourceFilter, String sortStrategy, DataSourceContext context) { + if (sourceFilter != null && sourceFilter.has("value")) { + String value = sourceFilter.get("value").asText(); + log.debug("解析STATIC_VALUE成功, value={}", value); + return value; + } + return null; + } + + @Override + public String getSupportedType() { + return DataSourceType.STATIC_VALUE.getCode(); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/enums/ConditionType.java b/src/main/java/com/ycwl/basic/puzzle/fill/enums/ConditionType.java new file mode 100644 index 00000000..a6857f36 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/enums/ConditionType.java @@ -0,0 +1,50 @@ +package com.ycwl.basic.puzzle.fill.enums; + +import lombok.Getter; + +/** + * 填充规则条件类型枚举 + */ +@Getter +public enum ConditionType { + + /** + * 机位数量匹配 + */ + DEVICE_COUNT("DEVICE_COUNT", "机位数量匹配"), + + /** + * 机位数量范围 + */ + DEVICE_COUNT_RANGE("DEVICE_COUNT_RANGE", "机位数量范围"), + + /** + * 机位ID精确匹配 + */ + DEVICE_ID_MATCH("DEVICE_ID_MATCH", "机位ID精确匹配"), + + /** + * 总是匹配(兜底规则) + */ + ALWAYS("ALWAYS", "总是匹配"); + + private final String code; + private final String description; + + ConditionType(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 根据code获取枚举 + */ + public static ConditionType fromCode(String code) { + for (ConditionType type : values()) { + if (type.code.equals(code)) { + return type; + } + } + throw new IllegalArgumentException("未知的条件类型: " + code); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/enums/DataSourceType.java b/src/main/java/com/ycwl/basic/puzzle/fill/enums/DataSourceType.java new file mode 100644 index 00000000..330fa7da --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/enums/DataSourceType.java @@ -0,0 +1,55 @@ +package com.ycwl.basic.puzzle.fill.enums; + +import lombok.Getter; + +/** + * 数据源类型枚举 + */ +@Getter +public enum DataSourceType { + + /** + * 人脸URL(来自face表的face_url) + */ + FACE_URL("FACE_URL", "人脸URL"), + + /** + * 素材图片(来自source表,type=2) + */ + SOURCE_IMAGE("SOURCE_IMAGE", "素材图片"), + + /** + * 素材视频(来自source表,type=1) + */ + SOURCE_VIDEO("SOURCE_VIDEO", "素材视频"), + + /** + * 设备图片(根据deviceIndex指定第N个设备的图片) + */ + DEVICE_IMAGE("DEVICE_IMAGE", "设备图片"), + + /** + * 静态值(直接使用fallbackValue) + */ + STATIC_VALUE("STATIC_VALUE", "静态值"); + + private final String code; + private final String description; + + DataSourceType(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 根据code获取枚举 + */ + public static DataSourceType fromCode(String code) { + for (DataSourceType type : values()) { + if (type.code.equals(code)) { + return type; + } + } + throw new IllegalArgumentException("未知的数据源类型: " + code); + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/fill/enums/SortStrategy.java b/src/main/java/com/ycwl/basic/puzzle/fill/enums/SortStrategy.java new file mode 100644 index 00000000..5dfc29bc --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/fill/enums/SortStrategy.java @@ -0,0 +1,50 @@ +package com.ycwl.basic.puzzle.fill.enums; + +import lombok.Getter; + +/** + * 素材排序策略枚举 + */ +@Getter +public enum SortStrategy { + + /** + * 最新创建(按create_time DESC) + */ + LATEST("LATEST", "最新创建"), + + /** + * 最早创建(按create_time ASC) + */ + EARLIEST("EARLIEST", "最早创建"), + + /** + * 分数降序(按score DESC,需要source表有score字段) + */ + SCORE_DESC("SCORE_DESC", "分数降序"), + + /** + * 优先已购买(按is_buy DESC, create_time DESC) + */ + PURCHASED_FIRST("PURCHASED_FIRST", "优先已购买"); + + private final String code; + private final String description; + + SortStrategy(String code, String description) { + this.code = code; + this.description = description; + } + + /** + * 根据code获取枚举 + */ + public static SortStrategy fromCode(String code) { + for (SortStrategy strategy : values()) { + if (strategy.code.equals(code)) { + return strategy; + } + } + return LATEST; // 默认返回最新 + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleFillRuleItemMapper.java b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleFillRuleItemMapper.java new file mode 100644 index 00000000..3a55423b --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleFillRuleItemMapper.java @@ -0,0 +1,39 @@ +package com.ycwl.basic.puzzle.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 拼图自动填充规则明细 Mapper + */ +@Mapper +public interface PuzzleFillRuleItemMapper extends BaseMapper { + + /** + * 根据规则ID查询所有明细(按item_order升序) + * + * @param ruleId 规则ID + * @return 明细列表 + */ + List listByRuleId(@Param("ruleId") Long ruleId); + + /** + * 批量插入明细 + * + * @param items 明细列表 + * @return 插入数量 + */ + int batchInsert(@Param("items") List items); + + /** + * 根据规则ID删除所有明细 + * + * @param ruleId 规则ID + * @return 删除数量 + */ + int deleteByRuleId(@Param("ruleId") Long ruleId); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleFillRuleMapper.java b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleFillRuleMapper.java new file mode 100644 index 00000000..402af382 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/mapper/PuzzleFillRuleMapper.java @@ -0,0 +1,32 @@ +package com.ycwl.basic.puzzle.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * 拼图自动填充规则 Mapper + */ +@Mapper +public interface PuzzleFillRuleMapper extends BaseMapper { + + /** + * 根据模板ID查询所有启用的规则(按优先级降序) + * + * @param templateId 模板ID + * @return 规则列表 + */ + List listByTemplateId(@Param("templateId") Long templateId); + + /** + * 根据模板ID和景区ID查询所有启用的规则(按优先级降序) + * + * @param templateId 模板ID + * @param scenicId 景区ID + * @return 规则列表 + */ + List listByTemplateAndScenic(@Param("templateId") Long templateId, @Param("scenicId") Long scenicId); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleFillRuleService.java b/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleFillRuleService.java new file mode 100644 index 00000000..b54cb302 --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/service/IPuzzleFillRuleService.java @@ -0,0 +1,61 @@ +package com.ycwl.basic.puzzle.service; + +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO; +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest; + +import java.util.List; + +/** + * 拼图填充规则服务接口 + */ +public interface IPuzzleFillRuleService { + + /** + * 创建规则(主+明细) + * + * @param request 保存请求 + * @return 规则ID + */ + Long create(PuzzleFillRuleSaveRequest request); + + /** + * 更新规则(主+明细) + * + * @param request 保存请求 + * @return 是否成功 + */ + Boolean update(PuzzleFillRuleSaveRequest request); + + /** + * 删除规则(级联删除明细) + * + * @param id 规则ID + * @return 是否成功 + */ + Boolean delete(Long id); + + /** + * 查询单条规则(含明细) + * + * @param id 规则ID + * @return 规则DTO + */ + PuzzleFillRuleDTO getById(Long id); + + /** + * 查询模板的所有规则(含明细) + * + * @param templateId 模板ID + * @return 规则列表 + */ + List listByTemplateId(Long templateId); + + /** + * 启用/禁用规则 + * + * @param id 规则ID + * @param enabled 是否启用 + * @return 是否成功 + */ + Boolean toggleEnabled(Long id, Integer enabled); +} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleFillRuleServiceImpl.java b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleFillRuleServiceImpl.java new file mode 100644 index 00000000..46bd609e --- /dev/null +++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleFillRuleServiceImpl.java @@ -0,0 +1,174 @@ +package com.ycwl.basic.puzzle.service.impl; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO; +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleItemDTO; +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper; +import com.ycwl.basic.puzzle.service.IPuzzleFillRuleService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.BeanUtils; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 拼图填充规则服务实现 + */ +@Slf4j +@Service +public class PuzzleFillRuleServiceImpl implements IPuzzleFillRuleService { + + @Autowired + private PuzzleFillRuleMapper ruleMapper; + + @Autowired + private PuzzleFillRuleItemMapper itemMapper; + + @Override + @Transactional(rollbackFor = Exception.class) + public Long create(PuzzleFillRuleSaveRequest request) { + // 1. 保存主规则 + PuzzleFillRuleEntity ruleEntity = new PuzzleFillRuleEntity(); + BeanUtils.copyProperties(request, ruleEntity); + ruleMapper.insert(ruleEntity); + + Long ruleId = ruleEntity.getId(); + log.info("创建填充规则成功, ruleId={}, ruleName={}", ruleId, request.getRuleName()); + + // 2. 批量保存明细 + if (request.getItems() != null && !request.getItems().isEmpty()) { + List itemEntities = request.getItems().stream() + .map(dto -> { + PuzzleFillRuleItemEntity entity = new PuzzleFillRuleItemEntity(); + BeanUtils.copyProperties(dto, entity); + entity.setRuleId(ruleId); + return entity; + }) + .collect(Collectors.toList()); + + itemMapper.batchInsert(itemEntities); + log.info("批量保存规则明细成功, count={}", itemEntities.size()); + } + + return ruleId; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean update(PuzzleFillRuleSaveRequest request) { + if (request.getId() == null) { + throw new IllegalArgumentException("更新规则时ID不能为空"); + } + + // 1. 更新主规则 + PuzzleFillRuleEntity ruleEntity = new PuzzleFillRuleEntity(); + BeanUtils.copyProperties(request, ruleEntity); + ruleMapper.updateById(ruleEntity); + + // 2. 删除旧明细 + itemMapper.deleteByRuleId(request.getId()); + + // 3. 批量插入新明细 + if (request.getItems() != null && !request.getItems().isEmpty()) { + List itemEntities = request.getItems().stream() + .map(dto -> { + PuzzleFillRuleItemEntity entity = new PuzzleFillRuleItemEntity(); + BeanUtils.copyProperties(dto, entity); + entity.setRuleId(request.getId()); + return entity; + }) + .collect(Collectors.toList()); + + itemMapper.batchInsert(itemEntities); + } + + log.info("更新填充规则成功, ruleId={}, itemCount={}", request.getId(), + request.getItems() != null ? request.getItems().size() : 0); + return true; + } + + @Override + @Transactional(rollbackFor = Exception.class) + public Boolean delete(Long id) { + // MyBatis-Plus会级联删除明细(通过外键ON DELETE CASCADE) + ruleMapper.deleteById(id); + log.info("删除填充规则成功, ruleId={}", id); + return true; + } + + @Override + public PuzzleFillRuleDTO getById(Long id) { + PuzzleFillRuleEntity ruleEntity = ruleMapper.selectById(id); + if (ruleEntity == null) { + return null; + } + + PuzzleFillRuleDTO dto = new PuzzleFillRuleDTO(); + BeanUtils.copyProperties(ruleEntity, dto); + + // 查询明细 + List itemEntities = itemMapper.listByRuleId(id); + if (itemEntities != null && !itemEntities.isEmpty()) { + List itemDTOs = itemEntities.stream() + .map(entity -> { + PuzzleFillRuleItemDTO itemDTO = new PuzzleFillRuleItemDTO(); + BeanUtils.copyProperties(entity, itemDTO); + return itemDTO; + }) + .collect(Collectors.toList()); + dto.setItems(itemDTOs); + } + + return dto; + } + + @Override + public List listByTemplateId(Long templateId) { + List ruleEntities = ruleMapper.listByTemplateId(templateId); + if (ruleEntities == null || ruleEntities.isEmpty()) { + return new ArrayList<>(); + } + + return ruleEntities.stream() + .map(ruleEntity -> { + PuzzleFillRuleDTO dto = new PuzzleFillRuleDTO(); + BeanUtils.copyProperties(ruleEntity, dto); + + // 查询明细 + List itemEntities = itemMapper.listByRuleId(ruleEntity.getId()); + if (itemEntities != null && !itemEntities.isEmpty()) { + List itemDTOs = itemEntities.stream() + .map(entity -> { + PuzzleFillRuleItemDTO itemDTO = new PuzzleFillRuleItemDTO(); + BeanUtils.copyProperties(entity, itemDTO); + return itemDTO; + }) + .collect(Collectors.toList()); + dto.setItems(itemDTOs); + } + + return dto; + }) + .collect(Collectors.toList()); + } + + @Override + public Boolean toggleEnabled(Long id, Integer enabled) { + LambdaUpdateWrapper updateWrapper = new LambdaUpdateWrapper<>(); + updateWrapper.eq(PuzzleFillRuleEntity::getId, id) + .set(PuzzleFillRuleEntity::getEnabled, enabled); + + int count = ruleMapper.update(null, updateWrapper); + log.info("切换规则启用状态, ruleId={}, enabled={}", id, enabled); + return count > 0; + } +} diff --git a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java index 4c01464a..fd5a4515 100644 --- a/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java +++ b/src/main/java/com/ycwl/basic/puzzle/service/impl/PuzzleGenerateServiceImpl.java @@ -7,6 +7,7 @@ import com.ycwl.basic.puzzle.dto.PuzzleGenerateResponse; import com.ycwl.basic.puzzle.entity.PuzzleElementEntity; import com.ycwl.basic.puzzle.entity.PuzzleGenerationRecordEntity; import com.ycwl.basic.puzzle.entity.PuzzleTemplateEntity; +import com.ycwl.basic.puzzle.fill.PuzzleElementFillEngine; import com.ycwl.basic.puzzle.mapper.PuzzleElementMapper; import com.ycwl.basic.puzzle.mapper.PuzzleGenerationRecordMapper; import com.ycwl.basic.puzzle.mapper.PuzzleTemplateMapper; @@ -23,7 +24,9 @@ import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.util.Comparator; +import java.util.HashMap; import java.util.List; +import java.util.Map; import java.util.UUID; /** @@ -41,6 +44,7 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { private final PuzzleElementMapper elementMapper; private final PuzzleGenerationRecordMapper recordMapper; private final PuzzleImageRenderer imageRenderer; + private final PuzzleElementFillEngine fillEngine; @Override public PuzzleGenerateResponse generate(PuzzleGenerateRequest request) { @@ -67,13 +71,16 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { elements.sort(Comparator.comparing(PuzzleElementEntity::getZIndex, Comparator.nullsFirst(Comparator.naturalOrder()))); - // 3. 创建生成记录 + // 3. 准备dynamicData(合并自动填充和手动数据) + Map finalDynamicData = buildDynamicData(template, request); + + // 4. 创建生成记录 PuzzleGenerationRecordEntity record = createRecord(template, request); recordMapper.insert(record); try { - // 4. 渲染图片 - BufferedImage resultImage = imageRenderer.render(template, elements, request.getDynamicData()); + // 5. 渲染图片 + BufferedImage resultImage = imageRenderer.render(template, elements, finalDynamicData); // 5. 上传到OSS String imageUrl = uploadImage(resultImage, template.getCode(), request.getOutputFormat(), request.getQuality()); @@ -179,4 +186,39 @@ public class PuzzleGenerateServiceImpl implements IPuzzleGenerateService { return 0L; } } + + /** + * 构建dynamicData(合并自动填充和手动数据) + * 优先级: 手动传入的数据 > 自动填充的数据 + */ + private Map buildDynamicData(PuzzleTemplateEntity template, PuzzleGenerateRequest request) { + Map dynamicData = new HashMap<>(); + + // 1. 自动填充(基于faceId和规则) + if (request.getFaceId() != null && request.getScenicId() != null) { + try { + Map autoFilled = fillEngine.execute( + template.getId(), + request.getFaceId(), + request.getScenicId() + ); + if (autoFilled != null && !autoFilled.isEmpty()) { + dynamicData.putAll(autoFilled); + log.info("自动填充成功, 填充了{}个元素", autoFilled.size()); + } + } catch (Exception e) { + log.error("自动填充异常, templateId={}, faceId={}", template.getId(), request.getFaceId(), e); + // 自动填充失败不影响整体流程,继续执行 + } + } + + // 2. 手动数据覆盖(优先级更高) + if (request.getDynamicData() != null && !request.getDynamicData().isEmpty()) { + dynamicData.putAll(request.getDynamicData()); + log.debug("合并手动传入的dynamicData, count={}", request.getDynamicData().size()); + } + + log.info("最终dynamicData: {}", dynamicData.keySet()); + return dynamicData; + } } diff --git a/src/main/resources/mapper/PuzzleFillRuleItemMapper.xml b/src/main/resources/mapper/PuzzleFillRuleItemMapper.xml new file mode 100644 index 00000000..606d2efd --- /dev/null +++ b/src/main/resources/mapper/PuzzleFillRuleItemMapper.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + + + + + + + INSERT INTO puzzle_fill_rule_item ( + rule_id, element_key, data_source, source_filter, + sort_strategy, fallback_value, item_order + ) VALUES + + ( + #{item.ruleId}, #{item.elementKey}, #{item.dataSource}, #{item.sourceFilter}, + #{item.sortStrategy}, #{item.fallbackValue}, #{item.itemOrder} + ) + + + + + DELETE FROM puzzle_fill_rule_item + WHERE rule_id = #{ruleId} + + + diff --git a/src/main/resources/mapper/PuzzleFillRuleMapper.xml b/src/main/resources/mapper/PuzzleFillRuleMapper.xml new file mode 100644 index 00000000..1e7a0f1b --- /dev/null +++ b/src/main/resources/mapper/PuzzleFillRuleMapper.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/resources/mapper/SourceMapper.xml b/src/main/resources/mapper/SourceMapper.xml index 6a8d03f3..ce65a36e 100644 --- a/src/main/resources/mapper/SourceMapper.xml +++ b/src/main/resources/mapper/SourceMapper.xml @@ -361,4 +361,57 @@ order by create_time desc limit 1 + + + + + + diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java new file mode 100644 index 00000000..623d5d7d --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/PuzzleElementFillEngineTest.java @@ -0,0 +1,400 @@ +package com.ycwl.basic.puzzle.fill; + +import com.ycwl.basic.mapper.SourceMapper; +import com.ycwl.basic.model.pc.source.entity.SourceEntity; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity; +import com.ycwl.basic.puzzle.fill.condition.ConditionEvaluator; +import com.ycwl.basic.puzzle.fill.datasource.DataSourceResolver; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 拼图元素填充引擎测试 + */ +@DisplayName("拼图元素填充引擎测试") +@ExtendWith(MockitoExtension.class) +class PuzzleElementFillEngineTest { + + @Mock + private PuzzleFillRuleMapper ruleMapper; + + @Mock + private PuzzleFillRuleItemMapper itemMapper; + + @Mock + private SourceMapper sourceMapper; + + @Mock + private ConditionEvaluator conditionEvaluator; + + @Mock + private DataSourceResolver dataSourceResolver; + + @InjectMocks + private PuzzleElementFillEngine engine; + + @BeforeEach + void setUp() { + // 默认设置 + } + + @Test + @DisplayName("当没有配置规则时应该返回空Map") + void shouldReturnEmptyMapWhenNoRulesConfigured() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(new ArrayList<>()); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertTrue(result.isEmpty()); + verify(ruleMapper, times(1)).listByTemplateAndScenic(templateId, scenicId); + verify(sourceMapper, never()).countDistinctDevicesByFaceId(anyLong()); + } + + @Test + @DisplayName("应该成功执行匹配的规则并填充多个元素") + void shouldExecuteMatchedRuleAndFillMultipleElements() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + // 模拟规则 + PuzzleFillRuleEntity rule = createRule(1L, "4机位规则", 100); + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(Arrays.asList(rule)); + + // 模拟机位数量和机位列表 + when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + + // 模拟条件匹配 + when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true); + + // 模拟规则明细 + List items = Arrays.asList( + createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{\"deviceIndex\":0}", "LATEST"), + createItem(2L, 1L, "sfp_2", "DEVICE_IMAGE", "{\"deviceIndex\":1}", "LATEST"), + createItem(3L, 1L, "sfp_3", "DEVICE_IMAGE", "{\"deviceIndex\":2}", "LATEST"), + createItem(4L, 1L, "sfp_4", "DEVICE_IMAGE", "{\"deviceIndex\":3}", "LATEST") + ); + when(itemMapper.listByRuleId(1L)).thenReturn(items); + + // 模拟数据源解析 + when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn("https://oss.example.com/img1.jpg") + .thenReturn("https://oss.example.com/img2.jpg") + .thenReturn("https://oss.example.com/img3.jpg") + .thenReturn("https://oss.example.com/img4.jpg"); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertEquals(4, result.size()); + assertEquals("https://oss.example.com/img1.jpg", result.get("sfp_1")); + assertEquals("https://oss.example.com/img2.jpg", result.get("sfp_2")); + assertEquals("https://oss.example.com/img3.jpg", result.get("sfp_3")); + assertEquals("https://oss.example.com/img4.jpg", result.get("sfp_4")); + + verify(conditionEvaluator, times(1)).evaluate(eq(rule), any()); + verify(itemMapper, times(1)).listByRuleId(1L); + verify(dataSourceResolver, times(4)).resolve(anyString(), anyString(), anyString(), anyString(), any()); + } + + @Test + @DisplayName("应该按优先级顺序评估规则并在匹配第一条后停止") + void shouldEvaluateRulesByPriorityAndStopAfterFirstMatch() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + // 模拟3条规则(按priority DESC排序) + PuzzleFillRuleEntity rule1 = createRule(1L, "高优先级规则", 100); + PuzzleFillRuleEntity rule2 = createRule(2L, "中优先级规则", 50); + PuzzleFillRuleEntity rule3 = createRule(3L, "低优先级规则", 10); + + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(Arrays.asList(rule1, rule2, rule3)); + + when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + + // 模拟第一条规则不匹配,第二条匹配 + when(conditionEvaluator.evaluate(eq(rule1), any())).thenReturn(false); + when(conditionEvaluator.evaluate(eq(rule2), any())).thenReturn(true); + + // 模拟规则2的明细 + List items = Arrays.asList( + createItem(1L, 2L, "sfp_1", "DEVICE_IMAGE", "{}", "LATEST") + ); + when(itemMapper.listByRuleId(2L)).thenReturn(items); + + when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn("https://oss.example.com/img.jpg"); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertEquals(1, result.size()); + assertEquals("https://oss.example.com/img.jpg", result.get("sfp_1")); + + // 应该评估了rule1和rule2,但没有评估rule3(因为rule2匹配后停止) + verify(conditionEvaluator, times(1)).evaluate(eq(rule1), any()); + verify(conditionEvaluator, times(1)).evaluate(eq(rule2), any()); + verify(conditionEvaluator, never()).evaluate(eq(rule3), any()); + + verify(itemMapper, times(1)).listByRuleId(2L); + verify(itemMapper, never()).listByRuleId(1L); + verify(itemMapper, never()).listByRuleId(3L); + } + + @Test + @DisplayName("当所有规则都不匹配时应该返回空Map") + void shouldReturnEmptyMapWhenNoRuleMatches() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + PuzzleFillRuleEntity rule1 = createRule(1L, "规则1", 100); + PuzzleFillRuleEntity rule2 = createRule(2L, "规则2", 50); + + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(Arrays.asList(rule1, rule2)); + + when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + + // 所有规则都不匹配 + when(conditionEvaluator.evaluate(any(), any())).thenReturn(false); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertTrue(result.isEmpty()); + verify(conditionEvaluator, times(2)).evaluate(any(), any()); + verify(itemMapper, never()).listByRuleId(anyLong()); + } + + @Test + @DisplayName("当数据源解析返回null时应该跳过该元素") + void shouldSkipElementWhenDataSourceReturnsNull() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + PuzzleFillRuleEntity rule = createRule(1L, "测试规则", 100); + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(Arrays.asList(rule)); + + when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true); + + List items = Arrays.asList( + createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{}", "LATEST"), + createItem(2L, 1L, "sfp_2", "DEVICE_IMAGE", "{}", "LATEST") + ); + when(itemMapper.listByRuleId(1L)).thenReturn(items); + + // 第一个返回值,第二个返回null + when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn("https://oss.example.com/img1.jpg") + .thenReturn(null); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertEquals(1, result.size()); + assertEquals("https://oss.example.com/img1.jpg", result.get("sfp_1")); + assertFalse(result.containsKey("sfp_2")); + } + + @Test + @DisplayName("当数据源解析失败时应该使用fallbackValue") + void shouldUseFallbackValueWhenDataSourceFails() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + PuzzleFillRuleEntity rule = createRule(1L, "测试规则", 100); + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(Arrays.asList(rule)); + + when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true); + + // 明细包含fallbackValue + PuzzleFillRuleItemEntity item = createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{}", "LATEST"); + item.setFallbackValue("https://oss.example.com/default.jpg"); + + when(itemMapper.listByRuleId(1L)).thenReturn(Arrays.asList(item)); + + // DataSourceResolver内部会处理fallback,这里模拟返回fallback值 + when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), eq("https://oss.example.com/default.jpg"), any())) + .thenReturn("https://oss.example.com/default.jpg"); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertEquals(1, result.size()); + assertEquals("https://oss.example.com/default.jpg", result.get("sfp_1")); + } + + @Test + @DisplayName("当规则匹配但没有明细时应该返回空Map") + void shouldReturnEmptyMapWhenRuleMatchesButHasNoItems() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + PuzzleFillRuleEntity rule = createRule(1L, "空明细规则", 100); + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(Arrays.asList(rule)); + + when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true); + + // 规则没有明细 + when(itemMapper.listByRuleId(1L)).thenReturn(new ArrayList<>()); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("当发生异常时应该返回空Map并记录日志") + void shouldReturnEmptyMapAndLogWhenExceptionOccurs() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenThrow(new RuntimeException("Database error")); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertTrue(result.isEmpty()); + } + + @Test + @DisplayName("应该支持DEVICE_ID_MATCH条件类型并正确填充") + void shouldSupportDeviceIdMatchCondition() { + // Given + Long templateId = 1L; + Long faceId = 123L; + Long scenicId = 1L; + + // 模拟规则 - 使用DEVICE_ID_MATCH条件 + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(1L); + rule.setRuleName("指定机位规则"); + rule.setPriority(100); + rule.setConditionType("DEVICE_ID_MATCH"); + rule.setConditionValue("{\"deviceIds\": [200, 300], \"matchMode\": \"ALL\"}"); + rule.setEnabled(1); + + when(ruleMapper.listByTemplateAndScenic(templateId, scenicId)) + .thenReturn(Arrays.asList(rule)); + + // 模拟机位数量和机位列表 + when(sourceMapper.countDistinctDevicesByFaceId(faceId)).thenReturn(4); + when(sourceMapper.getDeviceIdsByFaceId(faceId)).thenReturn(Arrays.asList(100L, 200L, 300L, 400L)); + + // 模拟条件匹配 + when(conditionEvaluator.evaluate(eq(rule), any())).thenReturn(true); + + // 模拟规则明细 + List items = Arrays.asList( + createItem(1L, 1L, "sfp_1", "DEVICE_IMAGE", "{\"deviceIndex\":1}", "LATEST"), + createItem(2L, 1L, "sfp_2", "DEVICE_IMAGE", "{\"deviceIndex\":2}", "LATEST") + ); + when(itemMapper.listByRuleId(1L)).thenReturn(items); + + // 模拟数据源解析 + when(dataSourceResolver.resolve(anyString(), anyString(), anyString(), anyString(), any())) + .thenReturn("https://oss.example.com/device200.jpg") + .thenReturn("https://oss.example.com/device300.jpg"); + + // When + Map result = engine.execute(templateId, faceId, scenicId); + + // Then + assertEquals(2, result.size()); + assertEquals("https://oss.example.com/device200.jpg", result.get("sfp_1")); + assertEquals("https://oss.example.com/device300.jpg", result.get("sfp_2")); + + // 验证deviceIds被传递到ConditionContext + verify(conditionEvaluator, times(1)).evaluate(eq(rule), any()); + verify(sourceMapper, times(1)).getDeviceIdsByFaceId(faceId); + } + + // 辅助方法 + private PuzzleFillRuleEntity createRule(Long id, String name, Integer priority) { + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(id); + rule.setRuleName(name); + rule.setPriority(priority); + rule.setConditionType("DEVICE_COUNT"); + rule.setConditionValue("{\"deviceCount\": 4}"); + rule.setEnabled(1); + return rule; + } + + private PuzzleFillRuleItemEntity createItem(Long id, Long ruleId, String elementKey, + String dataSource, String sourceFilter, String sortStrategy) { + PuzzleFillRuleItemEntity item = new PuzzleFillRuleItemEntity(); + item.setId(id); + item.setRuleId(ruleId); + item.setElementKey(elementKey); + item.setDataSource(dataSource); + item.setSourceFilter(sourceFilter); + item.setSortStrategy(sortStrategy); + item.setItemOrder(id.intValue()); + return item; + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/condition/AlwaysConditionStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/condition/AlwaysConditionStrategyTest.java new file mode 100644 index 00000000..d65193d6 --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/condition/AlwaysConditionStrategyTest.java @@ -0,0 +1,80 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 总是匹配策略测试 + */ +@DisplayName("总是匹配策略测试") +class AlwaysConditionStrategyTest { + + private AlwaysConditionStrategy strategy; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + strategy = new AlwaysConditionStrategy(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("应该返回正确的支持类型") + void shouldReturnCorrectSupportedType() { + assertEquals("ALWAYS", strategy.getSupportedType()); + } + + @Test + @DisplayName("无论conditionValue和context如何都应该返回true") + void shouldAlwaysReturnTrue() throws Exception { + // Given + String conditionValueJson = "{\"anyField\": \"anyValue\"}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(4) + .faceId(123L) + .scenicId(1L) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当conditionValue为null时也应该返回true") + void shouldReturnTrueEvenWhenConditionValueIsNull() { + // Given + ConditionContext context = ConditionContext.builder().build(); + + // When + boolean result = strategy.evaluate(null, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当context为空时也应该返回true") + void shouldReturnTrueEvenWhenContextIsEmpty() throws Exception { + // Given + String conditionValueJson = "{}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder().build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluatorTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluatorTest.java new file mode 100644 index 00000000..b13ee643 --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/condition/ConditionEvaluatorTest.java @@ -0,0 +1,239 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 条件评估器集成测试 + */ +@DisplayName("条件评估器集成测试") +class ConditionEvaluatorTest { + + private ConditionEvaluator evaluator; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + + // 注册所有策略 + List strategies = Arrays.asList( + new DeviceCountConditionStrategy(), + new DeviceCountRangeConditionStrategy(), + new DeviceIdMatchConditionStrategy(), + new AlwaysConditionStrategy() + ); + + evaluator = new ConditionEvaluator(strategies, objectMapper); + } + + @Test + @DisplayName("应该正确评估DEVICE_COUNT类型的规则") + void shouldEvaluateDeviceCountRule() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(1L); + rule.setRuleName("4机位规则"); + rule.setConditionType("DEVICE_COUNT"); + rule.setConditionValue("{\"deviceCount\": 4}"); + + ConditionContext context = ConditionContext.builder() + .deviceCount(4) + .build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("应该正确评估DEVICE_COUNT_RANGE类型的规则") + void shouldEvaluateDeviceCountRangeRule() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(2L); + rule.setRuleName("2-5机位规则"); + rule.setConditionType("DEVICE_COUNT_RANGE"); + rule.setConditionValue("{\"minCount\": 2, \"maxCount\": 5}"); + + ConditionContext context = ConditionContext.builder() + .deviceCount(3) + .build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("应该正确评估ALWAYS类型的规则") + void shouldEvaluateAlwaysRule() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(3L); + rule.setRuleName("兜底规则"); + rule.setConditionType("ALWAYS"); + rule.setConditionValue("{}"); + + ConditionContext context = ConditionContext.builder() + .deviceCount(999) + .build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("应该正确评估DEVICE_ID_MATCH类型的规则-单机位匹配") + void shouldEvaluateDeviceIdMatchRuleSingleDevice() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(4L); + rule.setRuleName("指定机位规则"); + rule.setConditionType("DEVICE_ID_MATCH"); + rule.setConditionValue("{\"deviceId\": 200}"); + + ConditionContext context = ConditionContext.builder() + .deviceIds(Arrays.asList(100L, 200L, 300L)) + .build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("应该正确评估DEVICE_ID_MATCH类型的规则-多机位ANY匹配") + void shouldEvaluateDeviceIdMatchRuleMultipleDevicesAny() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(5L); + rule.setRuleName("多机位ANY规则"); + rule.setConditionType("DEVICE_ID_MATCH"); + rule.setConditionValue("{\"deviceIds\": [200, 400], \"matchMode\": \"ANY\"}"); + + ConditionContext context = ConditionContext.builder() + .deviceIds(Arrays.asList(100L, 200L, 300L)) + .build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("应该正确评估DEVICE_ID_MATCH类型的规则-多机位ALL匹配") + void shouldEvaluateDeviceIdMatchRuleMultipleDevicesAll() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(6L); + rule.setRuleName("多机位ALL规则"); + rule.setConditionType("DEVICE_ID_MATCH"); + rule.setConditionValue("{\"deviceIds\": [100, 200], \"matchMode\": \"ALL\"}"); + + ConditionContext context = ConditionContext.builder() + .deviceIds(Arrays.asList(100L, 200L, 300L)) + .build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当条件类型不存在时应该返回false") + void shouldReturnFalseWhenConditionTypeNotFound() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(4L); + rule.setRuleName("未知类型规则"); + rule.setConditionType("UNKNOWN_TYPE"); + rule.setConditionValue("{}"); + + ConditionContext context = ConditionContext.builder().build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当conditionValue格式错误时应该返回false") + void shouldReturnFalseWhenConditionValueIsInvalid() { + // Given + PuzzleFillRuleEntity rule = new PuzzleFillRuleEntity(); + rule.setId(5L); + rule.setRuleName("格式错误规则"); + rule.setConditionType("DEVICE_COUNT"); + rule.setConditionValue("invalid json"); + + ConditionContext context = ConditionContext.builder() + .deviceCount(4) + .build(); + + // When + boolean result = evaluator.evaluate(rule, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("应该支持多个策略并正确路由") + void shouldSupportMultipleStrategies() { + // Test DEVICE_COUNT + ConditionContext context1 = ConditionContext.builder() + .deviceCount(4) + .build(); + + PuzzleFillRuleEntity rule1 = new PuzzleFillRuleEntity(); + rule1.setConditionType("DEVICE_COUNT"); + rule1.setConditionValue("{\"deviceCount\": 4}"); + assertTrue(evaluator.evaluate(rule1, context1)); + + // Test DEVICE_COUNT_RANGE + PuzzleFillRuleEntity rule2 = new PuzzleFillRuleEntity(); + rule2.setConditionType("DEVICE_COUNT_RANGE"); + rule2.setConditionValue("{\"minCount\": 2, \"maxCount\": 5}"); + assertTrue(evaluator.evaluate(rule2, context1)); + + // Test DEVICE_ID_MATCH + ConditionContext context2 = ConditionContext.builder() + .deviceIds(Arrays.asList(100L, 200L, 300L)) + .build(); + + PuzzleFillRuleEntity rule3 = new PuzzleFillRuleEntity(); + rule3.setConditionType("DEVICE_ID_MATCH"); + rule3.setConditionValue("{\"deviceId\": 200}"); + assertTrue(evaluator.evaluate(rule3, context2)); + + // Test ALWAYS + PuzzleFillRuleEntity rule4 = new PuzzleFillRuleEntity(); + rule4.setConditionType("ALWAYS"); + rule4.setConditionValue("{}"); + assertTrue(evaluator.evaluate(rule4, context1)); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountConditionStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountConditionStrategyTest.java new file mode 100644 index 00000000..65c435e0 --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountConditionStrategyTest.java @@ -0,0 +1,136 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 机位数量精确匹配策略测试 + */ +@DisplayName("机位数量精确匹配策略测试") +class DeviceCountConditionStrategyTest { + + private DeviceCountConditionStrategy strategy; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + strategy = new DeviceCountConditionStrategy(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("应该返回正确的支持类型") + void shouldReturnCorrectSupportedType() { + assertEquals("DEVICE_COUNT", strategy.getSupportedType()); + } + + @Test + @DisplayName("当机位数量完全匹配时应该返回true") + void shouldReturnTrueWhenDeviceCountMatches() throws Exception { + // Given + String conditionValueJson = "{\"deviceCount\": 4}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(4) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当机位数量不匹配时应该返回false") + void shouldReturnFalseWhenDeviceCountDoesNotMatch() throws Exception { + // Given + String conditionValueJson = "{\"deviceCount\": 4}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(2) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当conditionValue为null时应该返回false") + void shouldReturnFalseWhenConditionValueIsNull() { + // Given + ConditionContext context = ConditionContext.builder() + .deviceCount(4) + .build(); + + // When + boolean result = strategy.evaluate(null, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当conditionValue缺少deviceCount字段时应该返回false") + void shouldReturnFalseWhenDeviceCountFieldIsMissing() throws Exception { + // Given + String conditionValueJson = "{\"otherField\": 4}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(4) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当context中deviceCount为null时应该返回false") + void shouldReturnFalseWhenContextDeviceCountIsNull() throws Exception { + // Given + String conditionValueJson = "{\"deviceCount\": 4}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(null) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("应该支持deviceCount为0的情况") + void shouldSupportZeroDeviceCount() throws Exception { + // Given + String conditionValueJson = "{\"deviceCount\": 0}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(0) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountRangeConditionStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountRangeConditionStrategyTest.java new file mode 100644 index 00000000..b7cfd3e5 --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceCountRangeConditionStrategyTest.java @@ -0,0 +1,190 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 机位数量范围匹配策略测试 + */ +@DisplayName("机位数量范围匹配策略测试") +class DeviceCountRangeConditionStrategyTest { + + private DeviceCountRangeConditionStrategy strategy; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + strategy = new DeviceCountRangeConditionStrategy(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("应该返回正确的支持类型") + void shouldReturnCorrectSupportedType() { + assertEquals("DEVICE_COUNT_RANGE", strategy.getSupportedType()); + } + + @Test + @DisplayName("当机位数量在范围内时应该返回true") + void shouldReturnTrueWhenDeviceCountInRange() throws Exception { + // Given + String conditionValueJson = "{\"minCount\": 2, \"maxCount\": 5}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(3) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当机位数量等于最小值时应该返回true") + void shouldReturnTrueWhenDeviceCountEqualsMin() throws Exception { + // Given + String conditionValueJson = "{\"minCount\": 2, \"maxCount\": 5}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(2) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当机位数量等于最大值时应该返回true") + void shouldReturnTrueWhenDeviceCountEqualsMax() throws Exception { + // Given + String conditionValueJson = "{\"minCount\": 2, \"maxCount\": 5}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(5) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当机位数量小于最小值时应该返回false") + void shouldReturnFalseWhenDeviceCountBelowMin() throws Exception { + // Given + String conditionValueJson = "{\"minCount\": 2, \"maxCount\": 5}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(1) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当机位数量大于最大值时应该返回false") + void shouldReturnFalseWhenDeviceCountAboveMax() throws Exception { + // Given + String conditionValueJson = "{\"minCount\": 2, \"maxCount\": 5}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(6) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("仅指定最小值时应该正确验证") + void shouldValidateWithMinCountOnly() throws Exception { + // Given + String conditionValueJson = "{\"minCount\": 2}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(3) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("仅指定最大值时应该正确验证") + void shouldValidateWithMaxCountOnly() throws Exception { + // Given + String conditionValueJson = "{\"maxCount\": 5}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(3) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当conditionValue为null时应该返回false") + void shouldReturnFalseWhenConditionValueIsNull() { + // Given + ConditionContext context = ConditionContext.builder() + .deviceCount(3) + .build(); + + // When + boolean result = strategy.evaluate(null, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当context中deviceCount为null时应该返回false") + void shouldReturnFalseWhenContextDeviceCountIsNull() throws Exception { + // Given + String conditionValueJson = "{\"minCount\": 2, \"maxCount\": 5}"; + JsonNode conditionValue = objectMapper.readTree(conditionValueJson); + + ConditionContext context = ConditionContext.builder() + .deviceCount(null) + .build(); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceIdMatchConditionStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceIdMatchConditionStrategyTest.java new file mode 100644 index 00000000..9df8d33a --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/condition/DeviceIdMatchConditionStrategyTest.java @@ -0,0 +1,341 @@ +package com.ycwl.basic.puzzle.fill.condition; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 机位ID匹配条件策略测试 + */ +@DisplayName("机位ID匹配条件策略测试") +class DeviceIdMatchConditionStrategyTest { + + private DeviceIdMatchConditionStrategy strategy; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + strategy = new DeviceIdMatchConditionStrategy(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("应该返回正确的支持类型") + void shouldReturnCorrectSupportedType() { + assertEquals("DEVICE_ID_MATCH", strategy.getSupportedType()); + } + + @Test + @DisplayName("单机位匹配-机位存在应该返回true") + void shouldReturnTrueWhenSingleDeviceIdExists() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceId\": 200}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("单机位匹配-机位不存在应该返回false") + void shouldReturnFalseWhenSingleDeviceIdNotExists() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceId\": 999}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("多机位ANY匹配-至少存在一个机位应该返回true") + void shouldReturnTrueWhenAnyDeviceIdMatches() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + // 200存在,400不存在,但ANY模式只需一个匹配 + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": [200, 400], \"matchMode\": \"ANY\"}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("多机位ANY匹配-没有任何机位存在应该返回false") + void shouldReturnFalseWhenNoDeviceIdMatches() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": [400, 500], \"matchMode\": \"ANY\"}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("多机位ALL匹配-所有机位都存在应该返回true") + void shouldReturnTrueWhenAllDeviceIdsMatch() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": [100, 200], \"matchMode\": \"ALL\"}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("多机位ALL匹配-部分机位不存在应该返回false") + void shouldReturnFalseWhenNotAllDeviceIdsMatch() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + // 200存在但400不存在 + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": [200, 400], \"matchMode\": \"ALL\"}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("多机位匹配-未指定matchMode应该默认使用ANY模式") + void shouldDefaultToAnyModeWhenMatchModeNotSpecified() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + // 未指定matchMode,默认ANY,200存在即可 + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": [200, 400]}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("多机位匹配-matchMode不区分大小写") + void shouldBeCaseInsensitiveForMatchMode() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + // 使用小写all + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": [100, 200], \"matchMode\": \"all\"}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("当conditionValue为null时应该返回false") + void shouldReturnFalseWhenConditionValueIsNull() { + // Given + ConditionContext context = ConditionContext.builder() + .deviceIds(Arrays.asList(100L, 200L)) + .build(); + + // When + boolean result = strategy.evaluate(null, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当上下文中机位列表为null时应该返回false") + void shouldReturnFalseWhenContextDeviceIdsIsNull() throws Exception { + // Given + ConditionContext context = ConditionContext.builder() + .deviceIds(null) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceId\": 100}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当上下文中机位列表为空时应该返回false") + void shouldReturnFalseWhenContextDeviceIdsIsEmpty() throws Exception { + // Given + ConditionContext context = ConditionContext.builder() + .deviceIds(Collections.emptyList()) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceId\": 100}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当deviceIds不是数组时应该返回false") + void shouldReturnFalseWhenDeviceIdsIsNotArray() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + // deviceIds不是数组而是字符串 + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": \"100,200\"}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当deviceIds数组为空时应该返回false") + void shouldReturnFalseWhenDeviceIdsArrayIsEmpty() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": []}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("当缺少deviceId和deviceIds字段时应该返回false") + void shouldReturnFalseWhenBothFieldsAreMissing() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"otherField\": \"test\"}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertFalse(result); + } + + @Test + @DisplayName("应该支持单个机位匹配边界情况-机位ID为0") + void shouldSupportZeroDeviceId() throws Exception { + // Given + List deviceIds = Arrays.asList(0L, 100L, 200L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceId\": 0}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("应该支持大数值机位ID") + void shouldSupportLargeDeviceId() throws Exception { + // Given + List deviceIds = Arrays.asList(999999999999L, 100L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceId\": 999999999999}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } + + @Test + @DisplayName("多机位匹配-单个元素的数组应该正常工作") + void shouldWorkWithSingleElementArray() throws Exception { + // Given + List deviceIds = Arrays.asList(100L, 200L, 300L); + ConditionContext context = ConditionContext.builder() + .deviceIds(deviceIds) + .build(); + + JsonNode conditionValue = objectMapper.readTree("{\"deviceIds\": [200]}"); + + // When + boolean result = strategy.evaluate(conditionValue, context); + + // Then + assertTrue(result); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/datasource/DeviceImageDataSourceStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/DeviceImageDataSourceStrategyTest.java new file mode 100644 index 00000000..e14d6f9a --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/DeviceImageDataSourceStrategyTest.java @@ -0,0 +1,227 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ycwl.basic.mapper.SourceMapper; +import com.ycwl.basic.model.pc.source.entity.SourceEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.Mockito.*; + +/** + * 设备图片数据源策略测试 + */ +@DisplayName("设备图片数据源策略测试") +@ExtendWith(MockitoExtension.class) +class DeviceImageDataSourceStrategyTest { + + @Mock + private SourceMapper sourceMapper; + + @InjectMocks + private DeviceImageDataSourceStrategy strategy; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("应该返回正确的支持类型") + void shouldReturnCorrectSupportedType() { + assertEquals("DEVICE_IMAGE", strategy.getSupportedType()); + } + + @Test + @DisplayName("应该返回指定设备的图片URL") + void shouldReturnDeviceImageUrl() throws Exception { + // Given + Long faceId = 123L; + Integer deviceIndex = 0; + String expectedUrl = "https://oss.example.com/device/img1.jpg"; + + SourceEntity source = new SourceEntity(); + source.setId(1L); + source.setType(2); // 图片类型 + source.setUrl(expectedUrl); + + when(sourceMapper.getSourceByFaceAndDeviceIndex(faceId, deviceIndex, 2, "LATEST")) + .thenReturn(source); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"type\": 2, \"deviceIndex\": 0}"); + + // When + String result = strategy.resolve(sourceFilter, "LATEST", context); + + // Then + assertEquals(expectedUrl, result); + verify(sourceMapper, times(1)).getSourceByFaceAndDeviceIndex(faceId, deviceIndex, 2, "LATEST"); + } + + @Test + @DisplayName("当type为1(视频)时应该返回videoUrl") + void shouldReturnVideoUrlWhenTypeIs1() throws Exception { + // Given + Long faceId = 123L; + Integer deviceIndex = 1; + String expectedUrl = "https://oss.example.com/device/video1.mp4"; + + SourceEntity source = new SourceEntity(); + source.setId(2L); + source.setType(1); // 视频类型 + source.setVideoUrl(expectedUrl); + + when(sourceMapper.getSourceByFaceAndDeviceIndex(faceId, deviceIndex, 1, "LATEST")) + .thenReturn(source); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"type\": 1, \"deviceIndex\": 1}"); + + // When + String result = strategy.resolve(sourceFilter, "LATEST", context); + + // Then + assertEquals(expectedUrl, result); + } + + @Test + @DisplayName("当未指定type时应该默认使用type=2(图片)") + void shouldDefaultToImageTypeWhenTypeNotSpecified() throws Exception { + // Given + Long faceId = 123L; + Integer deviceIndex = 0; + + SourceEntity source = new SourceEntity(); + source.setType(2); + source.setUrl("https://oss.example.com/default.jpg"); + + when(sourceMapper.getSourceByFaceAndDeviceIndex(faceId, deviceIndex, 2, "LATEST")) + .thenReturn(source); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"deviceIndex\": 0}"); + + // When + strategy.resolve(sourceFilter, "LATEST", context); + + // Then + verify(sourceMapper, times(1)).getSourceByFaceAndDeviceIndex(eq(faceId), eq(deviceIndex), eq(2), anyString()); + } + + @Test + @DisplayName("当未指定deviceIndex时应该默认使用0") + void shouldDefaultToZeroWhenDeviceIndexNotSpecified() throws Exception { + // Given + Long faceId = 123L; + + SourceEntity source = new SourceEntity(); + source.setType(2); + source.setUrl("https://oss.example.com/default.jpg"); + + when(sourceMapper.getSourceByFaceAndDeviceIndex(faceId, 0, 2, "LATEST")) + .thenReturn(source); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"type\": 2}"); + + // When + strategy.resolve(sourceFilter, "LATEST", context); + + // Then + verify(sourceMapper, times(1)).getSourceByFaceAndDeviceIndex(eq(faceId), eq(0), anyInt(), anyString()); + } + + @Test + @DisplayName("当source不存在时应该返回null") + void shouldReturnNullWhenSourceNotFound() throws Exception { + // Given + Long faceId = 999L; + Integer deviceIndex = 0; + + when(sourceMapper.getSourceByFaceAndDeviceIndex(anyLong(), anyInt(), anyInt(), anyString())) + .thenReturn(null); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"type\": 2, \"deviceIndex\": 0}"); + + // When + String result = strategy.resolve(sourceFilter, "LATEST", context); + + // Then + assertNull(result); + } + + @Test + @DisplayName("当未指定sortStrategy时应该默认使用LATEST") + void shouldDefaultToLatestSortStrategy() throws Exception { + // Given + Long faceId = 123L; + + SourceEntity source = new SourceEntity(); + source.setType(2); + source.setUrl("https://oss.example.com/img.jpg"); + + when(sourceMapper.getSourceByFaceAndDeviceIndex(anyLong(), anyInt(), anyInt(), eq("LATEST"))) + .thenReturn(source); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"deviceIndex\": 0}"); + + // When + strategy.resolve(sourceFilter, null, context); + + // Then + verify(sourceMapper, times(1)).getSourceByFaceAndDeviceIndex(anyLong(), anyInt(), anyInt(), eq("LATEST")); + } + + @Test + @DisplayName("当sourceMapper抛出异常时应该返回null") + void shouldReturnNullWhenSourceMapperThrowsException() throws Exception { + // Given + Long faceId = 123L; + + when(sourceMapper.getSourceByFaceAndDeviceIndex(anyLong(), anyInt(), anyInt(), anyString())) + .thenThrow(new RuntimeException("Database error")); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"deviceIndex\": 0}"); + + // When + String result = strategy.resolve(sourceFilter, "LATEST", context); + + // Then + assertNull(result); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/datasource/FaceUrlDataSourceStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/FaceUrlDataSourceStrategyTest.java new file mode 100644 index 00000000..da46e27d --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/FaceUrlDataSourceStrategyTest.java @@ -0,0 +1,158 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.ycwl.basic.mapper.FaceMapper; +import com.ycwl.basic.model.pc.face.entity.FaceEntity; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * 人脸URL数据源策略测试 + */ +@DisplayName("人脸URL数据源策略测试") +@ExtendWith(MockitoExtension.class) +class FaceUrlDataSourceStrategyTest { + + @Mock + private FaceMapper faceMapper; + + @InjectMocks + private FaceUrlDataSourceStrategy strategy; + + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("应该返回正确的支持类型") + void shouldReturnCorrectSupportedType() { + assertEquals("FACE_URL", strategy.getSupportedType()); + } + + @Test + @DisplayName("当face存在且有faceUrl时应该返回URL") + void shouldReturnFaceUrlWhenFaceExists() { + // Given + Long faceId = 123L; + String expectedUrl = "https://oss.example.com/face/123.jpg"; + + FaceEntity face = new FaceEntity(); + face.setId(faceId); + face.setFaceUrl(expectedUrl); + + when(faceMapper.get(faceId)).thenReturn(face); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + // When + String result = strategy.resolve(null, null, context); + + // Then + assertEquals(expectedUrl, result); + verify(faceMapper, times(1)).get(faceId); + } + + @Test + @DisplayName("当face不存在时应该返回null") + void shouldReturnNullWhenFaceNotFound() { + // Given + Long faceId = 999L; + + when(faceMapper.get(faceId)).thenReturn(null); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + // When + String result = strategy.resolve(null, null, context); + + // Then + assertNull(result); + verify(faceMapper, times(1)).get(faceId); + } + + @Test + @DisplayName("当face存在但faceUrl为null时应该返回null") + void shouldReturnNullWhenFaceUrlIsNull() { + // Given + Long faceId = 123L; + + FaceEntity face = new FaceEntity(); + face.setId(faceId); + face.setFaceUrl(null); + + when(faceMapper.get(faceId)).thenReturn(face); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + // When + String result = strategy.resolve(null, null, context); + + // Then + assertNull(result); + } + + @Test + @DisplayName("当faceMapper抛出异常时应该返回null") + void shouldReturnNullWhenFaceMapperThrowsException() { + // Given + Long faceId = 123L; + + when(faceMapper.get(anyLong())).thenThrow(new RuntimeException("Database error")); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + // When + String result = strategy.resolve(null, null, context); + + // Then + assertNull(result); + } + + @Test + @DisplayName("sourceFilter和sortStrategy参数应该被忽略") + void shouldIgnoreSourceFilterAndSortStrategy() throws Exception { + // Given + Long faceId = 123L; + String expectedUrl = "https://oss.example.com/face/123.jpg"; + + FaceEntity face = new FaceEntity(); + face.setId(faceId); + face.setFaceUrl(expectedUrl); + + when(faceMapper.get(faceId)).thenReturn(face); + + DataSourceContext context = DataSourceContext.builder() + .faceId(faceId) + .build(); + + JsonNode sourceFilter = objectMapper.readTree("{\"type\": 2}"); + String sortStrategy = "LATEST"; + + // When + String result = strategy.resolve(sourceFilter, sortStrategy, context); + + // Then + assertEquals(expectedUrl, result); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategyTest.java b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategyTest.java new file mode 100644 index 00000000..acb2aa14 --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/fill/datasource/StaticValueDataSourceStrategyTest.java @@ -0,0 +1,114 @@ +package com.ycwl.basic.puzzle.fill.datasource; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.*; + +/** + * 静态值数据源策略测试 + */ +@DisplayName("静态值数据源策略测试") +class StaticValueDataSourceStrategyTest { + + private StaticValueDataSourceStrategy strategy; + private ObjectMapper objectMapper; + + @BeforeEach + void setUp() { + strategy = new StaticValueDataSourceStrategy(); + objectMapper = new ObjectMapper(); + } + + @Test + @DisplayName("应该返回正确的支持类型") + void shouldReturnCorrectSupportedType() { + assertEquals("STATIC_VALUE", strategy.getSupportedType()); + } + + @Test + @DisplayName("应该返回sourceFilter中的value值") + void shouldReturnValueFromSourceFilter() throws Exception { + // Given + String expectedValue = "https://oss.example.com/static/default.jpg"; + JsonNode sourceFilter = objectMapper.readTree("{\"value\": \"" + expectedValue + "\"}"); + + DataSourceContext context = DataSourceContext.builder().build(); + + // When + String result = strategy.resolve(sourceFilter, null, context); + + // Then + assertEquals(expectedValue, result); + } + + @Test + @DisplayName("当sourceFilter为null时应该返回null") + void shouldReturnNullWhenSourceFilterIsNull() { + // Given + DataSourceContext context = DataSourceContext.builder().build(); + + // When + String result = strategy.resolve(null, null, context); + + // Then + assertNull(result); + } + + @Test + @DisplayName("当sourceFilter缺少value字段时应该返回null") + void shouldReturnNullWhenValueFieldIsMissing() throws Exception { + // Given + JsonNode sourceFilter = objectMapper.readTree("{\"otherField\": \"test\"}"); + + DataSourceContext context = DataSourceContext.builder().build(); + + // When + String result = strategy.resolve(sourceFilter, null, context); + + // Then + assertNull(result); + } + + @Test + @DisplayName("sortStrategy参数应该被忽略") + void shouldIgnoreSortStrategy() throws Exception { + // Given + String expectedValue = "static_value"; + JsonNode sourceFilter = objectMapper.readTree("{\"value\": \"" + expectedValue + "\"}"); + + DataSourceContext context = DataSourceContext.builder().build(); + + // When + String result = strategy.resolve(sourceFilter, "LATEST", context); + + // Then + assertEquals(expectedValue, result); + } + + @Test + @DisplayName("应该支持各种类型的字符串值") + void shouldSupportVariousStringValues() throws Exception { + // 测试URL + testStaticValue("https://example.com/image.jpg"); + + // 测试普通文本 + testStaticValue("Hello World"); + + // 测试空字符串 + testStaticValue(""); + + // 测试包含特殊字符的文本 + testStaticValue("测试中文/Special!@#$%"); + } + + private void testStaticValue(String value) throws Exception { + JsonNode sourceFilter = objectMapper.readTree("{\"value\": \"" + value + "\"}"); + DataSourceContext context = DataSourceContext.builder().build(); + String result = strategy.resolve(sourceFilter, null, context); + assertEquals(value, result); + } +} diff --git a/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleFillRuleServiceImplTest.java b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleFillRuleServiceImplTest.java new file mode 100644 index 00000000..878bb18d --- /dev/null +++ b/src/test/java/com/ycwl/basic/puzzle/service/impl/PuzzleFillRuleServiceImplTest.java @@ -0,0 +1,306 @@ +package com.ycwl.basic.puzzle.service.impl; + +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleDTO; +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleItemDTO; +import com.ycwl.basic.puzzle.dto.PuzzleFillRuleSaveRequest; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleEntity; +import com.ycwl.basic.puzzle.entity.PuzzleFillRuleItemEntity; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleItemMapper; +import com.ycwl.basic.puzzle.mapper.PuzzleFillRuleMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Arrays; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.*; + +/** + * 拼图填充规则服务测试 + */ +@DisplayName("拼图填充规则服务测试") +@ExtendWith(MockitoExtension.class) +class PuzzleFillRuleServiceImplTest { + + @Mock + private PuzzleFillRuleMapper ruleMapper; + + @Mock + private PuzzleFillRuleItemMapper itemMapper; + + @InjectMocks + private PuzzleFillRuleServiceImpl service; + + @BeforeEach + void setUp() { + } + + @Test + @DisplayName("创建规则应该保存主规则和明细") + void shouldCreateRuleWithItems() { + // Given + PuzzleFillRuleSaveRequest request = createSaveRequest(null); + request.setItems(Arrays.asList( + createItemDTO("sfp_1", 1), + createItemDTO("sfp_2", 2) + )); + + // 模拟insert返回的ID + doAnswer(invocation -> { + PuzzleFillRuleEntity entity = invocation.getArgument(0); + entity.setId(100L); + return 1; + }).when(ruleMapper).insert(any(PuzzleFillRuleEntity.class)); + + when(itemMapper.batchInsert(anyList())).thenReturn(2); + + // When + Long ruleId = service.create(request); + + // Then + assertEquals(100L, ruleId); + + // 验证主规则保存 + verify(ruleMapper, times(1)).insert(any(PuzzleFillRuleEntity.class)); + + // 验证明细批量保存 + ArgumentCaptor> itemsCaptor = ArgumentCaptor.forClass(List.class); + verify(itemMapper, times(1)).batchInsert(itemsCaptor.capture()); + + List savedItems = itemsCaptor.getValue(); + assertEquals(2, savedItems.size()); + assertEquals(100L, savedItems.get(0).getRuleId()); + assertEquals(100L, savedItems.get(1).getRuleId()); + } + + @Test + @DisplayName("创建规则时如果没有明细应该只保存主规则") + void shouldCreateRuleWithoutItems() { + // Given + PuzzleFillRuleSaveRequest request = createSaveRequest(null); + request.setItems(null); + + doAnswer(invocation -> { + PuzzleFillRuleEntity entity = invocation.getArgument(0); + entity.setId(100L); + return 1; + }).when(ruleMapper).insert(any(PuzzleFillRuleEntity.class)); + + // When + Long ruleId = service.create(request); + + // Then + assertEquals(100L, ruleId); + verify(ruleMapper, times(1)).insert(any(PuzzleFillRuleEntity.class)); + verify(itemMapper, never()).batchInsert(anyList()); + } + + @Test + @DisplayName("更新规则应该先删除旧明细再插入新明细") + void shouldUpdateRuleByDeletingOldItemsAndInsertingNew() { + // Given + PuzzleFillRuleSaveRequest request = createSaveRequest(10L); + request.setItems(Arrays.asList( + createItemDTO("sfp_1", 1), + createItemDTO("sfp_2", 2) + )); + + when(ruleMapper.updateById(any(PuzzleFillRuleEntity.class))).thenReturn(1); + when(itemMapper.deleteByRuleId(10L)).thenReturn(2); + when(itemMapper.batchInsert(anyList())).thenReturn(2); + + // When + Boolean result = service.update(request); + + // Then + assertTrue(result); + + // 验证更新主规则 + verify(ruleMapper, times(1)).updateById(any(PuzzleFillRuleEntity.class)); + + // 验证删除旧明细 + verify(itemMapper, times(1)).deleteByRuleId(10L); + + // 验证插入新明细 + verify(itemMapper, times(1)).batchInsert(anyList()); + } + + @Test + @DisplayName("更新规则时ID为null应该抛出异常") + void shouldThrowExceptionWhenUpdateWithNullId() { + // Given + PuzzleFillRuleSaveRequest request = createSaveRequest(null); + + // When & Then + assertThrows(IllegalArgumentException.class, () -> service.update(request)); + verify(ruleMapper, never()).updateById(any()); + } + + @Test + @DisplayName("删除规则应该级联删除明细") + void shouldDeleteRuleWithCascade() { + // Given + Long ruleId = 10L; + when(ruleMapper.deleteById(ruleId)).thenReturn(1); + + // When + Boolean result = service.delete(ruleId); + + // Then + assertTrue(result); + verify(ruleMapper, times(1)).deleteById(ruleId); + // 明细由数据库外键级联删除,不需要手动删除 + } + + @Test + @DisplayName("查询单条规则应该包含明细") + void shouldGetRuleByIdWithItems() { + // Given + Long ruleId = 10L; + + PuzzleFillRuleEntity ruleEntity = createRuleEntity(ruleId); + when(ruleMapper.selectById(ruleId)).thenReturn(ruleEntity); + + List itemEntities = Arrays.asList( + createItemEntity(1L, ruleId, "sfp_1"), + createItemEntity(2L, ruleId, "sfp_2") + ); + when(itemMapper.listByRuleId(ruleId)).thenReturn(itemEntities); + + // When + PuzzleFillRuleDTO dto = service.getById(ruleId); + + // Then + assertNotNull(dto); + assertEquals(ruleId, dto.getId()); + assertEquals("测试规则", dto.getRuleName()); + assertNotNull(dto.getItems()); + assertEquals(2, dto.getItems().size()); + assertEquals("sfp_1", dto.getItems().get(0).getElementKey()); + assertEquals("sfp_2", dto.getItems().get(1).getElementKey()); + } + + @Test + @DisplayName("查询单条规则时规则不存在应该返回null") + void shouldReturnNullWhenRuleNotFound() { + // Given + Long ruleId = 999L; + when(ruleMapper.selectById(ruleId)).thenReturn(null); + + // When + PuzzleFillRuleDTO dto = service.getById(ruleId); + + // Then + assertNull(dto); + verify(itemMapper, never()).listByRuleId(anyLong()); + } + + @Test + @DisplayName("查询模板的所有规则应该包含明细") + void shouldListRulesByTemplateIdWithItems() { + // Given + Long templateId = 1L; + + List ruleEntities = Arrays.asList( + createRuleEntity(1L), + createRuleEntity(2L) + ); + when(ruleMapper.listByTemplateId(templateId)).thenReturn(ruleEntities); + + when(itemMapper.listByRuleId(1L)).thenReturn(Arrays.asList( + createItemEntity(1L, 1L, "sfp_1") + )); + when(itemMapper.listByRuleId(2L)).thenReturn(Arrays.asList( + createItemEntity(2L, 2L, "sfp_2") + )); + + // When + List dtos = service.listByTemplateId(templateId); + + // Then + assertEquals(2, dtos.size()); + assertEquals(1L, dtos.get(0).getId()); + assertEquals(1, dtos.get(0).getItems().size()); + assertEquals(2L, dtos.get(1).getId()); + assertEquals(1, dtos.get(1).getItems().size()); + + verify(itemMapper, times(1)).listByRuleId(1L); + verify(itemMapper, times(1)).listByRuleId(2L); + } + + @Test + @DisplayName("启用/禁用规则应该更新状态") + void shouldToggleEnabled() { + // Given + Long ruleId = 10L; + Integer enabled = 1; + + when(ruleMapper.update(any(), any())).thenReturn(1); + + // When + Boolean result = service.toggleEnabled(ruleId, enabled); + + // Then + assertTrue(result); + verify(ruleMapper, times(1)).update(any(), any()); + } + + // 辅助方法 + private PuzzleFillRuleSaveRequest createSaveRequest(Long id) { + PuzzleFillRuleSaveRequest request = new PuzzleFillRuleSaveRequest(); + request.setId(id); + request.setTemplateId(1L); + request.setRuleName("测试规则"); + request.setConditionType("DEVICE_COUNT"); + request.setConditionValue("{\"deviceCount\": 4}"); + request.setPriority(100); + request.setEnabled(1); + request.setScenicId(1L); + request.setDescription("测试描述"); + return request; + } + + private PuzzleFillRuleItemDTO createItemDTO(String elementKey, int order) { + PuzzleFillRuleItemDTO dto = new PuzzleFillRuleItemDTO(); + dto.setElementKey(elementKey); + dto.setDataSource("DEVICE_IMAGE"); + dto.setSourceFilter("{\"deviceIndex\": 0}"); + dto.setSortStrategy("LATEST"); + dto.setItemOrder(order); + return dto; + } + + private PuzzleFillRuleEntity createRuleEntity(Long id) { + PuzzleFillRuleEntity entity = new PuzzleFillRuleEntity(); + entity.setId(id); + entity.setTemplateId(1L); + entity.setRuleName("测试规则"); + entity.setConditionType("DEVICE_COUNT"); + entity.setConditionValue("{\"deviceCount\": 4}"); + entity.setPriority(100); + entity.setEnabled(1); + entity.setScenicId(1L); + return entity; + } + + private PuzzleFillRuleItemEntity createItemEntity(Long id, Long ruleId, String elementKey) { + PuzzleFillRuleItemEntity entity = new PuzzleFillRuleItemEntity(); + entity.setId(id); + entity.setRuleId(ruleId); + entity.setElementKey(elementKey); + entity.setDataSource("DEVICE_IMAGE"); + entity.setSourceFilter("{\"deviceIndex\": 0}"); + entity.setSortStrategy("LATEST"); + entity.setItemOrder(id.intValue()); + return entity; + } +}