feat(puzzle): 实现拼图自动填充规则引擎及相关功能

- 新增拼图填充规则管理Controller、DTO、Entity等核心类
- 实现条件评估策略模式,支持多种匹配规则
- 实现数据源解析策略模式,支持多种数据来源
- 新增拼图元素自动填充引擎,支持优先级匹配和动态填充
- 在SourceMapper中增加设备统计和查询相关方法
- 在PuzzleGenerateRequest中新增faceId字段用于触发自动填充
- 完善相关枚举类和工具类,提升系统可维护性和扩展性
This commit is contained in:
2025-11-19 11:10:23 +08:00
parent de421cf0d5
commit 778afaaa83
43 changed files with 4019 additions and 3 deletions

View File

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

View File

@@ -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<Long> 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<Boolean> 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<Boolean> 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<PuzzleFillRuleDTO> 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<List<PuzzleFillRuleDTO>> listByTemplateId(@PathVariable Long templateId) {
log.info("查询模板的所有填充规则, templateId={}", templateId);
List<PuzzleFillRuleDTO> rules = fillRuleService.listByTemplateId(templateId);
return ApiResponse.success(rules);
}
/**
* 启用/禁用规则
*
* @param id 规则ID
* @param enabled 是否启用(0-禁用 1-启用)
* @return 是否成功
*/
@PostMapping("/{id}/toggle")
public ApiResponse<Boolean> toggleEnabled(@PathVariable Long id,
@RequestParam Integer enabled) {
log.info("切换规则启用状态, ruleId={}, enabled={}", id, enabled);
Boolean success = fillRuleService.toggleEnabled(id, enabled);
return ApiResponse.success(success);
}
}

View File

@@ -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<PuzzleFillRuleItemDTO> items;
/**
* 创建时间
*/
private LocalDateTime createTime;
/**
* 更新时间
*/
private LocalDateTime updateTime;
}

View File

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

View File

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

View File

@@ -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<String, String> dynamicData;

View File

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

View File

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

View File

@@ -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<String, String> execute(Long templateId, Long faceId, Long scenicId) {
Map<String, String> dynamicData = new HashMap<>();
try {
// 1. 查询模板的所有启用规则(按priority DESC排序)
List<PuzzleFillRuleEntity> 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<Long> 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<PuzzleFillRuleItemEntity> 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;
}
}

View File

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

View File

@@ -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<Long> deviceIds;
/**
* 可扩展的其他上下文数据
*/
private Object extra;
}

View File

@@ -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<String, ConditionStrategy> strategyMap;
private final ObjectMapper objectMapper;
@Autowired
public ConditionEvaluator(List<ConditionStrategy> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<String, DataSourceStrategy> strategyMap;
private final ObjectMapper objectMapper;
@Autowired
public DataSourceResolver(List<DataSourceStrategy> 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;
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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; // 默认返回最新
}
}

View File

@@ -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<PuzzleFillRuleItemEntity> {
/**
* 根据规则ID查询所有明细(按item_order升序)
*
* @param ruleId 规则ID
* @return 明细列表
*/
List<PuzzleFillRuleItemEntity> listByRuleId(@Param("ruleId") Long ruleId);
/**
* 批量插入明细
*
* @param items 明细列表
* @return 插入数量
*/
int batchInsert(@Param("items") List<PuzzleFillRuleItemEntity> items);
/**
* 根据规则ID删除所有明细
*
* @param ruleId 规则ID
* @return 删除数量
*/
int deleteByRuleId(@Param("ruleId") Long ruleId);
}

View File

@@ -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<PuzzleFillRuleEntity> {
/**
* 根据模板ID查询所有启用的规则(按优先级降序)
*
* @param templateId 模板ID
* @return 规则列表
*/
List<PuzzleFillRuleEntity> listByTemplateId(@Param("templateId") Long templateId);
/**
* 根据模板ID和景区ID查询所有启用的规则(按优先级降序)
*
* @param templateId 模板ID
* @param scenicId 景区ID
* @return 规则列表
*/
List<PuzzleFillRuleEntity> listByTemplateAndScenic(@Param("templateId") Long templateId, @Param("scenicId") Long scenicId);
}

View File

@@ -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<PuzzleFillRuleDTO> listByTemplateId(Long templateId);
/**
* 启用/禁用规则
*
* @param id 规则ID
* @param enabled 是否启用
* @return 是否成功
*/
Boolean toggleEnabled(Long id, Integer enabled);
}

View File

@@ -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<PuzzleFillRuleItemEntity> 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<PuzzleFillRuleItemEntity> 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<PuzzleFillRuleItemEntity> itemEntities = itemMapper.listByRuleId(id);
if (itemEntities != null && !itemEntities.isEmpty()) {
List<PuzzleFillRuleItemDTO> 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<PuzzleFillRuleDTO> listByTemplateId(Long templateId) {
List<PuzzleFillRuleEntity> 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<PuzzleFillRuleItemEntity> itemEntities = itemMapper.listByRuleId(ruleEntity.getId());
if (itemEntities != null && !itemEntities.isEmpty()) {
List<PuzzleFillRuleItemDTO> 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<PuzzleFillRuleEntity> 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;
}
}

View File

@@ -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<String, String> 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<String, String> buildDynamicData(PuzzleTemplateEntity template, PuzzleGenerateRequest request) {
Map<String, String> dynamicData = new HashMap<>();
// 1. 自动填充(基于faceId和规则)
if (request.getFaceId() != null && request.getScenicId() != null) {
try {
Map<String, String> 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;
}
}