فهرست منبع

feat(api): 改版v1 通知模板管理 (W3)

- 新增 notification_templates 表 + NotificationTemplate 实体/Mapper(迁移245,幂等+种子数据)
- 新增 AdminNotificationTemplateController (/api/admin/notification-template list/save/delete)
- DanReportUploadService / ProblemCompletionService 改为从模板表读取文案
  - report_ready / reward_granted 支持 {domainName}/{reward} 占位符
  - 表不存在/停用/无记录时回退默认文案
- schema.sql 同步 notification_templates 建表

Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 2 ماه پیش
والد
کامیت
a8a2128074

+ 33 - 0
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9123,6 +9123,39 @@ private void runMigration100() {
 			log.warn("初始化sys_menu种子数据失败: " + e.getMessage());
 			log.warn("初始化sys_menu种子数据失败: " + e.getMessage());
 		}
 		}
 
 
+		// 迁移245: 创建 notification_templates 表(通知模板管理 W3)
+		try {
+			jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS notification_templates (" +
+					"id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+					"type VARCHAR(30) NOT NULL COMMENT '通知类型: report_ready/reward_granted/system', " +
+					"title VARCHAR(200) NOT NULL COMMENT '默认标题(支持占位符)', " +
+					"content VARCHAR(1000) NOT NULL COMMENT '默认内容模板(支持占位符)', " +
+					"channels VARCHAR(100) DEFAULT 'in_app' COMMENT '推送通道: in_app,sms,wechat,phone 逗号分隔', " +
+					"status TINYINT DEFAULT 1 COMMENT '1启用 0停用', " +
+					"created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+					"updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+					"UNIQUE KEY uk_type (type)" +
+					") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板(改版v1 W3)'");
+			log.info("已创建notification_templates表");
+		} catch (Exception e) {
+			log.warn("创建notification_templates表可能已存在: {}", e.getMessage());
+		}
+		// 迁移245b: notification_templates 种子数据(表为空才插入,幂等)
+		try {
+			Integer tplCount = jdbcTemplate.queryForObject("SELECT COUNT(*) FROM notification_templates", Integer.class);
+			if (tplCount == null || tplCount == 0) {
+				jdbcTemplate.execute("INSERT INTO notification_templates (type, title, content, channels) VALUES " +
+						"('report_ready', '健康报告已完成分析', '您的健康报告已完成解析,可前往查看结果与成长方案。', 'in_app,sms'), " +
+						"('reward_granted', '问题域完成奖励已到账', '恭喜完成「{domainName}」全部任务,奖励 {reward} CF 已发放至家庭账户。', 'in_app,sms,wechat'), " +
+						"('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app')");
+				log.info("notification_templates 种子数据已插入");
+			} else {
+				log.info("notification_templates 已有 {} 条数据,跳过种子插入", tplCount);
+			}
+		} catch (Exception e) {
+			log.warn("初始化notification_templates种子数据失败: " + e.getMessage());
+		}
+
 	}
 	}
 
 
 	/**
 	/**

+ 90 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminNotificationTemplateController.java

@@ -0,0 +1,90 @@
+package com.etotem.cfc.controller.admin;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.common.Result;
+import com.etotem.cfc.entity.NotificationTemplate;
+import com.etotem.cfc.mapper.NotificationTemplateMapper;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestAttribute;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 管理端-通知模板 CRUD(改版v1,W3)
+ * report_ready / reward_granted / system 文案配置,支持占位符 {domainName} {reward}
+ */
+@Tag(name = "管理端-通知模板", description = "通知模板 CRUD 管理")
+@RestController
+@RequestMapping("/api/admin/notification-template")
+public class AdminNotificationTemplateController {
+
+    @Resource
+    private NotificationTemplateMapper notificationTemplateMapper;
+
+    @Operation(summary = "通知模板列表")
+    @PostMapping("/list")
+    public Result<List<NotificationTemplate>> list() {
+        LambdaQueryWrapper<NotificationTemplate> wrapper = new LambdaQueryWrapper<NotificationTemplate>()
+                .orderByAsc(NotificationTemplate::getType)
+                .orderByAsc(NotificationTemplate::getId);
+        return Result.success(notificationTemplateMapper.selectList(wrapper));
+    }
+
+    @Operation(summary = "新增/更新通知模板")
+    @PostMapping("/save")
+    public Result<NotificationTemplate> save(@RequestBody NotificationTemplate template, @RequestAttribute("userId") Long userId) {
+        if (template.getType() == null || template.getType().isEmpty()) {
+            return Result.error("类型不能为空");
+        }
+        if (template.getTitle() == null || template.getTitle().isEmpty()) {
+            return Result.error("标题不能为空");
+        }
+        if (template.getContent() == null || template.getContent().isEmpty()) {
+            return Result.error("内容不能为空");
+        }
+        // 类型唯一性校验
+        NotificationTemplate exists = notificationTemplateMapper.selectOne(
+                new LambdaQueryWrapper<NotificationTemplate>()
+                        .eq(NotificationTemplate::getType, template.getType())
+                        .last("LIMIT 1"));
+        if (exists != null && (template.getId() == null || !exists.getId().equals(template.getId()))) {
+            return Result.error("类型已存在: " + template.getType());
+        }
+
+        Date now = new Date();
+        if (template.getId() == null) {
+            if (template.getStatus() == null) {
+                template.setStatus(1);
+            }
+            if (template.getChannels() == null || template.getChannels().isEmpty()) {
+                template.setChannels("in_app");
+            }
+            template.setCreatedAt(now);
+            template.setUpdatedAt(now);
+            notificationTemplateMapper.insert(template);
+        } else {
+            template.setUpdatedAt(now);
+            notificationTemplateMapper.updateById(template);
+        }
+        return Result.success(template);
+    }
+
+    @Operation(summary = "删除通知模板")
+    @PostMapping("/delete")
+    public Result<Void> delete(@RequestBody Map<String, Object> params, @RequestAttribute("userId") Long userId) {
+        Long id = params.get("id") != null ? Long.valueOf(params.get("id").toString()) : null;
+        if (id == null) {
+            return Result.error("缺少 id");
+        }
+        notificationTemplateMapper.deleteById(id);
+        return Result.success(null);
+    }
+}

