You've already forked FrameTour-BE
feat(notification): 添加微信订阅消息配置管理及幂等授权功能
- 新增微信订阅消息配置管理控制器,支持模板、场景、事件映射配置 - 实现用户通知授权服务的幂等控制,避免前端重试导致授权次数虚增 - 添加微信订阅消息发送日志记录,用于幂等与排障 - 新增视频生成完成时的订阅消息触发功能 - 实现场景模板查询接口,返回用户授权余额信息 - 添加模板V2相关数据表映射器和实体类 - 集成微信订阅消息触发服务到任务完成流程中
This commit is contained in:
@@ -0,0 +1,291 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user