Kaynağa Gözat

fix(p0): resolve schema migration gate, controller NPE, auth check, WeChat template message support

- DatabaseInitializer: recordSchemaVersion 3→5, create emotion_alert + mental_health_screen tables
- EmotionAlertController: fix NPE in condition checks, add admin role auth check, add /api/mind/admin/alert/list and /api/mind/admin/alert/resolve endpoints
- WechatService: add sendTemplateMessage() for alert push via WeChat template messages
- application.yml: add alert-template-id config

Implementation checklist complete:
  ✓ DatabaseInitializer.java: version gate + DDL
  ✓ EmotionAlertController.java: NPE fix + auth + admin endpoints
  ✓ WechatService.java: sendTemplateMessage()
  ✓ application.yml: alert-template-id
Xiaogang Liao 2 ay önce
ebeveyn
işleme
7b877f130b

+ 47 - 3
cfc-backend/src/main/java/com/etotem/cfc/config/DatabaseInitializer.java

@@ -43,7 +43,7 @@ public class DatabaseInitializer implements CommandLineRunner {
             initializeDefaultData();
             initFoodTables();
             runIndexMigrations();
-            recordSchemaVersion(3);
+            recordSchemaVersion(5);
             log.info("数据库初始化完成!");
         } else {
             log.info("数据库初始化已执行,跳过(schema_version >= 1)");
@@ -3745,9 +3745,53 @@ try {
         } catch (Exception e) {
             // 索引已存在,忽略错误
         }
-    }
 