+ 39 - 0
cfc-backend/src/main/java/com/etotem/cfc/entity/NotificationTemplate.java

@@ -0,0 +1,39 @@
+package com.etotem.cfc.entity;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 通知模板(改版v1 W3)
+ * 各类型通知的文案模板,支持占位符替换
+ */
+@Data
+@TableName("notification_templates")
+public class NotificationTemplate implements Serializable {
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    /** 通知类型: report_ready / reward_granted / system */
+    private String type;
+
+    /** 默认标题(支持占位符如 {domainName} {reward}) */
+    private String title;
+
+    /** 默认内容模板(支持占位符) */
+    private String content;
+
+    /** 推送通道: in_app,sms,wechat,phone 逗号分隔 */
+    private String channels;
+
+    /** 1启用 0停用 */
+    private Integer status;
+
+    private Date createdAt;
+    private Date updatedAt;
+}

+ 7 - 0
cfc-backend/src/main/java/com/etotem/cfc/mapper/NotificationTemplateMapper.java

@@ -0,0 +1,7 @@
+package com.etotem.cfc.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.etotem.cfc.entity.NotificationTemplate;
+
+public interface NotificationTemplateMapper extends BaseMapper<NotificationTemplate> {
+}

+ 49 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -5,10 +5,12 @@ import com.etotem.cfc.dto.DanReportEditDTO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.DanReportUpload;
 import com.etotem.cfc.entity.DanReportUpload;
+import com.etotem.cfc.entity.NotificationTemplate;
 import com.etotem.cfc.entity.UserIntent;
 import com.etotem.cfc.entity.UserIntent;
 import com.etotem.cfc.entity.UserNotification;
 import com.etotem.cfc.entity.UserNotification;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.DanReportUploadMapper;
 import com.etotem.cfc.mapper.DanReportUploadMapper;
+import com.etotem.cfc.mapper.NotificationTemplateMapper;
 import com.etotem.cfc.mapper.UserIntentMapper;
 import com.etotem.cfc.mapper.UserIntentMapper;
 import com.etotem.cfc.mapper.UserNotificationMapper;
 import com.etotem.cfc.mapper.UserNotificationMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@@ -65,6 +67,9 @@ public class DanReportUploadService {
     @Resource
     @Resource
     private UserIntentMapper userIntentMapper;
     private UserIntentMapper userIntentMapper;
 
 
+    @Resource
+    private NotificationTemplateMapper notificationTemplateMapper;
+
     private final ObjectMapper objectMapper = new ObjectMapper();
     private final ObjectMapper objectMapper = new ObjectMapper();
 
 
     // Phase 1: 上传并预览 PDF
     // Phase 1: 上传并预览 PDF
@@ -274,8 +279,9 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
             UserNotification notification = new UserNotification();
             UserNotification notification = new UserNotification();
             notification.setUserId(upload.getUploaderUserId());
             notification.setUserId(upload.getUploaderUserId());
             notification.setType("report_ready");
             notification.setType("report_ready");
-            notification.setTitle("健康报告已完成分析");
-            notification.setContent("您的健康报告已完成解析,可前往查看结果与成长方案。");
+            // W3: 从通知模板表读取文案,表不存在/停用时回退默认文案
+            notification.setTitle(getTemplateTitle("report_ready", "健康报告已完成分析"));
+            notification.setContent(getTemplateContent("report_ready", "您的健康报告已完成解析,可前往查看结果与成长方案。", null));
             notification.setRefType("dan_report");
             notification.setRefType("dan_report");
             notification.setRefId(upload.getId());
             notification.setRefId(upload.getId());
             notification.setIsRead(0);
             notification.setIsRead(0);
@@ -343,4 +349,45 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
         vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId()));
         vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId()));
         return Result.success(vo);
         return Result.success(vo);
     }
     }
