You've already forked FrameTour-BE
- 新增微信订阅消息配置管理控制器,支持模板、场景、事件映射配置 - 实现用户通知授权服务的幂等控制,避免前端重试导致授权次数虚增 - 添加微信订阅消息发送日志记录,用于幂等与排障 - 新增视频生成完成时的订阅消息触发功能 - 实现场景模板查询接口,返回用户授权余额信息 - 添加模板V2相关数据表映射器和实体类 - 集成微信订阅消息触发服务到任务完成流程中
292 lines
12 KiB
Java
292 lines
12 KiB
Java
package com.ycwl.basic.template.service;
|
|
|
|
import com.fasterxml.jackson.core.JsonProcessingException;
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
|
import com.ycwl.basic.exception.BizException;
|
|
import com.ycwl.basic.mapper.TemplateV2Mapper;
|
|
import com.ycwl.basic.mapper.TemplateV2SegmentMapper;
|
|
import com.ycwl.basic.template.api.ITemplateRenderPlanApi;
|
|
import com.ycwl.basic.template.api.dto.RenderPlan;
|
|
import com.ycwl.basic.template.api.dto.RenderPlanSegment;
|
|
import com.ycwl.basic.template.api.dto.TemplatePlanBuildCommand;
|
|
import com.ycwl.basic.template.api.dto.TemplateSlotMaterial;
|
|
import com.ycwl.basic.template.enums.TemplateV2SegmentType;
|
|
import com.ycwl.basic.template.enums.TemplateV2SourceType;
|
|
import com.ycwl.basic.template.model.entity.TemplateV2Entity;
|
|
import com.ycwl.basic.template.model.entity.TemplateV2SegmentEntity;
|
|
import com.ycwl.basic.template.service.onlyif.OnlyIfExpression;
|
|
import com.ycwl.basic.template.service.onlyif.OnlyIfExpressionParser;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.stereotype.Service;
|
|
|
|
import java.net.URI;
|
|
import java.util.ArrayList;
|
|
import java.util.HashMap;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
import java.util.Objects;
|
|
|
|
/**
|
|
* Template v2:RenderPlan 冻结服务
|
|
*/
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class TemplateRenderPlanService implements ITemplateRenderPlanApi {
|
|
|
|
private static final int STATUS_ENABLED = 1;
|
|
|
|
private final TemplateV2Mapper templateV2Mapper;
|
|
private final TemplateV2SegmentMapper templateV2SegmentMapper;
|
|
private final ObjectMapper objectMapper;
|
|
private final OnlyIfExpressionParser onlyIfExpressionParser;
|
|
|
|
@Override
|
|
public RenderPlan buildRenderPlan(TemplatePlanBuildCommand command) {
|
|
if (command == null || command.getTemplateId() == null) {
|
|
throw new BizException(400, "templateId不能为空");
|
|
}
|
|
|
|
TemplateV2Entity template = templateV2Mapper.getById(command.getTemplateId());
|
|
if (template == null) {
|
|
throw new BizException(404, "模板不存在");
|
|
}
|
|
if (command.getTemplateVersion() != null && !command.getTemplateVersion().equals(template.getVersion())) {
|
|
throw new BizException(400, "模板版本不匹配");
|
|
}
|
|
if (template.getStatus() != null && !Objects.equals(template.getStatus(), STATUS_ENABLED)) {
|
|
throw new BizException(400, "模板未启用");
|
|
}
|
|
validateOutputSpec(template);
|
|
|
|
List<TemplateV2SegmentEntity> templateSegments = templateV2SegmentMapper.listByTemplateId(template.getId());
|
|
if (templateSegments == null || templateSegments.isEmpty()) {
|
|
throw new BizException(400, "模板未配置片段");
|
|
}
|
|
|
|
Map<String, List<TemplateSlotMaterial>> materialsBySlot = Objects.requireNonNullElse(command.getMaterialsBySlot(), Map.of());
|
|
Map<String, Integer> slotCounts = buildSlotCounts(materialsBySlot);
|
|
|
|
List<RenderPlanSegment> planSegments = new ArrayList<>();
|
|
int startTimeMs = 0;
|
|
int planSegmentIndex = 0;
|
|
for (TemplateV2SegmentEntity templateSegment : templateSegments) {
|
|
validateSegment(templateSegment);
|
|
if (!shouldInclude(templateSegment, slotCounts)) {
|
|
continue;
|
|
}
|
|
|
|
RenderPlanSegment planSegment = new RenderPlanSegment();
|
|
planSegment.setTemplateSegmentIndex(templateSegment.getSegmentIndex());
|
|
planSegment.setPlanSegmentIndex(planSegmentIndex++);
|
|
planSegment.setStartTimeMs(startTimeMs);
|
|
planSegment.setDurationMs(templateSegment.getDurationMs());
|
|
planSegment.setSegmentType(templateSegment.getSegmentType());
|
|
planSegment.setSourceType(templateSegment.getSourceType());
|
|
planSegment.setSourceRef(templateSegment.getSourceRef());
|
|
|
|
String boundMaterialUrl = bindMaterial(templateSegment, materialsBySlot);
|
|
planSegment.setBoundMaterialUrl(boundMaterialUrl);
|
|
|
|
planSegment.setRenderSpec(parseJsonRequiredOrNull(templateSegment.getRenderSpecJson(), "renderSpecJson"));
|
|
planSegment.setAudioSpec(parseJsonRequiredOrNull(templateSegment.getAudioSpecJson(), "audioSpecJson"));
|
|
|
|
planSegments.add(planSegment);
|
|
startTimeMs = Math.addExact(startTimeMs, templateSegment.getDurationMs());
|
|
}
|
|
|
|
if (planSegments.isEmpty()) {
|
|
throw new BizException(400, "RenderPlan为空:所有片段均被only_if裁剪");
|
|
}
|
|
|
|
RenderPlan plan = new RenderPlan();
|
|
plan.setTemplateId(template.getId());
|
|
plan.setTemplateVersion(template.getVersion());
|
|
plan.setOutputWidth(template.getOutputWidth());
|
|
plan.setOutputHeight(template.getOutputHeight());
|
|
plan.setOutputFps(template.getOutputFps());
|
|
plan.setBgmUrl(template.getBgmUrl());
|
|
plan.setTotalDurationMs(startTimeMs);
|
|
plan.setSegments(planSegments);
|
|
return plan;
|
|
}
|
|
|
|
private void validateOutputSpec(TemplateV2Entity template) {
|
|
if (template.getOutputWidth() == null || template.getOutputWidth() <= 0) {
|
|
throw new BizException(400, "outputWidth非法");
|
|
}
|
|
if (template.getOutputHeight() == null || template.getOutputHeight() <= 0) {
|
|
throw new BizException(400, "outputHeight非法");
|
|
}
|
|
if (template.getOutputFps() == null || template.getOutputFps() <= 0) {
|
|
throw new BizException(400, "outputFps非法");
|
|
}
|
|
}
|
|
|
|
private Map<String, Integer> buildSlotCounts(Map<String, List<TemplateSlotMaterial>> materialsBySlot) {
|
|
Map<String, Integer> counts = new HashMap<>();
|
|
for (Map.Entry<String, List<TemplateSlotMaterial>> entry : materialsBySlot.entrySet()) {
|
|
String slotKey = entry.getKey();
|
|
List<TemplateSlotMaterial> materials = entry.getValue();
|
|
int count = 0;
|
|
if (materials != null) {
|
|
for (TemplateSlotMaterial material : materials) {
|
|
if (material != null && material.getUrl() != null && !material.getUrl().isBlank()) {
|
|
count++;
|
|
}
|
|
}
|
|
}
|
|
counts.put(slotKey, count);
|
|
}
|
|
return counts;
|
|
}
|
|
|
|
private void validateSegment(TemplateV2SegmentEntity segment) {
|
|
if (segment.getDurationMs() == null || segment.getDurationMs() <= 0) {
|
|
throw new BizException(400, "片段durationMs非法:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
TemplateV2SegmentType segmentType = TemplateV2SegmentType.parse(segment.getSegmentType());
|
|
if (segmentType == null) {
|
|
throw new BizException(400, "片段segmentType非法:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
TemplateV2SourceType sourceType = TemplateV2SourceType.parse(segment.getSourceType());
|
|
if (sourceType == null) {
|
|
throw new BizException(400, "片段sourceType非法:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
if (segmentType == TemplateV2SegmentType.FIXED && sourceType != TemplateV2SourceType.FIXED_URL) {
|
|
throw new BizException(400, "FIXED片段仅支持sourceType=FIXED_URL:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
if (segmentType == TemplateV2SegmentType.RENDER && sourceType != TemplateV2SourceType.SLOT) {
|
|
throw new BizException(400, "RENDER片段仅支持sourceType=SLOT:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
if (segment.getSourceRef() == null || segment.getSourceRef().isBlank()) {
|
|
throw new BizException(400, "片段sourceRef不能为空:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
}
|
|
|
|
private boolean shouldInclude(TemplateV2SegmentEntity segment, Map<String, Integer> slotCounts) {
|
|
String onlyIfExpr = segment.getOnlyIfExpr();
|
|
if (onlyIfExpr == null || onlyIfExpr.isBlank()) {
|
|
return true;
|
|
}
|
|
JsonNode node;
|
|
try {
|
|
node = objectMapper.readTree(onlyIfExpr);
|
|
} catch (JsonProcessingException e) {
|
|
throw new BizException(400, "onlyIfExpr解析失败:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
OnlyIfExpression expr = onlyIfExpressionParser.parse(node);
|
|
return expr.evaluate(slotCounts);
|
|
}
|
|
|
|
private String bindMaterial(TemplateV2SegmentEntity segment, Map<String, List<TemplateSlotMaterial>> materialsBySlot) {
|
|
TemplateV2SegmentType segmentType = TemplateV2SegmentType.parse(segment.getSegmentType());
|
|
if (segmentType == TemplateV2SegmentType.FIXED) {
|
|
if (!isHttpUrl(segment.getSourceRef())) {
|
|
throw new BizException(400, "FIXED片段sourceRef必须是http/https URL:segmentIndex=" + segment.getSegmentIndex());
|
|
}
|
|
return segment.getSourceRef();
|
|
}
|
|
String slotKey = segment.getSourceRef();
|
|
TemplateSlotMaterial chosen = chooseMaterial(materialsBySlot.get(slotKey));
|
|
if (chosen == null) {
|
|
throw new BizException(400, "缺少slot素材:" + slotKey);
|
|
}
|
|
if (!isHttpUrl(chosen.getUrl())) {
|
|
throw new BizException(400, "slot素材URL必须是http/https:" + slotKey);
|
|
}
|
|
return chosen.getUrl();
|
|
}
|
|
|
|
private TemplateSlotMaterial chooseMaterial(List<TemplateSlotMaterial> materials) {
|
|
if (materials == null || materials.isEmpty()) {
|
|
return null;
|
|
}
|
|
boolean hasCreateTime = false;
|
|
for (TemplateSlotMaterial material : materials) {
|
|
if (material != null && material.getCreateTimeMs() != null) {
|
|
hasCreateTime = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasCreateTime) {
|
|
for (TemplateSlotMaterial material : materials) {
|
|
if (material != null && material.getUrl() != null && !material.getUrl().isBlank()) {
|
|
return material;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
TemplateSlotMaterial best = null;
|
|
for (TemplateSlotMaterial material : materials) {
|
|
if (material == null || material.getUrl() == null || material.getUrl().isBlank()) {
|
|
continue;
|
|
}
|
|
if (best == null) {
|
|
best = material;
|
|
continue;
|
|
}
|
|
best = compareMaterial(material, best) < 0 ? material : best;
|
|
}
|
|
return best;
|
|
}
|
|
|
|
private int compareMaterial(TemplateSlotMaterial a, TemplateSlotMaterial b) {
|
|
Long aTime = a.getCreateTimeMs();
|
|
Long bTime = b.getCreateTimeMs();
|
|
if (aTime == null && bTime == null) {
|
|
return compareUrl(a.getUrl(), b.getUrl());
|
|
}
|
|
if (aTime == null) {
|
|
return 1;
|
|
}
|
|
if (bTime == null) {
|
|
return -1;
|
|
}
|
|
int timeCompare = Long.compare(bTime, aTime);
|
|
if (timeCompare != 0) {
|
|
return timeCompare;
|
|
}
|
|
return compareUrl(a.getUrl(), b.getUrl());
|
|
}
|
|
|
|
private int compareUrl(String a, String b) {
|
|
if (a == null && b == null) {
|
|
return 0;
|
|
}
|
|
if (a == null) {
|
|
return 1;
|
|
}
|
|
if (b == null) {
|
|
return -1;
|
|
}
|
|
return a.compareTo(b);
|
|
}
|
|
|
|
private JsonNode parseJsonRequiredOrNull(String json, String fieldName) {
|
|
if (json == null || json.isBlank()) {
|
|
return null;
|
|
}
|
|
try {
|
|
return objectMapper.readTree(json);
|
|
} catch (JsonProcessingException e) {
|
|
throw new BizException(400, fieldName + "解析失败");
|
|
}
|
|
}
|
|
|
|
private boolean isHttpUrl(String url) {
|
|
if (url == null || url.isBlank()) {
|
|
return false;
|
|
}
|
|
try {
|
|
URI uri = URI.create(url);
|
|
String scheme = uri.getScheme();
|
|
return "http".equalsIgnoreCase(scheme) || "https".equalsIgnoreCase(scheme);
|
|
} catch (Exception e) {
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|