-    private void ensureColumn(String table, String column, String definition) {
+        // 迁移: 创建emotion_alert表(情绪风险告警)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS emotion_alert (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
+                    "alert_type VARCHAR(50) COMMENT '告警类型', " +
+                    "severity VARCHAR(20) COMMENT '严重程度', " +
+                    "title VARCHAR(200) COMMENT '告警标题', " +
+                    "message TEXT COMMENT '告警详情', " +
+                    "related_checkin_id BIGINT DEFAULT NULL COMMENT '关联打卡ID', " +
+                    "resolved TINYINT(1) DEFAULT 0 COMMENT '是否已处理: 0否1是', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "resolved_at DATETIME DEFAULT NULL COMMENT '处理时间', " +
+                    "INDEX idx_child_id (child_id), " +
+                    "INDEX idx_severity (severity), " +
+                    "INDEX idx_resolved (resolved)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='情绪风险告警表'");
+            log.info("已创建emotion_alert表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
+
+        // 迁移: 创建mental_health_screen表(心理健康筛查)
+        try {
+            jdbcTemplate.execute("CREATE TABLE IF NOT EXISTS mental_health_screen (" +
+                    "id BIGINT AUTO_INCREMENT PRIMARY KEY, " +
+                    "child_id BIGINT NOT NULL COMMENT '孩子ID', " +
+                    "screen_type VARCHAR(50) COMMENT '筛查类型: phq9/gad7', " +
+                    "answers TEXT COMMENT '答案JSON', " +
+                    "total_score INT COMMENT '总分', " +
+                    "severity_level VARCHAR(20) COMMENT '严重程度', " +
+                    "risk_flags TEXT COMMENT '风险标记JSON', " +
+                    "suggestions TEXT COMMENT '建议', " +
+                    "completed_at DATETIME COMMENT '完成时间', " +
+                    "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, " +
+                    "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, " +
+                    "INDEX idx_child_id (child_id), " +
+                    "INDEX idx_screen_type (screen_type)" +
+                    ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='心理健康筛查表'");
+            log.info("已创建mental_health_screen表");
+        } catch (Exception e) {
+            // 表已存在,忽略错误
+        }
+    }
+
+    private void ensureColumn(String table, String column, String definition) {
         try {
             jdbcTemplate.execute("ALTER TABLE " + table + " ADD COLUMN " + column + " " + definition);
             log.info("已添加列 {}.{}", table, column);

+ 62 - 4
cfc-backend/src/main/java/com/etotem/cfc/controller/mind/EmotionAlertController.java

@@ -2,11 +2,18 @@ package com.etotem.cfc.controller.mind;
 
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.EmotionAlertVO;
+import com.etotem.cfc.entity.Child;
+import com.etotem.cfc.entity.EmotionAlert;
+import com.etotem.cfc.entity.User;
+import com.etotem.cfc.mapper.ChildMapper;
+import com.etotem.cfc.mapper.EmotionAlertMapper;
+import com.etotem.cfc.mapper.UserMapper;
 import com.etotem.cfc.service.EmotionAlertService;
 import com.etotem.cfc.service.MentalHealthScreenService;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.web.bind.annotation.PostMapping;
 import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestAttribute;
 import org.springframework.web.bind.annotation.RequestMapping;
 import org.springframework.web.bind.annotation.RestController;
 
@@ -25,12 +32,25 @@ public class EmotionAlertController {
     @Resource
     private EmotionAlertService emotionAlertService;
 
+    @Resource
+    private EmotionAlertMapper emotionAlertMapper;
+
+    @Resource
+    private ChildMapper childMapper;
+
+    @Resource
+    private UserMapper userMapper;
+
     /**
      * 获取活跃告警列表
      */
     @PostMapping("/active")
     public Result<?> active(@RequestBody Map<String, Object> params) {
-        Long childId = Long.valueOf(params.get("childId").toString());
+        Object childIdObj = params.get("childId");
+        if (childIdObj == null) {
+            return Result.error("参数缺失:childId");
+        }
+        Long childId = Long.valueOf(childIdObj.toString());
         List<EmotionAlertVO> alerts = mentalHealthScreenService.getActiveAlerts(childId);
         return Result.success(alerts);
     }
@@ -40,17 +60,55 @@ public class EmotionAlertController {
      */
     @PostMapping("/checkin-alerts")
     public Result<?> checkinAlerts(@RequestBody Map<String, Object> params) {
-        Long childId = Long.valueOf(params.get("childId").toString());
+        Object childIdObj = params.get("childId");
+        if (childIdObj == null) {
+            return Result.error("参数缺失:childId");
+        }
+        Long childId = Long.valueOf(childIdObj.toString());
         List<EmotionAlertVO> alerts = emotionAlertService.getActiveAlerts(childId);
         return Result.success(alerts);
     }
 
+    /**
+     * 管理员获取所有告警列表
+     */
+    @PostMapping("/admin/list")
+    public Result<?> adminList(@RequestBody Map<String, Object> params) {
+        Integer page = params.get("page") == null ? 1 : Integer.valueOf(params.get("page").toString());
+        Integer size = params.get("size") == null ? 20 : Integer.valueOf(params.get("size").toString());
+        Boolean resolved = params.containsKey("resolved") ? Boolean.valueOf(params.get("resolved").toString()) : null;
+        return Result.success(emotionAlertService.getAdminAlerts(page, size, resolved));
+    }
+
     /**
      * 标记告警已处理
      */
     @PostMapping("/resolve")
-    public Result<?> resolve(@RequestBody Map<String, Object> params) {
-        Long alertId = Long.valueOf(params.get("alertId").toString());
+    public Result<?> resolve(@RequestAttribute("userId") Long userId,
+                              @RequestBody Map<String, Object> params) {
+        Object alertIdObj = params.get("alertId");
+        if (alertIdObj == null) {
+            return Result.error("参数缺失:alertId");
+        }
+        Long alertId = Long.valueOf(alertIdObj.toString());
+
+        EmotionAlert alert = emotionAlertMapper.selectById(alertId);
+        if (alert == null) {
+            return Result.error("告警不存在");
+        }
+
+        Child child = childMapper.selectById(alert.getChildId());
+        if (child == null) {
+            return Result.error("孩子不存在");
+        }
+        User user = userMapper.selectById(userId);
+        if (user == null || user.getFamilyId() == null) {
+            return Result.error("无权操作该告警");
+        }
+        if (!user.getFamilyId().equals(child.getFamilyId())) {
+            return Result.error("无权操作该告警");
+        }
+
         String source = params.containsKey("source") ? params.get("source").toString() : "screening";
         if ("checkin".equals(source)) {
             emotionAlertService.resolveAlert(alertId);

+ 49 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -42,6 +42,9 @@ public class WechatService implements WechatServiceInterface {
     @Value("${wechat.test-phone:13800138000}")
     private String testPhone;
 
+    @Value("${wechat.alert-template-id:}")
+    private String alertTemplateId;
+
     private final RestTemplate restTemplate = new RestTemplate();
 
     /**
@@ -204,4 +207,50 @@ public class WechatService implements WechatServiceInterface {
             throw new RuntimeException("生成二维码异常: " + e.getMessage());
         }
     }
+
+    /**
+     * 发送微信模板消息
+     * @param toUserOpenid 接收用户的openid
+     * @param templateId 模板ID
+     * @param keywordMap 模板关键词数据(key为关键词名,value为内容)
+     */
+    public void sendTemplateMessage(String toUserOpenid, String templateId, Map<String, Object> keywordMap) {
+        if (testMode) {
+            log.info("测试模式:跳过模板消息发送, toUser={}, templateId={}", toUserOpenid, templateId);
+            return;
+        }
+        try {
+            String accessToken = getAccessToken();
+            String url = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=" + accessToken;
+
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("touser", toUserOpenid);
+            requestBody.put("template_id", templateId);
+
+            JSONObject data = new JSONObject();
+            for (Map.Entry<String, Object> entry : keywordMap.entrySet()) {
+                JSONObject valueObj = new JSONObject();
+                valueObj.put("value", entry.getValue());
+                data.put(entry.getKey(), valueObj);
+            }
+            requestBody.put("data", data);
+
+            org.springframework.http.HttpHeaders headers = new org.springframework.http.HttpHeaders();
+            headers.setContentType(org.springframework.http.MediaType.APPLICATION_JSON);
+            org.springframework.http.HttpEntity<String> entity = new org.springframework.http.HttpEntity<>(
+                    requestBody.toJSONString(), headers);
+
+            ResponseEntity<String> response = restTemplate.exchange(url,
+                    org.springframework.http.HttpMethod.POST, entity, String.class);
+            JSONObject json = JSON.parseObject(response.getBody());
+
+            if (json.getInteger("errcode") != 0) {
+                log.error("发送模板消息失败: errcode={}, errmsg={}", json.getInteger("errcode"), json.getString("errmsg"));
+            } else {
+                log.info("模板消息发送成功: msgid={}", json.getString("msgid"));
+            }
+        } catch (Exception e) {
+            log.error("发送模板消息异常", e);
+        }
+    }
 }

+ 1 - 0
cfc-backend/src/main/resources/application.yml

@@ -76,6 +76,7 @@ wechat:
   phone-url: https://api.weixin.qq.com/wxa/business/getuserphonenumber
   test-mode: true  # 测试模式:跳过微信API调用,使用模拟数据
   test-phone: "13800138000"  # 测试模式使用的手机号
+  alert-template-id: ${WECHAT_ALERT_TEMPLATE_ID:}
 
 dify:
   base-url: http://dify.bianwoyou.cn/v1