|
|
@@ -1,408 +1,605 @@
|
|
|
package com.zhentao.controller;
|
|
|
|
|
|
-import com.zhentao.common.Result;
|
|
|
-import com.fasterxml.jackson.databind.JsonNode;
|
|
|
-import com.fasterxml.jackson.databind.ObjectMapper;
|
|
|
import org.slf4j.Logger;
|
|
|
import org.slf4j.LoggerFactory;
|
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
|
-import org.springframework.core.io.ByteArrayResource;
|
|
|
-import org.springframework.http.*;
|
|
|
-import org.springframework.util.LinkedMultiValueMap;
|
|
|
-import org.springframework.util.MultiValueMap;
|
|
|
-import org.springframework.web.bind.annotation.*;
|
|
|
-import org.springframework.web.client.RestTemplate;
|
|
|
-
|
|
|
-import java.io.ByteArrayOutputStream;
|
|
|
-import java.io.InputStream;
|
|
|
+import org.springframework.http.MediaType;
|
|
|
+import org.springframework.web.bind.annotation.PostMapping;
|
|
|
+import org.springframework.web.bind.annotation.RequestMapping;
|
|
|
+import org.springframework.web.bind.annotation.ResponseBody;
|
|
|
+import org.springframework.web.bind.annotation.RestController;
|
|
|
+
|
|
|
+import javax.servlet.http.HttpServletRequest;
|
|
|
+import java.io.*;
|
|
|
import java.net.HttpURLConnection;
|
|
|
import java.net.URL;
|
|
|
-import java.util.*;
|
|
|
+import java.nio.charset.StandardCharsets;
|
|
|
+import java.util.Arrays;
|
|
|
+import java.util.List;
|
|
|
+import java.util.regex.Matcher;
|
|
|
import java.util.regex.Pattern;
|
|
|
|
|
|
/**
|
|
|
* 内容安全检测控制器
|
|
|
* 用于检测用户发布的文字、图片是否包含违规内容
|
|
|
- * 集成微信小程序内容安全API
|
|
|
+ * 使用微信小程序内容安全API
|
|
|
*/
|
|
|
@RestController
|
|
|
-@RequestMapping("/content-security")
|
|
|
+@RequestMapping("/api/content-security")
|
|
|
public class ContentSecurityController {
|
|
|
|
|
|
private static final Logger logger = LoggerFactory.getLogger(ContentSecurityController.class);
|
|
|
|
|
|
+ // ====================== 常量配置 (统一维护,无硬编码) ======================
|
|
|
+ private static final int MAX_IMAGE_SIZE = 2 * 1024 * 1024; // 图片最大下载大小 2MB,防止OOM
|
|
|
+ private static final int WX_IMAGE_LIMIT_SIZE = 1 * 1024 * 1024; // 微信同步检测图片限制1MB
|
|
|
+ private static final int TOKEN_EXPIRE_ADVANCE = 300; // token提前300秒刷新
|
|
|
+ private static final int WX_ERRCODE_ILLEGAL_CONTENT = 87014; // 内容违规错误码
|
|
|
+ private static final int WX_ERRCODE_TOKEN_EXPIRE_1 = 40001; // token过期错误码1
|
|
|
+ private static final int WX_ERRCODE_TOKEN_EXPIRE_2 = 42001; // token过期错误码2
|
|
|
+
|
|
|
@Value("${wechat.miniapp.appid:}")
|
|
|
private String appId;
|
|
|
|
|
|
@Value("${wechat.miniapp.secret:}")
|
|
|
private String appSecret;
|
|
|
|
|
|
- private final RestTemplate restTemplate = new RestTemplate();
|
|
|
- private final ObjectMapper objectMapper = new ObjectMapper();
|
|
|
-
|
|
|
- private String cachedAccessToken;
|
|
|
- private long tokenExpireTime = 0;
|
|
|
+ // AccessToken缓存,volatile保证多线程可见性
|
|
|
+ private volatile String cachedAccessToken;
|
|
|
+ private volatile long tokenExpireTime = 0;
|
|
|
|
|
|
private static final List<String> SENSITIVE_WORDS = Arrays.asList(
|
|
|
- "色情", "裸体", "性爱", "约炮", "一夜情", "援交", "卖淫", "嫖娼", "小姐服务",
|
|
|
- "做爱", "性交", "口交", "肛交", "自慰", "手淫", "阴茎", "阴道", "乳房",
|
|
|
- "傻逼", "操你", "草泥马", "妈的", "他妈的", "狗日的", "王八蛋", "贱人", "婊子",
|
|
|
- "滚蛋", "去死", "废物", "垃圾", "白痴", "智障",
|
|
|
- "赌博", "毒品", "枪支", "炸弹", "恐怖", "暴力", "杀人", "自杀",
|
|
|
- "刷单", "兼职日结", "高额返利", "免费领取", "中奖", "彩票内幕", "稳赚不赔",
|
|
|
- "法轮功", "邪教"
|
|
|
+ "色情", "裸体", "性爱", "约炮", "一夜情", "援交", "卖淫", "嫖娼", "小姐服务",
|
|
|
+ "做爱", "性交", "口交", "肛交", "自慰", "手淫",
|
|
|
+ "傻逼", "操你", "草泥马", "妈的", "他妈的", "狗日的", "王八蛋", "贱人", "婊子",
|
|
|
+ "滚蛋", "去死", "废物", "白痴", "智障",
|
|
|
+ "赌博", "毒品", "枪支", "炸弹", "恐怖", "暴力", "杀人", "自杀",
|
|
|
+ "刷单", "兼职日结", "高额返利", "免费领取", "中奖", "彩票内幕", "稳赚不赔",
|
|
|
+ "法轮功", "邪教"
|
|
|
);
|
|
|
|
|
|
- private static final List<Pattern> SENSITIVE_PATTERNS = new ArrayList<>();
|
|
|
-
|
|
|
- static {
|
|
|
- for (String word : SENSITIVE_WORDS) {
|
|
|
- StringBuilder patternStr = new StringBuilder();
|
|
|
- for (int i = 0; i < word.length(); i++) {
|
|
|
- if (i > 0) {
|
|
|
- patternStr.append("[\\s\\*\\-\\_\\.]*");
|
|
|
- }
|
|
|
- patternStr.append(Pattern.quote(String.valueOf(word.charAt(i))));
|
|
|
- }
|
|
|
- SENSITIVE_PATTERNS.add(Pattern.compile(patternStr.toString(), Pattern.CASE_INSENSITIVE));
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
/**
|
|
|
* 检测文字内容是否安全
|
|
|
*/
|
|
|
- @PostMapping("/check-text")
|
|
|
- public Result<Map<String, Object>> checkText(@RequestBody Map<String, String> request) {
|
|
|
- String content = request.get("content");
|
|
|
-
|
|
|
+ @PostMapping(value = "/check-text", produces = MediaType.APPLICATION_JSON_VALUE)
|
|
|
+ @ResponseBody
|
|
|
+ public String checkText(HttpServletRequest request) {
|
|
|
+ String content = null;
|
|
|
+ try {
|
|
|
+ String body = readRequestBody(request);
|
|
|
+ content = getJsonString(body, "content");
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("读取文字检测请求体失败", e);
|
|
|
+ return buildResponse(false, "请求参数解析失败");
|
|
|
+ }
|
|
|
+
|
|
|
if (content == null || content.trim().isEmpty()) {
|
|
|
- return Result.success(createSafeResult());
|
|
|
+ return buildResponse(true, "");
|
|
|
}
|
|
|
|
|
|
+ logger.info("开始检测文字内容,长度: {}", content.length());
|
|
|
+
|
|
|
+ // 本地敏感词检测
|
|
|
String localCheckResult = localTextCheck(content);
|
|
|
if (localCheckResult != null) {
|
|
|
logger.info("本地敏感词检测不通过: {}", localCheckResult);
|
|
|
- return Result.success(createUnsafeResult("内容包含敏感词,请修改后重试"));
|
|
|
+ return buildResponse(false, "内容包含敏感词,请修改后重试");
|
|
|
}
|
|
|
|
|
|
+ // 微信内容安全检测
|
|
|
try {
|
|
|
- Map<String, Object> wxResult = wxMsgSecCheck(content);
|
|
|
- if (wxResult != null && Boolean.FALSE.equals(wxResult.get("safe"))) {
|
|
|
+ String wxResult = wxMsgSecCheck(content);
|
|
|
+ if (wxResult != null) {
|
|
|
logger.info("微信内容安全检测不通过");
|
|
|
- return Result.success(createUnsafeResult((String) wxResult.get("message")));
|
|
|
+ return buildResponse(false, wxResult);
|
|
|
}
|
|
|
} catch (Exception e) {
|
|
|
- logger.error("微信内容安全检测异常", e);
|
|
|
+ logger.error("微信文字内容安全检测异常", e);
|
|
|
}
|
|
|
|
|
|
- return Result.success(createSafeResult());
|
|
|
+ logger.info("文字内容检测通过");
|
|
|
+ return buildResponse(true, "");
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 检测图片是否安全(同步检测)
|
|
|
+ * 检测图片是否安全
|
|
|
+ * 检测链路:同步接口(img_sec_check) → 异步接口(media_check_async)
|
|
|
*/
|
|
|
- @PostMapping("/check-image")
|
|
|
- public Result<Map<String, Object>> checkImage(@RequestBody Map<String, String> request) {
|
|
|
- String imageUrl = request.get("imageUrl");
|
|
|
-
|
|
|
+ @PostMapping(value = "/check-image", produces = MediaType.APPLICATION_JSON_VALUE)
|
|
|
+ @ResponseBody
|
|
|
+ public String checkImage(HttpServletRequest request) {
|
|
|
+ String imageUrl = null;
|
|
|
+ try {
|
|
|
+ String body = readRequestBody(request);
|
|
|
+ logger.info("收到图片检测请求: {}", body);
|
|
|
+ imageUrl = getJsonString(body, "imageUrl");
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("读取图片检测请求体失败", e);
|
|
|
+ return buildResponse(false, "请求参数错误");
|
|
|
+ }
|
|
|
+
|
|
|
if (imageUrl == null || imageUrl.trim().isEmpty()) {
|
|
|
- return Result.success(createSafeResult());
|
|
|
+ logger.warn("图片URL为空,跳过检测");
|
|
|
+ return buildResponse(true, "");
|
|
|
}
|
|
|
|
|
|
logger.info("开始检测图片: {}", imageUrl);
|
|
|
|
|
|
+ // 1. 先尝试使用微信同步接口 img_sec_check (精度高,实时返回结果)
|
|
|
+ try {
|
|
|
+ String wxResult = wxImgSecCheck(imageUrl);
|
|
|
+ if (wxResult != null) {
|
|
|
+ logger.info("微信图片同步检测不通过: {}", imageUrl);
|
|
|
+ return buildResponse(false, wxResult);
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("微信图片同步检测异常: {}", imageUrl, e);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 2. 如果同步接口无结果/图片过大,使用异步接口补充检测
|
|
|
try {
|
|
|
- // 下载图片并调用微信图片安全检测API
|
|
|
- Map<String, Object> wxResult = wxImgSecCheck(imageUrl);
|
|
|
- if (wxResult != null && Boolean.FALSE.equals(wxResult.get("safe"))) {
|
|
|
- logger.info("微信图片安全检测不通过: {}", imageUrl);
|
|
|
- return Result.success(createUnsafeResult((String) wxResult.get("message")));
|
|
|
+ String asyncResult = wxMediaCheckAsync(imageUrl);
|
|
|
+ if (asyncResult != null) {
|
|
|
+ logger.info("微信图片异步检测不通过: {}", imageUrl);
|
|
|
+ return buildResponse(false, asyncResult);
|
|
|
}
|
|
|
- logger.info("图片检测通过: {}", imageUrl);
|
|
|
} catch (Exception e) {
|
|
|
- logger.error("微信图片安全检测异常: {}", imageUrl, e);
|
|
|
- // 异常时默认通过,由人工审核
|
|
|
+ logger.error("微信图片异步检测异常: {}", imageUrl, e);
|
|
|
}
|
|
|
|
|
|
- return Result.success(createSafeResult());
|
|
|
+ logger.info("图片检测通过: {}", imageUrl);
|
|
|
+ return buildResponse(true, "");
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 批量检测图片
|
|
|
+ * 构建统一JSON响应体
|
|
|
*/
|
|
|
- @PostMapping("/check-images")
|
|
|
- public Result<Map<String, Object>> checkImages(@RequestBody Map<String, Object> request) {
|
|
|
- @SuppressWarnings("unchecked")
|
|
|
- List<String> imageUrls = (List<String>) request.get("imageUrls");
|
|
|
-
|
|
|
- if (imageUrls == null || imageUrls.isEmpty()) {
|
|
|
- return Result.success(createSafeResult());
|
|
|
- }
|
|
|
-
|
|
|
- for (int i = 0; i < imageUrls.size(); i++) {
|
|
|
- String imageUrl = imageUrls.get(i);
|
|
|
- if (imageUrl == null || imageUrl.trim().isEmpty()) {
|
|
|
- continue;
|
|
|
- }
|
|
|
+ private String buildResponse(boolean safe, String message) {
|
|
|
+ return "{\"code\":200,\"message\":\"成功\",\"data\":{\"safe\":" + safe +
|
|
|
+ ",\"message\":\"" + escapeJson(message) + "\"},\"timestamp\":" +
|
|
|
+ System.currentTimeMillis() + "}";
|
|
|
+ }
|
|
|
|
|
|
- try {
|
|
|
- Map<String, Object> wxResult = wxImgSecCheck(imageUrl);
|
|
|
- if (wxResult != null && Boolean.FALSE.equals(wxResult.get("safe"))) {
|
|
|
- Map<String, Object> result = createUnsafeResult("第" + (i + 1) + "张图片包含违规内容,请更换后重试");
|
|
|
- result.put("failedIndex", i);
|
|
|
- return Result.success(result);
|
|
|
- }
|
|
|
- } catch (Exception e) {
|
|
|
- logger.error("检测第{}张图片异常: {}", i + 1, imageUrl, e);
|
|
|
+ /**
|
|
|
+ * 读取request请求体
|
|
|
+ */
|
|
|
+ private String readRequestBody(HttpServletRequest request) throws IOException {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ try (BufferedReader reader = request.getReader()) {
|
|
|
+ String line;
|
|
|
+ while ((line = reader.readLine()) != null) {
|
|
|
+ sb.append(line);
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- return Result.success(createSafeResult());
|
|
|
+ return sb.toString();
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 本地敏感词检测-忽略大小写
|
|
|
+ */
|
|
|
private String localTextCheck(String content) {
|
|
|
String lowerContent = content.toLowerCase();
|
|
|
-
|
|
|
for (String word : SENSITIVE_WORDS) {
|
|
|
if (lowerContent.contains(word.toLowerCase())) {
|
|
|
return word;
|
|
|
}
|
|
|
}
|
|
|
-
|
|
|
- for (int i = 0; i < SENSITIVE_PATTERNS.size(); i++) {
|
|
|
- if (SENSITIVE_PATTERNS.get(i).matcher(content).find()) {
|
|
|
- return SENSITIVE_WORDS.get(i);
|
|
|
- }
|
|
|
- }
|
|
|
-
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 调用微信文字内容安全检测API
|
|
|
+ * 调用微信文字内容安全检测API (msg_sec_check) - 同步接口
|
|
|
*/
|
|
|
- private Map<String, Object> wxMsgSecCheck(String content) {
|
|
|
+ private String wxMsgSecCheck(String content) {
|
|
|
+ HttpURLConnection conn = null;
|
|
|
try {
|
|
|
String accessToken = getAccessToken();
|
|
|
if (accessToken == null || accessToken.isEmpty()) {
|
|
|
- logger.warn("获取access_token失败,跳过微信内容检测");
|
|
|
+ logger.warn("获取access_token失败,跳过微信文字检测");
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- String url = "https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + accessToken;
|
|
|
-
|
|
|
- HttpHeaders headers = new HttpHeaders();
|
|
|
- headers.setContentType(MediaType.APPLICATION_JSON);
|
|
|
-
|
|
|
- Map<String, Object> body = new HashMap<>();
|
|
|
- body.put("content", content);
|
|
|
- body.put("version", 2);
|
|
|
- body.put("scene", 2);
|
|
|
+ String urlStr = "https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + accessToken;
|
|
|
+ URL url = new URL(urlStr);
|
|
|
+ conn = (HttpURLConnection) url.openConnection();
|
|
|
+ conn.setRequestMethod("POST");
|
|
|
+ conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
|
|
+ conn.setDoOutput(true);
|
|
|
+ conn.setConnectTimeout(10000);
|
|
|
+ conn.setReadTimeout(10000);
|
|
|
+
|
|
|
+ // 修复:openid传空字符串(无用户openid时的兼容写法,微信允许),不再传appId
|
|
|
+ String jsonBody = "{\"content\":\"" + escapeJson(content) + "\",\"version\":2,\"scene\":2,\"openid\":\"\"}";
|
|
|
+ logger.debug("微信文字检测请求体: {}", jsonBody);
|
|
|
+
|
|
|
+ try (OutputStream os = conn.getOutputStream()) {
|
|
|
+ os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
|
|
|
- HttpEntity<Map<String, Object>> entity = new HttpEntity<>(body, headers);
|
|
|
- ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
|
|
+ int responseCode = conn.getResponseCode();
|
|
|
+ String responseBody = readHttpResponse(conn);
|
|
|
+ logger.info("微信文字检测响应: code={}, body={}", responseCode, responseBody);
|
|
|
|
|
|
- if (response.getStatusCode() == HttpStatus.OK) {
|
|
|
- JsonNode jsonNode = objectMapper.readTree(response.getBody());
|
|
|
- int errcode = jsonNode.has("errcode") ? jsonNode.get("errcode").asInt() : 0;
|
|
|
-
|
|
|
+ if (responseCode == 200 && responseBody != null) {
|
|
|
+ int errcode = getJsonInt(responseBody, "errcode");
|
|
|
+ // 全局处理token过期
|
|
|
+ if (isTokenExpireCode(errcode)) {
|
|
|
+ clearTokenCache();
|
|
|
+ return null;
|
|
|
+ }
|
|
|
if (errcode == 0) {
|
|
|
- JsonNode result = jsonNode.get("result");
|
|
|
- if (result != null) {
|
|
|
- String suggest = result.has("suggest") ? result.get("suggest").asText() : "pass";
|
|
|
- if ("risky".equals(suggest)) {
|
|
|
- String label = result.has("label") ? result.get("label").asText() : "违规内容";
|
|
|
- return createUnsafeResult("内容包含" + getLabelText(label) + ",请修改后重试");
|
|
|
- }
|
|
|
+ String suggest = getNestedJsonString(responseBody, "result", "suggest");
|
|
|
+ if ("risky".equals(suggest)) {
|
|
|
+ String label = getNestedJsonString(responseBody, "result", "label");
|
|
|
+ return "内容包含" + getLabelText(label) + ",请修改后重试";
|
|
|
}
|
|
|
- return createSafeResult();
|
|
|
- } else if (errcode == 87014) {
|
|
|
- return createUnsafeResult("内容包含违规信息,请修改后重试");
|
|
|
+ return null;
|
|
|
+ } else if (errcode == WX_ERRCODE_ILLEGAL_CONTENT) {
|
|
|
+ return "内容包含违规信息,请修改后重试";
|
|
|
} else {
|
|
|
- logger.warn("微信内容检测返回错误码: {}", errcode);
|
|
|
+ logger.warn("微信文字检测返回错误码: {}, 描述: {}", errcode, getJsonString(responseBody, "errmsg"));
|
|
|
}
|
|
|
}
|
|
|
} catch (Exception e) {
|
|
|
- logger.error("调用微信文字内容安全API异常", e);
|
|
|
+ logger.error("调用微信文字安全检测API异常", e);
|
|
|
+ } finally {
|
|
|
+ if (conn != null) conn.disconnect();
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 调用微信图片内容安全检测API(同步,通过上传图片文件)
|
|
|
- * https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/sec-center/sec-check/imgSecCheck.html
|
|
|
+ * 调用微信图片内容安全检测API (img_sec_check) - 同步接口
|
|
|
+ * 限制:图片大小 <= 1MB,实时返回检测结果
|
|
|
*/
|
|
|
- private Map<String, Object> wxImgSecCheck(String imageUrl) {
|
|
|
+ private String wxImgSecCheck(String imageUrl) {
|
|
|
+ HttpURLConnection conn = null;
|
|
|
try {
|
|
|
String accessToken = getAccessToken();
|
|
|
if (accessToken == null || accessToken.isEmpty()) {
|
|
|
- logger.warn("获取access_token失败,跳过微信图片检测");
|
|
|
+ logger.warn("获取access_token失败,跳过微信图片同步检测");
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- // 1. 下载图片
|
|
|
+ logger.info("开始下载图片: {}", imageUrl);
|
|
|
byte[] imageBytes = downloadImage(imageUrl);
|
|
|
if (imageBytes == null || imageBytes.length == 0) {
|
|
|
logger.warn("下载图片失败: {}", imageUrl);
|
|
|
return null;
|
|
|
}
|
|
|
+ logger.info("图片下载成功,大小: {} bytes", imageBytes.length);
|
|
|
|
|
|
- // 检查图片大小(微信限制1MB)
|
|
|
- if (imageBytes.length > 1024 * 1024) {
|
|
|
- logger.warn("图片大小超过1MB,跳过微信检测: {} bytes", imageBytes.length);
|
|
|
- // 大图片跳过微信检测,由人工审核
|
|
|
- return createSafeResult();
|
|
|
+ // 微信同步检测限制图片1MB,超过则跳过走异步
|
|
|
+ if (imageBytes.length > WX_IMAGE_LIMIT_SIZE) {
|
|
|
+ logger.warn("图片大小超过1MB({} bytes),跳过同步检测,走异步检测", imageBytes.length);
|
|
|
+ return null;
|
|
|
}
|
|
|
|
|
|
- // 2. 上传到微信进行检测
|
|
|
- String url = "https://api.weixin.qq.com/wxa/img_sec_check?access_token=" + accessToken;
|
|
|
+ String boundary = "----WebKitFormBoundary" + System.currentTimeMillis();
|
|
|
+ String urlStr = "https://api.weixin.qq.com/wxa/img_sec_check?access_token=" + accessToken;
|
|
|
+
|
|
|
+ URL url = new URL(urlStr);
|
|
|
+ conn = (HttpURLConnection) url.openConnection();
|
|
|
+ conn.setRequestMethod("POST");
|
|
|
+ conn.setRequestProperty("Content-Type", "multipart/form-data; boundary=" + boundary);
|
|
|
+ conn.setDoOutput(true);
|
|
|
+ conn.setConnectTimeout(15000);
|
|
|
+ conn.setReadTimeout(30000);
|
|
|
+
|
|
|
+ try (OutputStream os = conn.getOutputStream()) {
|
|
|
+ String header = "--" + boundary + "\r\n" +
|
|
|
+ "Content-Disposition: form-data; name=\"media\"; filename=\"image.jpg\"\r\n" +
|
|
|
+ "Content-Type: application/octet-stream\r\n\r\n";
|
|
|
+ os.write(header.getBytes(StandardCharsets.UTF_8));
|
|
|
+ os.write(imageBytes);
|
|
|
+ os.write(("\r\n--" + boundary + "--\r\n").getBytes(StandardCharsets.UTF_8));
|
|
|
+ os.flush();
|
|
|
+ }
|
|
|
|
|
|
- HttpHeaders headers = new HttpHeaders();
|
|
|
- headers.setContentType(MediaType.MULTIPART_FORM_DATA);
|
|
|
+ int responseCode = conn.getResponseCode();
|
|
|
+ String responseBody = readHttpResponse(conn);
|
|
|
+ logger.info("微信图片同步检测响应: code={}, body={}", responseCode, responseBody);
|
|
|
|
|
|
- // 创建文件资源
|
|
|
- ByteArrayResource fileResource = new ByteArrayResource(imageBytes) {
|
|
|
- @Override
|
|
|
- public String getFilename() {
|
|
|
- return "image.jpg";
|
|
|
+ if (responseCode == 200 && responseBody != null) {
|
|
|
+ int errcode = getJsonInt(responseBody, "errcode");
|
|
|
+ if (isTokenExpireCode(errcode)) {
|
|
|
+ clearTokenCache();
|
|
|
+ return null;
|
|
|
}
|
|
|
- };
|
|
|
+ if (errcode == 0) {
|
|
|
+ return null;
|
|
|
+ } else if (errcode == WX_ERRCODE_ILLEGAL_CONTENT) {
|
|
|
+ return "图片包含违规内容,请更换后重试";
|
|
|
+ } else {
|
|
|
+ logger.warn("微信图片同步检测错误码: {}, 描述: {}", errcode, getJsonString(responseBody, "errmsg"));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("调用微信图片同步检测API异常", e);
|
|
|
+ } finally {
|
|
|
+ if (conn != null) conn.disconnect();
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
|
|
|
- MultiValueMap<String, Object> body = new LinkedMultiValueMap<>();
|
|
|
- body.add("media", fileResource);
|
|
|
+ /**
|
|
|
+ * 调用微信媒体内容安全检测API (media_check_async) - 异步接口
|
|
|
+ * 适用:图片>1MB,提交检测后返回trace_id,结果需回调获取
|
|
|
+ */
|
|
|
+ private String wxMediaCheckAsync(String imageUrl) {
|
|
|
+ HttpURLConnection conn = null;
|
|
|
+ try {
|
|
|
+ String accessToken = getAccessToken();
|
|
|
+ if (accessToken == null || accessToken.isEmpty()) {
|
|
|
+ logger.warn("获取access_token失败,跳过微信图片异步检测");
|
|
|
+ return null;
|
|
|
+ }
|
|
|
|
|
|
- HttpEntity<MultiValueMap<String, Object>> entity = new HttpEntity<>(body, headers);
|
|
|
- ResponseEntity<String> response = restTemplate.postForEntity(url, entity, String.class);
|
|
|
+ String urlStr = "https://api.weixin.qq.com/wxa/media_check_async?access_token=" + accessToken;
|
|
|
+ URL url = new URL(urlStr);
|
|
|
+ conn = (HttpURLConnection) url.openConnection();
|
|
|
+ conn.setRequestMethod("POST");
|
|
|
+ conn.setRequestProperty("Content-Type", "application/json; charset=UTF-8");
|
|
|
+ conn.setDoOutput(true);
|
|
|
+ conn.setConnectTimeout(10000);
|
|
|
+ conn.setReadTimeout(10000);
|
|
|
+
|
|
|
+ // 修复:openid传空字符串,不再传appId
|
|
|
+ String jsonBody = "{\"media_url\":\"" + escapeJson(imageUrl) + "\",\"media_type\":2,\"version\":2,\"scene\":2,\"openid\":\"\"}";
|
|
|
+ logger.info("微信图片异步检测请求体: {}", jsonBody);
|
|
|
+
|
|
|
+ try (OutputStream os = conn.getOutputStream()) {
|
|
|
+ os.write(jsonBody.getBytes(StandardCharsets.UTF_8));
|
|
|
+ }
|
|
|
|
|
|
- logger.info("微信图片检测响应: {}", response.getBody());
|
|
|
+ int responseCode = conn.getResponseCode();
|
|
|
+ String responseBody = readHttpResponse(conn);
|
|
|
+ logger.info("微信图片异步检测响应: code={}, body={}", responseCode, responseBody);
|
|
|
|
|
|
- if (response.getStatusCode() == HttpStatus.OK) {
|
|
|
- JsonNode jsonNode = objectMapper.readTree(response.getBody());
|
|
|
- int errcode = jsonNode.has("errcode") ? jsonNode.get("errcode").asInt() : 0;
|
|
|
-
|
|
|
+ if (responseCode == 200 && responseBody != null) {
|
|
|
+ int errcode = getJsonInt(responseBody, "errcode");
|
|
|
+ if (isTokenExpireCode(errcode)) {
|
|
|
+ clearTokenCache();
|
|
|
+ return null;
|
|
|
+ }
|
|
|
if (errcode == 0) {
|
|
|
- // 检测通过
|
|
|
- return createSafeResult();
|
|
|
- } else if (errcode == 87014) {
|
|
|
- // 图片含有违法违规内容
|
|
|
- return createUnsafeResult("图片包含违规内容,请更换后重试");
|
|
|
+ String traceId = getJsonString(responseBody, "trace_id");
|
|
|
+ logger.info("微信异步检测已提交,trace_id: {}", traceId);
|
|
|
+ return null;
|
|
|
+ } else if (errcode == WX_ERRCODE_ILLEGAL_CONTENT) {
|
|
|
+ return "图片包含违规内容,请更换后重试";
|
|
|
} else {
|
|
|
- logger.warn("微信图片检测返回错误码: {}, errmsg: {}",
|
|
|
- errcode, jsonNode.has("errmsg") ? jsonNode.get("errmsg").asText() : "");
|
|
|
+ logger.warn("微信异步检测错误码: {}, 描述: {}", errcode, getJsonString(responseBody, "errmsg"));
|
|
|
}
|
|
|
}
|
|
|
} catch (Exception e) {
|
|
|
- logger.error("调用微信图片内容安全API异常", e);
|
|
|
+ logger.error("调用微信图片异步检测API异常", e);
|
|
|
+ } finally {
|
|
|
+ if (conn != null) conn.disconnect();
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * 下载图片
|
|
|
+ * 下载图片字节数组,增加大小限制防止OOM
|
|
|
*/
|
|
|
private byte[] downloadImage(String imageUrl) {
|
|
|
- HttpURLConnection connection = null;
|
|
|
- InputStream inputStream = null;
|
|
|
- ByteArrayOutputStream outputStream = null;
|
|
|
-
|
|
|
+ HttpURLConnection conn = null;
|
|
|
try {
|
|
|
URL url = new URL(imageUrl);
|
|
|
- connection = (HttpURLConnection) url.openConnection();
|
|
|
- connection.setRequestMethod("GET");
|
|
|
- connection.setConnectTimeout(10000);
|
|
|
- connection.setReadTimeout(30000);
|
|
|
- connection.setRequestProperty("User-Agent", "Mozilla/5.0");
|
|
|
-
|
|
|
- int responseCode = connection.getResponseCode();
|
|
|
- if (responseCode != HttpURLConnection.HTTP_OK) {
|
|
|
+ conn = (HttpURLConnection) url.openConnection();
|
|
|
+ conn.setRequestMethod("GET");
|
|
|
+ conn.setConnectTimeout(10000);
|
|
|
+ conn.setReadTimeout(30000);
|
|
|
+ conn.setRequestProperty("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36");
|
|
|
+ conn.setInstanceFollowRedirects(true);
|
|
|
+
|
|
|
+ int responseCode = conn.getResponseCode();
|
|
|
+ if (responseCode != 200) {
|
|
|
logger.warn("下载图片失败,HTTP状态码: {}", responseCode);
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
- inputStream = connection.getInputStream();
|
|
|
- outputStream = new ByteArrayOutputStream();
|
|
|
-
|
|
|
- byte[] buffer = new byte[4096];
|
|
|
- int bytesRead;
|
|
|
- while ((bytesRead = inputStream.read(buffer)) != -1) {
|
|
|
- outputStream.write(buffer, 0, bytesRead);
|
|
|
+ try (InputStream is = conn.getInputStream();
|
|
|
+ ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
|
|
|
+ byte[] buffer = new byte[8192];
|
|
|
+ int bytesRead;
|
|
|
+ while ((bytesRead = is.read(buffer)) != -1) {
|
|
|
+ // 超过最大限制,直接返回null,防止OOM
|
|
|
+ if (baos.size() + bytesRead > MAX_IMAGE_SIZE) {
|
|
|
+ logger.warn("图片大小超过{}MB,终止下载", MAX_IMAGE_SIZE / 1024 / 1024);
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ baos.write(buffer, 0, bytesRead);
|
|
|
+ }
|
|
|
+ return baos.toByteArray();
|
|
|
}
|
|
|
-
|
|
|
- return outputStream.toByteArray();
|
|
|
} catch (Exception e) {
|
|
|
logger.error("下载图片异常: {}", imageUrl, e);
|
|
|
return null;
|
|
|
} finally {
|
|
|
- try {
|
|
|
- if (inputStream != null) inputStream.close();
|
|
|
- if (outputStream != null) outputStream.close();
|
|
|
- if (connection != null) connection.disconnect();
|
|
|
- } catch (Exception e) {
|
|
|
- // ignore
|
|
|
- }
|
|
|
+ if (conn != null) conn.disconnect();
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- private synchronized String getAccessToken() {
|
|
|
+ /**
|
|
|
+ * 获取微信AccessToken,双重检查锁保证并发安全+性能,缓存过期自动刷新
|
|
|
+ */
|
|
|
+ private String getAccessToken() {
|
|
|
+ // 第一次无锁检查,提高并发性能
|
|
|
if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpireTime) {
|
|
|
+ logger.debug("使用缓存的access_token,剩余有效期:{}ms", tokenExpireTime - System.currentTimeMillis());
|
|
|
return cachedAccessToken;
|
|
|
}
|
|
|
|
|
|
- if (appId == null || appId.isEmpty() || appSecret == null || appSecret.isEmpty()) {
|
|
|
- logger.warn("微信小程序appId或appSecret未配置");
|
|
|
+ synchronized (this) {
|
|
|
+ // 第二次加锁检查,防止多线程重复请求
|
|
|
+ if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpireTime) {
|
|
|
+ return cachedAccessToken;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (appId == null || appId.isEmpty() || appSecret == null || appSecret.isEmpty()) {
|
|
|
+ logger.error("微信小程序appId或appSecret未配置! appId={}", appId != null ? appId.substring(0, 4) + "***" : "null");
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ HttpURLConnection conn = null;
|
|
|
+ try {
|
|
|
+ String urlStr = String.format(
|
|
|
+ "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
|
|
|
+ appId, appSecret
|
|
|
+ );
|
|
|
+ logger.info("开始请求微信access_token,appId:{}***", appId.substring(0, 4));
|
|
|
+
|
|
|
+ URL url = new URL(urlStr);
|
|
|
+ conn = (HttpURLConnection) url.openConnection();
|
|
|
+ conn.setRequestMethod("GET");
|
|
|
+ conn.setConnectTimeout(10000);
|
|
|
+ conn.setReadTimeout(10000);
|
|
|
+
|
|
|
+ int responseCode = conn.getResponseCode();
|
|
|
+ String responseBody = readHttpResponse(conn);
|
|
|
+ logger.info("获取token响应: code={}, body={}", responseCode, responseBody);
|
|
|
+
|
|
|
+ if (responseCode == 200 && responseBody != null) {
|
|
|
+ String token = getJsonString(responseBody, "access_token");
|
|
|
+ if (token != null && !token.isEmpty()) {
|
|
|
+ cachedAccessToken = token;
|
|
|
+ int expiresIn = getJsonInt(responseBody, "expires_in");
|
|
|
+ if (expiresIn == 0) expiresIn = 7200;
|
|
|
+ tokenExpireTime = System.currentTimeMillis() + (expiresIn - TOKEN_EXPIRE_ADVANCE) * 1000L;
|
|
|
+ logger.info("获取access_token成功,有效期:{}秒", expiresIn);
|
|
|
+ return cachedAccessToken;
|
|
|
+ } else {
|
|
|
+ int errcode = getJsonInt(responseBody, "errcode");
|
|
|
+ String errmsg = getJsonString(responseBody, "errmsg");
|
|
|
+ logger.error("获取token失败: errcode={}, errmsg={}", errcode, errmsg);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (Exception e) {
|
|
|
+ logger.error("获取微信access_token异常", e);
|
|
|
+ } finally {
|
|
|
+ if (conn != null) conn.disconnect();
|
|
|
+ }
|
|
|
return null;
|
|
|
}
|
|
|
+ }
|
|
|
|
|
|
- try {
|
|
|
- String url = String.format(
|
|
|
- "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s",
|
|
|
- appId, appSecret
|
|
|
- );
|
|
|
-
|
|
|
- ResponseEntity<String> response = restTemplate.getForEntity(url, String.class);
|
|
|
- if (response.getStatusCode() == HttpStatus.OK) {
|
|
|
- JsonNode jsonNode = objectMapper.readTree(response.getBody());
|
|
|
- if (jsonNode.has("access_token")) {
|
|
|
- cachedAccessToken = jsonNode.get("access_token").asText();
|
|
|
- int expiresIn = jsonNode.has("expires_in") ? jsonNode.get("expires_in").asInt() : 7200;
|
|
|
- tokenExpireTime = System.currentTimeMillis() + (expiresIn - 300) * 1000L;
|
|
|
- return cachedAccessToken;
|
|
|
- } else {
|
|
|
- logger.error("获取access_token失败: {}", response.getBody());
|
|
|
- }
|
|
|
+ // ====================== 通用工具方法 ======================
|
|
|
+ /**
|
|
|
+ * 读取Http响应体,兼容成功流和错误流
|
|
|
+ */
|
|
|
+ private String readHttpResponse(HttpURLConnection conn) throws IOException {
|
|
|
+ InputStream is = conn.getResponseCode() >= 400 ? conn.getErrorStream() : conn.getInputStream();
|
|
|
+ if (is == null) return "";
|
|
|
+ try (BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ String line;
|
|
|
+ while ((line = reader.readLine()) != null) {
|
|
|
+ sb.append(line);
|
|
|
}
|
|
|
- } catch (Exception e) {
|
|
|
- logger.error("获取access_token异常", e);
|
|
|
+ return sb.toString();
|
|
|
+ } finally {
|
|
|
+ if (is != null) is.close();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 标准JSON字符串转义,补全所有缺失的转义字符
|
|
|
+ */
|
|
|
+ private String escapeJson(String str) {
|
|
|
+ if (str == null) return "";
|
|
|
+ StringBuilder sb = new StringBuilder();
|
|
|
+ for (char c : str.toCharArray()) {
|
|
|
+ switch (c) {
|
|
|
+ case '\"': sb.append("\\\""); break;
|
|
|
+ case '\\': sb.append("\\\\"); break;
|
|
|
+ case '/': sb.append("\\/"); break;
|
|
|
+ case '\b': sb.append("\\b"); break;
|
|
|
+ case '\f': sb.append("\\f"); break;
|
|
|
+ case '\n': sb.append("\\n"); break;
|
|
|
+ case '\r': sb.append("\\r"); break;
|
|
|
+ case '\t': sb.append("\\t"); break;
|
|
|
+ default: sb.append(c); break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return sb.toString();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 正则解析JSON字符串中的字符串值,兼容空格/换行
|
|
|
+ */
|
|
|
+ private String getJsonString(String json, String key) {
|
|
|
+ if (json == null || key == null) return null;
|
|
|
+ Pattern pattern = Pattern.compile("\"" + key + "\"\\s*:\\s*\"([^\"]*)\"");
|
|
|
+ Matcher matcher = pattern.matcher(json);
|
|
|
+ return matcher.find() ? matcher.group(1) : null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 正则解析JSON字符串中的数字值
|
|
|
+ */
|
|
|
+ private int getJsonInt(String json, String key) {
|
|
|
+ if (json == null || key == null) return 0;
|
|
|
+ Pattern pattern = Pattern.compile("\"" + key + "\"\\s*:\\s*(-?\\d+)");
|
|
|
+ Matcher matcher = pattern.matcher(json);
|
|
|
+ if (matcher.find()) {
|
|
|
+ try {
|
|
|
+ return Integer.parseInt(matcher.group(1));
|
|
|
+ } catch (NumberFormatException e) {
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return 0;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析嵌套JSON中的字符串值
|
|
|
+ */
|
|
|
+ private String getNestedJsonString(String json, String parentKey, String childKey) {
|
|
|
+ if (json == null || parentKey == null || childKey == null) return null;
|
|
|
+ Pattern parentPattern = Pattern.compile("\"" + parentKey + "\"\\s*:\\s*\\{([^}]*)\\}");
|
|
|
+ Matcher parentMatcher = parentPattern.matcher(json);
|
|
|
+ if (parentMatcher.find()) {
|
|
|
+ String parentContent = parentMatcher.group(1);
|
|
|
+ return getJsonString("{" + parentContent + "}", childKey);
|
|
|
}
|
|
|
return null;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 新增:缺失的标签转换方法,解决运行报错
|
|
|
+ */
|
|
|
private String getLabelText(String label) {
|
|
|
- Map<String, String> labelMap = new HashMap<>();
|
|
|
- labelMap.put("100", "违规内容");
|
|
|
- labelMap.put("10001", "广告内容");
|
|
|
- labelMap.put("20001", "时政内容");
|
|
|
- labelMap.put("20002", "色情内容");
|
|
|
- labelMap.put("20003", "辱骂内容");
|
|
|
- labelMap.put("20006", "违法犯罪内容");
|
|
|
- labelMap.put("20008", "欺诈内容");
|
|
|
- labelMap.put("20012", "低俗内容");
|
|
|
- labelMap.put("20013", "版权内容");
|
|
|
- labelMap.put("21000", "其他违规内容");
|
|
|
- return labelMap.getOrDefault(label, "违规内容");
|
|
|
+ if (label == null) return "违规内容";
|
|
|
+ switch (label) {
|
|
|
+ case "100": return "违规内容";
|
|
|
+ case "10001": return "广告推广内容";
|
|
|
+ case "20001": return "时政敏感内容";
|
|
|
+ case "20002": return "色情低俗内容";
|
|
|
+ case "20003": return "辱骂攻击内容";
|
|
|
+ case "20006": return "违法犯罪内容";
|
|
|
+ case "20008": return "欺诈诱导内容";
|
|
|
+ case "20012": return "低俗恶心内容";
|
|
|
+ case "20013": return "侵权版权内容";
|
|
|
+ case "21000": return "其他违规内容";
|
|
|
+ default: return "违规内容";
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
- private Map<String, Object> createSafeResult() {
|
|
|
- Map<String, Object> result = new HashMap<>();
|
|
|
- result.put("safe", true);
|
|
|
- result.put("message", "");
|
|
|
- return result;
|
|
|
+ /**
|
|
|
+ * 判断是否为token过期错误码
|
|
|
+ */
|
|
|
+ private boolean isTokenExpireCode(int errcode) {
|
|
|
+ return errcode == WX_ERRCODE_TOKEN_EXPIRE_1 || errcode == WX_ERRCODE_TOKEN_EXPIRE_2;
|
|
|
}
|
|
|
|
|
|
- private Map<String, Object> createUnsafeResult(String message) {
|
|
|
- Map<String, Object> result = new HashMap<>();
|
|
|
- result.put("safe", false);
|
|
|
- result.put("message", message);
|
|
|
- return result;
|
|
|
+ /**
|
|
|
+ * 清空token缓存
|
|
|
+ */
|
|
|
+ private void clearTokenCache() {
|
|
|
+ cachedAccessToken = null;
|
|
|
+ tokenExpireTime = 0;
|
|
|
+ logger.info("检测到access_token过期,已清空缓存");
|
|
|
}
|
|
|
}
|