Browse Source

feat(api): 通知五通道模型 - 公众号通道 + 通道抽象 (W3)

- 迁移246: 已有模板 channels 补充公众号通道(gzh),种子数据含 gzh
- NotificationChannel 接口 + NotificationMessage DTO
- 5 通道实现:
  - InAppNotificationChannel 内置写库
  - Sms/Wechat/Gzh/Phone 外部通道预留 stub(enabled 配置开关 + 日志,TODO 接入网关)
- NotificationDispatchService 统一分发入口:读模板 → 占位符替换 → 按 channels 多选分发
- DanReportUploadService / ProblemCompletionService 改走分发服务(移除硬编码/模板辅助方法)

Ultraworked with [Sisyphus](https://github.com/OhMyOpenCode)
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
E2E Test Bot 2 months ago
parent
commit
861c48e515

+ 14 - 1
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -9146,7 +9146,7 @@ private void runMigration100() {
 			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'), " +
+						"('reward_granted', '问题域完成奖励已到账', '恭喜完成「{domainName}」全部任务,奖励 {reward} CF 已发放至家庭账户。', 'in_app,sms,wechat,gzh'), " +
 						"('system', '系统通知', '您有一条新的系统通知,请前往查看。', 'in_app')");
 				log.info("notification_templates 种子数据已插入");
 			} else {
@@ -9189,6 +9189,19 @@ private void runMigration100() {
 			// 表已存在,忽略错误
 		}
 
+		// 迁移247: 已有模板 channels 补充公众号通道(gzh)(五通道:in_app/sms/wechat/gzh/phone,幂等)
+		try {
+			int updated = jdbcTemplate.update("UPDATE notification_templates " +
+					"SET channels = CONCAT(channels, ',gzh') " +
+					"WHERE type IN ('report_ready', 'reward_granted') " +
+					"AND channels IS NOT NULL AND channels NOT LIKE '%gzh%'");
+			if (updated > 0) {
+				log.info("notification_templates 已为 {} 条模板补充公众号通道(gzh)", updated);
+			}
+		} catch (Exception e) {
+			log.warn("补充notification_templates公众号通道失败: " + e.getMessage());
+		}
+
 	}
 
 	/**

+ 131 - 0
cfc-backend/src/main/java/com/etotem/cfc/dto/NotificationMessage.java

@@ -0,0 +1,131 @@
+package com.etotem.cfc.dto;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 通知消息载体(W3 五通道模型)
+ * <p>
+ * 由 {@code NotificationDispatchService} 按模板 + 占位符构建,
+ * 携带 channels(逗号分隔的多选通道),分发给匹配的 {@code NotificationChannel}。
+ */
+public class NotificationMessage implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 接收用户ID */
+    private Long userId;
+
+    /** 通知类型: report_ready / reward_granted / system */
+    private String type;
+
+    /** 标题(模板占位符已替换) */
+    private String title;
+
+    /** 内容(模板占位符已替换) */
+    private String content;
+
+    /** 关联对象类型: report/package/task/completion */
+    private String refType;
+
+    /** 关联对象ID */
+    private Long refId;
+
+    /** 目标通道: in_app,sms,wechat,gzh,phone 逗号分隔 */
+    private String channels;
+
+    /** 创建时间 */
+    private Date createdAt;
+
+    public NotificationMessage() {
+    }
+
+    public NotificationMessage(Long userId, String type, String title, String content,
+                               String refType, Long refId, String channels) {
+        this.userId = userId;
+        this.type = type;
+        this.title = title;
+        this.content = content;
+        this.refType = refType;
+        this.refId = refId;
+        this.channels = channels;
+        this.createdAt = new Date();
+    }
+
+    /** 判断目标通道是否包含指定通道 */
+    public boolean containsChannel(String channelType) {
+        if (channels == null || channels.isEmpty()) {
+            return false;
+        }
+        for (String ch : channels.split(",")) {
+            if (channelType.equals(ch.trim())) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    public Long getUserId() {
+        return userId;
+    }
+
+    public void setUserId(Long userId) {
+        this.userId = userId;
+    }
+
+    public String getType() {
+        return type;
+    }
+
+    public void setType(String type) {
+        this.type = type;
+    }
+
+    public String getTitle() {
+        return title;
+    }
+
+    public void setTitle(String title) {
+        this.title = title;
+    }
+
+    public String getContent() {
+        return content;
+    }
+
+    public void setContent(String content) {
+        this.content = content;
+    }
+
+    public String getRefType() {
+        return refType;
+    }
+
+    public void setRefType(String refType) {
+        this.refType = refType;
+    }
+
+    public Long getRefId() {
+        return refId;
+    }
+
+    public void setRefId(Long refId) {
+        this.refId = refId;
+    }
+
+    public String getChannels() {
+        return channels;
+    }
+
+    public void setChannels(String channels) {
+        this.channels = channels;
+    }
+
+    public Date getCreatedAt() {
+        return createdAt;
+    }
+
+    public void setCreatedAt(Date createdAt) {
+        this.createdAt = createdAt;
+    }
+}

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

@@ -28,7 +28,7 @@ public class NotificationTemplate implements Serializable {
     /** 默认内容模板(支持占位符) */
     private String content;
 
-    /** 推送通道: in_app,sms,wechat,phone 逗号分隔 */
+    /** 推送通道: in_app,sms,wechat,gzh,phone 逗号分隔(用户可多选;in_app 内置,其余外部接口预留) */
     private String channels;
 
     /** 1启用 0停用 */

+ 6 - 60
cfc-backend/src/main/java/com/etotem/cfc/service/DanReportUploadService.java

@@ -5,14 +5,10 @@ import com.etotem.cfc.dto.DanReportEditDTO;
 import com.etotem.cfc.dto.DanReportUploadVO;
 import com.etotem.cfc.entity.DanAssessmentResult;
 import com.etotem.cfc.entity.DanReportUpload;
-import com.etotem.cfc.entity.NotificationTemplate;
 import com.etotem.cfc.entity.UserIntent;
-import com.etotem.cfc.entity.UserNotification;
 import com.etotem.cfc.mapper.DanAssessmentResultMapper;
 import com.etotem.cfc.mapper.DanReportUploadMapper;
-import com.etotem.cfc.mapper.NotificationTemplateMapper;
 import com.etotem.cfc.mapper.UserIntentMapper;
-import com.etotem.cfc.mapper.UserNotificationMapper;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.fasterxml.jackson.core.type.TypeReference;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -61,14 +57,11 @@ public class DanReportUploadService {
     @Resource
     private ReportBlockAssembler reportBlockAssembler;
 
-    @Resource
-    private UserNotificationMapper userNotificationMapper;
-
     @Resource
     private UserIntentMapper userIntentMapper;
 
     @Resource
-    private NotificationTemplateMapper notificationTemplateMapper;
+    private NotificationDispatchService notificationDispatchService;
 
     private final ObjectMapper objectMapper = new ObjectMapper();
 
@@ -276,17 +269,11 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
 
         // B16: 报告完成 → 写通知(report_ready) + 推进旅程 stage=5
         try {
-            UserNotification notification = new UserNotification();
-            notification.setUserId(upload.getUploaderUserId());
-            notification.setType("report_ready");
-            // W3: 从通知模板表读取文案,表不存在/停用时回退默认文案
-            notification.setTitle(getTemplateTitle("report_ready", "健康报告已完成分析"));
-            notification.setContent(getTemplateContent("report_ready", "您的健康报告已完成解析,可前往查看结果与成长方案。", null));
-            notification.setRefType("dan_report");
-            notification.setRefId(upload.getId());
-            notification.setIsRead(0);
-            notification.setCreatedAt(new Date());
-            userNotificationMapper.insert(notification);
+            // W3: 走通知分发服务(模板文案 + 五通道分发,report_ready 默认 in_app+sms)
+            notificationDispatchService.sendByTemplate("report_ready", upload.getUploaderUserId(),
+                    "dan_report", upload.getId(),
+                    "健康报告已完成分析",
+                    "您的健康报告已完成解析,可前往查看结果与成长方案。");
 
             // 推进 user_intents 旅程至 stage=5(报告完成)
             UserIntent intent = userIntentMapper.selectOne(
@@ -349,45 +336,4 @@ if (!DanReportUpload.DIM_MIND.equals(dimension) && !DanReportUpload.DIM_WISDOM.e
         vo.setBlocks(reportBlockService.getBlocks("dan", upload.getId()));
         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;
-        }
-    }
 }

+ 125 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/NotificationDispatchService.java

@@ -0,0 +1,125 @@
+package com.etotem.cfc.service;
+
+import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
+import com.etotem.cfc.dto.NotificationMessage;
+import com.etotem.cfc.entity.NotificationTemplate;
+import com.etotem.cfc.mapper.NotificationTemplateMapper;
+import com.etotem.cfc.service.notification.NotificationChannel;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.annotation.Resource;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 通知分发服务(W3 五通道模型统一入口)
+ * <p>
+ * 流程:读模板(回退默认文案)→ 占位符替换({domainName}/{reward} 等)→
+ * 按模板 channels 多选分发到匹配的 {@link NotificationChannel}。
+ * <p>
+ * 通道:in_app(内置写库)/ sms / wechat / gzh / phone(外部接口预留)。
+ * 外部通道未接入网关时(enabled=false)仅记录日志,不实际发送。
+ */
+@Slf4j
+@Service
+public class NotificationDispatchService {
+
+    /** 默认通道:模板不存在/无 channels 时至少走站内 */
+    private static final String DEFAULT_CHANNELS = "in_app";
+
+    @Resource
+    private NotificationTemplateMapper notificationTemplateMapper;
+
+    /** Spring 自动注入全部 NotificationChannel 实现(含 5 个通道) */
+    @Resource
+    private List<NotificationChannel> channels;
+
+    /**
+     * 按模板类型发送通知(简化入口,无占位符变量)
+     */
+    public void sendByTemplate(String type, Long userId, String refType, Long refId,
+                               String defaultTitle, String defaultContent) {
+        sendByTemplate(type, userId, null, refType, refId, defaultTitle, defaultContent);
+    }
+
+    /**
+     * 按模板类型发送通知
+     *
+     * @param type           通知类型(对应 notification_templates.type)
+     * @param userId         接收用户ID
+     * @param vars           占位符变量({key} → value)
+     * @param refType        关联对象类型
+     * @param refId          关联对象ID
+     * @param defaultTitle   模板不存在/停用时的回退标题
+     * @param defaultContent 模板不存在/停用时的回退内容
+     */
+    public void sendByTemplate(String type, Long userId, Map<String, Object> vars,
+                               String refType, Long refId,
+                               String defaultTitle, String defaultContent) {
+        // 1. 读取启用模板(表不存在/无记录/停用 → null)
+        NotificationTemplate tpl = getEnabledTemplate(type);
+
+        // 2. 解析标题/内容(占位符替换),回退默认文案
+        String title = (tpl != null && notEmpty(tpl.getTitle())) ? tpl.getTitle() : defaultTitle;
+        String content = (tpl != null && notEmpty(tpl.getContent())) ? tpl.getContent() : defaultContent;
+        if (vars != null && !vars.isEmpty()) {
+            for (Map.Entry<String, Object> entry : vars.entrySet()) {
+                content = content.replace("{" + entry.getKey() + "}", String.valueOf(entry.getValue()));
+                title = title.replace("{" + entry.getKey() + "}", String.valueOf(entry.getValue()));
+            }
+        }
+
+        // 3. 目标通道(多选,逗号分隔;模板无配置时默认站内)
+        String channelsStr = (tpl != null && notEmpty(tpl.getChannels())) ? tpl.getChannels() : DEFAULT_CHANNELS;
+        if (!channelsStr.contains("in_app")) {
+            channelsStr = channelsStr + ",in_app";
+        }
+
+        // 4. 按通道分发
+        NotificationMessage message = new NotificationMessage(userId, type, title, content, refType, refId, channelsStr);
+        for (NotificationChannel channel : channels) {
+            if (!message.containsChannel(channel.getChannelType())) {
+                continue;
+            }
+            try {
+                if (channel.isEnabled()) {
+                    channel.send(message);
+                } else {
+                    log.info("通知通道未启用,跳过: channel={}, type={}, userId={}, title={}",
+                            channel.getChannelType(), type, userId, title);
+                }
+            } catch (Exception e) {
+                log.warn("通知通道发送失败 channel={}, type={}, userId={}, error={}",
+                        channel.getChannelType(), type, userId, e.getMessage());
+            }
+        }
+    }
+
+    /** 查询启用中的模板,异常(如表不存在)返回 null */
+    private NotificationTemplate getEnabledTemplate(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;
+        }
+    }
+
+    private boolean notEmpty(String s) {
+        return s != null && !s.isEmpty();
+    }
+
+    // 供外部直接调用者便捷构造变量
+    public static Map<String, Object> vars(String key, Object value) {
+        Map<String, Object> map = new HashMap<>();
+        map.put(key, value);
+        return map;
+    }
+}

+ 6 - 58
cfc-backend/src/main/java/com/etotem/cfc/service/ProblemCompletionService.java

@@ -1,13 +1,11 @@
 package com.etotem.cfc.service;
 
 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.Task;
 import com.etotem.cfc.entity.TaskPlanInstance;
 import com.etotem.cfc.entity.UserIntent;
 import com.etotem.cfc.entity.UserNotification;
-import com.etotem.cfc.mapper.NotificationTemplateMapper;
 import com.etotem.cfc.mapper.ProblemDomainMapper;
 import com.etotem.cfc.mapper.TaskMapper;
 import com.etotem.cfc.mapper.TaskPlanInstanceMapper;
@@ -51,7 +49,7 @@ public class ProblemCompletionService {
     private UserNotificationMapper userNotificationMapper;
 
     @Resource
-    private NotificationTemplateMapper notificationTemplateMapper;
+    private NotificationDispatchService notificationDispatchService;
 
     @Resource
     private UserIntentMapper userIntentMapper;
@@ -140,24 +138,15 @@ public class ProblemCompletionService {
                         .eq(UserNotification::getRefId, domain.getId()));
         if (notified == null || notified == 0) {
             // 通知发给选择了该问题域的用户
-            // W3: 从通知模板表读取文案,支持 {domainName}/{reward} 占位符,回退默认文案
+            // 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) {
-                UserNotification n = new UserNotification();
-                n.setUserId(intent.getUserId());
-                n.setType("reward_granted");
-                n.setTitle(tplTitle);
-                n.setContent(tplContent);
-                n.setRefType("completion");
-                n.setRefId(domain.getId());
-                n.setIsRead(0);
-                n.setCreatedAt(new Date());
-                userNotificationMapper.insert(n);
+                notificationDispatchService.sendByTemplate("reward_granted", intent.getUserId(), vars,
+                        "completion", domain.getId(),
+                        "问题域完成奖励已到账",
+                        "恭喜完成「" + domain.getName() + "」全部任务,奖励 " + reward + " CF 已发放至家庭账户。");
             }
         }
 
@@ -174,45 +163,4 @@ public class ProblemCompletionService {
         log.info("问题域完成奖励已发放: familyId={}, domainCode={}, 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;
-        }
-    }
 }

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/GzhNotificationChannel.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 公众号消息通知通道(外部接口,预留)
+ * <p>
+ * 未接入微信公众号模板消息前不发送,仅记录日志;接入后实现 send()。
+ * 启用开关: notification.channels.gzh.enabled(默认 false)
+ */
+@Component
+public class GzhNotificationChannel implements NotificationChannel {
+
+    private static final Logger log = LoggerFactory.getLogger(GzhNotificationChannel.class);
+
+    @Value("${notification.channels.gzh.enabled:false}")
+    private boolean enabled;
+
+    @Override
+    public String getChannelType() {
+        return "gzh";
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public void send(NotificationMessage message) {
+        // TODO: 接入微信公众号模板消息后在此实现
+        log.warn("[公众号通道预留] 未接入公众号消息服务,跳过发送: userId={}, title={}", message.getUserId(), message.getTitle());
+    }
+}

+ 50 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/InAppNotificationChannel.java

@@ -0,0 +1,50 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+import com.etotem.cfc.entity.UserNotification;
+import com.etotem.cfc.mapper.UserNotificationMapper;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.annotation.Resource;
+import java.util.Date;
+
+/**
+ * 小程序内通知通道(内置实现)
+ * <p>
+ * 写入 user_notifications 表,小程序端通知列表 / 首页动态展示。
+ */
+@Component
+public class InAppNotificationChannel implements NotificationChannel {
+
+    private static final Logger log = LoggerFactory.getLogger(InAppNotificationChannel.class);
+
+    @Resource
+    private UserNotificationMapper userNotificationMapper;
+
+    @Override
+    public String getChannelType() {
+        return "in_app";
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return true;
+    }
+
+    @Override
+    public void send(NotificationMessage message) {
+        UserNotification n = new UserNotification();
+        n.setUserId(message.getUserId());
+        n.setType(message.getType());
+        n.setTitle(message.getTitle());
+        n.setContent(message.getContent());
+        n.setRefType(message.getRefType());
+        n.setRefId(message.getRefId());
+        n.setIsRead(0);
+        n.setCreatedAt(message.getCreatedAt() != null ? message.getCreatedAt() : new Date());
+        userNotificationMapper.insert(n);
+        log.info("站内通知已写入: type={}, userId={}, title={}", message.getType(), message.getUserId(), message.getTitle());
+    }
+}

+ 29 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/NotificationChannel.java

@@ -0,0 +1,29 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+
+/**
+ * 通知通道接口(W3 五通道模型)
+ * <p>
+ * 通道类型:
+ * <ul>
+ *   <li>in_app   - 小程序内(内置,写 user_notifications 表)</li>
+ *   <li>sms      - 短信(外部接口,预留)</li>
+ *   <li>wechat   - 微信(订阅消息/模板消息,外部接口,预留)</li>
+ *   <li>gzh      - 公众号消息(模板消息,外部接口,预留)</li>
+ *   <li>phone    - 电话(语音呼叫,外部接口,预留)</li>
+ * </ul>
+ * 用户可在模板配置中多选通道,由 {@code NotificationDispatchService} 按
+ * {@code NotificationMessage.channels} 遍历匹配的通道实例调用 send()。
+ */
+public interface NotificationChannel {
+
+    /** 通道标识: in_app / sms / wechat / gzh / phone */
+    String getChannelType();
+
+    /** 通道是否启用(外部通道由配置控制,未接入网关时返回 false) */
+    boolean isEnabled();
+
+    /** 发送通知(外部通道接入网关后在此实现;当前仅日志预留) */
+    void send(NotificationMessage message);
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/PhoneNotificationChannel.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 电话通知通道(外部接口,预留)
+ * <p>
+ * 未接入语音呼叫服务(阿里云/腾讯云)前不发送,仅记录日志;接入后实现 send()。
+ * 启用开关: notification.channels.phone.enabled(默认 false)
+ */
+@Component
+public class PhoneNotificationChannel implements NotificationChannel {
+
+    private static final Logger log = LoggerFactory.getLogger(PhoneNotificationChannel.class);
+
+    @Value("${notification.channels.phone.enabled:false}")
+    private boolean enabled;
+
+    @Override
+    public String getChannelType() {
+        return "phone";
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public void send(NotificationMessage message) {
+        // TODO: 接入语音呼叫服务(阿里云/腾讯云)后在此实现
+        log.warn("[电话通道预留] 未接入语音呼叫服务,跳过发送: userId={}, title={}", message.getUserId(), message.getTitle());
+    }
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/SmsNotificationChannel.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 短信通知通道(外部接口,预留)
+ * <p>
+ * 未接入短信网关前不发送,仅记录日志;接入后实现 send()。
+ * 启用开关: notification.channels.sms.enabled(默认 false)
+ */
+@Component
+public class SmsNotificationChannel implements NotificationChannel {
+
+    private static final Logger log = LoggerFactory.getLogger(SmsNotificationChannel.class);
+
+    @Value("${notification.channels.sms.enabled:false}")
+    private boolean enabled;
+
+    @Override
+    public String getChannelType() {
+        return "sms";
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public void send(NotificationMessage message) {
+        // TODO: 接入短信网关(阿里云/腾讯云)后在此实现
+        log.warn("[短信通道预留] 未接入短信网关,跳过发送: userId={}, title={}", message.getUserId(), message.getTitle());
+    }
+}

+ 38 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/notification/WechatNotificationChannel.java

@@ -0,0 +1,38 @@
+package com.etotem.cfc.service.notification;
+
+import com.etotem.cfc.dto.NotificationMessage;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * 微信通知通道(外部接口,预留)
+ * <p>
+ * 未接入微信订阅消息/模板消息前不发送,仅记录日志;接入后实现 send()。
+ * 启用开关: notification.channels.wechat.enabled(默认 false)
+ */
+@Component
+public class WechatNotificationChannel implements NotificationChannel {
+
+    private static final Logger log = LoggerFactory.getLogger(WechatNotificationChannel.class);
+
+    @Value("${notification.channels.wechat.enabled:false}")
+    private boolean enabled;
+
+    @Override
+    public String getChannelType() {
+        return "wechat";
+    }
+
+    @Override
+    public boolean isEnabled() {
+        return enabled;
+    }
+
+    @Override
+    public void send(NotificationMessage message) {
+        // TODO: 接入微信订阅消息/模板消息后在此实现
+        log.warn("[微信通道预留] 未接入微信消息服务,跳过发送: userId={}, title={}", message.getUserId(), message.getTitle());
+    }
+}

+ 1 - 1
cfc-backend/src/main/resources/schema.sql

@@ -4892,7 +4892,7 @@ CREATE TABLE IF NOT EXISTS notification_templates (
   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 逗号分隔',
+  channels VARCHAR(100) DEFAULT 'in_app' COMMENT '推送通道: in_app,sms,wechat,gzh,phone 逗号分隔',
   status TINYINT DEFAULT 1 COMMENT '1启用 0停用',
   created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
   updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,