+
+    // ==== W3: 通知模板读取辅助方法(表不存在/停用/无记录时回退默认文案) ====
+
+    /** 读取通知模板标题,回退默认值 */
+    private String getTemplateTitle(String type, String defaultTitle) {
+        NotificationTemplate tpl = getTemplate(type);
+        if (tpl != null && tpl.getTitle() != null && !tpl.getTitle().isEmpty()) {
+            return tpl.getTitle();
+        }
+        return defaultTitle;
+    }
+
+    /** 读取通知模板内容,支持 {占位符} 替换,回退默认值 */
+    private String getTemplateContent(String type, String defaultContent, Map<String, Object> vars) {
+        NotificationTemplate tpl = getTemplate(type);
+        String content = defaultContent;
+        if (tpl != null && tpl.getContent() != null && !tpl.getContent().isEmpty()) {
+            content = tpl.getContent();
+        }
+        if (vars != null && !vars.isEmpty()) {
+            for (Map.Entry<String, Object> entry : vars.entrySet()) {
+                content = content.replace("{" + entry.getKey() + "}", String.valueOf(entry.getValue()));
+            }
+        }
+        return content;
+    }
+
+    /** 查询启用的通知模板,异常(如表不存在)时返回 null */
+    private NotificationTemplate getTemplate(String type) {
+        try {
+            List<NotificationTemplate> list = notificationTemplateMapper.selectList(
+                    new LambdaQueryWrapper<NotificationTemplate>()
+                            .eq(NotificationTemplate::getType, type)
+                            .eq(NotificationTemplate::getStatus, 1)
+                            .last("LIMIT 1"));
+            return (list != null && !list.isEmpty()) ? list.get(0) : null;
+        } catch (Exception e) {
+            log.warn("读取通知模板失败 type={}, error={}", type, e.getMessage());
+            return null;
+        }
+    }
 }
 }

+ 57 - 3
cfc-backend/src/main/java/com/etotem/cfc/service/ProblemCompletionService.java

@@ -1,11 +1,13 @@
 package com.etotem.cfc.service;
 package com.etotem.cfc.service;
 
 
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.entity.NotificationTemplate;
 import com.etotem.cfc.entity.ProblemDomain;
 import com.etotem.cfc.entity.ProblemDomain;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.Task;
 import com.etotem.cfc.entity.TaskPlanInstance;
 import com.etotem.cfc.entity.TaskPlanInstance;
 import com.etotem.cfc.entity.UserIntent;
 import com.etotem.cfc.entity.UserIntent;
 import com.etotem.cfc.entity.UserNotification;
 import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.NotificationTemplateMapper;
 import com.etotem.cfc.mapper.ProblemDomainMapper;
 import com.etotem.cfc.mapper.ProblemDomainMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
 import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
@@ -18,7 +20,9 @@ import org.springframework.transaction.annotation.Transactional;
 import javax.annotation.Resource;
 import javax.annotation.Resource;
 import java.util.ArrayList;
 import java.util.ArrayList;
 import java.util.Date;
 import java.util.Date;
+import java.util.HashMap;
 import java.util.List;
 import java.util.List;
