diff --git a/src/main/java/com/ycwl/basic/controller/mobile/notify/UserNotificationAuthController.java b/src/main/java/com/ycwl/basic/controller/mobile/notify/UserNotificationAuthController.java index 198b42c5..a948d450 100644 --- a/src/main/java/com/ycwl/basic/controller/mobile/notify/UserNotificationAuthController.java +++ b/src/main/java/com/ycwl/basic/controller/mobile/notify/UserNotificationAuthController.java @@ -41,7 +41,8 @@ public class UserNotificationAuthController { @PostMapping("/record") public ApiResponse recordAuthorization( @RequestBody NotificationAuthRecordReq req) { - log.debug("记录用户通知授权: templateIds={}, scenicId={}", req.getTemplateIds(), req.getScenicId()); + log.debug("记录用户通知授权: templateIds={}, scenicId={}, requestId={}", + req.getTemplateIds(), req.getScenicId(), req.getRequestId()); try { // 获取当前用户ID @@ -50,7 +51,7 @@ public class UserNotificationAuthController { // 调用批量授权记录方法 List records = userNotificationAuthorizationService.batchRecordAuthorization( - memberId, req.getTemplateIds(), req.getScenicId()); + memberId, req.getTemplateIds(), req.getScenicId(), req.getRequestId()); NotificationAuthRecordResp resp = new NotificationAuthRecordResp(); @@ -187,4 +188,4 @@ public class UserNotificationAuthController { return ApiResponse.fail("获取授权信息失败: " + e.getMessage()); } } -} \ No newline at end of file +} diff --git a/src/main/java/com/ycwl/basic/controller/mobile/notify/WechatSubscribeNotifyController.java b/src/main/java/com/ycwl/basic/controller/mobile/notify/WechatSubscribeNotifyController.java new file mode 100644 index 00000000..d294e34d --- /dev/null +++ b/src/main/java/com/ycwl/basic/controller/mobile/notify/WechatSubscribeNotifyController.java @@ -0,0 +1,86 @@ +package com.ycwl.basic.controller.mobile.notify; + +import com.ycwl.basic.model.mobile.notify.resp.WechatSubscribeSceneTemplatesResp; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import com.ycwl.basic.service.notify.WechatSubscribeNotifyConfigService; +import com.ycwl.basic.utils.ApiResponse; +import com.ycwl.basic.utils.JwtTokenUtil; +import com.ycwl.basic.utils.NotificationAuthUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.ArrayList; +import java.util.List; +import java.util.Objects; + +/** + * 微信小程序订阅消息:场景模板查询(移动端API) + * + * @Author: System + * @Date: 2025/12/31 + */ +@RestController +@RequestMapping("/api/mobile/notify/subscribe") +@Slf4j +public class WechatSubscribeNotifyController { + + private final WechatSubscribeNotifyConfigService configService; + private final NotificationAuthUtils notificationAuthUtils; + + public WechatSubscribeNotifyController(WechatSubscribeNotifyConfigService configService, + NotificationAuthUtils notificationAuthUtils) { + this.configService = configService; + this.notificationAuthUtils = notificationAuthUtils; + } + + /** + * 获取“场景”下可申请授权的模板列表(支持按 scenicId 覆盖模板ID/开关/文案) + */ + @GetMapping("/scenic/{scenicId}/scenes/{sceneKey}/templates") + public ApiResponse listSceneTemplates(@PathVariable("scenicId") Long scenicId, + @PathVariable("sceneKey") String sceneKey) { + if (scenicId == null) { + return ApiResponse.fail("scenicId不能为空"); + } + if (StringUtils.isBlank(sceneKey)) { + return ApiResponse.fail("sceneKey不能为空"); + } + + Long memberId = JwtTokenUtil.getWorker().getUserId(); + List configs = configService.listSceneTemplateConfigs(scenicId, sceneKey); + + WechatSubscribeSceneTemplatesResp resp = new WechatSubscribeSceneTemplatesResp(); + resp.setScenicId(scenicId); + resp.setSceneKey(sceneKey); + + List templates = new ArrayList<>(); + for (WechatSubscribeTemplateConfigEntity cfg : configs) { + if (cfg == null || StringUtils.isBlank(cfg.getWechatTemplateId())) { + continue; + } + String title = StringUtils.isNotBlank(cfg.getTitleTemplate()) + ? cfg.getTitleTemplate() + : cfg.getTemplateKey(); + int remaining = notificationAuthUtils.getRemainingCount(memberId, cfg.getWechatTemplateId(), scenicId); + + WechatSubscribeSceneTemplatesResp.TemplateInfo info = new WechatSubscribeSceneTemplatesResp.TemplateInfo(); + info.setTemplateKey(cfg.getTemplateKey()); + info.setWechatTemplateId(cfg.getWechatTemplateId()); + info.setTitle(title); + info.setDescription(cfg.getDescription()); + info.setRemainingCount(remaining); + info.setHasAuthorization(remaining > 0); + templates.add(info); + } + resp.setTemplates(templates); + + log.debug("场景模板查询: scenicId={}, sceneKey={}, memberId={}, templateCount={}", + scenicId, sceneKey, memberId, Objects.requireNonNullElse(templates.size(), 0)); + return ApiResponse.success(resp); + } +} + diff --git a/src/main/java/com/ycwl/basic/controller/pc/WechatSubscribeNotifyAdminController.java b/src/main/java/com/ycwl/basic/controller/pc/WechatSubscribeNotifyAdminController.java new file mode 100644 index 00000000..750ca34b --- /dev/null +++ b/src/main/java/com/ycwl/basic/controller/pc/WechatSubscribeNotifyAdminController.java @@ -0,0 +1,122 @@ +package com.ycwl.basic.controller.pc; + +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplatePageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplateSaveReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplatePageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplateSaveReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSendLogPageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigPageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigSaveReq; +import com.ycwl.basic.service.pc.WechatSubscribeNotifyAdminService; +import com.ycwl.basic.utils.ApiResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * 微信小程序订阅消息:配置管理(管理后台) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Slf4j +@RestController +@RequestMapping("/api/wechatSubscribeNotify/v1") +@RequiredArgsConstructor +public class WechatSubscribeNotifyAdminController { + + private final WechatSubscribeNotifyAdminService adminService; + + // ========================= 模板配置 ========================= + + @PostMapping("/templateConfig/page") + public ApiResponse> pageTemplateConfig( + @RequestBody WechatSubscribeTemplateConfigPageReq req) { + return adminService.pageTemplateConfig(req); + } + + @GetMapping("/templateConfig/detail/{id}") + public ApiResponse getTemplateConfig(@PathVariable("id") Long id) { + return adminService.getTemplateConfig(id); + } + + @PostMapping("/templateConfig/save") + public ApiResponse saveTemplateConfig(@RequestBody WechatSubscribeTemplateConfigSaveReq req) { + return adminService.saveTemplateConfig(req); + } + + @DeleteMapping("/templateConfig/delete/{id}") + public ApiResponse deleteTemplateConfig(@PathVariable("id") Long id) { + return adminService.deleteTemplateConfig(id); + } + + // ========================= 场景映射 ========================= + + @PostMapping("/sceneTemplate/page") + public ApiResponse> pageSceneTemplate( + @RequestBody WechatSubscribeSceneTemplatePageReq req) { + return adminService.pageSceneTemplate(req); + } + + @GetMapping("/sceneTemplate/detail/{id}") + public ApiResponse getSceneTemplate(@PathVariable("id") Long id) { + return adminService.getSceneTemplate(id); + } + + @PostMapping("/sceneTemplate/save") + public ApiResponse saveSceneTemplate(@RequestBody WechatSubscribeSceneTemplateSaveReq req) { + return adminService.saveSceneTemplate(req); + } + + @DeleteMapping("/sceneTemplate/delete/{id}") + public ApiResponse deleteSceneTemplate(@PathVariable("id") Long id) { + return adminService.deleteSceneTemplate(id); + } + + // ========================= 事件映射 ========================= + + @PostMapping("/eventTemplate/page") + public ApiResponse> pageEventTemplate( + @RequestBody WechatSubscribeEventTemplatePageReq req) { + return adminService.pageEventTemplate(req); + } + + @GetMapping("/eventTemplate/detail/{id}") + public ApiResponse getEventTemplate(@PathVariable("id") Long id) { + return adminService.getEventTemplate(id); + } + + @PostMapping("/eventTemplate/save") + public ApiResponse saveEventTemplate(@RequestBody WechatSubscribeEventTemplateSaveReq req) { + return adminService.saveEventTemplate(req); + } + + @DeleteMapping("/eventTemplate/delete/{id}") + public ApiResponse deleteEventTemplate(@PathVariable("id") Long id) { + return adminService.deleteEventTemplate(id); + } + + // ========================= 发送日志 ========================= + + @PostMapping("/sendLog/page") + public ApiResponse> pageSendLog(@RequestBody WechatSubscribeSendLogPageReq req) { + return adminService.pageSendLog(req); + } + + @GetMapping("/sendLog/detail/{id}") + public ApiResponse getSendLog(@PathVariable("id") Long id) { + return adminService.getSendLog(id); + } +} + diff --git a/src/main/java/com/ycwl/basic/mapper/TemplateV2Mapper.java b/src/main/java/com/ycwl/basic/mapper/TemplateV2Mapper.java new file mode 100644 index 00000000..1509d927 --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/TemplateV2Mapper.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.mapper; + +import com.ycwl.basic.template.model.entity.TemplateV2Entity; +import com.ycwl.basic.template.model.req.TemplateV2ReqQuery; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * Template v2 Mapper + */ +@Mapper +public interface TemplateV2Mapper { + List list(TemplateV2ReqQuery query); + + TemplateV2Entity getById(@Param("id") Long id); + + int insert(TemplateV2Entity entity); + + int updateById(TemplateV2Entity entity); + + int updateStatus(@Param("id") Long id, @Param("status") Integer status); + + int softDelete(@Param("id") Long id); +} + diff --git a/src/main/java/com/ycwl/basic/mapper/TemplateV2SegmentMapper.java b/src/main/java/com/ycwl/basic/mapper/TemplateV2SegmentMapper.java new file mode 100644 index 00000000..f29f5dae --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/TemplateV2SegmentMapper.java @@ -0,0 +1,20 @@ +package com.ycwl.basic.mapper; + +import com.ycwl.basic.template.model.entity.TemplateV2SegmentEntity; +import org.apache.ibatis.annotations.Mapper; +import org.apache.ibatis.annotations.Param; + +import java.util.List; + +/** + * Template v2 Segment Mapper + */ +@Mapper +public interface TemplateV2SegmentMapper { + List listByTemplateId(@Param("templateId") Long templateId); + + int deleteByTemplateId(@Param("templateId") Long templateId); + + int batchInsert(@Param("segments") List segments); +} + diff --git a/src/main/java/com/ycwl/basic/mapper/UserNotificationAuthorizationRecordMapper.java b/src/main/java/com/ycwl/basic/mapper/UserNotificationAuthorizationRecordMapper.java new file mode 100644 index 00000000..d2d4ccc3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/UserNotificationAuthorizationRecordMapper.java @@ -0,0 +1,16 @@ +package com.ycwl.basic.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationRecordEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 用户订阅消息授权明细Mapper(幂等) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Mapper +public interface UserNotificationAuthorizationRecordMapper extends BaseMapper { +} + diff --git a/src/main/java/com/ycwl/basic/mapper/WechatSubscribeEventTemplateMapper.java b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeEventTemplateMapper.java new file mode 100644 index 00000000..da47aaee --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeEventTemplateMapper.java @@ -0,0 +1,16 @@ +package com.ycwl.basic.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 微信订阅消息事件模板映射Mapper + * + * @Author: System + * @Date: 2025/12/31 + */ +@Mapper +public interface WechatSubscribeEventTemplateMapper extends BaseMapper { +} + diff --git a/src/main/java/com/ycwl/basic/mapper/WechatSubscribeSceneTemplateMapper.java b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeSceneTemplateMapper.java new file mode 100644 index 00000000..97996ad9 --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeSceneTemplateMapper.java @@ -0,0 +1,16 @@ +package com.ycwl.basic.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 微信订阅消息场景模板映射Mapper + * + * @Author: System + * @Date: 2025/12/31 + */ +@Mapper +public interface WechatSubscribeSceneTemplateMapper extends BaseMapper { +} + diff --git a/src/main/java/com/ycwl/basic/mapper/WechatSubscribeSendLogMapper.java b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeSendLogMapper.java new file mode 100644 index 00000000..fbc74e9a --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeSendLogMapper.java @@ -0,0 +1,16 @@ +package com.ycwl.basic.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 微信订阅消息发送日志Mapper + * + * @Author: System + * @Date: 2025/12/31 + */ +@Mapper +public interface WechatSubscribeSendLogMapper extends BaseMapper { +} + diff --git a/src/main/java/com/ycwl/basic/mapper/WechatSubscribeTemplateConfigMapper.java b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeTemplateConfigMapper.java new file mode 100644 index 00000000..41954e98 --- /dev/null +++ b/src/main/java/com/ycwl/basic/mapper/WechatSubscribeTemplateConfigMapper.java @@ -0,0 +1,16 @@ +package com.ycwl.basic.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import org.apache.ibatis.annotations.Mapper; + +/** + * 微信订阅消息模板配置Mapper + * + * @Author: System + * @Date: 2025/12/31 + */ +@Mapper +public interface WechatSubscribeTemplateConfigMapper extends BaseMapper { +} + diff --git a/src/main/java/com/ycwl/basic/model/mobile/notify/req/NotificationAuthRecordReq.java b/src/main/java/com/ycwl/basic/model/mobile/notify/req/NotificationAuthRecordReq.java index 37324387..bccf3d12 100644 --- a/src/main/java/com/ycwl/basic/model/mobile/notify/req/NotificationAuthRecordReq.java +++ b/src/main/java/com/ycwl/basic/model/mobile/notify/req/NotificationAuthRecordReq.java @@ -14,16 +14,25 @@ import java.util.List; */ @Data public class NotificationAuthRecordReq { - + /** * 通知模板ID列表 - 支持批量授权 */ @NotEmpty(message = "模板ID列表不能为空") private List templateIds; - + /** * 景区ID */ @NotNull(message = "景区ID不能为空") private Long scenicId; -} \ No newline at end of file + + /** + * 前端幂等ID(可选) + *

+ * 目的:避免前端重试导致授权次数虚增。 + * 同一次用户授权动作(一次 requestSubscribeMessage)建议复用同一个 requestId。 + *

+ */ + private String requestId; +} diff --git a/src/main/java/com/ycwl/basic/model/mobile/notify/resp/WechatSubscribeSceneTemplatesResp.java b/src/main/java/com/ycwl/basic/model/mobile/notify/resp/WechatSubscribeSceneTemplatesResp.java new file mode 100644 index 00000000..7e01357a --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/mobile/notify/resp/WechatSubscribeSceneTemplatesResp.java @@ -0,0 +1,55 @@ +package com.ycwl.basic.model.mobile.notify.resp; + +import lombok.Data; + +import java.util.List; + +/** + * 场景可申请的订阅消息模板列表(含用户授权余额) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +public class WechatSubscribeSceneTemplatesResp { + + private Long scenicId; + + private String sceneKey; + + private List templates; + + @Data + public static class TemplateInfo { + /** + * 逻辑模板键(业务固定) + */ + private String templateKey; + + /** + * 微信订阅消息模板ID(tmplId) + */ + private String wechatTemplateId; + + /** + * 前端展示标题 + */ + private String title; + + /** + * 前端展示描述 + */ + private String description; + + /** + * 用户剩余授权次数 + */ + private Integer remainingCount; + + /** + * 是否有授权(remainingCount > 0) + */ + private Boolean hasAuthorization; + } +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/entity/UserNotificationAuthorizationRecordEntity.java b/src/main/java/com/ycwl/basic/model/pc/notify/entity/UserNotificationAuthorizationRecordEntity.java new file mode 100644 index 00000000..b86a312f --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/entity/UserNotificationAuthorizationRecordEntity.java @@ -0,0 +1,38 @@ +package com.ycwl.basic.model.pc.notify.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 用户订阅消息授权明细(幂等) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +@TableName("user_notification_authorization_record") +public class UserNotificationAuthorizationRecordEntity { + + @TableId + private Long id; + + private Long memberId; + + private Long scenicId; + + /** + * 微信订阅消息模板ID(tmplId) + */ + private String templateId; + + /** + * 前端幂等ID(同一次用户授权动作复用) + */ + private String requestId; + + private Date createTime; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeEventTemplateEntity.java b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeEventTemplateEntity.java new file mode 100644 index 00000000..22087b1b --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeEventTemplateEntity.java @@ -0,0 +1,46 @@ +package com.ycwl.basic.model.pc.notify.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 事件到模板映射(后端触发发送用,支持按景区覆盖) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +@TableName("wechat_subscribe_event_template") +public class WechatSubscribeEventTemplateEntity { + + @TableId + private Long id; + + private String eventKey; + + private String templateKey; + + /** + * 景区ID;0=默认配置 + */ + private Long scenicId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + private Integer sortOrder; + + private Integer sendDelaySeconds; + + private Integer dedupSeconds; + + private Date createTime; + + private Date updateTime; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeSceneTemplateEntity.java b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeSceneTemplateEntity.java new file mode 100644 index 00000000..8972b115 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeSceneTemplateEntity.java @@ -0,0 +1,42 @@ +package com.ycwl.basic.model.pc.notify.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 场景到模板映射(前端申请授权用,支持按景区覆盖) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +@TableName("wechat_subscribe_scene_template") +public class WechatSubscribeSceneTemplateEntity { + + @TableId + private Long id; + + private String sceneKey; + + private String templateKey; + + /** + * 景区ID;0=默认配置 + */ + private Long scenicId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + private Integer sortOrder; + + private Date createTime; + + private Date updateTime; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeSendLogEntity.java b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeSendLogEntity.java new file mode 100644 index 00000000..47502e9f --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeSendLogEntity.java @@ -0,0 +1,48 @@ +package com.ycwl.basic.model.pc.notify.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 微信订阅消息发送日志(用于幂等与排障) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +@TableName("wechat_subscribe_send_log") +public class WechatSubscribeSendLogEntity { + + @TableId + private Long id; + + private String idempotencyKey; + + private String eventKey; + + private String templateKey; + + private Long scenicId; + + private Long memberId; + + private String openId; + + private String wechatTemplateId; + + private String ztMessageId; + + private String status; + + private String errorMessage; + + private String payloadJson; + + private Date createTime; + + private Date updateTime; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeTemplateConfigEntity.java b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeTemplateConfigEntity.java new file mode 100644 index 00000000..0b7968fb --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/entity/WechatSubscribeTemplateConfigEntity.java @@ -0,0 +1,71 @@ +package com.ycwl.basic.model.pc.notify.entity; + +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import lombok.Data; + +import java.util.Date; + +/** + * 微信小程序订阅消息模板配置(支持按景区覆盖) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +@TableName("wechat_subscribe_template_config") +public class WechatSubscribeTemplateConfigEntity { + + @TableId + private Long id; + + /** + * 逻辑模板键(业务固定) + */ + private String templateKey; + + /** + * 景区ID;0=默认配置 + */ + private Long scenicId; + + /** + * 微信订阅消息模板ID(tmplId) + */ + private String wechatTemplateId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + /** + * 标题模板(用于日志/后台展示) + */ + private String titleTemplate; + + /** + * 内容模板(用于日志/后台展示) + */ + private String contentTemplate; + + /** + * 跳转页面模板(小程序 page) + */ + private String pageTemplate; + + /** + * data模板JSON:{ "thing1":"${scenicName}", "thing3":"${remark}" } + */ + private String dataTemplateJson; + + /** + * 前端展示用描述 + */ + private String description; + + private Date createTime; + + private Date updateTime; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeEventTemplatePageReq.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeEventTemplatePageReq.java new file mode 100644 index 00000000..afb89c9f --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeEventTemplatePageReq.java @@ -0,0 +1,37 @@ +package com.ycwl.basic.model.pc.notify.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 事件-模板映射分页查询请求 + * + * @Author: System + * @Date: 2025/12/31 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class WechatSubscribeEventTemplatePageReq extends BaseQueryParameterReq { + + /** + * 景区ID;0=默认配置;为空表示不筛选 + */ + private Long scenicId; + + /** + * 事件键(模糊匹配) + */ + private String eventKey; + + /** + * 逻辑模板键(模糊匹配) + */ + private String templateKey; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeEventTemplateSaveReq.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeEventTemplateSaveReq.java new file mode 100644 index 00000000..5cc88621 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeEventTemplateSaveReq.java @@ -0,0 +1,48 @@ +package com.ycwl.basic.model.pc.notify.req; + +import lombok.Data; + +/** + * 事件-模板映射保存请求(新增/修改) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +public class WechatSubscribeEventTemplateSaveReq { + + /** + * 主键ID(为空表示新增;不为空表示更新) + */ + private Long id; + + private String eventKey; + + private String templateKey; + + /** + * 景区ID;0=默认配置 + */ + private Long scenicId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + /** + * 排序(越小越靠前) + */ + private Integer sortOrder; + + /** + * 发送延迟(秒),0表示立即发送(预留) + */ + private Integer sendDelaySeconds; + + /** + * 去重窗口(秒),0表示仅依赖幂等键(预留) + */ + private Integer dedupSeconds; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeNotifyTriggerRequest.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeNotifyTriggerRequest.java new file mode 100644 index 00000000..af8930fe --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeNotifyTriggerRequest.java @@ -0,0 +1,37 @@ +package com.ycwl.basic.model.pc.notify.req; + +import lombok.Builder; +import lombok.Data; + +import java.util.Map; + +/** + * 微信订阅消息触发入参(后端内部调用) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +@Builder +public class WechatSubscribeNotifyTriggerRequest { + + private Long scenicId; + + private Long memberId; + + private String openId; + + /** + * 业务幂等ID(强烈建议必填) + *

+ * 示例:taskId、couponId、faceId+日期 等。 + *

+ */ + private String bizId; + + /** + * 模板渲染变量(${key}) + */ + private Map variables; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSceneTemplatePageReq.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSceneTemplatePageReq.java new file mode 100644 index 00000000..6656c9d8 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSceneTemplatePageReq.java @@ -0,0 +1,37 @@ +package com.ycwl.basic.model.pc.notify.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 场景-模板映射分页查询请求 + * + * @Author: System + * @Date: 2025/12/31 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class WechatSubscribeSceneTemplatePageReq extends BaseQueryParameterReq { + + /** + * 景区ID;0=默认配置;为空表示不筛选 + */ + private Long scenicId; + + /** + * 场景键(模糊匹配) + */ + private String sceneKey; + + /** + * 逻辑模板键(模糊匹配) + */ + private String templateKey; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSceneTemplateSaveReq.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSceneTemplateSaveReq.java new file mode 100644 index 00000000..91819329 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSceneTemplateSaveReq.java @@ -0,0 +1,38 @@ +package com.ycwl.basic.model.pc.notify.req; + +import lombok.Data; + +/** + * 场景-模板映射保存请求(新增/修改) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +public class WechatSubscribeSceneTemplateSaveReq { + + /** + * 主键ID(为空表示新增;不为空表示更新) + */ + private Long id; + + private String sceneKey; + + private String templateKey; + + /** + * 景区ID;0=默认配置 + */ + private Long scenicId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + /** + * 排序(越小越靠前) + */ + private Integer sortOrder; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSendLogPageReq.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSendLogPageReq.java new file mode 100644 index 00000000..da354a73 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeSendLogPageReq.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.model.pc.notify.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 微信订阅消息发送日志分页查询请求 + * + * @Author: System + * @Date: 2025/12/31 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class WechatSubscribeSendLogPageReq extends BaseQueryParameterReq { + + private Long scenicId; + + private Long memberId; + + private String eventKey; + + private String templateKey; + + private String status; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeTemplateConfigPageReq.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeTemplateConfigPageReq.java new file mode 100644 index 00000000..44639d40 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeTemplateConfigPageReq.java @@ -0,0 +1,37 @@ +package com.ycwl.basic.model.pc.notify.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * 微信订阅消息模板配置分页查询请求 + * + * @Author: System + * @Date: 2025/12/31 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class WechatSubscribeTemplateConfigPageReq extends BaseQueryParameterReq { + + /** + * 景区ID;0=默认配置;为空表示不筛选 + */ + private Long scenicId; + + /** + * 逻辑模板键(模糊匹配) + */ + private String templateKey; + + /** + * 微信订阅消息模板ID(模糊匹配) + */ + private String wechatTemplateId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeTemplateConfigSaveReq.java b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeTemplateConfigSaveReq.java new file mode 100644 index 00000000..2e2e55e6 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/req/WechatSubscribeTemplateConfigSaveReq.java @@ -0,0 +1,64 @@ +package com.ycwl.basic.model.pc.notify.req; + +import lombok.Data; + +/** + * 微信订阅消息模板配置保存请求(新增/修改) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +public class WechatSubscribeTemplateConfigSaveReq { + + /** + * 主键ID(为空表示新增;不为空表示更新) + */ + private Long id; + + /** + * 逻辑模板键(业务固定) + */ + private String templateKey; + + /** + * 景区ID;0=默认配置 + */ + private Long scenicId; + + /** + * 微信订阅消息模板ID(tmplId) + */ + private String wechatTemplateId; + + /** + * 是否启用:1启用 0禁用 + */ + private Integer enabled; + + /** + * 标题模板(用于日志/后台展示) + */ + private String titleTemplate; + + /** + * 内容模板(用于日志/后台展示) + */ + private String contentTemplate; + + /** + * 跳转页面模板(小程序 page) + */ + private String pageTemplate; + + /** + * data模板JSON:{ "thing1":"${scenicName}", "thing3":"${remark}" } + */ + private String dataTemplateJson; + + /** + * 前端展示用描述 + */ + private String description; +} + diff --git a/src/main/java/com/ycwl/basic/model/pc/notify/resp/WechatSubscribeNotifyTriggerResult.java b/src/main/java/com/ycwl/basic/model/pc/notify/resp/WechatSubscribeNotifyTriggerResult.java new file mode 100644 index 00000000..dfd76e82 --- /dev/null +++ b/src/main/java/com/ycwl/basic/model/pc/notify/resp/WechatSubscribeNotifyTriggerResult.java @@ -0,0 +1,29 @@ +package com.ycwl.basic.model.pc.notify.resp; + +import lombok.Data; + +/** + * 微信订阅消息触发结果(后端内部调用) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Data +public class WechatSubscribeNotifyTriggerResult { + + /** + * 是否找到了可用配置(eventKey -> templateKey -> templateConfig) + */ + private boolean configFound; + + /** + * 成功投递到消息系统的数量(以 producer send 成功返回为准) + */ + private int sentCount; + + /** + * 跳过数量(无授权/幂等命中/配置不完整等) + */ + private int skippedCount; +} + diff --git a/src/main/java/com/ycwl/basic/repository/WechatSubscribeNotifyConfigRepository.java b/src/main/java/com/ycwl/basic/repository/WechatSubscribeNotifyConfigRepository.java new file mode 100644 index 00000000..cf6748ee --- /dev/null +++ b/src/main/java/com/ycwl/basic/repository/WechatSubscribeNotifyConfigRepository.java @@ -0,0 +1,173 @@ +package com.ycwl.basic.repository; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.ycwl.basic.mapper.WechatSubscribeEventTemplateMapper; +import com.ycwl.basic.mapper.WechatSubscribeSceneTemplateMapper; +import com.ycwl.basic.mapper.WechatSubscribeTemplateConfigMapper; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import org.apache.commons.lang3.StringUtils; +import org.springframework.stereotype.Component; +import org.springframework.util.CollectionUtils; + +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; + +/** + * 微信订阅消息配置仓库(scenic 覆盖:scenic_id=具体值 > scenic_id=0) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Component +public class WechatSubscribeNotifyConfigRepository { + + private static final long DEFAULT_SCENIC_ID = 0L; + + private final WechatSubscribeTemplateConfigMapper templateConfigMapper; + private final WechatSubscribeSceneTemplateMapper sceneTemplateMapper; + private final WechatSubscribeEventTemplateMapper eventTemplateMapper; + + public WechatSubscribeNotifyConfigRepository(WechatSubscribeTemplateConfigMapper templateConfigMapper, + WechatSubscribeSceneTemplateMapper sceneTemplateMapper, + WechatSubscribeEventTemplateMapper eventTemplateMapper) { + this.templateConfigMapper = templateConfigMapper; + this.sceneTemplateMapper = sceneTemplateMapper; + this.eventTemplateMapper = eventTemplateMapper; + } + + public List listEffectiveSceneTemplateMappings(Long scenicId, String sceneKey) { + Objects.requireNonNull(scenicId, "scenicId is null"); + if (StringUtils.isBlank(sceneKey)) { + throw new IllegalArgumentException("sceneKey is blank"); + } + + List scenicIds = List.of(DEFAULT_SCENIC_ID, scenicId); + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("scene_key", sceneKey) + .in("scenic_id", scenicIds); + List rows = sceneTemplateMapper.selectList(wrapper); + return pickEffectiveByTemplateKey(rows, scenicId).values().stream() + .filter(this::isEnabled) + .sorted((a, b) -> { + int cmp = Integer.compare(safeInt(a.getSortOrder()), safeInt(b.getSortOrder())); + if (cmp != 0) { + return cmp; + } + return Objects.toString(a.getTemplateKey(), "").compareTo(Objects.toString(b.getTemplateKey(), "")); + }) + .collect(Collectors.toList()); + } + + public List listEffectiveEventTemplateMappings(Long scenicId, String eventKey) { + Objects.requireNonNull(scenicId, "scenicId is null"); + if (StringUtils.isBlank(eventKey)) { + throw new IllegalArgumentException("eventKey is blank"); + } + + List scenicIds = List.of(DEFAULT_SCENIC_ID, scenicId); + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.eq("event_key", eventKey) + .in("scenic_id", scenicIds); + List rows = eventTemplateMapper.selectList(wrapper); + return pickEffectiveByTemplateKey(rows, scenicId).values().stream() + .filter(this::isEnabled) + .sorted((a, b) -> { + int cmp = Integer.compare(safeInt(a.getSortOrder()), safeInt(b.getSortOrder())); + if (cmp != 0) { + return cmp; + } + return Objects.toString(a.getTemplateKey(), "").compareTo(Objects.toString(b.getTemplateKey(), "")); + }) + .collect(Collectors.toList()); + } + + public Map getEffectiveTemplateConfigs(Long scenicId, + Collection templateKeys) { + Objects.requireNonNull(scenicId, "scenicId is null"); + if (CollectionUtils.isEmpty(templateKeys)) { + return new HashMap<>(); + } + + List scenicIds = List.of(DEFAULT_SCENIC_ID, scenicId); + QueryWrapper wrapper = new QueryWrapper<>(); + wrapper.in("template_key", templateKeys) + .in("scenic_id", scenicIds); + List rows = templateConfigMapper.selectList(wrapper); + Map effective = new HashMap<>(); + for (WechatSubscribeTemplateConfigEntity row : rows) { + if (row == null || row.getTemplateKey() == null) { + continue; + } + WechatSubscribeTemplateConfigEntity existing = effective.get(row.getTemplateKey()); + if (existing == null) { + effective.put(row.getTemplateKey(), row); + continue; + } + if (Objects.equals(row.getScenicId(), scenicId)) { + effective.put(row.getTemplateKey(), row); + } + } + return effective; + } + + private boolean isEnabled(WechatSubscribeSceneTemplateEntity entity) { + return entity != null && Objects.equals(entity.getEnabled(), 1); + } + + private boolean isEnabled(WechatSubscribeEventTemplateEntity entity) { + return entity != null && Objects.equals(entity.getEnabled(), 1); + } + + private static int safeInt(Integer value) { + return value != null ? value : 0; + } + + private static Map pickEffectiveByTemplateKey(List rows, Long scenicId) { + Map effective = new HashMap<>(); + if (rows == null || rows.isEmpty()) { + return effective; + } + for (T row : rows) { + String templateKey = getTemplateKey(row); + if (templateKey == null) { + continue; + } + T existing = effective.get(templateKey); + if (existing == null) { + effective.put(templateKey, row); + continue; + } + Long rowScenicId = getScenicId(row); + if (Objects.equals(rowScenicId, scenicId)) { + effective.put(templateKey, row); + } + } + return effective; + } + + private static String getTemplateKey(Object row) { + if (row instanceof WechatSubscribeSceneTemplateEntity entity) { + return entity.getTemplateKey(); + } + if (row instanceof WechatSubscribeEventTemplateEntity entity) { + return entity.getTemplateKey(); + } + return null; + } + + private static Long getScenicId(Object row) { + if (row instanceof WechatSubscribeSceneTemplateEntity entity) { + return entity.getScenicId(); + } + if (row instanceof WechatSubscribeEventTemplateEntity entity) { + return entity.getScenicId(); + } + return null; + } +} diff --git a/src/main/java/com/ycwl/basic/service/UserNotificationAuthorizationService.java b/src/main/java/com/ycwl/basic/service/UserNotificationAuthorizationService.java index 3adf0125..305395ac 100644 --- a/src/main/java/com/ycwl/basic/service/UserNotificationAuthorizationService.java +++ b/src/main/java/com/ycwl/basic/service/UserNotificationAuthorizationService.java @@ -43,6 +43,13 @@ public interface UserNotificationAuthorizationService { * @return 授权记录 */ UserNotificationAuthorizationEntity recordAuthorization(Long memberId, String templateId, Long scenicId); + + /** + * 记录用户授权(支持幂等) + * + * @param requestId 前端幂等ID(同一次用户授权动作复用);为空则不做幂等控制 + */ + UserNotificationAuthorizationEntity recordAuthorization(Long memberId, String templateId, Long scenicId, String requestId); /** * 批量记录用户授权 @@ -53,6 +60,13 @@ public interface UserNotificationAuthorizationService { * @return 批量授权记录结果 */ List batchRecordAuthorization(Long memberId, List templateIds, Long scenicId); + + /** + * 批量记录用户授权(支持幂等) + * + * @param requestId 前端幂等ID(同一次用户授权动作复用);为空则不做幂等控制 + */ + List batchRecordAuthorization(Long memberId, List templateIds, Long scenicId, String requestId); /** * 消费一次授权 @@ -182,4 +196,4 @@ public interface UserNotificationAuthorizationService { public Date getQueryTime() { return queryTime; } public void setQueryTime(Date queryTime) { this.queryTime = queryTime; } } -} \ No newline at end of file +} diff --git a/src/main/java/com/ycwl/basic/service/impl/UserNotificationAuthorizationServiceImpl.java b/src/main/java/com/ycwl/basic/service/impl/UserNotificationAuthorizationServiceImpl.java index 57b7402a..f644ab70 100644 --- a/src/main/java/com/ycwl/basic/service/impl/UserNotificationAuthorizationServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/impl/UserNotificationAuthorizationServiceImpl.java @@ -1,10 +1,14 @@ package com.ycwl.basic.service.impl; import com.ycwl.basic.mapper.UserNotificationAuthorizationMapper; +import com.ycwl.basic.mapper.UserNotificationAuthorizationRecordMapper; import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationEntity; +import com.ycwl.basic.model.pc.notify.entity.UserNotificationAuthorizationRecordEntity; import com.ycwl.basic.service.UserNotificationAuthorizationService; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.dao.DuplicateKeyException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -23,6 +27,9 @@ public class UserNotificationAuthorizationServiceImpl implements UserNotificatio @Autowired private UserNotificationAuthorizationMapper userNotificationAuthorizationMapper; + + @Autowired + private UserNotificationAuthorizationRecordMapper userNotificationAuthorizationRecordMapper; @Override public UserNotificationAuthorizationEntity checkAuthorization(Long memberId, String templateId, Long scenicId) { @@ -40,7 +47,25 @@ public class UserNotificationAuthorizationServiceImpl implements UserNotificatio @Override @Transactional(rollbackFor = Exception.class) public UserNotificationAuthorizationEntity recordAuthorization(Long memberId, String templateId, Long scenicId) { + return recordAuthorization(memberId, templateId, scenicId, null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public UserNotificationAuthorizationEntity recordAuthorization(Long memberId, String templateId, Long scenicId, String requestId) { log.info("记录用户授权: memberId={}, templateId={}, scenicId={}", memberId, templateId, scenicId); + + // 幂等:同一次用户授权动作(requestId)只计数一次,避免前端重试导致授权次数虚增 + if (StringUtils.isNotBlank(requestId)) { + boolean inserted = tryInsertAuthorizationRecord(memberId, templateId, scenicId, requestId); + if (!inserted) { + UserNotificationAuthorizationEntity existing = + userNotificationAuthorizationMapper.selectByMemberAndTemplateAndScenic(memberId, templateId, scenicId); + if (existing != null) { + return existing; + } + } + } // 先查询是否已存在记录 UserNotificationAuthorizationEntity existingRecord = userNotificationAuthorizationMapper @@ -85,6 +110,12 @@ public class UserNotificationAuthorizationServiceImpl implements UserNotificatio @Override @Transactional(rollbackFor = Exception.class) public List batchRecordAuthorization(Long memberId, List templateIds, Long scenicId) { + return batchRecordAuthorization(memberId, templateIds, scenicId, null); + } + + @Override + @Transactional(rollbackFor = Exception.class) + public List batchRecordAuthorization(Long memberId, List templateIds, Long scenicId, String requestId) { log.info("批量记录用户授权: memberId={}, templateIds={}, scenicId={}", memberId, templateIds, scenicId); List results = new ArrayList<>(); @@ -98,7 +129,7 @@ public class UserNotificationAuthorizationServiceImpl implements UserNotificatio record.setTemplateId(templateId); try { - UserNotificationAuthorizationEntity entity = recordAuthorization(memberId, templateId, scenicId); + UserNotificationAuthorizationEntity entity = recordAuthorization(memberId, templateId, scenicId, requestId); // 转换为响应对象 record.setSuccess(true); @@ -123,6 +154,22 @@ public class UserNotificationAuthorizationServiceImpl implements UserNotificatio return results; } + + private boolean tryInsertAuthorizationRecord(Long memberId, String templateId, Long scenicId, String requestId) { + try { + UserNotificationAuthorizationRecordEntity record = new UserNotificationAuthorizationRecordEntity(); + record.setMemberId(memberId); + record.setTemplateId(templateId); + record.setScenicId(scenicId); + record.setRequestId(requestId); + record.setCreateTime(new Date()); + return userNotificationAuthorizationRecordMapper.insert(record) > 0; + } catch (DuplicateKeyException e) { + log.debug("授权幂等命中: memberId={}, scenicId={}, templateId={}, requestId={}", + memberId, scenicId, templateId, requestId); + return false; + } + } @Override @Transactional(rollbackFor = Exception.class) @@ -228,4 +275,4 @@ public class UserNotificationAuthorizationServiceImpl implements UserNotificatio return stats; } -} \ No newline at end of file +} diff --git a/src/main/java/com/ycwl/basic/service/notify/WechatSubscribeNotifyConfigService.java b/src/main/java/com/ycwl/basic/service/notify/WechatSubscribeNotifyConfigService.java new file mode 100644 index 00000000..3343cd6b --- /dev/null +++ b/src/main/java/com/ycwl/basic/service/notify/WechatSubscribeNotifyConfigService.java @@ -0,0 +1,81 @@ +package com.ycwl.basic.service.notify; + +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import com.ycwl.basic.repository.WechatSubscribeNotifyConfigRepository; +import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +/** + * 微信订阅消息配置查询服务(仅负责“配置解析”,不包含授权/发送逻辑) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Service +public class WechatSubscribeNotifyConfigService { + + private final WechatSubscribeNotifyConfigRepository configRepository; + + public WechatSubscribeNotifyConfigService(WechatSubscribeNotifyConfigRepository configRepository) { + this.configRepository = configRepository; + } + + public List listSceneTemplateConfigs(Long scenicId, String sceneKey) { + List mappings = + configRepository.listEffectiveSceneTemplateMappings(scenicId, sceneKey); + if (CollectionUtils.isEmpty(mappings)) { + return new ArrayList<>(); + } + + Set templateKeys = mappings.stream() + .map(WechatSubscribeSceneTemplateEntity::getTemplateKey) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map configMap = + configRepository.getEffectiveTemplateConfigs(scenicId, templateKeys); + + List result = new ArrayList<>(); + for (WechatSubscribeSceneTemplateEntity mapping : mappings) { + WechatSubscribeTemplateConfigEntity cfg = configMap.get(mapping.getTemplateKey()); + if (cfg == null || !Objects.equals(cfg.getEnabled(), 1)) { + continue; + } + result.add(cfg); + } + return result; + } + + public List listEventTemplateConfigs(Long scenicId, String eventKey) { + List mappings = + configRepository.listEffectiveEventTemplateMappings(scenicId, eventKey); + if (CollectionUtils.isEmpty(mappings)) { + return new ArrayList<>(); + } + + Set templateKeys = mappings.stream() + .map(WechatSubscribeEventTemplateEntity::getTemplateKey) + .filter(Objects::nonNull) + .collect(Collectors.toSet()); + Map configMap = + configRepository.getEffectiveTemplateConfigs(scenicId, templateKeys); + + List result = new ArrayList<>(); + for (WechatSubscribeEventTemplateEntity mapping : mappings) { + WechatSubscribeTemplateConfigEntity cfg = configMap.get(mapping.getTemplateKey()); + if (cfg == null || !Objects.equals(cfg.getEnabled(), 1)) { + continue; + } + result.add(cfg); + } + return result; + } +} diff --git a/src/main/java/com/ycwl/basic/service/notify/WechatSubscribeNotifyTriggerService.java b/src/main/java/com/ycwl/basic/service/notify/WechatSubscribeNotifyTriggerService.java new file mode 100644 index 00000000..0946a6c3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/service/notify/WechatSubscribeNotifyTriggerService.java @@ -0,0 +1,282 @@ +package com.ycwl.basic.service.notify; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.ycwl.basic.integration.message.dto.ZtMessage; +import com.ycwl.basic.integration.message.service.ZtMessageProducerService; +import com.ycwl.basic.mapper.WechatSubscribeSendLogMapper; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeNotifyTriggerRequest; +import com.ycwl.basic.model.pc.notify.resp.WechatSubscribeNotifyTriggerResult; +import com.ycwl.basic.utils.JacksonUtil; +import com.ycwl.basic.utils.NotificationAuthUtils; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 微信订阅消息统一触发器(后端内部调用) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Service +@Slf4j +public class WechatSubscribeNotifyTriggerService { + + private static final Pattern VAR_PATTERN = Pattern.compile("\\$\\{([^}]+)}"); + + private static final String STATUS_INIT = "INIT"; + private static final String STATUS_SENT = "SENT"; + private static final String STATUS_SKIPPED_NO_AUTH = "SKIPPED_NO_AUTH"; + private static final String STATUS_FAILED = "FAILED"; + + private final WechatSubscribeNotifyConfigService configService; + private final WechatSubscribeSendLogMapper sendLogMapper; + private final NotificationAuthUtils notificationAuthUtils; + private final ZtMessageProducerService ztMessageProducerService; + + public WechatSubscribeNotifyTriggerService(WechatSubscribeNotifyConfigService configService, + WechatSubscribeSendLogMapper sendLogMapper, + NotificationAuthUtils notificationAuthUtils, + ZtMessageProducerService ztMessageProducerService) { + this.configService = configService; + this.sendLogMapper = sendLogMapper; + this.notificationAuthUtils = notificationAuthUtils; + this.ztMessageProducerService = ztMessageProducerService; + } + + /** + * 触发订阅消息发送(支持按 scenicId 覆盖 + 幂等 + 授权消费) + */ + public WechatSubscribeNotifyTriggerResult trigger(String eventKey, WechatSubscribeNotifyTriggerRequest request) { + WechatSubscribeNotifyTriggerResult result = new WechatSubscribeNotifyTriggerResult(); + + if (StringUtils.isBlank(eventKey) || request == null) { + log.warn("订阅消息触发入参非法: eventKey={}, request={}", eventKey, request); + return result; + } + if (request.getScenicId() == null || request.getMemberId() == null || StringUtils.isBlank(request.getOpenId())) { + log.warn("订阅消息触发缺少必要字段: eventKey={}, scenicId={}, memberId={}, openId={}", + eventKey, request.getScenicId(), request.getMemberId(), request.getOpenId()); + return result; + } + + List templateConfigs = + configService.listEventTemplateConfigs(request.getScenicId(), eventKey); + if (templateConfigs.isEmpty()) { + return result; + } + result.setConfigFound(true); + + Map variables = buildVariables(eventKey, request); + int sentCount = 0; + int skippedCount = 0; + + for (WechatSubscribeTemplateConfigEntity cfg : templateConfigs) { + if (cfg == null || StringUtils.isBlank(cfg.getWechatTemplateId()) || StringUtils.isBlank(cfg.getTemplateKey())) { + skippedCount++; + continue; + } + + String idempotencyKey = buildIdempotencyKey(eventKey, cfg.getTemplateKey(), request); + WechatSubscribeSendLogEntity sendLog = buildInitLog(idempotencyKey, eventKey, cfg, request); + if (!tryInsertSendLog(sendLog)) { + skippedCount++; + continue; + } + + try { + // 检查并消费授权 + if (!notificationAuthUtils.checkAndConsumeAuthorization( + request.getMemberId(), cfg.getWechatTemplateId(), request.getScenicId())) { + updateSendLog(sendLog.getId(), STATUS_SKIPPED_NO_AUTH, null, null); + skippedCount++; + continue; + } + + ZtMessage msg = buildZtMessage(cfg, request.getOpenId(), variables, eventKey); + ztMessageProducerService.send(msg); + updateSendLog(sendLog.getId(), STATUS_SENT, msg.getMessageId(), null); + sentCount++; + } catch (Exception e) { + updateSendLog(sendLog.getId(), STATUS_FAILED, null, e.getMessage()); + skippedCount++; + log.error("订阅消息发送失败: eventKey={}, templateKey={}, memberId={}, scenicId={}, error={}", + eventKey, cfg.getTemplateKey(), request.getMemberId(), request.getScenicId(), e.getMessage(), e); + } + } + + result.setSentCount(sentCount); + result.setSkippedCount(skippedCount); + return result; + } + + private boolean tryInsertSendLog(WechatSubscribeSendLogEntity logEntity) { + try { + return sendLogMapper.insert(logEntity) > 0; + } catch (DuplicateKeyException e) { + // 幂等命中:直接跳过,不再重复消费授权 + return false; + } + } + + private void updateSendLog(Long id, String status, String messageId, String errorMessage) { + if (id == null) { + return; + } + WechatSubscribeSendLogEntity update = new WechatSubscribeSendLogEntity(); + update.setStatus(status); + update.setZtMessageId(messageId); + update.setErrorMessage(errorMessage); + update.setUpdateTime(new Date()); + sendLogMapper.update(update, new QueryWrapper().eq("id", id)); + } + + private static String buildIdempotencyKey(String eventKey, String templateKey, WechatSubscribeNotifyTriggerRequest request) { + String bizId = Objects.toString(request.getBizId(), ""); + String raw = eventKey + "|" + templateKey + "|" + request.getScenicId() + "|" + request.getMemberId() + "|" + bizId; + return sha256Hex(raw); + } + + private static String sha256Hex(String input) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] digest = md.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(digest.length * 2); + for (byte b : digest) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + throw new IllegalStateException("sha256计算失败", e); + } + } + + private WechatSubscribeSendLogEntity buildInitLog(String idempotencyKey, + String eventKey, + WechatSubscribeTemplateConfigEntity cfg, + WechatSubscribeNotifyTriggerRequest request) { + WechatSubscribeSendLogEntity logEntity = new WechatSubscribeSendLogEntity(); + logEntity.setIdempotencyKey(idempotencyKey); + logEntity.setEventKey(eventKey); + logEntity.setTemplateKey(cfg.getTemplateKey()); + logEntity.setScenicId(request.getScenicId()); + logEntity.setMemberId(request.getMemberId()); + logEntity.setOpenId(request.getOpenId()); + logEntity.setWechatTemplateId(cfg.getWechatTemplateId()); + logEntity.setStatus(STATUS_INIT); + logEntity.setPayloadJson(safeJson(request)); + logEntity.setCreateTime(new Date()); + logEntity.setUpdateTime(new Date()); + return logEntity; + } + + private static String safeJson(Object obj) { + try { + return JacksonUtil.toJson(obj); + } catch (Exception e) { + return "{}"; + } + } + + private static Map buildVariables(String eventKey, WechatSubscribeNotifyTriggerRequest request) { + Map vars = new HashMap<>(); + if (request.getVariables() != null) { + vars.putAll(request.getVariables()); + } + vars.put("eventKey", eventKey); + vars.put("scenicId", request.getScenicId()); + vars.put("memberId", request.getMemberId()); + vars.put("openId", request.getOpenId()); + vars.put("bizId", request.getBizId()); + return vars; + } + + private static ZtMessage buildZtMessage(WechatSubscribeTemplateConfigEntity cfg, + String openId, + Map variables, + String eventKey) { + String title = renderOrDefault(cfg.getTitleTemplate(), variables, cfg.getTemplateKey()); + String content = renderOrDefault(cfg.getContentTemplate(), variables, title); + String page = renderOrDefault(cfg.getPageTemplate(), variables, "pages/index/index"); + + Map dataParam = buildDataParam(cfg.getDataTemplateJson(), variables); + + Map extra = new HashMap<>(); + extra.put("data", dataParam); + extra.put("page", page); + + ZtMessage msg = new ZtMessage(); + msg.setChannelId(cfg.getWechatTemplateId()); + msg.setTitle(title); + msg.setContent(content); + msg.setTarget(openId); + msg.setExtra(extra); + msg.setSendReason(eventKey); + msg.setSendBiz("订阅消息"); + return msg; + } + + private static Map buildDataParam(String dataTemplateJson, Map variables) { + if (StringUtils.isBlank(dataTemplateJson)) { + throw new IllegalArgumentException("dataTemplateJson为空"); + } + Map templateMap = JacksonUtil.fromJson(dataTemplateJson, new TypeReference>() {}); + if (templateMap == null || templateMap.isEmpty()) { + throw new IllegalArgumentException("dataTemplateJson解析为空"); + } + Map dataParam = new HashMap<>(); + for (Map.Entry entry : templateMap.entrySet()) { + String key = entry.getKey(); + if (StringUtils.isBlank(key)) { + continue; + } + String rawValue = entry.getValue() != null ? entry.getValue().toString() : ""; + dataParam.put(key, render(rawValue, variables)); + } + return dataParam; + } + + private static String renderOrDefault(String template, Map variables, String defaultValue) { + if (StringUtils.isBlank(template)) { + return defaultValue; + } + String rendered = render(template, variables); + return StringUtils.isBlank(rendered) ? defaultValue : rendered; + } + + private static String render(String template, Map variables) { + if (template == null) { + return null; + } + if (variables == null || variables.isEmpty()) { + return template; + } + Matcher matcher = VAR_PATTERN.matcher(template); + StringBuffer sb = new StringBuffer(); + while (matcher.find()) { + String key = matcher.group(1); + if (key != null) { + key = key.trim(); + } + Object value = key != null ? variables.get(key) : null; + String replacement = value != null ? value.toString() : ""; + matcher.appendReplacement(sb, Matcher.quoteReplacement(replacement)); + } + matcher.appendTail(sb); + return sb.toString(); + } +} diff --git a/src/main/java/com/ycwl/basic/service/pc/WechatSubscribeNotifyAdminService.java b/src/main/java/com/ycwl/basic/service/pc/WechatSubscribeNotifyAdminService.java new file mode 100644 index 00000000..ed188b35 --- /dev/null +++ b/src/main/java/com/ycwl/basic/service/pc/WechatSubscribeNotifyAdminService.java @@ -0,0 +1,53 @@ +package com.ycwl.basic.service.pc; + +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplatePageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplateSaveReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplatePageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplateSaveReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSendLogPageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigPageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigSaveReq; +import com.ycwl.basic.utils.ApiResponse; + +/** + * 微信小程序订阅消息:配置管理(管理后台) + * + * @Author: System + * @Date: 2025/12/31 + */ +public interface WechatSubscribeNotifyAdminService { + + ApiResponse> pageTemplateConfig(WechatSubscribeTemplateConfigPageReq req); + + ApiResponse getTemplateConfig(Long id); + + ApiResponse saveTemplateConfig(WechatSubscribeTemplateConfigSaveReq req); + + ApiResponse deleteTemplateConfig(Long id); + + ApiResponse> pageSceneTemplate(WechatSubscribeSceneTemplatePageReq req); + + ApiResponse getSceneTemplate(Long id); + + ApiResponse saveSceneTemplate(WechatSubscribeSceneTemplateSaveReq req); + + ApiResponse deleteSceneTemplate(Long id); + + ApiResponse> pageEventTemplate(WechatSubscribeEventTemplatePageReq req); + + ApiResponse getEventTemplate(Long id); + + ApiResponse saveEventTemplate(WechatSubscribeEventTemplateSaveReq req); + + ApiResponse deleteEventTemplate(Long id); + + ApiResponse> pageSendLog(WechatSubscribeSendLogPageReq req); + + ApiResponse getSendLog(Long id); +} + diff --git a/src/main/java/com/ycwl/basic/service/pc/impl/WechatSubscribeNotifyAdminServiceImpl.java b/src/main/java/com/ycwl/basic/service/pc/impl/WechatSubscribeNotifyAdminServiceImpl.java new file mode 100644 index 00000000..ae8f39bd --- /dev/null +++ b/src/main/java/com/ycwl/basic/service/pc/impl/WechatSubscribeNotifyAdminServiceImpl.java @@ -0,0 +1,533 @@ +package com.ycwl.basic.service.pc.impl; + +import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.mapper.WechatSubscribeEventTemplateMapper; +import com.ycwl.basic.mapper.WechatSubscribeSceneTemplateMapper; +import com.ycwl.basic.mapper.WechatSubscribeSendLogMapper; +import com.ycwl.basic.mapper.WechatSubscribeTemplateConfigMapper; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeEventTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSceneTemplateEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeSendLogEntity; +import com.ycwl.basic.model.pc.notify.entity.WechatSubscribeTemplateConfigEntity; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplatePageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeEventTemplateSaveReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplatePageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSceneTemplateSaveReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeSendLogPageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigPageReq; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeTemplateConfigSaveReq; +import com.ycwl.basic.service.pc.WechatSubscribeNotifyAdminService; +import com.ycwl.basic.utils.ApiResponse; +import com.ycwl.basic.utils.JacksonUtil; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.StringUtils; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Service; + +import java.util.Date; +import java.util.List; +import java.util.Objects; + +/** + * 微信小程序订阅消息:配置管理(管理后台) + * + * @Author: System + * @Date: 2025/12/31 + */ +@Service +@Slf4j +public class WechatSubscribeNotifyAdminServiceImpl implements WechatSubscribeNotifyAdminService { + + private static final int MAX_PAGE_SIZE = 200; + + private final WechatSubscribeTemplateConfigMapper templateConfigMapper; + private final WechatSubscribeSceneTemplateMapper sceneTemplateMapper; + private final WechatSubscribeEventTemplateMapper eventTemplateMapper; + private final WechatSubscribeSendLogMapper sendLogMapper; + + public WechatSubscribeNotifyAdminServiceImpl(WechatSubscribeTemplateConfigMapper templateConfigMapper, + WechatSubscribeSceneTemplateMapper sceneTemplateMapper, + WechatSubscribeEventTemplateMapper eventTemplateMapper, + WechatSubscribeSendLogMapper sendLogMapper) { + this.templateConfigMapper = templateConfigMapper; + this.sceneTemplateMapper = sceneTemplateMapper; + this.eventTemplateMapper = eventTemplateMapper; + this.sendLogMapper = sendLogMapper; + } + + @Override + public ApiResponse> pageTemplateConfig(WechatSubscribeTemplateConfigPageReq req) { + try { + if (req == null) { + req = new WechatSubscribeTemplateConfigPageReq(); + } + sanitizePage(req); + + QueryWrapper wrapper = new QueryWrapper<>(); + if (req.getScenicId() != null) { + wrapper.eq("scenic_id", req.getScenicId()); + } + if (StringUtils.isNotBlank(req.getTemplateKey())) { + wrapper.like("template_key", req.getTemplateKey().trim()); + } + if (StringUtils.isNotBlank(req.getWechatTemplateId())) { + wrapper.like("wechat_template_id", req.getWechatTemplateId().trim()); + } + if (req.getEnabled() != null) { + wrapper.eq("enabled", req.getEnabled()); + } + wrapper.orderByDesc("scenic_id").orderByDesc("update_time").orderByDesc("id"); + + PageHelper.startPage(req.getPageNum(), req.getPageSize()); + List list = templateConfigMapper.selectList(wrapper); + return ApiResponse.success(new PageInfo<>(list)); + } catch (Exception e) { + log.error("订阅消息|模板配置分页查询失败", e); + return ApiResponse.fail("模板配置分页查询失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse getTemplateConfig(Long id) { + try { + if (id == null) { + return ApiResponse.fail("id不能为空"); + } + return ApiResponse.success(templateConfigMapper.selectById(id)); + } catch (Exception e) { + log.error("订阅消息|模板配置详情查询失败 id={}", id, e); + return ApiResponse.fail("模板配置详情查询失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse saveTemplateConfig(WechatSubscribeTemplateConfigSaveReq req) { + try { + String err = validateTemplateConfigSaveReq(req); + if (err != null) { + return ApiResponse.fail(err); + } + + WechatSubscribeTemplateConfigEntity entity = new WechatSubscribeTemplateConfigEntity(); + entity.setTemplateKey(req.getTemplateKey().trim()); + entity.setScenicId(req.getScenicId()); + entity.setWechatTemplateId(req.getWechatTemplateId().trim()); + entity.setEnabled(req.getEnabled()); + entity.setTitleTemplate(StringUtils.trimToNull(req.getTitleTemplate())); + entity.setContentTemplate(StringUtils.trimToNull(req.getContentTemplate())); + entity.setPageTemplate(StringUtils.trimToNull(req.getPageTemplate())); + entity.setDataTemplateJson(StringUtils.trimToNull(req.getDataTemplateJson())); + entity.setDescription(StringUtils.trimToNull(req.getDescription())); + entity.setUpdateTime(new Date()); + + if (req.getId() != null) { + WechatSubscribeTemplateConfigEntity existing = templateConfigMapper.selectById(req.getId()); + if (existing == null) { + return ApiResponse.fail("记录不存在"); + } + entity.setId(req.getId()); + int updated = templateConfigMapper.updateById(entity); + return ApiResponse.success(updated > 0); + } + + // upsert by (template_key, scenic_id) + WechatSubscribeTemplateConfigEntity existing = templateConfigMapper.selectOne(new QueryWrapper() + .eq("template_key", entity.getTemplateKey()) + .eq("scenic_id", entity.getScenicId())); + if (existing != null) { + entity.setId(existing.getId()); + int updated = templateConfigMapper.updateById(entity); + return ApiResponse.success(updated > 0); + } + + entity.setCreateTime(new Date()); + int inserted = templateConfigMapper.insert(entity); + return ApiResponse.success(inserted > 0); + } catch (DuplicateKeyException e) { + return ApiResponse.fail("保存失败:唯一键冲突(templateKey+scenicId已存在)"); + } catch (Exception e) { + log.error("订阅消息|模板配置保存失败", e); + return ApiResponse.fail("模板配置保存失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse deleteTemplateConfig(Long id) { + try { + if (id == null) { + return ApiResponse.fail("id不能为空"); + } + return ApiResponse.success(templateConfigMapper.deleteById(id) > 0); + } catch (Exception e) { + log.error("订阅消息|模板配置删除失败 id={}", id, e); + return ApiResponse.fail("模板配置删除失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse> pageSceneTemplate(WechatSubscribeSceneTemplatePageReq req) { + try { + if (req == null) { + req = new WechatSubscribeSceneTemplatePageReq(); + } + sanitizePage(req); + + QueryWrapper wrapper = new QueryWrapper<>(); + if (req.getScenicId() != null) { + wrapper.eq("scenic_id", req.getScenicId()); + } + if (StringUtils.isNotBlank(req.getSceneKey())) { + wrapper.like("scene_key", req.getSceneKey().trim()); + } + if (StringUtils.isNotBlank(req.getTemplateKey())) { + wrapper.like("template_key", req.getTemplateKey().trim()); + } + if (req.getEnabled() != null) { + wrapper.eq("enabled", req.getEnabled()); + } + wrapper.orderByDesc("scenic_id").orderByAsc("sort_order").orderByDesc("id"); + + PageHelper.startPage(req.getPageNum(), req.getPageSize()); + List list = sceneTemplateMapper.selectList(wrapper); + return ApiResponse.success(new PageInfo<>(list)); + } catch (Exception e) { + log.error("订阅消息|场景映射分页查询失败", e); + return ApiResponse.fail("场景映射分页查询失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse getSceneTemplate(Long id) { + try { + if (id == null) { + return ApiResponse.fail("id不能为空"); + } + return ApiResponse.success(sceneTemplateMapper.selectById(id)); + } catch (Exception e) { + log.error("订阅消息|场景映射详情查询失败 id={}", id, e); + return ApiResponse.fail("场景映射详情查询失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse saveSceneTemplate(WechatSubscribeSceneTemplateSaveReq req) { + try { + String err = validateSceneTemplateSaveReq(req); + if (err != null) { + return ApiResponse.fail(err); + } + + WechatSubscribeSceneTemplateEntity entity = new WechatSubscribeSceneTemplateEntity(); + entity.setSceneKey(req.getSceneKey().trim()); + entity.setTemplateKey(req.getTemplateKey().trim()); + entity.setScenicId(req.getScenicId()); + entity.setEnabled(req.getEnabled()); + entity.setSortOrder(Objects.requireNonNullElse(req.getSortOrder(), 0)); + entity.setUpdateTime(new Date()); + + if (req.getId() != null) { + WechatSubscribeSceneTemplateEntity existing = sceneTemplateMapper.selectById(req.getId()); + if (existing == null) { + return ApiResponse.fail("记录不存在"); + } + entity.setId(req.getId()); + int updated = sceneTemplateMapper.updateById(entity); + return ApiResponse.success(updated > 0); + } + + WechatSubscribeSceneTemplateEntity existing = sceneTemplateMapper.selectOne(new QueryWrapper() + .eq("scene_key", entity.getSceneKey()) + .eq("template_key", entity.getTemplateKey()) + .eq("scenic_id", entity.getScenicId())); + if (existing != null) { + entity.setId(existing.getId()); + int updated = sceneTemplateMapper.updateById(entity); + return ApiResponse.success(updated > 0); + } + + entity.setCreateTime(new Date()); + int inserted = sceneTemplateMapper.insert(entity); + return ApiResponse.success(inserted > 0); + } catch (DuplicateKeyException e) { + return ApiResponse.fail("保存失败:唯一键冲突(sceneKey+templateKey+scenicId已存在)"); + } catch (Exception e) { + log.error("订阅消息|场景映射保存失败", e); + return ApiResponse.fail("场景映射保存失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse deleteSceneTemplate(Long id) { + try { + if (id == null) { + return ApiResponse.fail("id不能为空"); + } + return ApiResponse.success(sceneTemplateMapper.deleteById(id) > 0); + } catch (Exception e) { + log.error("订阅消息|场景映射删除失败 id={}", id, e); + return ApiResponse.fail("场景映射删除失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse> pageEventTemplate(WechatSubscribeEventTemplatePageReq req) { + try { + if (req == null) { + req = new WechatSubscribeEventTemplatePageReq(); + } + sanitizePage(req); + + QueryWrapper wrapper = new QueryWrapper<>(); + if (req.getScenicId() != null) { + wrapper.eq("scenic_id", req.getScenicId()); + } + if (StringUtils.isNotBlank(req.getEventKey())) { + wrapper.like("event_key", req.getEventKey().trim()); + } + if (StringUtils.isNotBlank(req.getTemplateKey())) { + wrapper.like("template_key", req.getTemplateKey().trim()); + } + if (req.getEnabled() != null) { + wrapper.eq("enabled", req.getEnabled()); + } + wrapper.orderByDesc("scenic_id").orderByAsc("sort_order").orderByDesc("id"); + + PageHelper.startPage(req.getPageNum(), req.getPageSize()); + List list = eventTemplateMapper.selectList(wrapper); + return ApiResponse.success(new PageInfo<>(list)); + } catch (Exception e) { + log.error("订阅消息|事件映射分页查询失败", e); + return ApiResponse.fail("事件映射分页查询失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse getEventTemplate(Long id) { + try { + if (id == null) { + return ApiResponse.fail("id不能为空"); + } + return ApiResponse.success(eventTemplateMapper.selectById(id)); + } catch (Exception e) { + log.error("订阅消息|事件映射详情查询失败 id={}", id, e); + return ApiResponse.fail("事件映射详情查询失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse saveEventTemplate(WechatSubscribeEventTemplateSaveReq req) { + try { + String err = validateEventTemplateSaveReq(req); + if (err != null) { + return ApiResponse.fail(err); + } + + WechatSubscribeEventTemplateEntity entity = new WechatSubscribeEventTemplateEntity(); + entity.setEventKey(req.getEventKey().trim()); + entity.setTemplateKey(req.getTemplateKey().trim()); + entity.setScenicId(req.getScenicId()); + entity.setEnabled(req.getEnabled()); + entity.setSortOrder(Objects.requireNonNullElse(req.getSortOrder(), 0)); + entity.setSendDelaySeconds(Objects.requireNonNullElse(req.getSendDelaySeconds(), 0)); + entity.setDedupSeconds(Objects.requireNonNullElse(req.getDedupSeconds(), 0)); + entity.setUpdateTime(new Date()); + + if (req.getId() != null) { + WechatSubscribeEventTemplateEntity existing = eventTemplateMapper.selectById(req.getId()); + if (existing == null) { + return ApiResponse.fail("记录不存在"); + } + entity.setId(req.getId()); + int updated = eventTemplateMapper.updateById(entity); + return ApiResponse.success(updated > 0); + } + + WechatSubscribeEventTemplateEntity existing = eventTemplateMapper.selectOne(new QueryWrapper() + .eq("event_key", entity.getEventKey()) + .eq("template_key", entity.getTemplateKey()) + .eq("scenic_id", entity.getScenicId())); + if (existing != null) { + entity.setId(existing.getId()); + int updated = eventTemplateMapper.updateById(entity); + return ApiResponse.success(updated > 0); + } + + entity.setCreateTime(new Date()); + int inserted = eventTemplateMapper.insert(entity); + return ApiResponse.success(inserted > 0); + } catch (DuplicateKeyException e) { + return ApiResponse.fail("保存失败:唯一键冲突(eventKey+templateKey+scenicId已存在)"); + } catch (Exception e) { + log.error("订阅消息|事件映射保存失败", e); + return ApiResponse.fail("事件映射保存失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse deleteEventTemplate(Long id) { + try { + if (id == null) { + return ApiResponse.fail("id不能为空"); + } + return ApiResponse.success(eventTemplateMapper.deleteById(id) > 0); + } catch (Exception e) { + log.error("订阅消息|事件映射删除失败 id={}", id, e); + return ApiResponse.fail("事件映射删除失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse> pageSendLog(WechatSubscribeSendLogPageReq req) { + try { + if (req == null) { + req = new WechatSubscribeSendLogPageReq(); + } + sanitizePage(req); + + QueryWrapper wrapper = new QueryWrapper<>(); + if (req.getScenicId() != null) { + wrapper.eq("scenic_id", req.getScenicId()); + } + if (req.getMemberId() != null) { + wrapper.eq("member_id", req.getMemberId()); + } + if (StringUtils.isNotBlank(req.getEventKey())) { + wrapper.eq("event_key", req.getEventKey().trim()); + } + if (StringUtils.isNotBlank(req.getTemplateKey())) { + wrapper.eq("template_key", req.getTemplateKey().trim()); + } + if (StringUtils.isNotBlank(req.getStatus())) { + wrapper.eq("status", req.getStatus().trim()); + } + wrapper.orderByDesc("create_time").orderByDesc("id"); + + PageHelper.startPage(req.getPageNum(), req.getPageSize()); + List list = sendLogMapper.selectList(wrapper); + return ApiResponse.success(new PageInfo<>(list)); + } catch (Exception e) { + log.error("订阅消息|发送日志分页查询失败", e); + return ApiResponse.fail("发送日志分页查询失败: " + e.getMessage()); + } + } + + @Override + public ApiResponse getSendLog(Long id) { + try { + if (id == null) { + return ApiResponse.fail("id不能为空"); + } + return ApiResponse.success(sendLogMapper.selectById(id)); + } catch (Exception e) { + log.error("订阅消息|发送日志详情查询失败 id={}", id, e); + return ApiResponse.fail("发送日志详情查询失败: " + e.getMessage()); + } + } + + private static void sanitizePage(Object req) { + if (req instanceof com.ycwl.basic.model.common.BaseQueryParameterReq base) { + if (base.getPageNum() == null || base.getPageNum() < 1) { + base.setPageNum(1); + } + if (base.getPageSize() == null || base.getPageSize() < 1) { + base.setPageSize(10); + } + if (base.getPageSize() > MAX_PAGE_SIZE) { + base.setPageSize(MAX_PAGE_SIZE); + } + } + } + + private static String validateEnabled(Integer enabled) { + if (enabled == null) { + return "enabled不能为空"; + } + if (!Objects.equals(enabled, 0) && !Objects.equals(enabled, 1)) { + return "enabled仅支持0或1"; + } + return null; + } + + private static String validateTemplateConfigSaveReq(WechatSubscribeTemplateConfigSaveReq req) { + if (req == null) { + return "请求体不能为空"; + } + if (StringUtils.isBlank(req.getTemplateKey())) { + return "templateKey不能为空"; + } + if (req.getScenicId() == null) { + return "scenicId不能为空"; + } + if (StringUtils.isBlank(req.getWechatTemplateId())) { + return "wechatTemplateId不能为空"; + } + String enabledErr = validateEnabled(req.getEnabled()); + if (enabledErr != null) { + return enabledErr; + } + if (StringUtils.isBlank(req.getDataTemplateJson())) { + return "dataTemplateJson不能为空"; + } + + // 仅验证 JSON 结构为对象,避免运行时发送失败 + try { + JsonNode node = JacksonUtil.getJsonNode(req.getDataTemplateJson()); + if (node == null || !node.isObject()) { + return "dataTemplateJson必须为JSON对象"; + } + } catch (Exception e) { + return "dataTemplateJson不是合法JSON: " + e.getMessage(); + } + return null; + } + + private static String validateSceneTemplateSaveReq(WechatSubscribeSceneTemplateSaveReq req) { + if (req == null) { + return "请求体不能为空"; + } + if (StringUtils.isBlank(req.getSceneKey())) { + return "sceneKey不能为空"; + } + if (StringUtils.isBlank(req.getTemplateKey())) { + return "templateKey不能为空"; + } + if (req.getScenicId() == null) { + return "scenicId不能为空"; + } + String enabledErr = validateEnabled(req.getEnabled()); + if (enabledErr != null) { + return enabledErr; + } + return null; + } + + private static String validateEventTemplateSaveReq(WechatSubscribeEventTemplateSaveReq req) { + if (req == null) { + return "请求体不能为空"; + } + if (StringUtils.isBlank(req.getEventKey())) { + return "eventKey不能为空"; + } + if (StringUtils.isBlank(req.getTemplateKey())) { + return "templateKey不能为空"; + } + if (req.getScenicId() == null) { + return "scenicId不能为空"; + } + String enabledErr = validateEnabled(req.getEnabled()); + if (enabledErr != null) { + return enabledErr; + } + if (req.getSendDelaySeconds() != null && req.getSendDelaySeconds() < 0) { + return "sendDelaySeconds不能小于0"; + } + if (req.getDedupSeconds() != null && req.getDedupSeconds() < 0) { + return "dedupSeconds不能小于0"; + } + return null; + } +} + diff --git a/src/main/java/com/ycwl/basic/service/task/impl/TaskTaskServiceImpl.java b/src/main/java/com/ycwl/basic/service/task/impl/TaskTaskServiceImpl.java index 33869eb8..dc796367 100644 --- a/src/main/java/com/ycwl/basic/service/task/impl/TaskTaskServiceImpl.java +++ b/src/main/java/com/ycwl/basic/service/task/impl/TaskTaskServiceImpl.java @@ -37,6 +37,8 @@ import com.ycwl.basic.model.pc.task.resp.TaskRespVO; import com.ycwl.basic.model.pc.template.resp.TemplateRespVO; import com.ycwl.basic.model.pc.video.entity.MemberVideoEntity; import com.ycwl.basic.model.pc.video.entity.VideoEntity; +import com.ycwl.basic.model.pc.notify.req.WechatSubscribeNotifyTriggerRequest; +import com.ycwl.basic.model.pc.notify.resp.WechatSubscribeNotifyTriggerResult; import com.ycwl.basic.model.task.req.ClientStatusReqVo; import com.ycwl.basic.model.task.req.TaskReqVo; import com.ycwl.basic.model.task.req.TaskSuccessReqVo; @@ -49,6 +51,7 @@ import com.ycwl.basic.repository.ScenicRepository; import com.ycwl.basic.repository.VideoRepository; import com.ycwl.basic.repository.VideoTaskRepository; import com.ycwl.basic.service.pc.ScenicService; +import com.ycwl.basic.service.notify.WechatSubscribeNotifyTriggerService; import com.ycwl.basic.service.task.TaskService; import com.ycwl.basic.storage.StorageFactory; import com.ycwl.basic.storage.adapters.IStorageAdapter; @@ -129,6 +132,8 @@ public class TaskTaskServiceImpl implements TaskService { @Autowired private NotificationAuthUtils notificationAuthUtils; @Autowired + private WechatSubscribeNotifyTriggerService wechatSubscribeNotifyTriggerService; + @Autowired private FaceStatusManager faceStatusManager; private RenderWorkerEntity getWorker(@NonNull WorkerAuthReqVo req) { @@ -641,6 +646,36 @@ public class TaskTaskServiceImpl implements TaskService { String openId = member.getOpenId(); MpConfigEntity scenicMp = scenicRepository.getScenicMpConfig(member.getScenicId()); if (StringUtils.isNotBlank(openId) && scenicMp != null) { + Map variables = new HashMap<>(); + variables.put("taskId", taskId); + variables.put("faceId", item.getFaceId()); + variables.put("videoId", item.getVideoId()); + variables.put("nowTime", DateUtil.format(new Date(), "yyyy-MM-dd HH:mm")); + try { + ScenicV2DTO scenicBasic = scenicRepository.getScenicBasic(item.getScenicId()); + if (scenicBasic != null && StringUtils.isNotBlank(scenicBasic.getName())) { + variables.put("scenicName", scenicBasic.getName()); + } + } catch (Exception e) { + log.debug("获取景区名称失败: scenicId={}, error={}", item.getScenicId(), e.getMessage()); + } + + WechatSubscribeNotifyTriggerResult triggerResult = wechatSubscribeNotifyTriggerService.trigger( + "VIDEO_GENERATED", + WechatSubscribeNotifyTriggerRequest.builder() + .scenicId(item.getScenicId()) + .memberId(memberId) + .openId(openId) + .bizId(String.valueOf(taskId)) + .variables(variables) + .build() + ); + if (triggerResult.isConfigFound()) { + log.info("memberId:{} VIDEO_GENERATED订阅消息触发完成 sentCount={}, skippedCount={}", + memberId, triggerResult.getSentCount(), triggerResult.getSkippedCount()); + return; + } + String templateId = scenicRepository.getVideoGeneratedTemplateId(item.getScenicId()); if (StringUtils.isBlank(templateId)) { log.warn("未配置视频生成通知模板"); diff --git a/src/main/java/com/ycwl/basic/template/api/ITemplateRenderPlanApi.java b/src/main/java/com/ycwl/basic/template/api/ITemplateRenderPlanApi.java new file mode 100644 index 00000000..4aa6ee74 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/api/ITemplateRenderPlanApi.java @@ -0,0 +1,12 @@ +package com.ycwl.basic.template.api; + +import com.ycwl.basic.template.api.dto.RenderPlan; +import com.ycwl.basic.template.api.dto.TemplatePlanBuildCommand; + +/** + * Template v2 对外能力:生成冻结 RenderPlan + */ +public interface ITemplateRenderPlanApi { + RenderPlan buildRenderPlan(TemplatePlanBuildCommand command); +} + diff --git a/src/main/java/com/ycwl/basic/template/api/dto/RenderPlan.java b/src/main/java/com/ycwl/basic/template/api/dto/RenderPlan.java new file mode 100644 index 00000000..439b06d0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/api/dto/RenderPlan.java @@ -0,0 +1,23 @@ +package com.ycwl.basic.template.api.dto; + +import lombok.Data; + +import java.util.List; + +/** + * 冻结后的最小可执行 RenderPlan + */ +@Data +public class RenderPlan { + private Long templateId; + private Integer templateVersion; + + private Integer outputWidth; + private Integer outputHeight; + private Integer outputFps; + private String bgmUrl; + + private Integer totalDurationMs; + private List segments; +} + diff --git a/src/main/java/com/ycwl/basic/template/api/dto/RenderPlanSegment.java b/src/main/java/com/ycwl/basic/template/api/dto/RenderPlanSegment.java new file mode 100644 index 00000000..5f2e95f3 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/api/dto/RenderPlanSegment.java @@ -0,0 +1,26 @@ +package com.ycwl.basic.template.api.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +/** + * RenderPlan 片段(已裁剪/已绑定/已计算时间轴) + */ +@Data +public class RenderPlanSegment { + private Integer templateSegmentIndex; + private Integer planSegmentIndex; + + private Integer startTimeMs; + private Integer durationMs; + + private String segmentType; + private String sourceType; + private String sourceRef; + + private String boundMaterialUrl; + + private JsonNode renderSpec; + private JsonNode audioSpec; +} + diff --git a/src/main/java/com/ycwl/basic/template/api/dto/TemplatePlanBuildCommand.java b/src/main/java/com/ycwl/basic/template/api/dto/TemplatePlanBuildCommand.java new file mode 100644 index 00000000..92997309 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/api/dto/TemplatePlanBuildCommand.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.template.api.dto; + +import lombok.Data; + +import java.util.List; +import java.util.Map; + +/** + * RenderPlan 生成命令 + */ +@Data +public class TemplatePlanBuildCommand { + private Long scenicId; + private Long templateId; + /** + * 可选:指定模板版本(用于校验模板是否被更新) + */ + private Integer templateVersion; + private Long faceId; + private Long memberId; + + /** + * slotKey -> 素材列表 + */ + private Map> materialsBySlot; +} + diff --git a/src/main/java/com/ycwl/basic/template/api/dto/TemplateSlotMaterial.java b/src/main/java/com/ycwl/basic/template/api/dto/TemplateSlotMaterial.java new file mode 100644 index 00000000..97282631 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/api/dto/TemplateSlotMaterial.java @@ -0,0 +1,20 @@ +package com.ycwl.basic.template.api.dto; + +import lombok.Data; + +/** + * Slot 素材引用(由调用方提供,Template v2 不直接跨模块查素材库) + */ +@Data +public class TemplateSlotMaterial { + /** + * 素材URL(http/https) + */ + private String url; + + /** + * 创建时间(毫秒时间戳,可选,用于确定性选材) + */ + private Long createTimeMs; +} + diff --git a/src/main/java/com/ycwl/basic/template/controller/pc/TemplateV2Controller.java b/src/main/java/com/ycwl/basic/template/controller/pc/TemplateV2Controller.java new file mode 100644 index 00000000..bb80287b --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/controller/pc/TemplateV2Controller.java @@ -0,0 +1,72 @@ +package com.ycwl.basic.template.controller.pc; + +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.template.api.ITemplateRenderPlanApi; +import com.ycwl.basic.template.api.dto.RenderPlan; +import com.ycwl.basic.template.api.dto.TemplatePlanBuildCommand; +import com.ycwl.basic.template.model.req.TemplateV2ReqQuery; +import com.ycwl.basic.template.model.req.TemplateV2SaveReq; +import com.ycwl.basic.template.model.req.TemplateV2StatusUpdateReq; +import com.ycwl.basic.template.model.resp.TemplateV2DetailResp; +import com.ycwl.basic.template.model.resp.TemplateV2ListResp; +import com.ycwl.basic.template.service.TemplateV2ManagementService; +import com.ycwl.basic.utils.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * Template v2 管理端接口 + */ +@RestController +@RequestMapping("/api/template/v2") +@RequiredArgsConstructor +public class TemplateV2Controller { + + private final TemplateV2ManagementService templateV2ManagementService; + private final ITemplateRenderPlanApi templateRenderPlanApi; + + @PostMapping("/page") + public ApiResponse> page(@RequestBody TemplateV2ReqQuery query) { + return templateV2ManagementService.pageQuery(query); + } + + @PostMapping("/list") + public ApiResponse> list(@RequestBody TemplateV2ReqQuery query) { + return templateV2ManagementService.list(query); + } + + @GetMapping("/{id}") + public ApiResponse detail(@PathVariable("id") Long id) { + return templateV2ManagementService.getDetail(id); + } + + @PostMapping("/add") + public ApiResponse add(@RequestBody TemplateV2SaveReq req) { + return templateV2ManagementService.create(req); + } + + @PutMapping("/{id}") + public ApiResponse update(@PathVariable("id") Long id, @RequestBody TemplateV2SaveReq req) { + return templateV2ManagementService.update(id, req); + } + + @PutMapping("/{id}/status") + public ApiResponse updateStatus(@PathVariable("id") Long id, @RequestBody TemplateV2StatusUpdateReq req) { + return templateV2ManagementService.updateStatus(id, req.getStatus()); + } + + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable("id") Long id) { + return templateV2ManagementService.delete(id); + } + + /** + * 管理端预览:根据模板 + materialsBySlot 生成冻结 RenderPlan + */ + @PostMapping("/render-plan/preview") + public ApiResponse previewRenderPlan(@RequestBody TemplatePlanBuildCommand command) { + return ApiResponse.success(templateRenderPlanApi.buildRenderPlan(command)); + } +} diff --git a/src/main/java/com/ycwl/basic/template/enums/TemplateV2SegmentType.java b/src/main/java/com/ycwl/basic/template/enums/TemplateV2SegmentType.java new file mode 100644 index 00000000..ff904f05 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/enums/TemplateV2SegmentType.java @@ -0,0 +1,22 @@ +package com.ycwl.basic.template.enums; + +/** + * Template v2 片段类型 + */ +public enum TemplateV2SegmentType { + FIXED, + RENDER; + + public static TemplateV2SegmentType parse(String value) { + if (value == null || value.isBlank()) { + return null; + } + for (TemplateV2SegmentType type : values()) { + if (type.name().equalsIgnoreCase(value)) { + return type; + } + } + return null; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/enums/TemplateV2SourceType.java b/src/main/java/com/ycwl/basic/template/enums/TemplateV2SourceType.java new file mode 100644 index 00000000..24571805 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/enums/TemplateV2SourceType.java @@ -0,0 +1,22 @@ +package com.ycwl.basic.template.enums; + +/** + * Template v2 素材来源类型 + */ +public enum TemplateV2SourceType { + SLOT, + FIXED_URL; + + public static TemplateV2SourceType parse(String value) { + if (value == null || value.isBlank()) { + return null; + } + for (TemplateV2SourceType type : values()) { + if (type.name().equalsIgnoreCase(value)) { + return type; + } + } + return null; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/model/entity/TemplateV2Entity.java b/src/main/java/com/ycwl/basic/template/model/entity/TemplateV2Entity.java new file mode 100644 index 00000000..d6e26f88 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/model/entity/TemplateV2Entity.java @@ -0,0 +1,29 @@ +package com.ycwl.basic.template.model.entity; + +import lombok.Data; + +import java.util.Date; + +/** + * Template v2 主表实体(template_v2) + */ +@Data +public class TemplateV2Entity { + private Long id; + private Long scenicId; + private String name; + private Integer version; + private Integer status; + + private Integer outputWidth; + private Integer outputHeight; + private Integer outputFps; + + private String bgmUrl; + private Integer totalDurationMs; + + private Date createTime; + private Date updateTime; + private Integer deleted; +} + diff --git a/src/main/java/com/ycwl/basic/template/model/entity/TemplateV2SegmentEntity.java b/src/main/java/com/ycwl/basic/template/model/entity/TemplateV2SegmentEntity.java new file mode 100644 index 00000000..ad9cf423 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/model/entity/TemplateV2SegmentEntity.java @@ -0,0 +1,29 @@ +package com.ycwl.basic.template.model.entity; + +import lombok.Data; + +import java.util.Date; + +/** + * Template v2 片段表实体(template_v2_segment) + */ +@Data +public class TemplateV2SegmentEntity { + private Long id; + private Long templateId; + private Integer segmentIndex; + + private Integer durationMs; + private String segmentType; + + private String sourceType; + private String sourceRef; + + private String onlyIfExpr; + private String renderSpecJson; + private String audioSpecJson; + + private Date createTime; + private Date updateTime; +} + diff --git a/src/main/java/com/ycwl/basic/template/model/req/TemplateV2ReqQuery.java b/src/main/java/com/ycwl/basic/template/model/req/TemplateV2ReqQuery.java new file mode 100644 index 00000000..2ed5eb9e --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/model/req/TemplateV2ReqQuery.java @@ -0,0 +1,17 @@ +package com.ycwl.basic.template.model.req; + +import com.ycwl.basic.model.common.BaseQueryParameterReq; +import lombok.Data; +import lombok.EqualsAndHashCode; + +/** + * Template v2 管理端查询参数 + */ +@EqualsAndHashCode(callSuper = true) +@Data +public class TemplateV2ReqQuery extends BaseQueryParameterReq { + private Long scenicId; + private Integer status; + private String name; +} + diff --git a/src/main/java/com/ycwl/basic/template/model/req/TemplateV2SaveReq.java b/src/main/java/com/ycwl/basic/template/model/req/TemplateV2SaveReq.java new file mode 100644 index 00000000..204f6d12 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/model/req/TemplateV2SaveReq.java @@ -0,0 +1,62 @@ +package com.ycwl.basic.template.model.req; + +import com.fasterxml.jackson.databind.JsonNode; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +import java.util.List; + +/** + * Template v2 新建/更新请求 + */ +@Data +public class TemplateV2SaveReq { + @NotNull(message = "scenicId不能为空") + private Long scenicId; + + @NotBlank(message = "name不能为空") + private String name; + + @NotNull(message = "outputWidth不能为空") + @Min(value = 1, message = "outputWidth必须大于0") + private Integer outputWidth; + + @NotNull(message = "outputHeight不能为空") + @Min(value = 1, message = "outputHeight必须大于0") + private Integer outputHeight; + + @NotNull(message = "outputFps不能为空") + @Min(value = 1, message = "outputFps必须大于0") + private Integer outputFps; + + private String bgmUrl; + private Integer status; + + @Valid + @NotNull(message = "segments不能为空") + private List segments; + + @Data + public static class TemplateV2SegmentSaveReq { + @NotNull(message = "durationMs不能为空") + @Min(value = 1, message = "durationMs必须大于0") + private Integer durationMs; + + @NotBlank(message = "segmentType不能为空") + private String segmentType; + + @NotBlank(message = "sourceType不能为空") + private String sourceType; + + @NotBlank(message = "sourceRef不能为空") + private String sourceRef; + + private JsonNode onlyIfExpr; + private JsonNode renderSpec; + private JsonNode audioSpec; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/model/req/TemplateV2StatusUpdateReq.java b/src/main/java/com/ycwl/basic/template/model/req/TemplateV2StatusUpdateReq.java new file mode 100644 index 00000000..737b8bba --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/model/req/TemplateV2StatusUpdateReq.java @@ -0,0 +1,14 @@ +package com.ycwl.basic.template.model.req; + +import jakarta.validation.constraints.NotNull; +import lombok.Data; + +/** + * Template v2 启停用更新 + */ +@Data +public class TemplateV2StatusUpdateReq { + @NotNull(message = "status不能为空") + private Integer status; +} + diff --git a/src/main/java/com/ycwl/basic/template/model/resp/TemplateV2DetailResp.java b/src/main/java/com/ycwl/basic/template/model/resp/TemplateV2DetailResp.java new file mode 100644 index 00000000..8b98bfa0 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/model/resp/TemplateV2DetailResp.java @@ -0,0 +1,43 @@ +package com.ycwl.basic.template.model.resp; + +import com.fasterxml.jackson.databind.JsonNode; +import lombok.Data; + +import java.util.Date; +import java.util.List; + +/** + * Template v2 详情返回(含片段) + */ +@Data +public class TemplateV2DetailResp { + private Long id; + private Long scenicId; + private String scenicName; + private String name; + private Integer version; + private Integer status; + private Integer outputWidth; + private Integer outputHeight; + private Integer outputFps; + private String bgmUrl; + private Integer totalDurationMs; + private Date createTime; + private Date updateTime; + + private List segments; + + @Data + public static class Segment { + private Long id; + private Integer segmentIndex; + private Integer durationMs; + private String segmentType; + private String sourceType; + private String sourceRef; + private JsonNode onlyIfExpr; + private JsonNode renderSpec; + private JsonNode audioSpec; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/model/resp/TemplateV2ListResp.java b/src/main/java/com/ycwl/basic/template/model/resp/TemplateV2ListResp.java new file mode 100644 index 00000000..c4912139 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/model/resp/TemplateV2ListResp.java @@ -0,0 +1,21 @@ +package com.ycwl.basic.template.model.resp; + +import lombok.Data; + +import java.util.Date; + +/** + * Template v2 列表项返回 + */ +@Data +public class TemplateV2ListResp { + private Long id; + private Long scenicId; + private String scenicName; + private String name; + private Integer version; + private Integer status; + private Integer totalDurationMs; + private Date updateTime; +} + diff --git a/src/main/java/com/ycwl/basic/template/service/TemplateRenderPlanService.java b/src/main/java/com/ycwl/basic/template/service/TemplateRenderPlanService.java new file mode 100644 index 00000000..e63ed533 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/TemplateRenderPlanService.java @@ -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 templateSegments = templateV2SegmentMapper.listByTemplateId(template.getId()); + if (templateSegments == null || templateSegments.isEmpty()) { + throw new BizException(400, "模板未配置片段"); + } + + Map> materialsBySlot = Objects.requireNonNullElse(command.getMaterialsBySlot(), Map.of()); + Map slotCounts = buildSlotCounts(materialsBySlot); + + List 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 buildSlotCounts(Map> materialsBySlot) { + Map counts = new HashMap<>(); + for (Map.Entry> entry : materialsBySlot.entrySet()) { + String slotKey = entry.getKey(); + List 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 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> 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 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; + } + } +} + diff --git a/src/main/java/com/ycwl/basic/template/service/TemplateV2ManagementService.java b/src/main/java/com/ycwl/basic/template/service/TemplateV2ManagementService.java new file mode 100644 index 00000000..401df56f --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/TemplateV2ManagementService.java @@ -0,0 +1,30 @@ +package com.ycwl.basic.template.service; + +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.template.model.req.TemplateV2ReqQuery; +import com.ycwl.basic.template.model.req.TemplateV2SaveReq; +import com.ycwl.basic.template.model.resp.TemplateV2DetailResp; +import com.ycwl.basic.template.model.resp.TemplateV2ListResp; +import com.ycwl.basic.utils.ApiResponse; + +import java.util.List; + +/** + * Template v2 管理端服务 + */ +public interface TemplateV2ManagementService { + ApiResponse> pageQuery(TemplateV2ReqQuery query); + + ApiResponse> list(TemplateV2ReqQuery query); + + ApiResponse getDetail(Long templateId); + + ApiResponse create(TemplateV2SaveReq req); + + ApiResponse update(Long templateId, TemplateV2SaveReq req); + + ApiResponse updateStatus(Long templateId, Integer status); + + ApiResponse delete(Long templateId); +} + diff --git a/src/main/java/com/ycwl/basic/template/service/impl/TemplateV2ManagementServiceImpl.java b/src/main/java/com/ycwl/basic/template/service/impl/TemplateV2ManagementServiceImpl.java new file mode 100644 index 00000000..29ed7921 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/impl/TemplateV2ManagementServiceImpl.java @@ -0,0 +1,319 @@ +package com.ycwl.basic.template.service.impl; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.pagehelper.PageHelper; +import com.github.pagehelper.PageInfo; +import com.ycwl.basic.mapper.TemplateV2Mapper; +import com.ycwl.basic.mapper.TemplateV2SegmentMapper; +import com.ycwl.basic.repository.ScenicRepository; +import com.ycwl.basic.template.model.entity.TemplateV2Entity; +import com.ycwl.basic.template.model.entity.TemplateV2SegmentEntity; +import com.ycwl.basic.template.model.req.TemplateV2ReqQuery; +import com.ycwl.basic.template.model.req.TemplateV2SaveReq; +import com.ycwl.basic.template.model.resp.TemplateV2DetailResp; +import com.ycwl.basic.template.model.resp.TemplateV2ListResp; +import com.ycwl.basic.template.service.TemplateV2ManagementService; +import com.ycwl.basic.template.service.onlyif.OnlyIfExpressionParser; +import com.ycwl.basic.utils.ApiResponse; +import com.ycwl.basic.utils.SnowFlakeUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.ycwl.basic.template.enums.TemplateV2SegmentType; +import com.ycwl.basic.template.enums.TemplateV2SourceType; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import com.ycwl.basic.exception.BizException; +import java.net.URI; + +@Service +@RequiredArgsConstructor +public class TemplateV2ManagementServiceImpl implements TemplateV2ManagementService { + + private static final int STATUS_ENABLED = 1; + private static final int STATUS_DISABLED = 0; + + private final TemplateV2Mapper templateV2Mapper; + private final TemplateV2SegmentMapper templateV2SegmentMapper; + private final ScenicRepository scenicRepository; + private final ObjectMapper objectMapper; + private final OnlyIfExpressionParser onlyIfExpressionParser; + + @Override + public ApiResponse> pageQuery(TemplateV2ReqQuery query) { + PageHelper.startPage(query.getPageNum(), query.getPageSize()); + List entities = templateV2Mapper.list(query); + List items = toListResp(entities); + return ApiResponse.success(new PageInfo<>(items)); + } + + @Override + public ApiResponse> list(TemplateV2ReqQuery query) { + List entities = templateV2Mapper.list(query); + return ApiResponse.success(toListResp(entities)); + } + + @Override + public ApiResponse getDetail(Long templateId) { + TemplateV2Entity template = templateV2Mapper.getById(templateId); + if (template == null) { + return ApiResponse.fail("模板不存在"); + } + List segments = templateV2SegmentMapper.listByTemplateId(templateId); + TemplateV2DetailResp resp = toDetailResp(template, segments); + return ApiResponse.success(resp); + } + + @Override + @Transactional + public ApiResponse create(TemplateV2SaveReq req) { + validateSaveReq(req); + + long templateId = SnowFlakeUtil.getLongId(); + int totalDurationMs = sumDurationMs(req.getSegments()); + + TemplateV2Entity entity = new TemplateV2Entity(); + entity.setId(templateId); + entity.setScenicId(req.getScenicId()); + entity.setName(req.getName()); + entity.setVersion(1); + entity.setStatus(normalizeStatus(req.getStatus())); + entity.setOutputWidth(req.getOutputWidth()); + entity.setOutputHeight(req.getOutputHeight()); + entity.setOutputFps(req.getOutputFps()); + entity.setBgmUrl(req.getBgmUrl()); + entity.setTotalDurationMs(totalDurationMs); + templateV2Mapper.insert(entity); + + saveSegments(templateId, req.getSegments()); + return ApiResponse.success(templateId); + } + + @Override + @Transactional + public ApiResponse update(Long templateId, TemplateV2SaveReq req) { + validateSaveReq(req); + + TemplateV2Entity old = templateV2Mapper.getById(templateId); + if (old == null) { + return ApiResponse.fail("模板不存在"); + } + + int totalDurationMs = sumDurationMs(req.getSegments()); + + TemplateV2Entity entity = new TemplateV2Entity(); + entity.setId(templateId); + entity.setScenicId(req.getScenicId()); + entity.setName(req.getName()); + entity.setVersion(old.getVersion() == null ? 1 : old.getVersion() + 1); + entity.setStatus(req.getStatus() == null ? normalizeStatus(old.getStatus()) : normalizeStatus(req.getStatus())); + entity.setOutputWidth(req.getOutputWidth()); + entity.setOutputHeight(req.getOutputHeight()); + entity.setOutputFps(req.getOutputFps()); + entity.setBgmUrl(req.getBgmUrl()); + entity.setTotalDurationMs(totalDurationMs); + templateV2Mapper.updateById(entity); + + templateV2SegmentMapper.deleteByTemplateId(templateId); + saveSegments(templateId, req.getSegments()); + return ApiResponse.success(true); + } + + @Override + public ApiResponse updateStatus(Long templateId, Integer status) { + Integer normalized = normalizeStatus(status); + int updated = templateV2Mapper.updateStatus(templateId, normalized); + if (updated <= 0) { + return ApiResponse.fail("更新失败:模板不存在或已删除"); + } + return ApiResponse.success(true); + } + + @Override + public ApiResponse delete(Long templateId) { + int updated = templateV2Mapper.softDelete(templateId); + if (updated <= 0) { + return ApiResponse.fail("删除失败:模板不存在或已删除"); + } + return ApiResponse.success(true); + } + + private List toListResp(List entities) { + if (entities == null || entities.isEmpty()) { + return new ArrayList<>(); + } + List scenicIds = entities.stream() + .map(TemplateV2Entity::getScenicId) + .filter(Objects::nonNull) + .distinct() + .collect(Collectors.toList()); + Map scenicNames = scenicRepository.batchGetScenicNames(scenicIds); + + return entities.stream().map(item -> { + TemplateV2ListResp resp = new TemplateV2ListResp(); + resp.setId(item.getId()); + resp.setScenicId(item.getScenicId()); + resp.setScenicName(item.getScenicId() == null ? null : scenicNames.get(item.getScenicId())); + resp.setName(item.getName()); + resp.setVersion(item.getVersion()); + resp.setStatus(item.getStatus()); + resp.setTotalDurationMs(item.getTotalDurationMs()); + resp.setUpdateTime(item.getUpdateTime()); + return resp; + }).collect(Collectors.toList()); + } + + private TemplateV2DetailResp toDetailResp(TemplateV2Entity template, List segments) { + TemplateV2DetailResp resp = new TemplateV2DetailResp(); + resp.setId(template.getId()); + resp.setScenicId(template.getScenicId()); + if (template.getScenicId() != null) { + Map scenicNames = scenicRepository.batchGetScenicNames(List.of(template.getScenicId())); + resp.setScenicName(scenicNames.get(template.getScenicId())); + } + resp.setName(template.getName()); + resp.setVersion(template.getVersion()); + resp.setStatus(template.getStatus()); + resp.setOutputWidth(template.getOutputWidth()); + resp.setOutputHeight(template.getOutputHeight()); + resp.setOutputFps(template.getOutputFps()); + resp.setBgmUrl(template.getBgmUrl()); + resp.setTotalDurationMs(template.getTotalDurationMs()); + resp.setCreateTime(template.getCreateTime()); + resp.setUpdateTime(template.getUpdateTime()); + + List segmentList = new ArrayList<>(); + for (TemplateV2SegmentEntity segment : Objects.requireNonNullElse(segments, List.of())) { + TemplateV2DetailResp.Segment seg = new TemplateV2DetailResp.Segment(); + seg.setId(segment.getId()); + seg.setSegmentIndex(segment.getSegmentIndex()); + seg.setDurationMs(segment.getDurationMs()); + seg.setSegmentType(segment.getSegmentType()); + seg.setSourceType(segment.getSourceType()); + seg.setSourceRef(segment.getSourceRef()); + seg.setOnlyIfExpr(parseJsonOrNull(segment.getOnlyIfExpr())); + seg.setRenderSpec(parseJsonOrNull(segment.getRenderSpecJson())); + seg.setAudioSpec(parseJsonOrNull(segment.getAudioSpecJson())); + segmentList.add(seg); + } + resp.setSegments(segmentList); + return resp; + } + + private void validateSaveReq(TemplateV2SaveReq req) { + if (req.getSegments() == null || req.getSegments().isEmpty()) { + throw new BizException(400, "segments不能为空"); + } + } + + private int normalizeStatus(Integer status) { + if (status == null) { + return STATUS_ENABLED; + } + if (status == STATUS_ENABLED || status == STATUS_DISABLED) { + return status; + } + throw new BizException(400, "status仅支持0/1"); + } + + private int sumDurationMs(List segments) { + int total = 0; + for (TemplateV2SaveReq.TemplateV2SegmentSaveReq item : segments) { + total = Math.addExact(total, item.getDurationMs()); + } + return total; + } + + private void saveSegments(Long templateId, List segments) { + List entities = new ArrayList<>(); + for (int index = 0; index < segments.size(); index++) { + TemplateV2SaveReq.TemplateV2SegmentSaveReq req = segments.get(index); + validateOnlyIfExpr(req.getOnlyIfExpr()); + validateSegmentDefinition(req); + + TemplateV2SegmentEntity entity = new TemplateV2SegmentEntity(); + entity.setId(SnowFlakeUtil.getLongId()); + entity.setTemplateId(templateId); + entity.setSegmentIndex(index); + entity.setDurationMs(req.getDurationMs()); + entity.setSegmentType(req.getSegmentType()); + entity.setSourceType(req.getSourceType()); + entity.setSourceRef(req.getSourceRef()); + entity.setOnlyIfExpr(writeJsonOrNull(req.getOnlyIfExpr())); + entity.setRenderSpecJson(writeJsonOrNull(req.getRenderSpec())); + entity.setAudioSpecJson(writeJsonOrNull(req.getAudioSpec())); + entities.add(entity); + } + if (!entities.isEmpty()) { + templateV2SegmentMapper.batchInsert(entities); + } + } + + private void validateSegmentDefinition(TemplateV2SaveReq.TemplateV2SegmentSaveReq req) { + TemplateV2SegmentType segmentType = TemplateV2SegmentType.parse(req.getSegmentType()); + if (segmentType == null) { + throw new BizException(400, "segmentType仅支持FIXED/RENDER"); + } + TemplateV2SourceType sourceType = TemplateV2SourceType.parse(req.getSourceType()); + if (sourceType == null) { + throw new BizException(400, "sourceType仅支持SLOT/FIXED_URL"); + } + if (segmentType == TemplateV2SegmentType.FIXED && sourceType != TemplateV2SourceType.FIXED_URL) { + throw new BizException(400, "FIXED片段仅支持sourceType=FIXED_URL"); + } + if (segmentType == TemplateV2SegmentType.RENDER && sourceType != TemplateV2SourceType.SLOT) { + throw new BizException(400, "RENDER片段仅支持sourceType=SLOT"); + } + if (sourceType == TemplateV2SourceType.FIXED_URL && !isHttpUrl(req.getSourceRef())) { + throw new BizException(400, "sourceRef必须是http/https URL"); + } + } + + private void validateOnlyIfExpr(JsonNode onlyIfExpr) { + if (onlyIfExpr == null || onlyIfExpr.isNull()) { + return; + } + onlyIfExpressionParser.parse(onlyIfExpr); + } + + private String writeJsonOrNull(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + try { + return objectMapper.writeValueAsString(node); + } catch (JsonProcessingException e) { + throw new BizException(400, "JSON序列化失败:" + e.getMessage()); + } + } + + private JsonNode parseJsonOrNull(String json) { + if (json == null || json.isBlank()) { + return null; + } + try { + return objectMapper.readTree(json); + } catch (JsonProcessingException e) { + return null; + } + } + + 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; + } + } +} diff --git a/src/main/java/com/ycwl/basic/template/service/onlyif/AndExpression.java b/src/main/java/com/ycwl/basic/template/service/onlyif/AndExpression.java new file mode 100644 index 00000000..91a7a0f1 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/onlyif/AndExpression.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.template.service.onlyif; + +import java.util.List; +import java.util.Map; + +public class AndExpression implements OnlyIfExpression { + private final List items; + + public AndExpression(List items) { + this.items = items; + } + + @Override + public boolean evaluate(Map slotCounts) { + for (OnlyIfExpression item : items) { + if (!item.evaluate(slotCounts)) { + return false; + } + } + return true; + } + + public List getItems() { + return items; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/service/onlyif/CountGteExpression.java b/src/main/java/com/ycwl/basic/template/service/onlyif/CountGteExpression.java new file mode 100644 index 00000000..e279ccac --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/onlyif/CountGteExpression.java @@ -0,0 +1,30 @@ +package com.ycwl.basic.template.service.onlyif; + +import java.util.Map; + +public class CountGteExpression implements OnlyIfExpression { + private final String slotKey; + private final int value; + + public CountGteExpression(String slotKey, int value) { + this.slotKey = slotKey; + this.value = value; + } + + @Override + public boolean evaluate(Map slotCounts) { + if (slotCounts == null) { + return false; + } + return slotCounts.getOrDefault(slotKey, 0) >= value; + } + + public String getSlotKey() { + return slotKey; + } + + public int getValue() { + return value; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/service/onlyif/ExistsExpression.java b/src/main/java/com/ycwl/basic/template/service/onlyif/ExistsExpression.java new file mode 100644 index 00000000..bb6c4e6a --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/onlyif/ExistsExpression.java @@ -0,0 +1,24 @@ +package com.ycwl.basic.template.service.onlyif; + +import java.util.Map; + +public class ExistsExpression implements OnlyIfExpression { + private final String slotKey; + + public ExistsExpression(String slotKey) { + this.slotKey = slotKey; + } + + @Override + public boolean evaluate(Map slotCounts) { + if (slotCounts == null) { + return false; + } + return slotCounts.getOrDefault(slotKey, 0) > 0; + } + + public String getSlotKey() { + return slotKey; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/service/onlyif/NotExpression.java b/src/main/java/com/ycwl/basic/template/service/onlyif/NotExpression.java new file mode 100644 index 00000000..92033dcf --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/onlyif/NotExpression.java @@ -0,0 +1,21 @@ +package com.ycwl.basic.template.service.onlyif; + +import java.util.Map; + +public class NotExpression implements OnlyIfExpression { + private final OnlyIfExpression expr; + + public NotExpression(OnlyIfExpression expr) { + this.expr = expr; + } + + @Override + public boolean evaluate(Map slotCounts) { + return !expr.evaluate(slotCounts); + } + + public OnlyIfExpression getExpr() { + return expr; + } +} + diff --git a/src/main/java/com/ycwl/basic/template/service/onlyif/OnlyIfExpression.java b/src/main/java/com/ycwl/basic/template/service/onlyif/OnlyIfExpression.java new file mode 100644 index 00000000..f0f097b4 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/onlyif/OnlyIfExpression.java @@ -0,0 +1,11 @@ +package com.ycwl.basic.template.service.onlyif; + +import java.util.Map; + +/** + * only_if 受限表达式(基于 slotKey 的数量判断) + */ +public interface OnlyIfExpression { + boolean evaluate(Map slotCounts); +} + diff --git a/src/main/java/com/ycwl/basic/template/service/onlyif/OnlyIfExpressionParser.java b/src/main/java/com/ycwl/basic/template/service/onlyif/OnlyIfExpressionParser.java new file mode 100644 index 00000000..98869905 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/onlyif/OnlyIfExpressionParser.java @@ -0,0 +1,103 @@ +package com.ycwl.basic.template.service.onlyif; + +import com.fasterxml.jackson.databind.JsonNode; +import com.ycwl.basic.exception.BizException; +import org.springframework.stereotype.Component; + +import java.util.ArrayList; +import java.util.List; + +/** + * only_if 表达式解析器(JSON AST): + *
+ * {"op":"exists","slotKey":"device:123"}
+ * {"op":"count_gte","slotKey":"P:123","value":2}
+ * {"op":"and","items":[...]}
+ * {"op":"or","items":[...]}
+ * {"op":"not","expr":{...}}
+ * 
+ */ +@Component +public class OnlyIfExpressionParser { + + public OnlyIfExpression parse(JsonNode node) { + if (node == null || node.isNull()) { + return null; + } + if (!node.isObject()) { + throw new BizException(400, "onlyIfExpr必须是JSON对象"); + } + String op = text(node, "op"); + if (op == null) { + op = text(node, "type"); + } + if (op == null) { + throw new BizException(400, "onlyIfExpr缺少op字段"); + } + + return switch (op.toLowerCase()) { + case "exists" -> new ExistsExpression(requiredText(node, "slotKey")); + case "count_gte" -> new CountGteExpression(requiredText(node, "slotKey"), requiredInt(node, "value", "n")); + case "and" -> new AndExpression(parseItems(node)); + case "or" -> new OrExpression(parseItems(node)); + case "not" -> new NotExpression(parse(requiredNode(node, "expr", "item"))); + default -> throw new BizException(400, "onlyIfExpr不支持的op: " + op); + }; + } + + private List parseItems(JsonNode node) { + JsonNode itemsNode = node.get("items"); + if (itemsNode == null) { + itemsNode = node.get("children"); + } + if (itemsNode == null || !itemsNode.isArray() || itemsNode.isEmpty()) { + throw new BizException(400, "onlyIfExpr的items必须是非空数组"); + } + List items = new ArrayList<>(); + for (JsonNode item : itemsNode) { + items.add(parse(item)); + } + return items; + } + + private String requiredText(JsonNode node, String fieldName) { + String value = text(node, fieldName); + if (value == null || value.isBlank()) { + throw new BizException(400, "onlyIfExpr缺少字段: " + fieldName); + } + return value; + } + + private int requiredInt(JsonNode node, String... fieldNames) { + for (String fieldName : fieldNames) { + JsonNode valueNode = node.get(fieldName); + if (valueNode != null && valueNode.isNumber()) { + int value = valueNode.asInt(); + if (value < 0) { + throw new BizException(400, "onlyIfExpr字段" + fieldName + "不能为负数"); + } + return value; + } + } + throw new BizException(400, "onlyIfExpr缺少字段: value"); + } + + private JsonNode requiredNode(JsonNode node, String... fieldNames) { + for (String fieldName : fieldNames) { + JsonNode valueNode = node.get(fieldName); + if (valueNode != null && !valueNode.isNull()) { + return valueNode; + } + } + throw new BizException(400, "onlyIfExpr缺少字段: expr"); + } + + private String text(JsonNode node, String fieldName) { + JsonNode valueNode = node.get(fieldName); + if (valueNode == null || valueNode.isNull() || !valueNode.isTextual()) { + return null; + } + return valueNode.asText(); + } +} + diff --git a/src/main/java/com/ycwl/basic/template/service/onlyif/OrExpression.java b/src/main/java/com/ycwl/basic/template/service/onlyif/OrExpression.java new file mode 100644 index 00000000..96f2aed2 --- /dev/null +++ b/src/main/java/com/ycwl/basic/template/service/onlyif/OrExpression.java @@ -0,0 +1,27 @@ +package com.ycwl.basic.template.service.onlyif; + +import java.util.List; +import java.util.Map; + +public class OrExpression implements OnlyIfExpression { + private final List items; + + public OrExpression(List items) { + this.items = items; + } + + @Override + public boolean evaluate(Map slotCounts) { + for (OnlyIfExpression item : items) { + if (item.evaluate(slotCounts)) { + return true; + } + } + return false; + } + + public List getItems() { + return items; + } +} + diff --git a/src/main/resources/mapper/TemplateV2Mapper.xml b/src/main/resources/mapper/TemplateV2Mapper.xml new file mode 100644 index 00000000..a60a8b8f --- /dev/null +++ b/src/main/resources/mapper/TemplateV2Mapper.xml @@ -0,0 +1,72 @@ + + + + + + + + + insert into template_v2( + id, scenic_id, `name`, version, status, + output_width, output_height, output_fps, + bgm_url, total_duration_ms, + deleted, create_time, update_time + ) + values ( + #{id}, #{scenicId}, #{name}, #{version}, #{status}, + #{outputWidth}, #{outputHeight}, #{outputFps}, + #{bgmUrl}, #{totalDurationMs}, + 0, now(), now() + ) + + + + update template_v2 + + update_time = now(), + scenic_id = #{scenicId}, + `name` = #{name}, + version = #{version}, + status = #{status}, + output_width = #{outputWidth}, + output_height = #{outputHeight}, + output_fps = #{outputFps}, + bgm_url = #{bgmUrl}, + total_duration_ms = #{totalDurationMs}, + + where id = #{id} and deleted = 0 + + + + update template_v2 + set status = #{status}, update_time = now() + where id = #{id} and deleted = 0 + + + + update template_v2 + set deleted = 1, update_time = now() + where id = #{id} and deleted = 0 + + + diff --git a/src/main/resources/mapper/TemplateV2SegmentMapper.xml b/src/main/resources/mapper/TemplateV2SegmentMapper.xml new file mode 100644 index 00000000..3afc5c59 --- /dev/null +++ b/src/main/resources/mapper/TemplateV2SegmentMapper.xml @@ -0,0 +1,35 @@ + + + + + + + delete from template_v2_segment where template_id = #{templateId} + + + + insert into template_v2_segment( + id, template_id, segment_index, + duration_ms, segment_type, + source_type, source_ref, + only_if_expr, render_spec_json, audio_spec_json, + create_time, update_time + ) + values + + ( + #{item.id}, #{item.templateId}, #{item.segmentIndex}, + #{item.durationMs}, #{item.segmentType}, + #{item.sourceType}, #{item.sourceRef}, + #{item.onlyIfExpr}, #{item.renderSpecJson}, #{item.audioSpecJson}, + now(), now() + ) + + + +