feat(notification): 添加微信订阅消息配置管理及幂等授权功能

- 新增微信订阅消息配置管理控制器,支持模板、场景、事件映射配置
- 实现用户通知授权服务的幂等控制,避免前端重试导致授权次数虚增
- 添加微信订阅消息发送日志记录,用于幂等与排障
- 新增视频生成完成时的订阅消息触发功能
- 实现场景模板查询接口,返回用户授权余额信息
- 添加模板V2相关数据表映射器和实体类
- 集成微信订阅消息触发服务到任务完成流程中
This commit is contained in:
2026-01-01 17:53:59 +08:00
parent 81dc2f1b86
commit f1a2958251
61 changed files with 3655 additions and 9 deletions

View File

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