+import java.util.Map;
 
 
 /**
 /**
  * 问题域完成奖励 — 改版v1(B17)
  * 问题域完成奖励 — 改版v1(B17)
@@ -46,6 +50,9 @@ public class ProblemCompletionService {
     @Resource
     @Resource
     private UserNotificationMapper userNotificationMapper;
     private UserNotificationMapper userNotificationMapper;
 
 
+    @Resource
+    private NotificationTemplateMapper notificationTemplateMapper;
+
     @Resource
     @Resource
     private UserIntentMapper userIntentMapper;
     private UserIntentMapper userIntentMapper;
 
 
@@ -133,13 +140,19 @@ public class ProblemCompletionService {
                         .eq(UserNotification::getRefId, domain.getId()));
                         .eq(UserNotification::getRefId, domain.getId()));
         if (notified == null || notified == 0) {
         if (notified == null || notified == 0) {
             // 通知发给选择了该问题域的用户
             // 通知发给选择了该问题域的用户
+            // W3: 从通知模板表读取文案,支持 {domainName}/{reward} 占位符,回退默认文案
+            Map<String, Object> vars = new HashMap<>();
+            vars.put("domainName", domain.getName());
+            vars.put("reward", reward);
+            String tplTitle = getTemplateTitle("reward_granted", "问题域完成奖励已到账");
+            String tplContent = getTemplateContent("reward_granted",
+                    "恭喜完成「" + domain.getName() + "」全部任务,奖励 " + reward + " CF 已发放至家庭账户。", vars);
             for (UserIntent intent : intents) {
             for (UserIntent intent : intents) {
                 UserNotification n = new UserNotification();
                 UserNotification n = new UserNotification();
                 n.setUserId(intent.getUserId());
                 n.setUserId(intent.getUserId());
                 n.setType("reward_granted");
                 n.setType("reward_granted");
-                n.setTitle("问题域完成奖励已到账");
-                n.setContent("恭喜完成「" + domain.getName() + "」全部任务,奖励 "
-                        + reward + " CF 已发放至家庭账户。");
+                n.setTitle(tplTitle);
+                n.setContent(tplContent);
                 n.setRefType("completion");
                 n.setRefType("completion");
                 n.setRefId(domain.getId());
                 n.setRefId(domain.getId());
                 n.setIsRead(0);
                 n.setIsRead(0);
@@ -161,4 +174,45 @@ public class ProblemCompletionService {
         log.info("问题域完成奖励已发放: familyId={}, domainCode={}, reward={}",
         log.info("问题域完成奖励已发放: familyId={}, domainCode={}, reward={}",
                 familyId, domain.getCode(), reward);
                 familyId, domain.getCode(), reward);
     }
     }
+
+    // ==== W3: 通知模板读取辅助方法(表不存在/停用/无记录时回退默认文案) ====
+
+    /** 读取通知模板标题,回退默认值 */
+    private String getTemplateTitle(String type, String defaultTitle) {
+        NotificationTemplate tpl = getTemplate(type);
+        if (tpl != null && tpl.getTitle() != null && !tpl.getTitle().isEmpty()) {
+            return tpl.getTitle();
+        }
+        return defaultTitle;
+    }
+
+    /** 读取通知模板内容,支持 {占位符} 替换,回退默认值 */
+    private String getTemplateContent(String type, String defaultContent, Map<String, Object> vars) {
+        NotificationTemplate tpl = getTemplate(type);
+        String content = defaultContent;
+        if (tpl != null && tpl.getContent() != null && !tpl.getContent().isEmpty()) {
+            content = tpl.getContent();
+        }
+        if (vars != null && !vars.isEmpty()) {
+            for (Map.Entry<String, Object> entry : vars.entrySet()) {
+                content = content.replace("{" + entry.getKey() + "}", String.valueOf(entry.getValue()));
+            }
+        }
+        return content;
+    }
+
+    /** 查询启用的通知模板,异常(如表不存在)时返回 null */
+    private NotificationTemplate getTemplate(String type) {
+        try {
+            List<NotificationTemplate> list = notificationTemplateMapper.selectList(
+                    new LambdaQueryWrapper<NotificationTemplate>()
+                            .eq(NotificationTemplate::getType, type)
+                            .eq(NotificationTemplate::getStatus, 1)
+                            .last("LIMIT 1"));
+            return (list != null && !list.isEmpty()) ? list.get(0) : null;
+        } catch (Exception e) {
+            log.warn("读取通知模板失败 type={}, error={}", type, e.getMessage());
+            return null;
+        }
+    }
 }
 }

+ 13 - 0
cfc-backend/src/main/resources/schema.sql

@@ -4884,6 +4884,19 @@ CREATE TABLE IF NOT EXISTS user_notifications (
   INDEX idx_user_read (user_id, is_read)
   INDEX idx_user_read (user_id, is_read)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(改版v1)';
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='站内通知(改版v1)';
 
 
+-- 通知模板(迁移245 W3)
+CREATE TABLE IF NOT EXISTS notification_templates (
+  id BIGINT AUTO_INCREMENT PRIMARY KEY,
+  type VARCHAR(30) NOT NULL COMMENT '通知类型: report_ready/reward_granted/system',
+  title VARCHAR(200) NOT NULL COMMENT '默认标题(支持占位符)',
+  content VARCHAR(1000) NOT NULL COMMENT '默认内容模板(支持占位符)',
+  channels VARCHAR(100) DEFAULT 'in_app' COMMENT '推送通道: in_app,sms,wechat,phone 逗号分隔',
+  status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
+  created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
+  updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+  UNIQUE KEY uk_type (type)
+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='通知模板(改版v1 W3)';
+
 -- 当前状态调研-健康现状档案(迁移239)
 -- 当前状态调研-健康现状档案(迁移239)
 CREATE TABLE IF NOT EXISTS health_status (
 CREATE TABLE IF NOT EXISTS health_status (
     id BIGINT AUTO_INCREMENT PRIMARY KEY,
     id BIGINT AUTO_INCREMENT PRIMARY KEY,