소스 검색

feat: 微信内容安全检测(msg_sec_check + img_sec_check)

- WechatServiceInterface: 新增 getAccessToken(), checkText(), checkImage()
- WechatService: access_token 加缓存(TTL 7000s); 实现 msg_sec_check / img_sec_check
- MediaController: 上传图片/文本检测 (P0)
- AdminArticleController: 文章图片/标题/正文检测 (P1)
- HealthReportController: 健康报告图片上传检测 (P1)
- ServiceRoleApplicationController: 资质图片上传检测 (P1)
- 微信API异常时 fail-open 不阻塞上传; testMode 跳过检测
Xiaogang Liao 1 개월 전
부모
커밋
078f3a684d

+ 14 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/HealthReportController.java

@@ -32,6 +32,7 @@ import com.etotem.cfc.service.HealthReportService;
 import com.etotem.cfc.service.NutritionDeficiencyService;
 import com.etotem.cfc.service.PdfParseService;
 import com.etotem.cfc.service.TongueDiagnosisService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import com.fasterxml.jackson.databind.ObjectMapper;
@@ -104,6 +105,9 @@ public class HealthReportController {
     @Resource
     private HealthReportDraftMapper healthReportDraftMapper;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     /**
      * 创建健康报告(含指标明细)
      */
@@ -424,6 +428,16 @@ public class HealthReportController {
         try {
             // 图片上传:保存文件 + 创建空草稿(无自动解析)
             if ("image".equals(type) || isImageContent(contentType)) {
+                // 图片内容安全检测
+                try {
+                    byte[] imageBytes = file.getBytes();
+                    if (!wechatService.checkImage(imageBytes)) {
+                        return Result.error("图片内容包含敏感信息,上传失败");
+                    }
+                } catch (IOException e) {
+                    return Result.error("图片读取失败: " + e.getMessage());
+                }
+
                 String fileUrl = saveUploadFile(file, userId);
                 String reportType = familyId != null ? "physical_exam" : "gut_flora";
 

+ 22 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/MediaController.java

@@ -3,12 +3,14 @@ package com.etotem.cfc.controller;
 import com.etotem.cfc.common.Result;
 import com.etotem.cfc.entity.MediaRecord;
 import com.etotem.cfc.service.MediaRecordService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
 
 import javax.annotation.Resource;
+import java.io.IOException;
 import java.util.List;
 import java.util.Map;
 
@@ -20,6 +22,9 @@ public class MediaController {
     @Resource
     private MediaRecordService mediaRecordService;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @Operation(summary = "上传媒体文件")
     @PostMapping("/upload")
     public Result<MediaRecord> uploadMedia(
@@ -33,6 +38,18 @@ public class MediaController {
             return Result.error("文件大小超过限制");
         }
 
+        // 图片内容安全检测
+        if (file.getContentType() != null && file.getContentType().startsWith("image")) {
+            try {
+                byte[] imageBytes = file.getBytes();
+                if (!wechatService.checkImage(imageBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
+                }
+            } catch (IOException e) {
+                return Result.error("图片读取失败: " + e.getMessage());
+            }
+        }
+
         MediaRecord record = mediaRecordService.uploadMedia(file, taskId, creatorId, description);
         return Result.success(record);
     }
@@ -44,6 +61,11 @@ public class MediaController {
         Long creatorId = Long.parseLong(body.get("creatorId").toString());
         String content = body.get("content").toString();
 
+        // 文本内容安全检测
+        if (!content.isEmpty() && !wechatService.checkText(content)) {
+            return Result.error("文本内容包含敏感信息,上传失败");
+        }
+
         MediaRecord record = mediaRecordService.saveTextRecord(taskId, creatorId, content);
         return Result.success(record);
     }

+ 17 - 0
cfc-backend/src/main/java/com/etotem/cfc/controller/ServiceRoleApplicationController.java

@@ -4,6 +4,7 @@ import com.etotem.cfc.common.Result;
 import com.etotem.cfc.dto.ServiceRoleApplyDTO;
 import com.etotem.cfc.entity.ServiceRoleApplication;
 import com.etotem.cfc.service.ServiceRoleApplicationService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import io.swagger.v3.oas.annotations.Operation;
 import io.swagger.v3.oas.annotations.tags.Tag;
 import org.springframework.web.bind.annotation.*;
@@ -26,6 +27,9 @@ public class ServiceRoleApplicationController {
     @Resource
     private ServiceRoleApplicationService serviceRoleApplicationService;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @PostMapping("/apply")
     @Operation(summary = "提交服务角色申请")
     public Result apply(@RequestAttribute("userId") Long userId,
@@ -46,6 +50,19 @@ public class ServiceRoleApplicationController {
         if (file.isEmpty()) {
             return Result.error("请选择文件");
         }
+
+        // 图片内容安全检测
+        if (file.getContentType() != null && file.getContentType().startsWith("image")) {
+            try {
+                byte[] imageBytes = file.getBytes();
+                if (!wechatService.checkImage(imageBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
+                }
+            } catch (IOException e) {
+                return Result.error("图片读取失败: " + e.getMessage());
+            }
+        }
+
         try {
             String uploadDir = System.getProperty("user.dir") + "/uploads/service-role/" + userId;
             File dirFile = new File(uploadDir);

+ 40 - 1
cfc-backend/src/main/java/com/etotem/cfc/controller/admin/AdminArticleController.java

@@ -7,6 +7,7 @@ import com.etotem.cfc.service.ArticleCategoryService;
 import com.etotem.cfc.service.ArticleCommentService;
 import com.etotem.cfc.service.ArticleService;
 import com.etotem.cfc.service.FileStorageService;
+import com.etotem.cfc.service.api.WechatServiceInterface;
 import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
 import org.springframework.web.bind.annotation.*;
 import org.springframework.web.multipart.MultipartFile;
@@ -31,6 +32,9 @@ public class AdminArticleController {
     @Resource
     private ArticleCommentService articleCommentService;
 
+    @Resource
+    private WechatServiceInterface wechatService;
+
     @PostMapping("/list")
     public Result<Page<Article>> list(@RequestBody Map<String, Object> body) {
         String status = (String) body.get("status");
@@ -48,7 +52,18 @@ public class AdminArticleController {
 
     @PostMapping("/create")
     public Result<Map<String, Object>> create(@RequestBody Map<String, Object> body,
-                                  @RequestAttribute("userId") Long adminId) {
+                                   @RequestAttribute("userId") Long adminId) {
+        String title = (String) body.get("title");
+        String content = (String) body.get("content");
+
+        // 文本内容安全检测
+        if (title != null && !title.isEmpty() && !wechatService.checkText(title)) {
+            return Result.error("文章标题包含敏感信息,发布失败");
+        }
+        if (content != null && !content.isEmpty() && !wechatService.checkText(content)) {
+            return Result.error("文章内容包含敏感信息,发布失败");
+        }
+
         Article article = new Article();
         article.setTitle((String) body.get("title"));
         article.setContent((String) body.get("content"));
@@ -106,6 +121,17 @@ public class AdminArticleController {
 
     @PostMapping("/update")
     public Result<String> update(@RequestBody Map<String, Object> body) {
+        String title = (String) body.get("title");
+        String content = (String) body.get("content");
+
+        // 文本内容安全检测
+        if (title != null && !title.isEmpty() && !wechatService.checkText(title)) {
+            return Result.error("文章标题包含敏感信息,更新失败");
+        }
+        if (content != null && !content.isEmpty() && !wechatService.checkText(content)) {
+            return Result.error("文章内容包含敏感信息,更新失败");
+        }
+
         Long articleId = body.get("id") != null ? Long.valueOf(body.get("id").toString()) : null;
         if (articleId != null) {
             Article existing = articleService.getById(articleId);
@@ -254,6 +280,19 @@ public class AdminArticleController {
         if (file.isEmpty()) {
             return Result.error("文件为空");
         }
+
+        // 图片内容安全检测
+        if (file.getContentType() != null && file.getContentType().startsWith("image")) {
+            try {
+                byte[] imageBytes = file.getBytes();
+                if (!wechatService.checkImage(imageBytes)) {
+                    return Result.error("图片内容包含敏感信息,上传失败");
+                }
+            } catch (java.io.IOException e) {
+                return Result.error("图片读取失败: " + e.getMessage());
+            }
+        }
+
         try {
             String url = fileStorageService.store(file, "articles");
             return Result.success(url);

+ 146 - 2
cfc-backend/src/main/java/com/etotem/cfc/service/WechatService.java

@@ -14,8 +14,15 @@ import org.springframework.web.client.RestTemplate;
 import com.etotem.cfc.service.api.WechatServiceInterface;
 
 import javax.annotation.PostConstruct;
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
 import java.io.UnsupportedEncodingException;
 import java.net.URLEncoder;
+import java.net.HttpURLConnection;
+import java.net.URL;
 import java.nio.charset.StandardCharsets;
 import java.util.Base64;
 import java.util.HashMap;
@@ -57,6 +64,10 @@ public class WechatService implements WechatServiceInterface {
 
     private final RestTemplate restTemplate = new RestTemplate();
 
+    // ===== access_token 缓存 =====
+    private volatile String cachedAccessToken;
+    private volatile long tokenExpireTime;
+
     /**
      * 判断是否测试模式
      */
@@ -200,9 +211,32 @@ public class WechatService implements WechatServiceInterface {
     }
 
     /**
-     * 获取access_token
+     * 获取access_token(带缓存,TTL 7000秒,提前200秒刷新)
      */
-    private String getAccessToken() {
+    public String getAccessToken() {
+        // 测试模式:每次都重新获取
+        if (testMode) {
+            return doGetAccessToken();
+        }
+
+        long now = System.currentTimeMillis();
+        if (cachedAccessToken != null && now < tokenExpireTime) {
+            return cachedAccessToken;
+        }
+
+        synchronized (this) {
+            if (cachedAccessToken != null && now < tokenExpireTime) {
+                return cachedAccessToken;
+            }
+            String token = doGetAccessToken();
+            // 微信access_token有效期7200秒,提前200秒过期缓存
+            cachedAccessToken = token;
+            tokenExpireTime = now + 7000 * 1000;
+            return token;
+        }
+    }
+
+    private String doGetAccessToken() {
         try {
             String url = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
                     appid, secret);
@@ -222,6 +256,116 @@ public class WechatService implements WechatServiceInterface {
         }
     }
 
+    /**
+     * 文本内容安全检测(msg_sec_check)
+     * @param content 要检测的文本内容,长度不超过500K字节
+     * @return true=安全, false=包含敏感信息
+     */
+    public boolean checkText(String content) {
+        if (testMode) {
+            log.info("测试模式:跳过文本内容检测");
+            return true;
+        }
+
+        if (content == null || content.trim().isEmpty()) {
+            return true;
+        }
+
+        try {
+            String accessToken = getAccessToken();
+            String url = "https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + accessToken;
+
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("content", content);
+
+            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());
+            int errcode = json.getInteger("errcode");
+
+            if (errcode == 0) {
+                return true;
+            } else if (errcode == 87014) {
+                log.warn("文本内容安全检测发现敏感信息: errcode={}", errcode);
+                return false;
+            } else {
+                log.warn("文本内容安全检测返回异常: errcode={}, errmsg={}", errcode, json.getString("errmsg"));
+                // API 异常时 fail-open,不阻塞上传
+                return true;
+            }
+        } catch (Exception e) {
+            log.warn("文本内容安全检测调用失败(fail-open): {}", e.getMessage());
+            return true;
+        }
+    }
+
+    /**
+     * 图片内容安全检测(img_sec_check)
+     * @param imageBytes 图片二进制数据,格式支持PNG/JPEG/JPG/GIF
+     * @return true=安全, false=包含敏感内容
+     */
+    public boolean checkImage(byte[] imageBytes) {
+        if (testMode) {
+            log.info("测试模式:跳过图片内容检测");
+            return true;
+        }
+
+        if (imageBytes == null || imageBytes.length == 0) {
+            return true;
+        }
+
+        try {
+            String accessToken = getAccessToken();
+            String url = "https://api.weixin.qq.com/wxa/img_sec_check?access_token=" + accessToken;
+
+            // 使用 HttpURLConnection 发送 multipart/form-data
+            HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
+            conn.setRequestMethod("POST");
+            conn.setConnectTimeout(10000);
+            conn.setReadTimeout(30000);
+
+            String boundary = "----WebKitFormBoundary" + UUID.randomUUID().toString().replace("-", "");
+            conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
+            conn.setDoOutput(true);
+
+            OutputStream os = conn.getOutputStream();
+            // media field
+            os.write(("--" + boundary + "\r\n").getBytes(StandardCharsets.UTF_8));
+            os.write("Content-Disposition: form-data; name=\"media\"; filename=\"check.jpg\"\r\n".getBytes(StandardCharsets.UTF_8));
+            os.write("Content-Type: image/jpeg\r\n\r\n".getBytes(StandardCharsets.UTF_8));
+            os.write(imageBytes);
+            os.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
+            os.flush();
+            os.close();
+
+            InputStream is = conn.getInputStream();
+            byte[] responseBytes = is.readAllBytes();
+            is.close();
+            String responseBody = new String(responseBytes, StandardCharsets.UTF_8);
+
+            JSONObject json = JSON.parseObject(responseBody);
+            int errcode = json.getInteger("errcode");
+
+            if (errcode == 0) {
+                return true;
+            } else if (errcode == 87014) {
+                log.warn("图片内容安全检测发现敏感内容: errcode={}", errcode);
+                return false;
+            } else {
+                log.warn("图片内容安全检测返回异常: errcode={}, errmsg={}", errcode, json.getString("errmsg"));
+                return true;
+            }
+        } catch (Exception e) {
+            log.warn("图片内容安全检测调用失败(fail-open): {}", e.getMessage());
+            return true;
+        }
+    }
+
     /**
      * 生成小程序二维码(wxacode)
      * @param scene 场景参数(最大32个可见字符)

+ 3 - 0
cfc-backend/src/main/java/com/etotem/cfc/service/api/WechatServiceInterface.java

@@ -6,4 +6,7 @@ public interface WechatServiceInterface {
     boolean isTestMode();
     Map<String, String> code2Session(String code);
     String getPhoneNumber(String code);
+    String getAccessToken();
+    boolean checkText(String content);
+    boolean checkImage(byte[] imageBytes);
 }