Browse Source

对接拼团数据1

master
wangfukang 2 months ago
parent
commit
8cd00a3afb
  1. 27
      hiver-admin/src/main/resources/application.yml
  2. 16
      hiver-admin/test-output/test-report.html
  3. 2
      hiver-modules/hiver-file/src/main/java/cc/hiver/file/controller/UploadController.java
  4. 6
      hiver-modules/hiver-mall/pom.xml
  5. 2
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/constant/IeConstants.java
  6. 36
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeAdminController.java
  7. 233
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeController.java
  8. 13
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeMomentDTO.java
  9. 2
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeReportDTO.java
  10. 2
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeStatusDTO.java
  11. 49
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeMoment.java
  12. 12
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRecord.java
  13. 4
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeReport.java
  14. 6
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRoomMessage.java
  15. 9
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mapper/IeMomentMapper.java
  16. 33
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatEventProducer.java
  17. 97
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatMqConfig.java
  18. 31
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeMediaAuditConsumer.java
  19. 27
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeAliyunModerationService.java
  20. 8
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeChatService.java
  21. 24
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeMomentService.java
  22. 2
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeRedisService.java
  23. 4
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeSecurityAuditService.java
  24. 10
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeWechatSecurityService.java
  25. 333
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeAliyunModerationServiceImpl.java
  26. 588
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeChatServiceImpl.java
  27. 150
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMatchServiceImpl.java
  28. 336
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMomentServiceImpl.java
  29. 20
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeQuizServiceImpl.java
  30. 18
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeRedisServiceImpl.java
  31. 54
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeSecurityAuditServiceImpl.java
  32. 151
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeWechatSecurityServiceImpl.java
  33. 66
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeAliyunModerationResult.java
  34. 3
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeMessageAckVO.java
  35. 11
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMediaSecResult.java
  36. 14
      hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMsgSecResult.java
  37. 16
      hiver-modules/hiver-mall/src/main/resources/db/ie_moment.sql
  38. 9
      hiver-modules/hiver-mall/src/main/resources/db/ie_report_admin_fields.sql

27
hiver-admin/src/main/resources/application.yml

@ -4,14 +4,22 @@ jasypt:
password: hiver
ie:
wechat:
media-check:
# 微信小程序后台“服务器配置”的 Token,生产建议用环境变量注入
callback-token: qwaszxcdfertgfdertyhjyijm2145632
# 微信小程序后台“服务器配置”的 EncodingAESKey,安全模式必填
encoding-aes-key: 7syLspCxZ5CXsxuvUMI66j17ue0Orq4mzuHMliDJoKp
# 默认复用 wechatpay.appid,也可单独覆盖
appid: wx21a1caccef085db7
aliyun:
green:
# 阿里云内容安全增强版(图片/语音/视频审核),控制台开通后填入 RAM 子账号 AK
access-key-id: LTAI5tExTVT1eJoRcC1ea1qV
access-key-secret: 2mCarE5SlUVhK3Fe6zzSHc5lpuyy0l
# 服务器在华北2(北京)地域时可走内网 endpoint:green-cip-vpc.cn-beijing.aliyuncs.com
region: cn-beijing
endpoint: green-cip-vpc.cn-beijing.aliyuncs.com
# 控制台规则配置的 serviceCode1
text-service: chat_detection
image-service: baselineCheck
video-service: videoDetection
# 注意:语音审核 service 是下划线格式(视频是驼峰,阿里云两个产品命名不一致)
voice-service: audio_media_detection
# 拦截等级:high=只拦高风险(确认违规),medium(疑似)及以下放行;改成 medium 则中高风险都拦
block-level: high
server:
port: 8888
@ -239,7 +247,7 @@ hiver:
logRecord:
es: false
# 文件大小上传配置 单位MB
maxUploadFile: 5
maxUploadFile: 100
# 腾讯位置服务key配置 腾讯位置服务官网注册添加key
qqlbs:
key: 你的腾讯位置服务key
@ -427,7 +435,6 @@ ignored:
- /hiver/user/officialAccount
- /hiver/social/wechat/echo
- /hiver/user/getUserInfo
- /hiver/app/ie/wechat/media-check-callback
- /hiver/app/customer/updateUserId
- /hiver/api/wechat/pay/notify
- /hiver/api/wechat/pay/

16
hiver-admin/test-output/test-report.html

@ -35,7 +35,7 @@
<a href="#"><span class="badge badge-primary">Hiver</span></a>
</li>
<li class="m-r-10">
<a href="#"><span class="badge badge-primary">六月 11, 2026 19:44:28</span></a>
<a href="#"><span class="badge badge-primary">六月 13, 2026 16:59:20</span></a>
</li>
</ul>
</div>
@ -84,7 +84,7 @@
<div class="test-detail">
<span class="meta text-white badge badge-sm"></span>
<p class="name">passTest</p>
<p class="text-sm"><span>19:44:28 下午</span> / <span>0.018 secs</span></p>
<p class="text-sm"><span>16:59:20 下午</span> / <span>0.022 secs</span></p>
</div>
<div class="test-contents d-none">
<div class="detail-head">
@ -92,9 +92,9 @@
<div class="info">
<div class='float-right'><span class='badge badge-default'>#test-id=1</span></div>
<h5 class="test-status text-pass">passTest</h5>
<span class='badge badge-success'>06.11.2026 19:44:28</span>
<span class='badge badge-danger'>06.11.2026 19:44:28</span>
<span class='badge badge-default'>0.018 secs</span>
<span class='badge badge-success'>06.13.2026 16:59:20</span>
<span class='badge badge-danger'>06.13.2026 16:59:20</span>
<span class='badge badge-default'>0.022 secs</span>
</div>
<div class="m-t-10 m-l-5"></div>
</div>
@ -104,7 +104,7 @@
<tbody>
<tr class="event-row">
<td><span class="badge log pass-bg">Pass</span></td>
<td>19:44:28</td>
<td>16:59:20</td>
<td>
Test passed
</td>
@ -128,13 +128,13 @@
<div class="col-md-3">
<div class="card"><div class="card-body">
<p class="m-b-0">Started</p>
<h3>六月 11, 2026 19:44:28</h3>
<h3>六月 13, 2026 16:59:20</h3>
</div></div>
</div>
<div class="col-md-3">
<div class="card"><div class="card-body">
<p class="m-b-0">Ended</p>
<h3>六月 11, 2026 19:44:28</h3>
<h3>六月 13, 2026 16:59:20</h3>
</div></div>
</div>
<div class="col-md-3">

2
hiver-modules/hiver-file/src/main/java/cc/hiver/file/controller/UploadController.java

@ -43,7 +43,7 @@ import java.io.InputStream;
@RequestMapping("/hiver/upload")
@Transactional
public class UploadController {
@Value("15")
@Value("${hiver.maxUploadFile:50}")
private Long maxUploadFile;
@Autowired

6
hiver-modules/hiver-mall/pom.xml

@ -44,6 +44,12 @@
<artifactId>sensitive-word</artifactId>
<version>0.26.0</version>
</dependency>
<!-- 阿里云内容安全增强版(图片/语音/视频审核) -->
<dependency>
<groupId>com.aliyun</groupId>
<artifactId>green20220302</artifactId>
<version>3.3.3</version>
</dependency>
</dependencies>
</project>

2
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/constant/IeConstants.java

@ -3,7 +3,7 @@ package cc.hiver.mall.ie.constant;
public final class IeConstants {
public static final int DEFAULT_ROOM_MINUTES = 15;
public static final int DEFAULT_DAILY_QUOTA = 3;
public static final int DEFAULT_DAILY_QUOTA = 20;
public static final int ROOM_STATUS_ACTIVE = 0;
public static final int ROOM_STATUS_NATURAL_END = 1;

36
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeAdminController.java

@ -1,27 +1,61 @@
package cc.hiver.mall.ie.controller;
import cc.hiver.core.common.utils.ResultUtil;
import cc.hiver.core.common.utils.SecurityUtil;
import cc.hiver.core.common.vo.Result;
import cc.hiver.core.entity.User;
import cc.hiver.mall.ie.entity.IeReport;
import cc.hiver.mall.ie.service.IeChatService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@RestController
@Api(tags = "i/e后台维护接口")
@RequestMapping("/hiver/admin/ie")
@RequestMapping({"/hiver/admin/ie", "/hiver/app/ie/admin"})
public class IeAdminController {
@Autowired
private IeChatService chatService;
@Autowired
private SecurityUtil securityUtil;
@RequestMapping(value = "/rooms/finishExpired", method = RequestMethod.POST)
@ApiOperation("批量结束已过期陪伴房间")
public Result<Object> finishExpiredRooms(Integer limit) {
chatService.finishExpiredRooms(limit == null ? 100 : limit);
return ResultUtil.success("处理完成");
}
@RequestMapping(value = "/reports/page", method = RequestMethod.GET)
@ApiOperation("i/e投诉举报分页")
public Result<Object> reportPage(@RequestParam(required = false) String regionId,
@RequestParam(required = false) Integer status,
@RequestParam(required = false, defaultValue = "1") Integer pageNumber,
@RequestParam(required = false, defaultValue = "10") Integer pageSize) {
return new ResultUtil<>().setData(chatService.adminPageReports(regionId, status, pageNumber, pageSize));
}
@RequestMapping(value = "/reports/handle", method = RequestMethod.POST)
@ApiOperation("处理i/e投诉举报")
public Result<IeReport> handleReport(@RequestParam Long reportId,
@RequestParam(required = false, defaultValue = "1") Integer status,
@RequestParam(required = false) String handleResult,
@RequestParam(required = false, defaultValue = "false") Boolean blockIe) {
User user = securityUtil.getCurrUser();
Long handlerId = user == null ? null : Long.valueOf(user.getId());
return new ResultUtil<IeReport>().setData(chatService.handleReport(handlerId, reportId, status, handleResult, blockIe));
}
@RequestMapping(value = "/reports/delete", method = RequestMethod.POST)
@ApiOperation("删除i/e投诉举报")
public Result<Object> deleteReport(@RequestParam Long reportId) {
chatService.deleteReport(reportId);
return ResultUtil.success("删除成功");
}
}

233
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeController.java

@ -12,25 +12,14 @@ import cc.hiver.mall.ie.vo.IeMatchVO;
import cc.hiver.mall.ie.vo.IeMessageAckVO;
import cc.hiver.mall.ie.vo.IeUserProfileVO;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.util.StreamUtils;
import org.springframework.web.bind.annotation.*;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.servlet.http.HttpServletRequest;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.*;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.List;
import java.util.Map;
@RestController
@Slf4j
@ -56,16 +45,8 @@ public class IeController {
@Autowired
private IeRedisService redisService;
@Value("${ie.wechat.media-check.callback-token:}")
private String mediaCheckCallbackToken;
@Value("${ie.wechat.media-check.encoding-aes-key:}")
private String mediaCheckEncodingAesKey;
@Value("${ie.wechat.media-check.appid:${wechatpay.appid:}}")
private String mediaCheckAppId;
private final ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private IeMomentService momentService;
@RequestMapping(value = "/home", method = RequestMethod.GET)
@ApiOperation("首页状态")
@ -111,55 +92,6 @@ public class IeController {
return ResultUtil.success("更新成功");
}
@RequestMapping(value = "/wechat/media-check-callback", method = RequestMethod.GET)
@ApiOperation("微信媒体内容安全回调URL验证")
public String verifyMediaCheckCallback(@RequestParam String signature,
@RequestParam String timestamp,
@RequestParam String nonce,
@RequestParam String echostr) {
boolean verified = verifyWechatSignature(signature, timestamp, nonce);
log.info("微信媒体审核回调URL验证 verified={}, timestamp={}, nonce={}, signature={}", verified, timestamp, nonce, signature);
if (!verified) {
return "";
}
return echostr;
}
@RequestMapping(value = "/wechat/media-check-callback", method = RequestMethod.POST)
@ApiOperation("微信媒体内容安全异步回调")
public String mediaCheckCallback(HttpServletRequest request,
@RequestParam(required = false) String signature,
@RequestParam(required = false) String timestamp,
@RequestParam(required = false) String nonce,
@RequestParam(required = false, name = "msg_signature") String msgSignature) throws Exception {
String rawPayload = StreamUtils.copyToString(request.getInputStream(), StandardCharsets.UTF_8);
boolean encrypted = parseMediaCallbackString(rawPayload, "Encrypt") != null;
log.info("微信媒体审核POST回调收到 encrypted={}, timestamp={}, nonce={}, signature={}, msgSignature={}, rawLength={}",
encrypted, timestamp, nonce, signature, msgSignature, rawPayload == null ? 0 : rawPayload.length());
String payload = parseWechatCallbackPayload(rawPayload, signature, timestamp, nonce, msgSignature);
if (payload == null) {
log.warn("微信媒体审核POST回调签名校验或解密失败 encrypted={}, timestamp={}, nonce={}", encrypted, timestamp, nonce);
return "success";
}
String traceId = firstNotBlank(parseMediaCallbackString(payload, "trace_id"),
parseMediaCallbackString(payload, "TraceId"),
parseMediaCallbackString(payload, "traceId"));
String riskyText = firstNotBlank(parseMediaCallbackString(payload, "isrisky"),
parseMediaCallbackString(payload, "IsRisky"));
String statusText = firstNotBlank(parseMediaCallbackString(payload, "status_code"),
parseMediaCallbackString(payload, "StatusCode"));
if (statusText == null) {
statusText = firstNotBlank(parseMediaCallbackString(payload, "errcode"),
parseMediaCallbackString(payload, "ErrCode"));
}
boolean risky = "1".equals(riskyText);
Integer statusCode = parseInteger(statusText);
log.info("微信媒体审核POST回调解析成功 traceId={}, risky={}, statusCode={}, payloadLength={}",
traceId, risky, statusCode, payload.length());
chatService.handleMediaAuditCallback(traceId, risky, statusCode, payload);
return "success";
}
@RequestMapping(value = "/match/start", method = RequestMethod.POST)
@ApiOperation("发起随机匹配")
public Result<IeMatchVO> startMatch(@RequestBody IeMatchStartDTO dto) {
@ -334,143 +266,30 @@ public class IeController {
return new ResultUtil<IPage<IeBlock>>().setData(chatService.pageBlocks(currentUserId(), pageNumber, pageSize));
}
@RequestMapping(value = "/moments", method = RequestMethod.POST)
@ApiOperation("发布个人空间动态")
public Result<IeMoment> publishMoment(@RequestBody IeMomentDTO dto) {
return new ResultUtil<IeMoment>().setData(momentService.publish(currentUserId(), dto));
}
@RequestMapping(value = "/moments/page", method = RequestMethod.GET)
@ApiOperation("分页查询个人空间动态(不传userId看自己;roomId为聊天入口凭证)")
public Result<IPage<IeMoment>> pageMoments(@RequestParam(required = false) Long userId,
@RequestParam(required = false) Long roomId,
@RequestParam(required = false, defaultValue = "1") Integer pageNumber,
@RequestParam(required = false, defaultValue = "10") Integer pageSize) {
return new ResultUtil<IPage<IeMoment>>().setData(momentService.pageMoments(currentUserId(), userId, roomId, pageNumber, pageSize));
}
@RequestMapping(value = "/moments/{momentId}", method = RequestMethod.DELETE)
@ApiOperation("删除自己的动态")
public Result<Object> deleteMoment(@PathVariable Long momentId) {
momentService.delete(currentUserId(), momentId);
return ResultUtil.success("删除成功");
}
private Long currentUserId() {
User user = securityUtil.getCurrUser();
return Long.valueOf(user.getId());
}
private boolean verifyWechatSignature(String signature, String timestamp, String nonce) {
if (mediaCheckCallbackToken == null || mediaCheckCallbackToken.trim().length() == 0) {
return false;
}
String localSignature = sha1Sorted(mediaCheckCallbackToken, timestamp, nonce);
return localSignature != null && localSignature.equals(signature);
}
private boolean verifyWechatMsgSignature(String msgSignature, String timestamp, String nonce, String encrypt) {
if (msgSignature == null || msgSignature.trim().length() == 0 || encrypt == null) {
return false;
}
String localSignature = sha1Sorted(mediaCheckCallbackToken, timestamp, nonce, encrypt);
return localSignature != null && localSignature.equals(msgSignature);
}
private String parseWechatCallbackPayload(String rawPayload, String signature, String timestamp, String nonce, String msgSignature) {
String encrypt = parseMediaCallbackString(rawPayload, "Encrypt");
if (encrypt != null && encrypt.length() > 0) {
if (!verifyWechatMsgSignature(msgSignature, timestamp, nonce, encrypt)) {
return null;
}
return decryptWechatPayload(encrypt);
}
if (!verifyWechatSignature(signature, timestamp, nonce)) {
return null;
}
return rawPayload;
}
private String decryptWechatPayload(String encrypt) {
try {
if (mediaCheckEncodingAesKey == null || mediaCheckEncodingAesKey.length() != 43) {
return null;
}
byte[] aesKey = Base64.getDecoder().decode(mediaCheckEncodingAesKey + "=");
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);
byte[] decrypted = cipher.doFinal(Base64.getDecoder().decode(encrypt));
byte[] plain = pkcs7Unpad(decrypted);
if (plain.length < 20) {
return null;
}
int xmlLength = ((plain[16] & 0xff) << 24) | ((plain[17] & 0xff) << 16) | ((plain[18] & 0xff) << 8) | (plain[19] & 0xff);
if (xmlLength < 0 || 20 + xmlLength > plain.length) {
return null;
}
String xml = new String(plain, 20, xmlLength, StandardCharsets.UTF_8);
String appId = new String(plain, 20 + xmlLength, plain.length - 20 - xmlLength, StandardCharsets.UTF_8);
if (mediaCheckAppId != null && mediaCheckAppId.length() > 0 && !mediaCheckAppId.equals(appId)) {
return null;
}
return xml;
} catch (Exception e) {
return null;
}
}
private byte[] pkcs7Unpad(byte[] data) {
if (data == null || data.length == 0) {
return new byte[0];
}
int pad = data[data.length - 1] & 0xff;
if (pad < 1 || pad > 32 || pad > data.length) {
return data;
}
return Arrays.copyOf(data, data.length - pad);
}
private String sha1Sorted(String... parts) {
try {
List<String> values = new ArrayList<>();
for (String part : parts) {
values.add(part == null ? "" : part);
}
values.sort(String::compareTo);
StringBuilder plain = new StringBuilder();
for (String value : values) {
plain.append(value);
}
MessageDigest digest = MessageDigest.getInstance("SHA-1");
byte[] bytes = digest.digest(plain.toString().getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder();
for (byte b : bytes) {
String item = Integer.toHexString(b & 0xff);
if (item.length() == 1) {
hex.append('0');
}
hex.append(item);
}
return hex.toString();
} catch (Exception e) {
return null;
}
}
private String parseMediaCallbackString(String payload, String field) {
if (payload == null || payload.trim().length() == 0) {
return null;
}
try {
JsonNode root = objectMapper.readTree(payload);
JsonNode node = root.path(field);
if (!node.isMissingNode()) {
return node.asText();
}
} catch (Exception ignored) {
}
Pattern pattern = Pattern.compile("<" + field + ">(?:<!\\[CDATA\\[(.*?)]]>|(.*?))</" + field + ">", Pattern.DOTALL);
Matcher matcher = pattern.matcher(payload);
if (!matcher.find()) {
return null;
}
return matcher.group(1) != null ? matcher.group(1) : matcher.group(2);
}
private Integer parseInteger(String value) {
try {
return value == null ? null : Integer.valueOf(value.trim());
} catch (Exception e) {
return null;
}
}
private String firstNotBlank(String... values) {
for (String value : values) {
if (value != null && value.trim().length() > 0) {
return value;
}
}
return null;
}
}

13
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeMomentDTO.java

@ -0,0 +1,13 @@
package cc.hiver.mall.ie.dto;
import lombok.Data;
import java.util.List;
@Data
public class IeMomentDTO {
private String content;
private List<String> images;
private String videoUrl;
private String videoPoster;
}

2
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeReportDTO.java

@ -7,6 +7,8 @@ public class IeReportDTO {
private Long roomId;
private Long messageId;
private Long reportedUserId;
private String regionId;
private String regionName;
private String reasonType;
private String reasonText;
}

2
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeStatusDTO.java

@ -7,6 +7,8 @@ import java.util.List;
@Data
public class IeStatusDTO {
private String mode;
private String targetMode;
private String targetGender;
private String mood;
private String statusText;
private Double longitude;

49
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeMoment.java

@ -0,0 +1,49 @@
package cc.hiver.mall.ie.entity;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.util.List;
/**
* i/e 个人空间动态类朋友圈
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("ie_moment")
public class IeMoment extends IeBaseEntity {
public static final int STATUS_AUDITING = 0;
public static final int STATUS_PUBLISHED = 1;
public static final int STATUS_BLOCKED = 2;
private Long userId;
private String content;
/** 图片URL JSON数组,最多5张 */
private String images;
private String videoUrl;
private String videoPoster;
/** 0视频审核中 1已发布 2审核未通过 */
private Integer status;
private String auditTaskId;
@TableField(exist = false)
private List<String> imageList;
@TableField(exist = false)
private String anonymousName;
@TableField(exist = false)
private String avatarText;
@TableField(exist = false)
private String avatarUrl;
@TableField(exist = false)
private String currentMode;
@TableField(exist = false)
private Boolean mine;
}

12
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRecord.java

@ -38,4 +38,16 @@ public class IeRecord extends IeBaseEntity {
@TableField(exist = false)
private String currentMode;
@TableField(exist = false)
private Integer streakDays;
@TableField(exist = false)
private Boolean streakBothToday;
@TableField(exist = false)
private Boolean streakExpiring;
@TableField(exist = false)
private Integer streakRewardInterval;
}

4
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeReport.java

@ -12,8 +12,12 @@ public class IeReport extends IeBaseEntity {
private Long reportedUserId;
private Long roomId;
private Long messageId;
private String regionId;
private String regionName;
private String reasonType;
private String reasonText;
private Integer status;
private String handleResult;
private Integer blockedIe;
private Long handlerId;
}

6
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRoomMessage.java

@ -35,4 +35,10 @@ public class IeRoomMessage extends IeBaseEntity {
@TableField(exist = false)
private Boolean mine;
@TableField(exist = false)
private String auditTip;
@TableField(exist = false)
private String poster;
}

9
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mapper/IeMomentMapper.java

@ -0,0 +1,9 @@
package cc.hiver.mall.ie.mapper;
import cc.hiver.mall.ie.entity.IeMoment;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import org.apache.ibatis.annotations.Mapper;
@Mapper
public interface IeMomentMapper extends BaseMapper<IeMoment> {
}

33
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatEventProducer.java

@ -27,6 +27,39 @@ public class IeChatEventProducer {
});
}
public void sendMediaAuditPoll(Long messageId, long delayMillis) {
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_DELAY_EXCHANGE, IeChatMqConfig.IE_MEDIA_AUDIT_POLL_DELAY_ROUTING, String.valueOf(messageId), message -> {
message.getMessageProperties().setExpiration(String.valueOf(Math.max(delayMillis, 1000L)));
return message;
});
}
/** 提交审核失败后的延迟重试 */
public void sendMediaAuditRetry(Long messageId, long delayMillis) {
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_DELAY_EXCHANGE, IeChatMqConfig.IE_MEDIA_AUDIT_SUBMIT_DELAY_ROUTING, String.valueOf(messageId), message -> {
message.getMessageProperties().setExpiration(String.valueOf(Math.max(delayMillis, 1000L)));
return message;
});
}
public void sendMomentAuditRetry(Long momentId, long delayMillis) {
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_DELAY_EXCHANGE, IeChatMqConfig.IE_MOMENT_AUDIT_SUBMIT_DELAY_ROUTING, String.valueOf(momentId), message -> {
message.getMessageProperties().setExpiration(String.valueOf(Math.max(delayMillis, 1000L)));
return message;
});
}
public void sendMomentAudit(Long momentId) {
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_EXCHANGE, IeChatMqConfig.IE_MOMENT_AUDIT_ROUTING, String.valueOf(momentId));
}
public void sendMomentAuditPoll(Long momentId, long delayMillis) {
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_DELAY_EXCHANGE, IeChatMqConfig.IE_MOMENT_AUDIT_POLL_DELAY_ROUTING, String.valueOf(momentId), message -> {
message.getMessageProperties().setExpiration(String.valueOf(Math.max(delayMillis, 1000L)));
return message;
});
}
public void sendRoomExpire(Long roomId, long delayMillis) {
rabbitTemplate.convertAndSend(IeChatMqConfig.IE_CHAT_DELAY_EXCHANGE, IeChatMqConfig.IE_CHAT_DELAY_ROUTING, String.valueOf(roomId), message -> {
message.getMessageProperties().setExpiration(String.valueOf(Math.max(delayMillis, 1000L)));

97
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatMqConfig.java

@ -24,6 +24,20 @@ public class IeChatMqConfig {
public static final String IE_MEDIA_AUDIT_TIMEOUT_DELAY_ROUTING = "ie.media.audit.timeout.delay.routing";
public static final String IE_MEDIA_AUDIT_TIMEOUT_QUEUE = "ie.media.audit.timeout.queue";
public static final String IE_MEDIA_AUDIT_TIMEOUT_ROUTING = "ie.media.audit.timeout.routing";
public static final String IE_MEDIA_AUDIT_POLL_DELAY_QUEUE = "ie.media.audit.poll.delay.queue";
public static final String IE_MEDIA_AUDIT_POLL_DELAY_ROUTING = "ie.media.audit.poll.delay.routing";
public static final String IE_MEDIA_AUDIT_POLL_QUEUE = "ie.media.audit.poll.queue";
public static final String IE_MEDIA_AUDIT_POLL_ROUTING = "ie.media.audit.poll.routing";
public static final String IE_MEDIA_AUDIT_SUBMIT_DELAY_QUEUE = "ie.media.audit.submit.delay.queue";
public static final String IE_MEDIA_AUDIT_SUBMIT_DELAY_ROUTING = "ie.media.audit.submit.delay.routing";
public static final String IE_MOMENT_AUDIT_SUBMIT_DELAY_QUEUE = "ie.moment.audit.submit.delay.queue";
public static final String IE_MOMENT_AUDIT_SUBMIT_DELAY_ROUTING = "ie.moment.audit.submit.delay.routing";
public static final String IE_MOMENT_AUDIT_QUEUE = "ie.moment.audit.queue";
public static final String IE_MOMENT_AUDIT_ROUTING = "ie.moment.audit.routing";
public static final String IE_MOMENT_AUDIT_POLL_DELAY_QUEUE = "ie.moment.audit.poll.delay.queue";
public static final String IE_MOMENT_AUDIT_POLL_DELAY_ROUTING = "ie.moment.audit.poll.delay.routing";
public static final String IE_MOMENT_AUDIT_POLL_QUEUE = "ie.moment.audit.poll.queue";
public static final String IE_MOMENT_AUDIT_POLL_ROUTING = "ie.moment.audit.poll.routing";
@Bean
public DirectExchange ieChatExchange() {
@ -73,6 +87,89 @@ public class IeChatMqConfig {
return BindingBuilder.bind(ieMediaAuditTimeoutQueue()).to(ieChatExchange()).with(IE_MEDIA_AUDIT_TIMEOUT_ROUTING);
}
@Bean
public Queue ieMediaAuditPollDelayQueue() {
java.util.Map<String, Object> args = new java.util.HashMap<>();
args.put("x-dead-letter-exchange", IE_CHAT_EXCHANGE);
args.put("x-dead-letter-routing-key", IE_MEDIA_AUDIT_POLL_ROUTING);
return new Queue(IE_MEDIA_AUDIT_POLL_DELAY_QUEUE, true, false, false, args);
}
@Bean
public Queue ieMediaAuditPollQueue() {
return new Queue(IE_MEDIA_AUDIT_POLL_QUEUE, true);
}
@Bean
public Binding bindingIeMediaAuditPollDelayQueue() {
return BindingBuilder.bind(ieMediaAuditPollDelayQueue()).to(ieChatDelayExchange()).with(IE_MEDIA_AUDIT_POLL_DELAY_ROUTING);
}
@Bean
public Binding bindingIeMediaAuditPollQueue() {
return BindingBuilder.bind(ieMediaAuditPollQueue()).to(ieChatExchange()).with(IE_MEDIA_AUDIT_POLL_ROUTING);
}
/** 提交审核失败的延迟重试队列:到期后死信回提交队列(同一队列内 TTL 一致,不会队头阻塞) */
@Bean
public Queue ieMediaAuditSubmitDelayQueue() {
java.util.Map<String, Object> args = new java.util.HashMap<>();
args.put("x-dead-letter-exchange", IE_CHAT_EXCHANGE);
args.put("x-dead-letter-routing-key", IE_MEDIA_AUDIT_ROUTING);
return new Queue(IE_MEDIA_AUDIT_SUBMIT_DELAY_QUEUE, true, false, false, args);
}
@Bean
public Binding bindingIeMediaAuditSubmitDelayQueue() {
return BindingBuilder.bind(ieMediaAuditSubmitDelayQueue()).to(ieChatDelayExchange()).with(IE_MEDIA_AUDIT_SUBMIT_DELAY_ROUTING);
}
@Bean
public Queue ieMomentAuditSubmitDelayQueue() {
java.util.Map<String, Object> args = new java.util.HashMap<>();
args.put("x-dead-letter-exchange", IE_CHAT_EXCHANGE);
args.put("x-dead-letter-routing-key", IE_MOMENT_AUDIT_ROUTING);
return new Queue(IE_MOMENT_AUDIT_SUBMIT_DELAY_QUEUE, true, false, false, args);
}
@Bean
public Binding bindingIeMomentAuditSubmitDelayQueue() {
return BindingBuilder.bind(ieMomentAuditSubmitDelayQueue()).to(ieChatDelayExchange()).with(IE_MOMENT_AUDIT_SUBMIT_DELAY_ROUTING);
}
@Bean
public Queue ieMomentAuditQueue() {
return new Queue(IE_MOMENT_AUDIT_QUEUE, true);
}
@Bean
public Binding bindingIeMomentAuditQueue() {
return BindingBuilder.bind(ieMomentAuditQueue()).to(ieChatExchange()).with(IE_MOMENT_AUDIT_ROUTING);
}
@Bean
public Queue ieMomentAuditPollDelayQueue() {
java.util.Map<String, Object> args = new java.util.HashMap<>();
args.put("x-dead-letter-exchange", IE_CHAT_EXCHANGE);
args.put("x-dead-letter-routing-key", IE_MOMENT_AUDIT_POLL_ROUTING);
return new Queue(IE_MOMENT_AUDIT_POLL_DELAY_QUEUE, true, false, false, args);
}
@Bean
public Queue ieMomentAuditPollQueue() {
return new Queue(IE_MOMENT_AUDIT_POLL_QUEUE, true);
}
@Bean
public Binding bindingIeMomentAuditPollDelayQueue() {
return BindingBuilder.bind(ieMomentAuditPollDelayQueue()).to(ieChatDelayExchange()).with(IE_MOMENT_AUDIT_POLL_DELAY_ROUTING);
}
@Bean
public Binding bindingIeMomentAuditPollQueue() {
return BindingBuilder.bind(ieMomentAuditPollQueue()).to(ieChatExchange()).with(IE_MOMENT_AUDIT_POLL_ROUTING);
}
@Bean
public DirectExchange ieChatDelayExchange() {
return new DirectExchange(IE_CHAT_DELAY_EXCHANGE, true, false);

31
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeMediaAuditConsumer.java

@ -1,6 +1,7 @@
package cc.hiver.mall.ie.mq;
import cc.hiver.mall.ie.service.IeChatService;
import cc.hiver.mall.ie.service.IeMomentService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.beans.factory.annotation.Autowired;
@ -13,6 +14,9 @@ public class IeMediaAuditConsumer {
@Autowired
private IeChatService chatService;
@Autowired
private IeMomentService momentService;
@RabbitListener(queues = IeChatMqConfig.IE_MEDIA_AUDIT_QUEUE, containerFactory = "ieMediaAuditContainerFactory")
public void handle(String messageId) {
try {
@ -22,6 +26,15 @@ public class IeMediaAuditConsumer {
}
}
@RabbitListener(queues = IeChatMqConfig.IE_MEDIA_AUDIT_POLL_QUEUE, containerFactory = "ieMediaAuditContainerFactory")
public void handlePoll(String messageId) {
try {
chatService.pollMediaAudit(Long.valueOf(messageId));
} catch (Exception e) {
log.warn("i/e媒体审核结果轮询失败 messageId={}", messageId, e);
}
}
@RabbitListener(queues = IeChatMqConfig.IE_MEDIA_AUDIT_TIMEOUT_QUEUE, containerFactory = "ieMediaAuditContainerFactory")
public void handleTimeout(String messageId) {
try {
@ -30,4 +43,22 @@ public class IeMediaAuditConsumer {
log.warn("i/e媒体审核超时处理失败 messageId={}", messageId, e);
}
}
@RabbitListener(queues = IeChatMqConfig.IE_MOMENT_AUDIT_QUEUE, containerFactory = "ieMediaAuditContainerFactory")
public void handleMomentAudit(String momentId) {
try {
momentService.submitVideoAudit(Long.valueOf(momentId));
} catch (Exception e) {
log.warn("i/e动态视频审核提交失败 momentId={}", momentId, e);
}
}
@RabbitListener(queues = IeChatMqConfig.IE_MOMENT_AUDIT_POLL_QUEUE, containerFactory = "ieMediaAuditContainerFactory")
public void handleMomentAuditPoll(String momentId) {
try {
momentService.pollVideoAudit(Long.valueOf(momentId));
} catch (Exception e) {
log.warn("i/e动态视频审核轮询失败 momentId={}", momentId, e);
}
}
}

27
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeAliyunModerationService.java

@ -0,0 +1,27 @@
package cc.hiver.mall.ie.service;
import cc.hiver.mall.ie.vo.IeAliyunModerationResult;
/**
* 阿里云内容安全增强版图片同步审核语音/视频提交异步任务后轮询结果
*/
public interface IeAliyunModerationService {
/** 文本同步审核(聊天场景),毫秒级返回结论 */
IeAliyunModerationResult checkText(String content);
/** 图片同步审核,几百毫秒内返回结论 */
IeAliyunModerationResult checkImage(String imageUrl);
/** 提交语音文件审核任务,返回 PENDING(taskId) 或 FAILED */
IeAliyunModerationResult submitVoice(String voiceUrl);
/** 提交视频文件审核任务(云端自动抽帧+音轨检测),返回 PENDING(taskId) 或 FAILED */
IeAliyunModerationResult submitVideo(String videoUrl);
/** 查询语音审核任务结果 */
IeAliyunModerationResult queryVoiceResult(String taskId);
/** 查询视频审核任务结果 */
IeAliyunModerationResult queryVideoResult(String taskId);
}

8
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeChatService.java

@ -52,11 +52,17 @@ public interface IeChatService {
IPage<IeReport> pageReports(Long userId, Integer pageNumber, Integer pageSize);
IPage<IeReport> adminPageReports(String regionId, Integer status, Integer pageNumber, Integer pageSize);
IeReport handleReport(Long handlerId, Long reportId, Integer status, String handleResult, Boolean blockIe);
void deleteReport(Long reportId);
IPage<IeBlock> pageBlocks(Long userId, Integer pageNumber, Integer pageSize);
void submitMediaAudit(Long messageId);
void handleMediaAuditCallback(String traceId, boolean risky, Integer statusCode, String payload);
void pollMediaAudit(Long messageId);
void timeoutMediaAudit(Long messageId);
}

24
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeMomentService.java

@ -0,0 +1,24 @@
package cc.hiver.mall.ie.service;
import cc.hiver.mall.ie.dto.IeMomentDTO;
import cc.hiver.mall.ie.entity.IeMoment;
import com.baomidou.mybatisplus.core.metadata.IPage;
/**
* i/e 个人空间动态发布文字+图片同步审视频先审后发分页查看删除
*/
public interface IeMomentService {
IeMoment publish(Long userId, IeMomentDTO dto);
/** roomId 可选:从聊天页进入时携带,凭房间成员关系即可查看对方空间 */
IPage<IeMoment> pageMoments(Long viewerId, Long targetUserId, Long roomId, Integer pageNumber, Integer pageSize);
void delete(Long userId, Long momentId);
/** MQ:提交视频审核任务 */
void submitVideoAudit(Long momentId);
/** MQ:轮询视频审核结果 */
void pollVideoAudit(Long momentId);
}

2
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeRedisService.java

@ -64,6 +64,8 @@ public interface IeRedisService {
boolean allowMessage(Long userId, Long roomId);
boolean allowMediaMessage(Long userId, Long roomId);
long increaseRisk(Long userId, long seconds);
void publishChatEvent(String eventJson);

4
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeSecurityAuditService.java

@ -4,8 +4,4 @@ import cc.hiver.mall.ie.vo.IeAuditResult;
public interface IeSecurityAuditService {
IeAuditResult audit(String bizType, Long bizId, Long userId, String content);
IeAuditResult audit(String bizType, Long bizId, Long userId, String openid, String content);
IeAuditResult auditMedia(String bizType, Long bizId, Long userId, String openid, String mediaUrl, Integer mediaType);
}

10
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeWechatSecurityService.java

@ -1,10 +0,0 @@
package cc.hiver.mall.ie.service;
import cc.hiver.mall.ie.vo.IeWechatMediaSecResult;
import cc.hiver.mall.ie.vo.IeWechatMsgSecResult;
public interface IeWechatSecurityService {
IeWechatMsgSecResult msgSecCheck(String openid, String content);
IeWechatMediaSecResult mediaCheckAsync(String openid, String mediaUrl, Integer mediaType);
}

333
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeAliyunModerationServiceImpl.java

@ -0,0 +1,333 @@
package cc.hiver.mall.ie.service.impl;
import cc.hiver.mall.ie.service.IeAliyunModerationService;
import cc.hiver.mall.ie.vo.IeAliyunModerationResult;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.json.JSONObject;
import com.aliyun.green20220302.Client;
import com.aliyun.green20220302.models.*;
import com.aliyun.teaopenapi.models.Config;
import com.aliyun.teautil.models.RuntimeOptions;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Slf4j
@Service
public class IeAliyunModerationServiceImpl implements IeAliyunModerationService {
/** 阿里云异步任务处理中的响应码 */
private static final int CODE_PROCESSING = 280;
private static final int CODE_OK = 200;
@Value("${ie.aliyun.green.access-key-id:}")
private String accessKeyId;
@Value("${ie.aliyun.green.access-key-secret:}")
private String accessKeySecret;
@Value("${ie.aliyun.green.endpoint:green-cip.cn-shanghai.aliyuncs.com}")
private String endpoint;
@Value("${ie.aliyun.green.region:cn-shanghai}")
private String region;
@Value("${ie.aliyun.green.text-service:chat_detection}")
private String textService;
@Value("${ie.aliyun.green.image-service:baselineCheck}")
private String imageService;
@Value("${ie.aliyun.green.video-service:videoDetection}")
private String videoService;
@Value("${ie.aliyun.green.voice-service:audio_media_detection}")
private String voiceService;
/** 拦截等级:达到该等级及以上才拦截。high=仅拦高风险(medium放行),medium=拦中高风险 */
@Value("${ie.aliyun.green.block-level:high}")
private String blockLevel;
private volatile Client client;
@Override
public IeAliyunModerationResult checkText(String content) {
if (CharSequenceUtil.isBlank(content)) {
return IeAliyunModerationResult.pass(null, "empty content");
}
IeAliyunModerationResult result = doCheckText(content);
if (result.isFailed()) {
// 网络抖动/限流等瞬时失败重试一次,避免正常内容被误判为审核失败
log.warn("阿里云文本审核首次失败,重试一次 detail={}", result.getDetail());
result = doCheckText(content);
}
return result;
}
private IeAliyunModerationResult doCheckText(String content) {
try {
JSONObject parameters = new JSONObject();
parameters.set("content", content);
TextModerationRequest request = new TextModerationRequest();
request.setService(textService);
request.setServiceParameters(parameters.toString());
TextModerationResponse response = getClient().textModerationWithOptions(request, runtime());
if (response == null || response.getBody() == null) {
return IeAliyunModerationResult.failed("empty text moderation response");
}
TextModerationResponseBody body = response.getBody();
if (body.getCode() == null || body.getCode() != CODE_OK || body.getData() == null) {
return IeAliyunModerationResult.failed("text moderation code=" + body.getCode() + ", msg=" + body.getMessage());
}
// 命中风险时 labels 非空(如 porn,abuse),无风险为空串
String labels = body.getData().getLabels();
if (CharSequenceUtil.isBlank(labels)) {
return IeAliyunModerationResult.pass(null, "text pass");
}
// 文本的风险等级在 reason JSON 里({"riskLevel":"high",...}),按等级阈值统一判定,
// 不再"命中任何标签就拦"(轻度辱骂等 low/medium 误伤太多)
String riskLevel = parseTextRiskLevel(body.getData().getReason());
return toFinalResult(riskLevel, "text labels=" + labels + ", riskLevel=" + riskLevel
+ ", reason=" + body.getData().getReason());
} catch (Exception e) {
log.warn("阿里云文本审核调用失败", e);
return IeAliyunModerationResult.failed("text moderation error: " + e.getMessage());
}
}
@Override
public IeAliyunModerationResult checkImage(String imageUrl) {
if (CharSequenceUtil.isBlank(imageUrl)) {
return IeAliyunModerationResult.failed("image url missing");
}
IeAliyunModerationResult result = doCheckImage(imageUrl);
if (result.isFailed()) {
log.warn("阿里云图片审核首次失败,重试一次 url={}, detail={}", imageUrl, result.getDetail());
result = doCheckImage(imageUrl);
}
return result;
}
private IeAliyunModerationResult doCheckImage(String imageUrl) {
try {
JSONObject parameters = new JSONObject();
parameters.set("imageUrl", imageUrl);
parameters.set("dataId", UUID.randomUUID().toString());
ImageModerationRequest request = new ImageModerationRequest();
request.setService(imageService);
request.setServiceParameters(parameters.toString());
ImageModerationResponse response = getClient().imageModerationWithOptions(request, runtime());
if (response == null || response.getBody() == null) {
return IeAliyunModerationResult.failed("empty image moderation response");
}
ImageModerationResponseBody body = response.getBody();
if (body.getCode() == null || body.getCode() != CODE_OK || body.getData() == null) {
return IeAliyunModerationResult.failed("image moderation code=" + body.getCode() + ", msg=" + body.getMsg());
}
String riskLevel = body.getData().getRiskLevel();
// 带上命中标签和置信度,方便在日志里直接定位误判原因
StringBuilder labels = new StringBuilder();
if (body.getData().getResult() != null) {
body.getData().getResult().forEach(item -> {
if (item.getLabel() != null && !"nonLabel".equals(item.getLabel())) {
if (labels.length() > 0) {
labels.append('|');
}
labels.append(item.getLabel()).append(':').append(item.getConfidence());
}
});
}
return toFinalResult(riskLevel, "image riskLevel=" + riskLevel + ", labels=" + labels + ", url=" + imageUrl);
} catch (Exception e) {
log.warn("阿里云图片审核调用失败 url={}", imageUrl, e);
return IeAliyunModerationResult.failed("image moderation error: " + e.getMessage());
}
}
@Override
public IeAliyunModerationResult submitVoice(String voiceUrl) {
if (CharSequenceUtil.isBlank(voiceUrl)) {
return IeAliyunModerationResult.failed("voice url missing");
}
try {
JSONObject parameters = new JSONObject();
parameters.set("url", voiceUrl);
parameters.set("dataId", UUID.randomUUID().toString());
VoiceModerationRequest request = new VoiceModerationRequest();
request.setService(voiceService);
request.setServiceParameters(parameters.toString());
VoiceModerationResponse response = getClient().voiceModerationWithOptions(request, runtime());
if (response == null || response.getBody() == null || response.getBody().getCode() == null
|| response.getBody().getCode() != CODE_OK || response.getBody().getData() == null
|| CharSequenceUtil.isBlank(response.getBody().getData().getTaskId())) {
return IeAliyunModerationResult.failed("voice submit failed: "
+ (response == null || response.getBody() == null ? "empty response" : response.getBody().getMessage()));
}
return IeAliyunModerationResult.pending(response.getBody().getData().getTaskId());
} catch (Exception e) {
log.warn("阿里云语音审核提交失败 url={}", voiceUrl, e);
return IeAliyunModerationResult.failed("voice submit error: " + e.getMessage());
}
}
@Override
public IeAliyunModerationResult submitVideo(String videoUrl) {
if (CharSequenceUtil.isBlank(videoUrl)) {
return IeAliyunModerationResult.failed("video url missing");
}
try {
JSONObject parameters = new JSONObject();
parameters.set("url", videoUrl);
parameters.set("dataId", UUID.randomUUID().toString());
VideoModerationRequest request = new VideoModerationRequest();
request.setService(videoService);
request.setServiceParameters(parameters.toString());
VideoModerationResponse response = getClient().videoModerationWithOptions(request, runtime());
if (response == null || response.getBody() == null || response.getBody().getCode() == null
|| response.getBody().getCode() != CODE_OK || response.getBody().getData() == null
|| CharSequenceUtil.isBlank(response.getBody().getData().getTaskId())) {
return IeAliyunModerationResult.failed("video submit failed: "
+ (response == null || response.getBody() == null ? "empty response" : response.getBody().getMessage()));
}
return IeAliyunModerationResult.pending(response.getBody().getData().getTaskId());
} catch (Exception e) {
log.warn("阿里云视频审核提交失败 url={}", videoUrl, e);
return IeAliyunModerationResult.failed("video submit error: " + e.getMessage());
}
}
@Override
public IeAliyunModerationResult queryVoiceResult(String taskId) {
if (CharSequenceUtil.isBlank(taskId)) {
return IeAliyunModerationResult.failed("voice taskId missing");
}
try {
JSONObject parameters = new JSONObject();
parameters.set("taskId", taskId);
VoiceModerationResultRequest request = new VoiceModerationResultRequest();
request.setService(voiceService);
request.setServiceParameters(parameters.toString());
VoiceModerationResultResponse response = getClient().voiceModerationResultWithOptions(request, runtime());
if (response == null || response.getBody() == null || response.getBody().getCode() == null) {
return IeAliyunModerationResult.failed("empty voice result response");
}
VoiceModerationResultResponseBody body = response.getBody();
if (body.getCode() == CODE_PROCESSING) {
return IeAliyunModerationResult.pending(taskId);
}
if (body.getCode() != CODE_OK || body.getData() == null) {
return IeAliyunModerationResult.failed("voice result code=" + body.getCode() + ", msg=" + body.getMessage());
}
String riskLevel = body.getData().getRiskLevel();
return toFinalResult(riskLevel, "voice riskLevel=" + riskLevel + ", taskId=" + taskId);
} catch (Exception e) {
log.warn("阿里云语音审核结果查询失败 taskId={}", taskId, e);
return IeAliyunModerationResult.failed("voice result error: " + e.getMessage());
}
}
@Override
public IeAliyunModerationResult queryVideoResult(String taskId) {
if (CharSequenceUtil.isBlank(taskId)) {
return IeAliyunModerationResult.failed("video taskId missing");
}
try {
JSONObject parameters = new JSONObject();
parameters.set("taskId", taskId);
VideoModerationResultRequest request = new VideoModerationResultRequest();
request.setService(videoService);
request.setServiceParameters(parameters.toString());
VideoModerationResultResponse response = getClient().videoModerationResultWithOptions(request, runtime());
if (response == null || response.getBody() == null || response.getBody().getCode() == null) {
return IeAliyunModerationResult.failed("empty video result response");
}
VideoModerationResultResponseBody body = response.getBody();
if (body.getCode() == CODE_PROCESSING) {
return IeAliyunModerationResult.pending(taskId);
}
if (body.getCode() != CODE_OK || body.getData() == null) {
return IeAliyunModerationResult.failed("video result code=" + body.getCode() + ", msg=" + body.getMessage());
}
String riskLevel = body.getData().getRiskLevel();
return toFinalResult(riskLevel, "video riskLevel=" + riskLevel + ", taskId=" + taskId);
} catch (Exception e) {
log.warn("阿里云视频审核结果查询失败 taskId={}", taskId, e);
return IeAliyunModerationResult.failed("video result error: " + e.getMessage());
}
}
/**
* 风险定级策略达到 blockLevel 及以上才拦截默认 high medium 疑似内容放行
* 放行的 medium 留日志便于人工复查具体标签的松紧还可以在阿里云控制台规则配置里调
*/
private IeAliyunModerationResult toFinalResult(String riskLevel, String detail) {
int threshold = levelRank(blockLevel);
if (threshold <= 0) {
threshold = 3;
}
if (levelRank(riskLevel) >= threshold) {
return IeAliyunModerationResult.block(riskLevel, detail);
}
if ("medium".equalsIgnoreCase(riskLevel)) {
log.info("阿里云审核中风险放行 {}", detail);
}
return IeAliyunModerationResult.pass(riskLevel, detail);
}
private int levelRank(String level) {
if ("high".equalsIgnoreCase(level)) {
return 3;
}
if ("medium".equalsIgnoreCase(level)) {
return 2;
}
if ("low".equalsIgnoreCase(level)) {
return 1;
}
return 0;
}
/** 文本审核的 reason 是 JSON 字符串,风险等级藏在里面;解析不到时按 high 保守处理 */
private String parseTextRiskLevel(String reason) {
if (CharSequenceUtil.isBlank(reason)) {
return "high";
}
try {
String parsed = cn.hutool.json.JSONUtil.parseObj(reason).getStr("riskLevel");
return CharSequenceUtil.isBlank(parsed) ? "high" : parsed;
} catch (Exception e) {
return "high";
}
}
private RuntimeOptions runtime() {
RuntimeOptions runtime = new RuntimeOptions();
// 小带宽/突发性能实例上网卡瞬时拥塞时 TCP 握手会变慢,超时给宽一些,配合 MQ 延迟重试兜底
runtime.readTimeout = 15000;
runtime.connectTimeout = 10000;
return runtime;
}
private Client getClient() throws Exception {
if (CharSequenceUtil.isBlank(accessKeyId) || CharSequenceUtil.isBlank(accessKeySecret)) {
throw new RuntimeException("阿里云内容安全AccessKey未配置");
}
if (client == null) {
synchronized (this) {
if (client == null) {
Config config = new Config()
.setAccessKeyId(accessKeyId)
.setAccessKeySecret(accessKeySecret);
config.setRegionId(region);
config.setEndpoint(endpoint);
config.setReadTimeout(15000);
config.setConnectTimeout(10000);
client = new Client(config);
}
}
}
return client;
}
}

588
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeChatServiceImpl.java

@ -1,14 +1,5 @@
package cc.hiver.mall.ie.service.impl;
import cc.hiver.core.common.constant.OssConstant;
import cc.hiver.core.common.constant.SettingConstant;
import cc.hiver.core.dao.UserDao;
import cc.hiver.core.entity.Setting;
import cc.hiver.core.entity.User;
import cc.hiver.core.service.SettingService;
import cc.hiver.core.vo.OssSetting;
import cc.hiver.file.manage.FileManageFactory;
import cc.hiver.file.service.FileService;
import cc.hiver.mall.ie.constant.IeChatEventType;
import cc.hiver.mall.ie.constant.IeConstants;
import cc.hiver.mall.ie.dto.IePresenceDTO;
@ -18,17 +9,16 @@ import cc.hiver.mall.ie.entity.*;
import cc.hiver.mall.ie.mapper.*;
import cc.hiver.mall.ie.mq.IeChatEventProducer;
import cc.hiver.mall.ie.service.*;
import cc.hiver.mall.ie.vo.IeAliyunModerationResult;
import cc.hiver.mall.ie.vo.IeAuditResult;
import cc.hiver.mall.ie.vo.IeChatEvent;
import cc.hiver.mall.ie.vo.IeMessageAckVO;
import cc.hiver.mall.ie.vo.IeWechatMediaSecResult;
import cc.hiver.mall.utils.ByteArrayMultipartFile;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.google.gson.Gson;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.messaging.simp.SimpMessagingTemplate;
@ -36,17 +26,11 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
@lombok.extern.slf4j.Slf4j
@Service
public class IeChatServiceImpl implements IeChatService {
@ -81,39 +65,30 @@ public class IeChatServiceImpl implements IeChatService {
private IeSecurityAuditService auditService;
@Autowired
private IeWechatSecurityService wechatSecurityService;
private IeAliyunModerationService aliyunModerationService;
@Autowired
private SimpMessagingTemplate messagingTemplate;
@Autowired
private UserDao userDao;
@Autowired
private IeChatEventProducer chatEventProducer;
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private FileManageFactory fileManageFactory;
@Autowired
private FileService fileService;
@Autowired
private SettingService settingService;
private static final String STREAK_REWARD_KEY = "ie:streak:reward:";
private static final int STREAK_LOOKBACK_DAYS = 60;
private static final int STREAK_REWARD_INTERVAL = 7;
private static final String MEDIA_AUDIT_TRACE_KEY = "ie:media_audit:trace:";
private static final String MEDIA_AUDIT_PENDING_KEY = "ie:media_audit:pending:";
private static final String MEDIA_AUDIT_CALLBACK_KEY = "ie:media_audit:callback:";
private static final long WX_MEDIA_CHECK_MAX_BYTES = 10L * 1024 * 1024;
private static final long VIDEO_PROCESS_MAX_BYTES = 50L * 1024 * 1024;
private static final long MEDIA_MAX_BYTES = 10L * 1024 * 1024;
private static final long VIDEO_MAX_BYTES = 100L * 1024 * 1024;
private static final int USER_PENDING_MEDIA_LIMIT = 3;
private static final int ROOM_PENDING_MEDIA_LIMIT = 6;
private static final long MEDIA_AUDIT_TIMEOUT_MILLIS = 10L * 60 * 1000;
/** 阿里云异步审核结果轮询间隔 */
private static final long MEDIA_AUDIT_POLL_MILLIS = 10L * 1000;
/** 提交审核失败后的延迟重试间隔 */
private static final long MEDIA_AUDIT_SUBMIT_RETRY_MILLIS = 15L * 1000;
@Override
public IeRoom getRoom(Long roomId) {
@ -141,9 +116,23 @@ public class IeChatServiceImpl implements IeChatService {
ensureMessageVisibleRecords(room, senderId, receiverId);
assertReplyWindow(senderId, receiverId, room.getId());
Integer messageType = dto.getMessageType() == null ? 1 : dto.getMessageType();
assertMediaCapacity(senderId, room.getId(), messageType);
validateMediaPayload(messageType, dto);
IeAuditResult audit = auditMessage(room.getId(), senderId, messageType, dto);
// 图片走阿里云同步审核,毫秒级出结论,无需异步等待
boolean imageBlocked = false;
if (messageType == 2 && !Boolean.TRUE.equals(audit.getBlocked())) {
IeAliyunModerationResult imageResult = aliyunModerationService.checkImage(
firstNotBlank(dto.getMediaCheckUrl(), dto.getContent()));
if (imageResult.isBlock()) {
imageBlocked = true;
} else if (!imageResult.isPass()) {
// 审核服务异常时不放行也不落库,让用户重试
throw new RuntimeException("图片审核服务繁忙,请稍后再试");
}
}
IeRoomMessage message = new IeRoomMessage();
message.setRoomId(room.getId());
message.setSenderId(senderId);
@ -154,14 +143,27 @@ public class IeChatServiceImpl implements IeChatService {
message.setAuditStatus(audit.getAuditStatus());
message.setRiskLevel(audit.getRiskLevel());
message.setHitWords(String.join(",", audit.getHitWords()));
message.setIsBlocked(Boolean.TRUE.equals(audit.getBlocked()) ? 1 : 0);
message.setIsBlocked(Boolean.TRUE.equals(audit.getBlocked()) || imageBlocked ? 1 : 0);
if (imageBlocked) {
message.setAuditStatus(IeConstants.AUDIT_BLOCK);
message.setRiskLevel(IeConstants.RISK_HIGH);
}
message.setMediaDuration(safeMediaDuration(dto.getMediaDuration()));
message.setMediaSize(dto.getMediaSize());
message.setMediaFormat(dto.getMediaFormat());
message.setMediaCheckUrl(dto.getMediaCheckUrl());
message.setMediaCheckType(dto.getMediaCheckType());
boolean needMediaAudit = message.getIsBlocked() == 0 && isMediaMessage(messageType);
message.setMediaAuditStatus(needMediaAudit ? IeConstants.MEDIA_AUDIT_PENDING : IeConstants.MEDIA_AUDIT_NONE);
// 图片已同步审完;只有语音/视频还需要异步任务
boolean needMediaAudit = message.getIsBlocked() == 0 && (messageType == 4 || messageType == 5);
if (needMediaAudit) {
message.setMediaAuditStatus(IeConstants.MEDIA_AUDIT_PENDING);
} else if (messageType == 2 && message.getIsBlocked() == 0) {
message.setMediaAuditStatus(IeConstants.MEDIA_AUDIT_PASS);
} else if (imageBlocked) {
message.setMediaAuditStatus(IeConstants.MEDIA_AUDIT_BLOCK);
} else {
message.setMediaAuditStatus(IeConstants.MEDIA_AUDIT_NONE);
}
message.setExpireTime(cleanupTime());
message.setCreateTime(new Date());
roomMessageMapper.insert(message);
@ -201,6 +203,27 @@ public class IeChatServiceImpl implements IeChatService {
return messageType != null && (messageType == 2 || messageType == 4 || messageType == 5);
}
private void assertMediaCapacity(Long senderId, Long roomId, Integer messageType) {
if (!isMediaMessage(messageType)) {
return;
}
if (!redisService.allowMediaMessage(senderId, roomId)) {
throw new RuntimeException("消息太火爆了,稍后再试");
}
Long userPending = roomMessageMapper.selectCount(new LambdaQueryWrapper<IeRoomMessage>()
.eq(IeRoomMessage::getSenderId, senderId)
.eq(IeRoomMessage::getMediaAuditStatus, IeConstants.MEDIA_AUDIT_PENDING));
if (userPending != null && userPending >= USER_PENDING_MEDIA_LIMIT) {
throw new RuntimeException("还有媒体正在处理,稍后再试");
}
Long roomPending = roomMessageMapper.selectCount(new LambdaQueryWrapper<IeRoomMessage>()
.eq(IeRoomMessage::getRoomId, roomId)
.eq(IeRoomMessage::getMediaAuditStatus, IeConstants.MEDIA_AUDIT_PENDING));
if (roomPending != null && roomPending >= ROOM_PENDING_MEDIA_LIMIT) {
throw new RuntimeException("当前聊天太火爆了,稍后再试");
}
}
private void validateMediaPayload(Integer messageType, IeRoomMessageDTO dto) {
if (!isMediaMessage(messageType)) {
return;
@ -212,11 +235,11 @@ public class IeChatServiceImpl implements IeChatService {
if (mediaSize == null || mediaSize <= 0) {
return;
}
if ((messageType == 2 || messageType == 4) && mediaSize > WX_MEDIA_CHECK_MAX_BYTES) {
if ((messageType == 2 || messageType == 4) && mediaSize > MEDIA_MAX_BYTES) {
throw new RuntimeException("图片/语音不能超过10MB");
}
if (messageType == 5 && mediaSize > VIDEO_PROCESS_MAX_BYTES) {
throw new RuntimeException("视频不能超过50MB");
if (messageType == 5 && mediaSize > VIDEO_MAX_BYTES) {
throw new RuntimeException("视频不能超过100MB,请先剪辑后再发送");
}
}
@ -236,6 +259,14 @@ public class IeChatServiceImpl implements IeChatService {
ack.setMediaSize(message.getMediaSize());
ack.setMediaFormat(message.getMediaFormat());
ack.setMediaAuditStatus(message.getMediaAuditStatus());
if (message.getMessageType() != null && message.getMessageType() == 5) {
ack.setPoster(message.getMediaCheckUrl());
}
if (Integer.valueOf(IeConstants.MEDIA_AUDIT_FAILED).equals(message.getMediaAuditStatus())) {
ack.setAuditTip("审核服务繁忙,消息没有送出,请重新发送");
} else if (message.getIsBlocked() != null && message.getIsBlocked() == 1) {
ack.setAuditTip("内容未通过审核,请换一个更合适的内容");
}
return ack;
}
@ -280,7 +311,7 @@ public class IeChatServiceImpl implements IeChatService {
result.setBlocked(false);
return result;
}
return auditService.audit("message", roomId, senderId, senderOpenid(senderId), content);
return auditService.audit("message", roomId, senderId, content);
}
private Integer safeMediaDuration(Integer mediaDuration) {
@ -295,11 +326,20 @@ public class IeChatServiceImpl implements IeChatService {
IeRoom room = assertActiveRoom(senderId, dto.getRoomId());
Long receiverId = room.getUserAId().equals(senderId) ? room.getUserBId() : room.getUserAId();
assertNotBlocked(senderId, receiverId);
// 轻互动文案会实时推给对方,带文字时同样要过审
String eventText = dto.getEventText();
if (CharSequenceUtil.isNotBlank(eventText)) {
IeAuditResult audit = auditService.audit("presence", room.getId(), senderId, eventText);
if (Boolean.TRUE.equals(audit.getBlocked())) {
throw new RuntimeException("这条互动内容不太合适,换一个吧");
}
eventText = audit.getFilteredContent();
}
IePresenceEvent event = new IePresenceEvent();
event.setRoomId(room.getId());
event.setSenderId(senderId);
event.setEventType(dto.getEventType());
event.setEventText(dto.getEventText());
event.setEventText(eventText);
event.setCreateTime(new Date());
presenceEventMapper.insert(event);
messagingTemplate.convertAndSendToUser(String.valueOf(receiverId), "/queue/ie/presence", event);
@ -348,9 +388,10 @@ public class IeChatServiceImpl implements IeChatService {
throw new RuntimeException("无法举报:你不在该陪伴房间中");
}
Long otherId = reporterId.equals(room.getUserAId()) ? room.getUserBId() : room.getUserAId();
if (dto.getReportedUserId() == null || !dto.getReportedUserId().equals(otherId)) {
if (dto.getReportedUserId() != null && !dto.getReportedUserId().equals(otherId)) {
throw new RuntimeException("无法举报:被举报人与房间不匹配");
}
dto.setReportedUserId(otherId);
if (dto.getMessageId() != null) {
Long count = roomMessageMapper.selectCount(new LambdaQueryWrapper<IeRoomMessage>()
.eq(IeRoomMessage::getId, dto.getMessageId())
@ -361,13 +402,17 @@ public class IeChatServiceImpl implements IeChatService {
}
}
IeReport report = new IeReport();
IeUserProfile reportedProfile = findProfile(dto.getReportedUserId());
report.setReporterId(reporterId);
report.setReportedUserId(dto.getReportedUserId());
report.setRoomId(dto.getRoomId());
report.setMessageId(dto.getMessageId());
report.setRegionId(reportedProfile == null || reportedProfile.getRegionId() == null ? dto.getRegionId() : reportedProfile.getRegionId());
report.setRegionName(reportedProfile == null || reportedProfile.getRegionName() == null ? dto.getRegionName() : reportedProfile.getRegionName());
report.setReasonType(dto.getReasonType());
report.setReasonText(dto.getReasonText());
report.setStatus(0);
report.setBlockedIe(0);
report.setCreateTime(new Date());
reportMapper.insert(report);
publishEvent(IeChatEventType.REPORT_CREATED, dto.getRoomId(), dto.getMessageId(), reporterId, dto.getReportedUserId(), 0, dto.getReasonType());
@ -492,10 +537,25 @@ public class IeChatServiceImpl implements IeChatService {
.orderByDesc(IeRoomMessage::getCreateTime));
for (IeRoomMessage message : result.getRecords()) {
message.setMine(message.getSenderId() != null && message.getSenderId().equals(userId));
fillAuditTip(message);
}
return result;
}
private void fillAuditTip(IeRoomMessage message) {
if (message == null) {
return;
}
if (Integer.valueOf(IeConstants.MEDIA_AUDIT_FAILED).equals(message.getMediaAuditStatus())) {
message.setAuditTip("审核服务繁忙,消息没有送出,请重新发送");
} else if (message.getIsBlocked() != null && message.getIsBlocked() == 1) {
message.setAuditTip("内容未通过审核,请换一个更合适的内容");
}
if (message.getMessageType() != null && message.getMessageType() == 5) {
message.setPoster(message.getMediaCheckUrl());
}
}
@Override
public void submitMediaAudit(Long messageId) {
IeRoomMessage message = roomMessageMapper.selectById(messageId);
@ -505,79 +565,88 @@ public class IeChatServiceImpl implements IeChatService {
if (!Integer.valueOf(IeConstants.MEDIA_AUDIT_PENDING).equals(message.getMediaAuditStatus())) {
return;
}
List<MediaAuditItem> items;
try {
items = buildMediaAuditItems(message);
} catch (Exception e) {
failMediaAudit(message, e.getMessage());
return;
}
if (items.isEmpty()) {
failMediaAudit(message, "media audit material missing");
return;
}
List<String> traceIds = new ArrayList<>();
String openid = senderOpenid(message.getSenderId());
stringRedisTemplate.opsForValue().set(MEDIA_AUDIT_PENDING_KEY + message.getId(), String.valueOf(items.size()), 24, TimeUnit.HOURS);
for (MediaAuditItem item : items) {
IeWechatMediaSecResult result = wechatSecurityService.mediaCheckAsync(openid, item.url, item.type);
if (result == null || !result.isSubmitted() || CharSequenceUtil.isBlank(result.getTraceId())) {
failMediaAudit(message, "media audit submit failed");
Integer type = message.getMessageType();
// 视频封面是用户上传的图片,先同步审一道,违规直接拦截省一次视频任务
if (type != null && type == 5 && message.getMediaCheckType() != null && message.getMediaCheckType() == 2
&& CharSequenceUtil.isNotBlank(message.getMediaCheckUrl())) {
IeAliyunModerationResult coverResult = aliyunModerationService.checkImage(message.getMediaCheckUrl());
if (coverResult.isBlock()) {
blockMediaMessage(message, "aliyun video cover blocked: " + coverResult.getDetail());
return;
}
traceIds.add(result.getTraceId());
stringRedisTemplate.opsForValue().set(MEDIA_AUDIT_TRACE_KEY + result.getTraceId(), String.valueOf(message.getId()), 24, TimeUnit.HOURS);
}
message.setMediaAuditTraceIds(String.join(",", traceIds));
IeAliyunModerationResult submitResult;
if (type != null && type == 5) {
submitResult = aliyunModerationService.submitVideo(message.getRawContent());
} else {
submitResult = aliyunModerationService.submitVoice(firstNotBlank(message.getMediaCheckUrl(), message.getRawContent()));
}
if (!submitResult.isPending() || CharSequenceUtil.isBlank(submitResult.getTaskId())) {
// 提交失败多为网络抖动/限流等瞬时问题,延迟重试而不是立刻判失败;
// 发送时已埋了 10 分钟超时兜底消息,持续失败最终会标记未通过
log.warn("i/e媒体审核提交未成功,{}秒后重试 messageId={}, detail={}",
MEDIA_AUDIT_SUBMIT_RETRY_MILLIS / 1000, message.getId(), submitResult.getDetail());
chatEventProducer.sendMediaAuditRetry(message.getId(), MEDIA_AUDIT_SUBMIT_RETRY_MILLIS);
return;
}
message.setMediaAuditTraceIds(submitResult.getTaskId());
message.setUpdateTime(new Date());
roomMessageMapper.updateById(message);
chatEventProducer.sendMediaAuditPoll(message.getId(), MEDIA_AUDIT_POLL_MILLIS);
}
@Override
public void handleMediaAuditCallback(String traceId, boolean risky, Integer statusCode, String payload) {
if (CharSequenceUtil.isBlank(traceId)) {
return;
}
String cachedMessageId = stringRedisTemplate.opsForValue().get(MEDIA_AUDIT_TRACE_KEY + traceId);
IeRoomMessage message = null;
if (CharSequenceUtil.isNotBlank(cachedMessageId)) {
message = roomMessageMapper.selectById(Long.valueOf(cachedMessageId));
}
if (message == null) {
message = roomMessageMapper.selectOne(new LambdaQueryWrapper<IeRoomMessage>()
.like(IeRoomMessage::getMediaAuditTraceIds, traceId)
.last("limit 1"));
}
public void pollMediaAudit(Long messageId) {
IeRoomMessage message = roomMessageMapper.selectById(messageId);
if (message == null || !Integer.valueOf(IeConstants.MEDIA_AUDIT_PENDING).equals(message.getMediaAuditStatus())) {
return;
}
Boolean firstCallback = stringRedisTemplate.opsForValue().setIfAbsent(MEDIA_AUDIT_CALLBACK_KEY + traceId, "1", 24, TimeUnit.HOURS);
if (Boolean.FALSE.equals(firstCallback)) {
return;
}
if (risky) {
blockMediaMessage(message, "wechat media risky traceId=" + traceId + ", payload=" + payload);
return;
}
if (statusCode != null && statusCode != 0) {
failMediaAudit(message, "wechat media callback status=" + statusCode + ", traceId=" + traceId);
String taskId = message.getMediaAuditTraceIds();
if (CharSequenceUtil.isBlank(taskId)) {
failMediaAudit(message, "media audit taskId missing");
return;
}
String pendingValue = stringRedisTemplate.opsForValue().get(MEDIA_AUDIT_PENDING_KEY + message.getId());
if (CharSequenceUtil.isBlank(pendingValue)) {
failMediaAudit(message, "media audit pending counter missing, traceId=" + traceId);
Integer type = message.getMessageType();
IeAliyunModerationResult result = type != null && type == 5
? aliyunModerationService.queryVideoResult(taskId)
: aliyunModerationService.queryVoiceResult(taskId);
if (result.isPending()) {
// 还在审,继续轮询;10 分钟超时兜底会把卡死的任务标记失败
chatEventProducer.sendMediaAuditPoll(messageId, MEDIA_AUDIT_POLL_MILLIS);
return;
}
Long remaining = stringRedisTemplate.opsForValue().decrement(MEDIA_AUDIT_PENDING_KEY + message.getId());
if (remaining == null || remaining > 0) {
if (result.isBlock()) {
blockMediaMessage(message, "aliyun media blocked: " + result.getDetail());
return;
}
if (remaining < 0) {
if (result.isPass()) {
passMediaAudit(message);
return;
}
// 结果查询失败同样按瞬时问题处理,继续轮询,由超时兜底收口
log.warn("i/e媒体审核结果查询未成功,继续轮询 messageId={}, detail={}", messageId, result.getDetail());
chatEventProducer.sendMediaAuditPoll(messageId, MEDIA_AUDIT_POLL_MILLIS);
}
/**
* 审核定论只能发生一次CAS仅当仍处于审核中时才允许写入终态
* 防止 MQ 重投/提交重试/并发轮询导致先提示不通过几分钟后又变成通过的状态翻转
*
* @return true 表示本次调用赢得定论权可以继续发 ack/事件
*/
private boolean finalizeMediaAudit(IeRoomMessage message) {
return roomMessageMapper.update(message, new LambdaUpdateWrapper<IeRoomMessage>()
.eq(IeRoomMessage::getId, message.getId())
.eq(IeRoomMessage::getMediaAuditStatus, IeConstants.MEDIA_AUDIT_PENDING)) > 0;
}
private void passMediaAudit(IeRoomMessage message) {
message.setMediaAuditStatus(IeConstants.MEDIA_AUDIT_PASS);
message.setUpdateTime(new Date());
roomMessageMapper.updateById(message);
if (!finalizeMediaAudit(message)) {
log.warn("i/e媒体审核已有定论,忽略本次通过结果 messageId={}", message.getId());
return;
}
IeMessageAckVO ack = buildAck(message, null);
deliverMessage(message, ack);
messagingTemplate.convertAndSendToUser(String.valueOf(message.getSenderId()), "/queue/ie/ack", ack);
@ -595,189 +664,6 @@ public class IeChatServiceImpl implements IeChatService {
failMediaAudit(message, "media audit callback timeout");
}
private List<MediaAuditItem> buildMediaAuditItems(IeRoomMessage message) {
Integer type = message.getMessageType();
String content = message.getRawContent();
if (type == null) {
return Collections.emptyList();
}
if (type == 2) {
return singletonAuditItem(firstNotBlank(message.getMediaCheckUrl(), content), 2);
}
if (type == 4) {
return singletonAuditItem(firstNotBlank(message.getMediaCheckUrl(), content), 1);
}
if (type == 5) {
List<MediaAuditItem> items = new ArrayList<>();
String coverUrl = message.getMediaCheckType() != null && message.getMediaCheckType() == 2 ? message.getMediaCheckUrl() : null;
if (CharSequenceUtil.isNotBlank(coverUrl)) {
items.add(new MediaAuditItem(coverUrl, 2));
}
items.addAll(extractVideoAuditItems(content, message.getId()));
return items;
}
return Collections.emptyList();
}
/**
* SSRF 防护服务端要主动拉取的媒体地址只允许公网 http(s)
* 拒绝回环/内网/链路本地地址防止借审核流程探测内网
*/
private void assertSafeMediaUrl(String url) {
try {
URL parsed = new URL(url);
String protocol = parsed.getProtocol();
if (!"http".equalsIgnoreCase(protocol) && !"https".equalsIgnoreCase(protocol)) {
throw new RuntimeException("非法的媒体地址协议");
}
java.net.InetAddress address = java.net.InetAddress.getByName(parsed.getHost());
if (address.isLoopbackAddress() || address.isSiteLocalAddress()
|| address.isAnyLocalAddress() || address.isLinkLocalAddress()) {
throw new RuntimeException("非法的媒体地址");
}
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("媒体地址解析失败");
}
}
private List<MediaAuditItem> singletonAuditItem(String url, Integer type) {
if (CharSequenceUtil.isBlank(url) || type == null) {
return Collections.emptyList();
}
return Collections.singletonList(new MediaAuditItem(url, type));
}
private List<MediaAuditItem> extractVideoAuditItems(String videoUrl, Long messageId) {
if (CharSequenceUtil.isBlank(videoUrl)) {
return Collections.emptyList();
}
assertSafeMediaUrl(videoUrl);
Path dir = null;
try {
dir = Files.createTempDirectory("ie-media-audit-" + messageId + "-");
Path videoPath = dir.resolve("source.mp4");
java.net.URLConnection connection = new URL(videoUrl).openConnection();
connection.setConnectTimeout(5000);
connection.setReadTimeout(30000);
try (InputStream inputStream = connection.getInputStream()) {
Files.copy(inputStream, videoPath);
}
Path framePattern = dir.resolve("frame-%03d.jpg");
runFfmpeg(Arrays.asList("ffmpeg", "-y", "-v", "error", "-i", videoPath.toString(), "-vf", "fps=1/3", "-vframes", "3", framePattern.toString()));
Path audioPath = dir.resolve("audio.mp3");
boolean audioExtracted = runFfmpegQuiet(Arrays.asList("ffmpeg", "-y", "-v", "error", "-i", videoPath.toString(), "-t", "60", "-vn", "-ac", "1", "-ar", "16000", audioPath.toString()));
List<MediaAuditItem> items = new ArrayList<>();
List<Path> frames;
try (java.util.stream.Stream<Path> stream = Files.list(dir)) {
frames = stream
.filter(path -> path.getFileName().toString().startsWith("frame-") && path.getFileName().toString().endsWith(".jpg"))
.sorted()
.collect(Collectors.toList());
}
for (Path frame : frames) {
items.add(new MediaAuditItem(uploadAuditFile(frame, "image/jpeg"), 2));
}
if (audioExtracted && Files.exists(audioPath) && Files.size(audioPath) > 0) {
items.add(new MediaAuditItem(uploadAuditFile(audioPath, "audio/mpeg"), 1));
}
return items;
} catch (Exception e) {
throw new RuntimeException("视频审核素材处理失败", e);
} finally {
cleanupTempDirectory(dir);
}
}
private void runFfmpeg(List<String> command) throws IOException, InterruptedException {
boolean success = runFfmpegQuiet(command);
if (!success) {
throw new RuntimeException("ffmpeg command failed: " + String.join(" ", command));
}
}
private boolean runFfmpegQuiet(List<String> command) throws IOException, InterruptedException {
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
Thread drainThread = new Thread(() -> drainProcessOutput(process));
drainThread.setDaemon(true);
drainThread.start();
boolean finished = process.waitFor(60, TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
return false;
}
return process.exitValue() == 0;
}
private void drainProcessOutput(Process process) {
try (InputStream inputStream = process.getInputStream()) {
byte[] buffer = new byte[1024];
while (inputStream.read(buffer) != -1) {
// drain only
}
} catch (Exception ignored) {
}
}
private String uploadAuditFile(Path path, String contentType) throws Exception {
byte[] bytes = Files.readAllBytes(path);
MultipartFile multipartFile = new ByteArrayMultipartFile(bytes, path.getFileName().toString(), contentType);
Setting setting = settingService.get(SettingConstant.OSS_USED);
if (setting == null || CharSequenceUtil.isBlank(setting.getValue())) {
throw new RuntimeException("OSS存储服务未配置");
}
String fKey = UUID.randomUUID().toString().replace("-", "") + "-" + path.getFileName();
String url;
try (InputStream inputStream = multipartFile.getInputStream()) {
url = fileManageFactory.getFileManage(null).inputStreamUpload(inputStream, fKey, multipartFile);
}
cc.hiver.file.entity.File file = new cc.hiver.file.entity.File();
file.setLocation(getOssType(setting.getValue()))
.setTitle(path.getFileName().toString())
.setSize(multipartFile.getSize())
.setType(contentType)
.setFKey(fKey)
.setUrl(url)
.setCategoryId("0");
fileService.save(file);
if (setting.getValue().equals(SettingConstant.LOCAL_OSS)) {
OssSetting os = new Gson().fromJson(settingService.get(SettingConstant.LOCAL_OSS).getValue(), OssSetting.class);
return os.getHttp() + os.getEndpoint() + "/" + file.getId();
}
return url;
}
private Integer getOssType(String type) {
switch (type) {
case SettingConstant.QINIU_OSS:
return OssConstant.OSS_QINIU;
case SettingConstant.ALI_OSS:
return OssConstant.OSS_ALI;
case SettingConstant.TENCENT_OSS:
return OssConstant.OSS_TENCENT;
case SettingConstant.MINIO_OSS:
return OssConstant.OSS_MINIO;
case SettingConstant.LOCAL_OSS:
return OssConstant.OSS_LOCAL;
default:
return -1;
}
}
private void cleanupTempDirectory(Path dir) {
if (dir == null) {
return;
}
try {
List<Path> paths = Files.walk(dir).sorted(Comparator.reverseOrder()).collect(Collectors.toList());
for (Path path : paths) {
Files.deleteIfExists(path);
}
} catch (Exception ignored) {
}
}
private String firstNotBlank(String first, String second) {
return CharSequenceUtil.isNotBlank(first) ? first : second;
}
@ -788,7 +674,10 @@ public class IeChatServiceImpl implements IeChatService {
message.setAuditStatus(IeConstants.AUDIT_BLOCK);
message.setRiskLevel(IeConstants.RISK_HIGH);
message.setUpdateTime(new Date());
roomMessageMapper.updateById(message);
if (!finalizeMediaAudit(message)) {
log.warn("i/e媒体审核已有定论,忽略本次失败结果 messageId={}, reason={}", message.getId(), reason);
return;
}
long riskCount = redisService.increaseRisk(message.getSenderId(), 3600);
IeMessageAckVO ack = buildAck(message, null);
ack.setIsBlocked(1);
@ -802,7 +691,10 @@ public class IeChatServiceImpl implements IeChatService {
message.setRiskLevel(IeConstants.RISK_HIGH);
message.setMediaAuditStatus(IeConstants.MEDIA_AUDIT_BLOCK);
message.setUpdateTime(new Date());
roomMessageMapper.updateById(message);
if (!finalizeMediaAudit(message)) {
log.warn("i/e媒体审核已有定论,忽略本次拦截结果 messageId={}, reason={}", message.getId(), reason);
return;
}
long riskCount = redisService.increaseRisk(message.getSenderId(), 3600);
IeMessageAckVO ack = buildAck(message, null);
ack.setIsBlocked(1);
@ -810,16 +702,6 @@ public class IeChatServiceImpl implements IeChatService {
publishEvent(IeChatEventType.MESSAGE_BLOCKED, message.getRoomId(), message.getId(), message.getSenderId(), message.getReceiverId(), message.getRiskLevel(), reason + ", riskCount=" + riskCount);
}
private static class MediaAuditItem {
private final String url;
private final Integer type;
private MediaAuditItem(String url, Integer type) {
this.url = url;
this.type = type;
}
}
private void ensureRoomVisibleRecords(Long userId, Long roomId) {
IeRoom room = getRoom(roomId);
// 任意一方进入房间都补齐双方记录,保证被匹配方的消息列表同样可见
@ -895,11 +777,15 @@ public class IeChatServiceImpl implements IeChatService {
if (room == null || (!room.getUserAId().equals(userId) && !room.getUserBId().equals(userId))) {
throw new RuntimeException("你不在这个陪伴房间中");
}
return calculateRoomStreak(room, true);
}
private Map<String, Object> calculateRoomStreak(IeRoom room, boolean rewardEnabled) {
Calendar since = Calendar.getInstance();
since.add(Calendar.DAY_OF_YEAR, -STREAK_LOOKBACK_DAYS);
List<IeRoomMessage> messages = roomMessageMapper.selectList(new LambdaQueryWrapper<IeRoomMessage>()
.select(IeRoomMessage::getSenderId, IeRoomMessage::getCreateTime)
.eq(IeRoomMessage::getRoomId, roomId)
.eq(IeRoomMessage::getRoomId, room.getId())
.eq(IeRoomMessage::getIsBlocked, 0)
.ge(IeRoomMessage::getCreateTime, since.getTime()));
java.text.SimpleDateFormat dayFormat = new java.text.SimpleDateFormat("yyyyMMdd");
@ -928,8 +814,8 @@ public class IeChatServiceImpl implements IeChatService {
}
// 昨天有火花但今天还没续上 → 即将熄灭,提醒用户今天互发一句
boolean expiring = !bothToday && streak > 0;
if (bothToday && streak > 0 && streak % STREAK_REWARD_INTERVAL == 0) {
String rewardKey = STREAK_REWARD_KEY + roomId + ":" + streak;
if (rewardEnabled && bothToday && streak > 0 && streak % STREAK_REWARD_INTERVAL == 0) {
String rewardKey = STREAK_REWARD_KEY + room.getId() + ":" + streak;
Boolean first = stringRedisTemplate.opsForValue().setIfAbsent(rewardKey, "1", 40, TimeUnit.DAYS);
if (Boolean.TRUE.equals(first)) {
matchService.rewardMatchQuota(room.getUserAId(), 1);
@ -950,17 +836,64 @@ public class IeChatServiceImpl implements IeChatService {
@Override
public IPage<IeRecord> pageRecords(Long userId, Integer pageNumber, Integer pageSize) {
Page<IeRecord> page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize));
IPage<IeRecord> result = recordMapper.selectPage(page, new LambdaQueryWrapper<IeRecord>()
long current = safePageNumber(pageNumber);
long size = safePageSize(pageSize);
Page<IeRecord> result = new Page<>(current, size);
List<IeRecord> records = recordMapper.selectList(new LambdaQueryWrapper<IeRecord>()
.eq(IeRecord::getUserId, userId)
.orderByDesc(IeRecord::getUpdateTime)
.orderByDesc(IeRecord::getCreateTime));
for (IeRecord record : result.getRecords()) {
for (IeRecord record : records) {
record.setUnreadCount(unreadMessageCount(userId, record.getRoomId(), record.getTargetUserId()));
fillLatestTargetProfile(record);
fillRoomStreak(record);
}
records.sort((left, right) -> {
int unreadCompare = Integer.compare(right.getUnreadCount() == null ? 0 : right.getUnreadCount(),
left.getUnreadCount() == null ? 0 : left.getUnreadCount());
if (unreadCompare != 0) {
return unreadCompare;
}
return compareRecordTimeDesc(left, right);
});
int fromIndex = (int) Math.min((current - 1) * size, (long) records.size());
int toIndex = (int) Math.min(fromIndex + size, (long) records.size());
result.setRecords(records.subList(fromIndex, toIndex));
result.setTotal(records.size());
result.setPages((records.size() + size - 1L) / size);
return result;
}
private int compareRecordTimeDesc(IeRecord left, IeRecord right) {
Date leftTime = left.getUpdateTime() == null ? left.getCreateTime() : left.getUpdateTime();
Date rightTime = right.getUpdateTime() == null ? right.getCreateTime() : right.getUpdateTime();
if (leftTime == null && rightTime == null) {
return 0;
}
if (leftTime == null) {
return 1;
}
if (rightTime == null) {
return -1;
}
return rightTime.compareTo(leftTime);
}
private void fillRoomStreak(IeRecord record) {
if (record == null || record.getRoomId() == null) {
return;
}
IeRoom room = getRoom(record.getRoomId());
if (room == null) {
return;
}
Map<String, Object> streak = calculateRoomStreak(room, false);
record.setStreakDays((Integer) streak.get("streakDays"));
record.setStreakBothToday((Boolean) streak.get("bothToday"));
record.setStreakExpiring((Boolean) streak.get("expiring"));
record.setStreakRewardInterval((Integer) streak.get("rewardInterval"));
}
@Override
@Transactional
public void deleteRecord(Long userId, Long recordId) {
@ -1024,6 +957,58 @@ public class IeChatServiceImpl implements IeChatService {
.orderByDesc(IeReport::getCreateTime));
}
@Override
public IPage<IeReport> adminPageReports(String regionId, Integer status, Integer pageNumber, Integer pageSize) {
Page<IeReport> page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize));
return reportMapper.selectPage(page, new LambdaQueryWrapper<IeReport>()
.eq(regionId != null && !regionId.trim().isEmpty(), IeReport::getRegionId, regionId)
.eq(status != null, IeReport::getStatus, status)
.orderByAsc(IeReport::getStatus)
.orderByDesc(IeReport::getCreateTime));
}
@Override
@Transactional
public IeReport handleReport(Long handlerId, Long reportId, Integer status, String handleResult, Boolean blockIe) {
IeReport report = reportMapper.selectById(reportId);
if (report == null) {
throw new RuntimeException("举报记录不存在");
}
Date now = new Date();
report.setStatus(status == null ? 1 : status);
report.setHandleResult(handleResult == null || handleResult.trim().isEmpty() ? "已处理" : handleResult.trim());
report.setBlockedIe(Boolean.TRUE.equals(blockIe) ? 1 : 0);
report.setHandlerId(handlerId);
report.setUpdateTime(now);
reportMapper.updateById(report);
if (Boolean.TRUE.equals(blockIe) && report.getReportedUserId() != null) {
IeUserProfile profile = findProfile(report.getReportedUserId());
if (profile != null) {
profile.setIsDeleted(1);
profile.setUpdateTime(now);
userProfileMapper.updateById(profile);
}
}
return report;
}
@Override
public void deleteReport(Long reportId) {
if (reportId == null) {
return;
}
reportMapper.deleteById(reportId);
}
private IeUserProfile findProfile(Long userId) {
if (userId == null) {
return null;
}
return userProfileMapper.selectOne(new LambdaQueryWrapper<IeUserProfile>()
.eq(IeUserProfile::getUserId, userId)
.last("limit 1"));
}
@Override
public IPage<IeBlock> pageBlocks(Long userId, Integer pageNumber, Integer pageSize) {
Page<IeBlock> page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize));
@ -1110,11 +1095,6 @@ public class IeChatServiceImpl implements IeChatService {
return calendar.getTime();
}
private String senderOpenid(Long senderId) {
User user = userDao.findById(String.valueOf(senderId)).orElse(null);
return user == null ? null : user.getMiniProgramOpenid();
}
private void createRecord(IeRoom room, Long userId, Long targetUserId) {
IeRecord exists = recordMapper.selectOne(new LambdaQueryWrapper<IeRecord>()
.eq(IeRecord::getRoomId, room.getId())

150
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMatchServiceImpl.java

@ -6,13 +6,11 @@ import cc.hiver.mall.ie.dto.IeProfileDTO;
import cc.hiver.mall.ie.dto.IeStatusDTO;
import cc.hiver.mall.ie.entity.*;
import cc.hiver.mall.ie.mapper.*;
import cc.hiver.mall.ie.service.IeAliyunModerationService;
import cc.hiver.mall.ie.service.IeMatchService;
import cc.hiver.mall.ie.service.IeRedisService;
import cc.hiver.mall.ie.service.IeSecurityAuditService;
import cc.hiver.mall.ie.vo.IeAuditResult;
import cc.hiver.mall.ie.vo.IeHomeVO;
import cc.hiver.mall.ie.vo.IeMatchVO;
import cc.hiver.mall.ie.vo.IeUserProfileVO;
import cc.hiver.mall.ie.vo.*;
import cn.hutool.core.util.IdUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
@ -56,19 +54,25 @@ public class IeMatchServiceImpl implements IeMatchService {
@Autowired
private IeSecurityAuditService auditService;
@Autowired
private IeAliyunModerationService aliyunModerationService;
@Override
public IeHomeVO home(Long userId) {
IeUserProfile profile = ensureProfile(userId);
assertIeAvailable(profile);
IeUserStatus status = findStatusByUserId(userId);
redisService.refreshOnline(userId);
redisService.recordIeOpen(userId);
String currentMood = status == null || status.getMood() == null ? "quiet" : status.getMood();
IeHomeVO vo = new IeHomeVO();
vo.setOnlineCount(redisService.onlineCount());
vo.setWaitingCount(redisService.waitingCount(profile.getCurrentMode(), "quiet", profile.getRegionId(), hasText(profile.getRegionId())));
vo.setDailyQuota(profile.getDailyQuota());
vo.setWaitingCount(redisService.waitingCount(profile.getCurrentMode(), currentMood, profile.getRegionId(), hasText(profile.getRegionId())));
vo.setDailyQuota(effectiveDailyQuota(profile));
vo.setUsedQuota((int) redisService.todayUsedQuota(userId));
vo.setUnreadCount(totalUnreadCount(userId));
vo.setCurrentMode(profile.getCurrentMode());
vo.setCurrentMood("quiet");
vo.setCurrentMood(currentMood);
vo.setTargetModePreference(profile.getTargetModePreference());
vo.setTargetGenderPreference(profile.getTargetGenderPreference());
vo.setProfileCompleted(profile.getProfileCompleted() == null ? 0 : profile.getProfileCompleted());
@ -134,16 +138,40 @@ public class IeMatchServiceImpl implements IeMatchService {
if (dto == null) {
dto = new IeProfileDTO();
}
String anonymousName = localAuditProfileText(defaultText(dto.getAnonymousName(), profile.getAnonymousName()));
String intro = localAuditProfileText(defaultText(dto.getIntro(), ""));
// 昵称/一句话介绍单独审(取过滤后的文案),头像字、兴趣标签、地区名合并成一次审核(只判拦截)
String anonymousName = auditProfileText("profile-name", userId, defaultText(dto.getAnonymousName(), profile.getAnonymousName()));
String intro = auditProfileText("profile-intro", userId, defaultText(dto.getIntro(), ""));
String avatarText = defaultText(dto.getAvatarText(), "我");
StringBuilder extraText = new StringBuilder(avatarText);
if (dto.getInterestTags() != null) {
for (String tag : dto.getInterestTags()) {
if (tag != null && !tag.trim().isEmpty()) {
extraText.append(' ').append(tag.trim());
}
}
}
if (dto.getRegionName() != null && !dto.getRegionName().trim().isEmpty()) {
extraText.append(' ').append(dto.getRegionName().trim());
}
auditProfileText("profile-extra", userId, extraText.toString());
profile.setAnonymousName(anonymousName);
profile.setAvatarText(defaultText(dto.getAvatarText(), "我"));
profile.setAvatarText(avatarText);
if (dto.getAvatarUrl() != null && !dto.getAvatarUrl().trim().isEmpty()) {
auditProfileImage(dto.getAvatarUrl().trim(), profile.getAvatarUrl());
profile.setAvatarUrl(dto.getAvatarUrl());
}
profile.setGender(normalizeGender(dto.getGender(), "unknown"));
profile.setIntro(intro);
profile.setInterestTags(tagsToJson(dto.getInterestTags()));
// 人格卡片图片:只审本次新增的,已通过的旧图不重复送审
if (dto.getPersonaImages() != null) {
String existingImages = profile.getPersonaImages() == null ? "" : profile.getPersonaImages();
for (String image : dto.getPersonaImages()) {
if (image != null && !image.trim().isEmpty() && !existingImages.contains(image.trim())) {
auditProfileImage(image.trim(), null);
}
}
}
profile.setPersonaImages(imagesToJson(dto.getPersonaImages()));
profile.setCurrentMode(normalizeMode(dto.getCurrentMode(), "i"));
profile.setRecentPreference(profile.getCurrentMode());
@ -178,18 +206,34 @@ public class IeMatchServiceImpl implements IeMatchService {
dto.setMood("quiet");
}
IeUserProfile profile = ensureProfile(userId);
String mode = normalizeMode(dto.getMode(), profile.getCurrentMode());
String targetMode = normalizeTargetMode(dto.getTargetMode() == null ? profile.getTargetModePreference() : dto.getTargetMode());
String targetGender = normalizeTargetGender(dto.getTargetGender() == null ? profile.getTargetGenderPreference() : dto.getTargetGender());
dto.setMode(mode);
dto.setTargetMode(targetMode);
dto.setTargetGender(targetGender);
dto.setRegionId(defaultText(dto.getRegionId(), profile.getRegionId()));
dto.setRegionName(defaultText(dto.getRegionName(), profile.getRegionName()));
IeUserStatus status = userStatusMapper.selectOne(new LambdaQueryWrapper<IeUserStatus>()
.eq(IeUserStatus::getUserId, userId)
.last("limit 1"));
// 状态文案对外展示(热门状态/匹配卡片),变化时才送审,心跳级调用不重复烧钱
String statusText = dto.getStatusText();
if (statusText != null && !statusText.trim().isEmpty()
&& (status == null || !statusText.equals(status.getStatusText()))) {
IeAuditResult audit = auditService.audit("status-text", userId, userId, statusText);
if (Boolean.TRUE.equals(audit.getBlocked())) {
throw new RuntimeException("这条状态不太合适展示,换个说法吧");
}
dto.setStatusText(audit.getFilteredContent());
}
Date now = new Date();
if (status == null) {
status = new IeUserStatus();
status.setUserId(userId);
status.setCreateTime(now);
}
status.setMode(dto.getMode());
status.setMode(mode);
status.setMood(dto.getMood());
status.setStatusText(dto.getStatusText());
status.setRegionId(dto.getRegionId());
@ -202,6 +246,33 @@ public class IeMatchServiceImpl implements IeMatchService {
} else {
userStatusMapper.updateById(status);
}
boolean profileChanged = false;
if (!Objects.equals(profile.getCurrentMode(), mode)) {
profile.setCurrentMode(mode);
profile.setRecentPreference(mode);
profileChanged = true;
}
if (!Objects.equals(profile.getTargetModePreference(), targetMode)) {
profile.setTargetModePreference(targetMode);
profileChanged = true;
}
if (!Objects.equals(profile.getTargetGenderPreference(), targetGender)) {
profile.setTargetGenderPreference(targetGender);
profileChanged = true;
}
if (!Objects.equals(profile.getRegionId(), dto.getRegionId())) {
profile.setRegionId(dto.getRegionId());
profileChanged = true;
}
if (!Objects.equals(profile.getRegionName(), dto.getRegionName())) {
profile.setRegionName(dto.getRegionName());
profileChanged = true;
}
if (profileChanged) {
profile.setLastActiveTime(now);
profile.setUpdateTime(now);
userProfileMapper.updateById(profile);
}
redisService.refreshOnline(userId);
redisService.cacheUserStatus(userId, dto);
redisService.addToMatchPool(userId, dto);
@ -215,7 +286,7 @@ public class IeMatchServiceImpl implements IeMatchService {
if (profile.getProfileCompleted() == null || profile.getProfileCompleted() != 1) {
return fail(userId, dto, "请先完善资料后再开始匹配");
}
long maxQuota = profile.getDailyQuota() == null ? IeConstants.DEFAULT_DAILY_QUOTA : profile.getDailyQuota();
long maxQuota = effectiveDailyQuota(profile);
if (!redisService.tryLockMatchUser(userId, 8)) {
return fail(userId, dto, "正在匹配中,请稍后");
}
@ -297,6 +368,7 @@ public class IeMatchServiceImpl implements IeMatchService {
}
try {
IeUserProfile targetProfile = ensureProfile(targetUserId);
IeUserStatus targetStatus = findStatusByUserId(targetUserId);
IeMatchAttempt match = new IeMatchAttempt();
match.setMatchNo(IdUtil.fastSimpleUUID());
@ -325,6 +397,10 @@ public class IeMatchServiceImpl implements IeMatchService {
ensureMatchRecords(room, userId, targetUserId, profile, targetProfile);
IeMatchVO vo = toMatchVO(match);
vo.setMood(targetStatus == null || targetStatus.getMood() == null ? "quiet" : targetStatus.getMood());
if (targetStatus != null && hasText(targetStatus.getStatusText())) {
vo.setStateText(targetStatus.getStatusText());
}
vo.setRoomId(room.getId());
vo.setRoomNo(room.getRoomNo());
vo.setAvatarUrl(targetProfile.getAvatarUrl());
@ -370,8 +446,9 @@ public class IeMatchServiceImpl implements IeMatchService {
}
IeUserProfile targetProfile = ensureProfile(targetUserId);
IeRoom existing = findReusableRoom(userId, targetUserId);
long maxQuota = profile.getDailyQuota() == null ? IeConstants.DEFAULT_DAILY_QUOTA : profile.getDailyQuota();
if (existing == null) {
boolean hasExistingRelation = existing != null || hasRoomRelation(userId, targetUserId);
long maxQuota = effectiveDailyQuota(profile);
if (!hasExistingRelation) {
long used = redisService.increaseTodayQuota(userId, maxQuota);
if (used > maxQuota) {
throw new RuntimeException("今日陪伴机会已经用完,平台下单可增加匹配次数");
@ -413,7 +490,7 @@ public class IeMatchServiceImpl implements IeMatchService {
vo.setPersonaImages(jsonToTags(targetProfile.getPersonaImages()));
return vo;
} catch (RuntimeException e) {
if (existing == null) {
if (!hasExistingRelation) {
redisService.rollbackTodayQuota(userId);
}
throw e;
@ -629,12 +706,34 @@ public class IeMatchServiceImpl implements IeMatchService {
}
}
private void assertIeAvailable(IeUserProfile profile) {
if (profile != null && Integer.valueOf(1).equals(profile.getIsDeleted())) {
throw new RuntimeException("你的 i/e 身份已被封禁,如有疑问请联系平台处理");
}
}
private int effectiveDailyQuota(IeUserProfile profile) {
if (profile == null || profile.getDailyQuota() == null) {
return IeConstants.DEFAULT_DAILY_QUOTA;
}
return Math.max(profile.getDailyQuota(), IeConstants.DEFAULT_DAILY_QUOTA);
}
private IeUserProfile findProfileByUserId(Long userId) {
return userProfileMapper.selectOne(new LambdaQueryWrapper<IeUserProfile>()
.eq(IeUserProfile::getUserId, userId)
.last("limit 1"));
}
private IeUserStatus findStatusByUserId(Long userId) {
if (userId == null) {
return null;
}
return userStatusMapper.selectOne(new LambdaQueryWrapper<IeUserStatus>()
.eq(IeUserStatus::getUserId, userId)
.last("limit 1"));
}
private List<Long> candidateUsers(String targetMode, String selfMode, String mood, String regionId, boolean regionScope) {
LinkedHashSet<Long> set = new LinkedHashSet<>();
if (!"any".equals(targetMode)) {
@ -839,18 +938,17 @@ public class IeMatchServiceImpl implements IeMatchService {
return result.getFilteredContent();
}
private String localAuditProfileText(String content) {
if (content == null) {
return "";
private void auditProfileImage(String imageUrl, String existingUrl) {
if (imageUrl == null || imageUrl.trim().isEmpty() || imageUrl.equals(existingUrl)) {
return;
}
String lower = content.toLowerCase();
String[] riskyWords = {"黄色", "政治", "辱骂", "傻逼", "操你", "约炮", "色情"};
for (String word : riskyWords) {
if (lower.contains(word)) {
throw new RuntimeException("资料内容包含不适合展示的表达,请换一种说法");
}
IeAliyunModerationResult result = aliyunModerationService.checkImage(imageUrl);
if (result.isBlock()) {
throw new RuntimeException("图片包含不适合展示的内容,请更换一张");
}
if (!result.isPass()) {
throw new RuntimeException("图片审核服务繁忙,请稍后再试");
}
return content;
}
private IeUserProfileVO toProfileVO(IeUserProfile profile) {
@ -870,7 +968,7 @@ public class IeMatchServiceImpl implements IeMatchService {
vo.setRegionId(profile.getRegionId());
vo.setRegionName(profile.getRegionName());
vo.setDefaultRoomMinutes(profile.getDefaultRoomMinutes());
vo.setDailyQuota(profile.getDailyQuota());
vo.setDailyQuota(effectiveDailyQuota(profile));
vo.setUsedQuota(profile.getUsedQuota());
vo.setProfileCompleted(profile.getProfileCompleted() == null ? 0 : profile.getProfileCompleted());
vo.setExists(true);

336
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMomentServiceImpl.java

@ -0,0 +1,336 @@
package cc.hiver.mall.ie.service.impl;
import cc.hiver.mall.ie.dto.IeMomentDTO;
import cc.hiver.mall.ie.entity.IeMoment;
import cc.hiver.mall.ie.entity.IeRoom;
import cc.hiver.mall.ie.entity.IeUserProfile;
import cc.hiver.mall.ie.mapper.IeMomentMapper;
import cc.hiver.mall.ie.mapper.IeRoomMapper;
import cc.hiver.mall.ie.mapper.IeUserProfileMapper;
import cc.hiver.mall.ie.mq.IeChatEventProducer;
import cc.hiver.mall.ie.service.IeAliyunModerationService;
import cc.hiver.mall.ie.service.IeMomentService;
import cc.hiver.mall.ie.service.IeSecurityAuditService;
import cc.hiver.mall.ie.vo.IeAliyunModerationResult;
import cc.hiver.mall.ie.vo.IeAuditResult;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.json.JSONUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.util.*;
@Slf4j
@Service
public class IeMomentServiceImpl implements IeMomentService {
private static final int MAX_IMAGES = 5;
private static final int MAX_CONTENT_LENGTH = 1000;
/** 视频审核超时兜底:超过该时长仍无结论按未通过处理 */
private static final long VIDEO_AUDIT_TIMEOUT_MILLIS = 10L * 60 * 1000;
private static final long VIDEO_AUDIT_POLL_MILLIS = 10L * 1000;
/** 提交审核失败后的延迟重试间隔 */
private static final long VIDEO_AUDIT_SUBMIT_RETRY_MILLIS = 15L * 1000;
@Autowired
private IeMomentMapper momentMapper;
@Autowired
private IeUserProfileMapper userProfileMapper;
@Autowired
private IeRoomMapper roomMapper;
@Autowired
private IeSecurityAuditService auditService;
@Autowired
private IeAliyunModerationService aliyunModerationService;
@Autowired
private IeChatEventProducer chatEventProducer;
@Override
@Transactional
public IeMoment publish(Long userId, IeMomentDTO dto) {
if (dto == null) {
throw new RuntimeException("发布内容不能为空");
}
String content = dto.getContent() == null ? "" : dto.getContent().trim();
List<String> images = normalizeImages(dto.getImages());
String videoUrl = CharSequenceUtil.isBlank(dto.getVideoUrl()) ? null : dto.getVideoUrl().trim();
String videoPoster = CharSequenceUtil.isBlank(dto.getVideoPoster()) ? null : dto.getVideoPoster().trim();
if (CharSequenceUtil.isBlank(content) && images.isEmpty() && videoUrl == null) {
throw new RuntimeException("写点什么或选张图片再发布吧");
}
if (content.length() > MAX_CONTENT_LENGTH) {
throw new RuntimeException("文字内容不能超过 " + MAX_CONTENT_LENGTH + " 字");
}
if (!images.isEmpty() && videoUrl != null) {
throw new RuntimeException("图片和视频不能同时发布");
}
// 文字同步审核,取过滤后的文案
if (CharSequenceUtil.isNotBlank(content)) {
IeAuditResult audit = auditService.audit("moment", null, userId, content);
if (Boolean.TRUE.equals(audit.getBlocked())) {
throw new RuntimeException("文字内容包含不适合展示的表达,换一种说法吧");
}
content = audit.getFilteredContent();
}
// 图片同步审核,逐张过
for (int i = 0; i < images.size(); i++) {
checkImageOrThrow(images.get(i), "第" + (i + 1) + "张图片包含不适合展示的内容,请更换");
}
// 视频封面也是用户上传的图,同步先审
if (videoPoster != null) {
checkImageOrThrow(videoPoster, "视频封面包含不适合展示的内容,请更换");
}
IeMoment moment = new IeMoment();
moment.setUserId(userId);
moment.setContent(content);
moment.setImages(JSONUtil.toJsonStr(images));
moment.setVideoUrl(videoUrl);
moment.setVideoPoster(videoPoster);
// 视频走先审后发:审核中仅自己可见;其余内容已同步审完直接发布
moment.setStatus(videoUrl != null ? IeMoment.STATUS_AUDITING : IeMoment.STATUS_PUBLISHED);
moment.setCreateTime(new Date());
momentMapper.insert(moment);
if (videoUrl != null) {
Long momentId = moment.getId();
afterCommitOrNow(() -> chatEventProducer.sendMomentAudit(momentId));
}
decorate(moment, userId, loadProfileMap(userId));
return moment;
}
@Override
public IPage<IeMoment> pageMoments(Long viewerId, Long targetUserId, Long roomId, Integer pageNumber, Integer pageSize) {
Long ownerId = targetUserId == null ? viewerId : targetUserId;
// 聊天入口带 roomId:对方是谁以服务端房间数据为准。
// 雪花 ID 超出 JS Number 53 位精度,前端回传的 userId 可能已失真(如 ...295232 变 ...295200),不能信任
if (roomId != null && !ownerId.equals(viewerId)) {
Long resolved = resolveRoomPartner(viewerId, roomId);
if (resolved != null) {
ownerId = resolved;
}
}
boolean mineSpace = ownerId.equals(viewerId);
if (!mineSpace && !hasRoomRelation(viewerId, ownerId)) {
log.warn("i/e个人空间访问被拒 viewerId={}, ownerId={}, roomId={}", viewerId, ownerId, roomId);
throw new RuntimeException("只能查看聊过天的漂流者的个人空间");
}
Page<IeMoment> page = new Page<>(pageNumber == null || pageNumber < 1 ? 1 : pageNumber,
pageSize == null || pageSize < 1 || pageSize > 20 ? 10 : pageSize);
LambdaQueryWrapper<IeMoment> wrapper = new LambdaQueryWrapper<IeMoment>()
.eq(IeMoment::getUserId, ownerId)
.orderByDesc(IeMoment::getCreateTime)
.orderByDesc(IeMoment::getId);
if (!mineSpace) {
wrapper.eq(IeMoment::getStatus, IeMoment.STATUS_PUBLISHED);
}
IPage<IeMoment> result = momentMapper.selectPage(page, wrapper);
Map<Long, IeUserProfile> profileMap = loadProfileMap(ownerId);
for (IeMoment moment : result.getRecords()) {
decorate(moment, viewerId, profileMap);
}
return result;
}
@Override
public void delete(Long userId, Long momentId) {
IeMoment moment = momentMapper.selectById(momentId);
if (moment == null) {
return;
}
if (!userId.equals(moment.getUserId())) {
throw new RuntimeException("只能删除自己的动态");
}
momentMapper.deleteById(momentId);
}
@Override
public void submitVideoAudit(Long momentId) {
IeMoment moment = momentMapper.selectById(momentId);
if (moment == null || !Integer.valueOf(IeMoment.STATUS_AUDITING).equals(moment.getStatus())
|| CharSequenceUtil.isBlank(moment.getVideoUrl())) {
return;
}
if (auditExpired(moment)) {
blockMoment(moment, "moment video audit timeout (submit phase)");
return;
}
IeAliyunModerationResult result = aliyunModerationService.submitVideo(moment.getVideoUrl());
if (!result.isPending() || CharSequenceUtil.isBlank(result.getTaskId())) {
// 提交失败多为瞬时问题,延迟重试;超过 10 分钟仍未成功由上面的超时判断收口
log.warn("i/e动态视频审核提交未成功,{}秒后重试 momentId={}, detail={}",
VIDEO_AUDIT_SUBMIT_RETRY_MILLIS / 1000, momentId, result.getDetail());
chatEventProducer.sendMomentAuditRetry(momentId, VIDEO_AUDIT_SUBMIT_RETRY_MILLIS);
return;
}
moment.setAuditTaskId(result.getTaskId());
moment.setUpdateTime(new Date());
momentMapper.updateById(moment);
chatEventProducer.sendMomentAuditPoll(momentId, VIDEO_AUDIT_POLL_MILLIS);
}
@Override
public void pollVideoAudit(Long momentId) {
IeMoment moment = momentMapper.selectById(momentId);
if (moment == null || !Integer.valueOf(IeMoment.STATUS_AUDITING).equals(moment.getStatus())) {
return;
}
if (CharSequenceUtil.isBlank(moment.getAuditTaskId())) {
blockMoment(moment, "moment audit taskId missing");
return;
}
// 超时兜底:发布太久仍无结论按未通过处理,让用户重发
if (auditExpired(moment)) {
blockMoment(moment, "moment video audit timeout");
return;
}
IeAliyunModerationResult result = aliyunModerationService.queryVideoResult(moment.getAuditTaskId());
if (result.isPending()) {
chatEventProducer.sendMomentAuditPoll(momentId, VIDEO_AUDIT_POLL_MILLIS);
return;
}
if (result.isPass()) {
moment.setStatus(IeMoment.STATUS_PUBLISHED);
moment.setUpdateTime(new Date());
if (!finalizeMomentAudit(moment)) {
log.warn("i/e动态视频审核已有定论,忽略本次通过结果 momentId={}", momentId);
}
return;
}
if (result.isFailed()) {
// 查询失败按瞬时问题继续轮询,由超时兜底收口;只有明确 block 才拦截
log.warn("i/e动态视频审核结果查询未成功,继续轮询 momentId={}, detail={}", momentId, result.getDetail());
chatEventProducer.sendMomentAuditPoll(momentId, VIDEO_AUDIT_POLL_MILLIS);
return;
}
blockMoment(moment, "aliyun moment video result: " + result.getDetail());
}
private boolean auditExpired(IeMoment moment) {
long createdAt = moment.getCreateTime() == null ? 0 : moment.getCreateTime().getTime();
return System.currentTimeMillis() - createdAt > VIDEO_AUDIT_TIMEOUT_MILLIS;
}
private void blockMoment(IeMoment moment, String reason) {
moment.setStatus(IeMoment.STATUS_BLOCKED);
moment.setUpdateTime(new Date());
if (!finalizeMomentAudit(moment)) {
log.warn("i/e动态视频审核已有定论,忽略本次拦截结果 momentId={}, reason={}", moment.getId(), reason);
return;
}
log.info("i/e动态视频审核未通过 momentId={}, reason={}", moment.getId(), reason);
}
/**
* 审核定论一次性写入CAS仅当动态仍处于审核中时才允许改成通过/拦截
* 防止 MQ 重投或并发轮询把已定论的状态来回翻转
*/
private boolean finalizeMomentAudit(IeMoment moment) {
return momentMapper.update(moment, new LambdaUpdateWrapper<IeMoment>()
.eq(IeMoment::getId, moment.getId())
.eq(IeMoment::getStatus, IeMoment.STATUS_AUDITING)) > 0;
}
private void checkImageOrThrow(String imageUrl, String blockMessage) {
IeAliyunModerationResult result = aliyunModerationService.checkImage(imageUrl);
if (result.isBlock()) {
throw new RuntimeException(blockMessage);
}
if (!result.isPass()) {
throw new RuntimeException("图片审核服务繁忙,请稍后再试");
}
}
private List<String> normalizeImages(List<String> images) {
List<String> result = new ArrayList<>();
if (images == null) {
return result;
}
for (String image : images) {
if (image != null && !image.trim().isEmpty()) {
result.add(image.trim());
}
if (result.size() >= MAX_IMAGES) {
break;
}
}
return result;
}
/** 查看者是房间成员时返回房间内另一方的真实 userId,否则返回 null */
private Long resolveRoomPartner(Long viewerId, Long roomId) {
IeRoom room = roomMapper.selectById(roomId);
if (room == null || room.getUserAId() == null || room.getUserBId() == null) {
return null;
}
if (viewerId.equals(room.getUserAId())) {
return room.getUserBId();
}
if (viewerId.equals(room.getUserBId())) {
return room.getUserAId();
}
return null;
}
private boolean hasRoomRelation(Long viewerId, Long targetUserId) {
Long count = roomMapper.selectCount(new LambdaQueryWrapper<IeRoom>()
.and(w -> w.eq(IeRoom::getUserAId, viewerId).eq(IeRoom::getUserBId, targetUserId)
.or(o -> o.eq(IeRoom::getUserAId, targetUserId).eq(IeRoom::getUserBId, viewerId))));
return count != null && count > 0;
}
private Map<Long, IeUserProfile> loadProfileMap(Long userId) {
Map<Long, IeUserProfile> map = new HashMap<>();
IeUserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper<IeUserProfile>()
.eq(IeUserProfile::getUserId, userId)
.last("limit 1"));
if (profile != null) {
map.put(userId, profile);
}
return map;
}
private void decorate(IeMoment moment, Long viewerId, Map<Long, IeUserProfile> profileMap) {
moment.setMine(moment.getUserId() != null && moment.getUserId().equals(viewerId));
moment.setImageList(CharSequenceUtil.isBlank(moment.getImages())
? new ArrayList<>()
: JSONUtil.toList(JSONUtil.parseArray(moment.getImages()), String.class));
IeUserProfile profile = profileMap.get(moment.getUserId());
if (profile != null) {
moment.setAnonymousName(profile.getAnonymousName());
moment.setAvatarText(profile.getAvatarText());
moment.setAvatarUrl(profile.getAvatarUrl());
moment.setCurrentMode(profile.getCurrentMode());
}
moment.setAuditTaskId(null);
}
private void afterCommitOrNow(Runnable runnable) {
if (!TransactionSynchronizationManager.isSynchronizationActive()) {
runnable.run();
return;
}
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
runnable.run();
}
});
}
}

20
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeQuizServiceImpl.java

@ -13,13 +13,11 @@ import org.springframework.stereotype.Service;
import java.util.*;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
@Service
public class IeQuizServiceImpl implements IeQuizService {
private static final String QUIZ_SESSION_KEY = "ie:quiz:session:";
private static final long QUIZ_TTL_HOURS = 24L;
private static final int QUIZ_SIZE = 5;
/** 题库:{题目, 选项A, 选项B},面向大学生的轻松二选一 */
@ -58,11 +56,8 @@ public class IeQuizServiceImpl implements IeQuizService {
String key = QUIZ_SESSION_KEY + roomId;
String existing = stringRedisTemplate.opsForValue().get(key);
if (CharSequenceUtil.isNotBlank(existing)) {
JSONObject session = JSONUtil.parseObj(existing);
// 已有未完成的测试直接复用,避免一方刷新覆盖对方答题进度
if (!bothDone(session, room)) {
return buildView(session, userId, room);
}
// 每个聊天房间只允许一次默契测试,完成后也只返回结果,不再重开。
return buildView(JSONUtil.parseObj(existing), userId, room);
}
JSONObject session = new JSONObject();
session.set("quizId", IdUtil.fastSimpleUUID());
@ -70,7 +65,7 @@ public class IeQuizServiceImpl implements IeQuizService {
session.set("questions", randomQuestionIndexes());
session.set("answers", new JSONObject());
session.set("createTime", System.currentTimeMillis());
stringRedisTemplate.opsForValue().set(key, session.toString(), QUIZ_TTL_HOURS, TimeUnit.HOURS);
stringRedisTemplate.opsForValue().set(key, session.toString());
return buildView(session, userId, room);
}
@ -101,7 +96,7 @@ public class IeQuizServiceImpl implements IeQuizService {
JSONObject answerMap = session.getJSONObject("answers");
answerMap.set(String.valueOf(userId), answers);
session.set("answers", answerMap);
stringRedisTemplate.opsForValue().set(key, session.toString(), QUIZ_TTL_HOURS, TimeUnit.HOURS);
stringRedisTemplate.opsForValue().set(key, session.toString());
return buildView(session, userId, room);
}
@ -121,13 +116,6 @@ public class IeQuizServiceImpl implements IeQuizService {
return new ArrayList<>(indexes);
}
private boolean bothDone(JSONObject session, IeRoom room) {
JSONObject answers = session.getJSONObject("answers");
return answers != null
&& CharSequenceUtil.isNotBlank(answers.getStr(String.valueOf(room.getUserAId())))
&& CharSequenceUtil.isNotBlank(answers.getStr(String.valueOf(room.getUserBId())));
}
private Map<String, Object> buildView(JSONObject session, Long userId, IeRoom room) {
Long otherId = userId.equals(room.getUserAId()) ? room.getUserBId() : room.getUserAId();
JSONObject answers = session.getJSONObject("answers");

18
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeRedisServiceImpl.java

@ -362,6 +362,24 @@ public class IeRedisServiceImpl implements IeRedisService {
return count == null || count <= 8;
}
@Override
public boolean allowMediaMessage(Long userId, Long roomId) {
String userKey = IeRedisKey.MESSAGE_RATE + "media:user:" + userId;
Long userCount = stringRedisTemplate.opsForValue().increment(userKey);
if (userCount != null && userCount == 1L) {
stringRedisTemplate.expire(userKey, 60, TimeUnit.SECONDS);
}
if (userCount != null && userCount > 6) {
return false;
}
String roomKey = IeRedisKey.MESSAGE_RATE + "media:room:" + roomId;
Long roomCount = stringRedisTemplate.opsForValue().increment(roomKey);
if (roomCount != null && roomCount == 1L) {
stringRedisTemplate.expire(roomKey, 60, TimeUnit.SECONDS);
}
return roomCount == null || roomCount <= 10;
}
@Override
public long increaseRisk(Long userId, long seconds) {
String key = IeRedisKey.USER_RISK + userId;

54
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeSecurityAuditServiceImpl.java

@ -6,11 +6,10 @@ import cc.hiver.mall.ie.entity.IeContentAuditLog;
import cc.hiver.mall.ie.entity.IeSensitiveWord;
import cc.hiver.mall.ie.mapper.IeContentAuditLogMapper;
import cc.hiver.mall.ie.mapper.IeSensitiveWordMapper;
import cc.hiver.mall.ie.service.IeAliyunModerationService;
import cc.hiver.mall.ie.service.IeSecurityAuditService;
import cc.hiver.mall.ie.service.IeWechatSecurityService;
import cc.hiver.mall.ie.vo.IeAliyunModerationResult;
import cc.hiver.mall.ie.vo.IeAuditResult;
import cc.hiver.mall.ie.vo.IeWechatMediaSecResult;
import cc.hiver.mall.ie.vo.IeWechatMsgSecResult;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.json.JSONUtil;
@ -46,15 +45,10 @@ public class IeSecurityAuditServiceImpl implements IeSecurityAuditService {
private StringRedisTemplate stringRedisTemplate;
@Autowired
private IeWechatSecurityService wechatSecurityService;
private IeAliyunModerationService aliyunModerationService;
@Override
public IeAuditResult audit(String bizType, Long bizId, Long userId, String content) {
return audit(bizType, bizId, userId, null, content);
}
@Override
public IeAuditResult audit(String bizType, Long bizId, Long userId, String openid, String content) {
IeAuditResult result = new IeAuditResult();
result.setRawContent(content);
result.setFilteredContent(content);
@ -97,14 +91,15 @@ public class IeSecurityAuditServiceImpl implements IeSecurityAuditService {
result.setRiskLevel(IeConstants.RISK_LOW);
}
// 本地词库没拦住的,再过一遍阿里云文本审核;服务异常时降级为本地结果,不阻断
if (!Boolean.TRUE.equals(result.getBlocked())) {
IeWechatMsgSecResult wxResult = wechatSecurityService.msgSecCheck(openid, content);
if (wxResult.isChecked() && !wxResult.isPass()) {
IeAliyunModerationResult aliResult = aliyunModerationService.checkText(content);
if (aliResult.isBlock()) {
result.setAuditStatus(IeConstants.AUDIT_BLOCK);
result.setRiskLevel(IeConstants.RISK_HIGH);
result.setBlocked(true);
result.getHitWords().add("wechat:" + wxResult.getSuggest());
result.getHitCategories().add(wxResult.getLabel() == null ? "wechat" : wxResult.getLabel());
result.getHitWords().add("aliyun:" + aliResult.getRiskLevel());
result.getHitCategories().add(CharSequenceUtil.isBlank(aliResult.getRiskLevel()) ? "aliyun" : aliResult.getRiskLevel());
}
}
@ -125,39 +120,6 @@ public class IeSecurityAuditServiceImpl implements IeSecurityAuditService {
return result;
}
@Override
public IeAuditResult auditMedia(String bizType, Long bizId, Long userId, String openid, String mediaUrl, Integer mediaType) {
IeAuditResult result = new IeAuditResult();
result.setRawContent(mediaUrl);
result.setFilteredContent(mediaUrl);
result.setAuditStatus(IeConstants.AUDIT_PASS);
result.setRiskLevel(IeConstants.RISK_NONE);
result.setBlocked(false);
if (CharSequenceUtil.isBlank(mediaUrl) || mediaType == null) {
return result;
}
IeWechatMediaSecResult wxResult = wechatSecurityService.mediaCheckAsync(openid, mediaUrl, mediaType);
if (wxResult != null && wxResult.isSubmitted()) {
String traceId = wxResult.getTraceId();
if (CharSequenceUtil.isNotBlank(traceId)) {
IeContentAuditLog log = new IeContentAuditLog();
log.setBizType(bizType);
log.setBizId(bizId);
log.setUserId(userId);
log.setRawContent(mediaUrl);
log.setFilteredContent(mediaUrl);
log.setAuditStatus(result.getAuditStatus());
log.setRiskLevel(result.getRiskLevel());
log.setHitWords("wechat_media_trace:" + traceId);
log.setHitCategories("wechat_media_async");
log.setCreateTime(new Date());
auditLogMapper.insert(log);
}
}
return result;
}
private SensitiveWordBs getSensitiveWordBs() {
long now = System.currentTimeMillis();
if (cachedWordBs != null && now < cachedExpireAt) {

151
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeWechatSecurityServiceImpl.java

@ -1,151 +0,0 @@
package cc.hiver.mall.ie.service.impl;
import cc.hiver.mall.ie.service.IeWechatSecurityService;
import cc.hiver.mall.ie.vo.IeWechatMediaSecResult;
import cc.hiver.mall.ie.vo.IeWechatMsgSecResult;
import cc.hiver.mall.utils.WechatPayConfig;
import cn.hutool.core.text.CharSequenceUtil;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
public class IeWechatSecurityServiceImpl implements IeWechatSecurityService {
private static final String ACCESS_TOKEN_KEY = "ie:wechat:access_token";
private static final String ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={appid}&secret={secret}";
private static final String MSG_SEC_CHECK_URL = "https://api.weixin.qq.com/wxa/msg_sec_check?access_token={accessToken}";
private static final String MEDIA_CHECK_ASYNC_URL = "https://api.weixin.qq.com/wxa/media_check_async?access_token={accessToken}";
@Autowired
private StringRedisTemplate stringRedisTemplate;
@Autowired
private WechatPayConfig wechatPayConfig;
private final RestTemplate restTemplate = new RestTemplate();
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public IeWechatMsgSecResult msgSecCheck(String openid, String content) {
IeWechatMsgSecResult result = new IeWechatMsgSecResult();
result.setPass(true);
result.setChecked(false);
if (CharSequenceUtil.isBlank(openid) || CharSequenceUtil.isBlank(content)) {
return result;
}
String accessToken = getAccessToken();
if (CharSequenceUtil.isBlank(accessToken)) {
log.warn("微信内容安全检查跳过,access_token为空");
return result;
}
try {
Map<String, Object> body = new HashMap<>();
body.put("content", content);
body.put("version", 2);
body.put("scene", 4);
body.put("openid", openid);
ResponseEntity<String> response = restTemplate.postForEntity(MSG_SEC_CHECK_URL, body, String.class, accessToken);
JsonNode root = objectMapper.readTree(response.getBody());
int errcode = root.path("errcode").asInt(-1);
result.setChecked(true);
result.setErrcode(errcode);
result.setErrmsg(root.path("errmsg").asText());
if (errcode != 0) {
log.warn("微信内容安全检查失败 errcode={}, body={}", errcode, response.getBody());
return result;
}
JsonNode detail = root.path("detail");
if (detail.isArray() && detail.size() > 0) {
JsonNode first = detail.get(0);
result.setStrategy(first.path("strategy").asInt(0));
result.setErrcode(first.path("errcode").asInt(0));
result.setSuggest(first.path("suggest").asText(null));
result.setLabel(first.path("label").asText(null));
}
if (CharSequenceUtil.isBlank(result.getSuggest())) {
result.setSuggest(root.path("result").path("suggest").asText("pass"));
result.setLabel(root.path("result").path("label").asText(null));
}
result.setPass("pass".equalsIgnoreCase(result.getSuggest()));
return result;
} catch (Exception e) {
log.warn("微信内容安全检查异常,降级为本地审核结果", e);
return result;
}
}
@Override
public IeWechatMediaSecResult mediaCheckAsync(String openid, String mediaUrl, Integer mediaType) {
IeWechatMediaSecResult result = new IeWechatMediaSecResult();
result.setSubmitted(false);
if (CharSequenceUtil.isBlank(openid) || CharSequenceUtil.isBlank(mediaUrl) || mediaType == null) {
return result;
}
String accessToken = getAccessToken();
if (CharSequenceUtil.isBlank(accessToken)) {
log.warn("微信媒体内容安全检查跳过,access_token为空");
return result;
}
try {
Map<String, Object> body = new HashMap<>();
body.put("media_url", mediaUrl);
body.put("media_type", mediaType);
body.put("version", 2);
body.put("scene", 4);
body.put("openid", openid);
ResponseEntity<String> response = restTemplate.postForEntity(MEDIA_CHECK_ASYNC_URL, body, String.class, accessToken);
JsonNode root = objectMapper.readTree(response.getBody());
int errcode = root.path("errcode").asInt(-1);
result.setErrcode(errcode);
result.setErrmsg(root.path("errmsg").asText());
result.setTraceId(root.path("trace_id").asText(null));
if (errcode == 0) {
result.setSubmitted(true);
} else {
log.warn("微信媒体内容安全检查提交失败 errcode={}, body={}", errcode, response.getBody());
}
return result;
} catch (Exception e) {
log.warn("微信媒体内容安全检查提交异常,暂不阻断消息", e);
return result;
}
}
private String getAccessToken() {
String cached = stringRedisTemplate.opsForValue().get(ACCESS_TOKEN_KEY);
if (CharSequenceUtil.isNotBlank(cached)) {
return cached;
}
String appId = wechatPayConfig.getAppId();
String appSecret = wechatPayConfig.getAppSecret();
if (CharSequenceUtil.isBlank(appId) || CharSequenceUtil.isBlank(appSecret)) {
log.warn("微信小程序 appId/appSecret 未配置,跳过 msgSecCheck");
return null;
}
try {
ResponseEntity<String> response = restTemplate.getForEntity(ACCESS_TOKEN_URL, String.class, appId, appSecret);
JsonNode root = objectMapper.readTree(response.getBody());
String accessToken = root.path("access_token").asText(null);
int expiresIn = root.path("expires_in").asInt(7200);
if (CharSequenceUtil.isNotBlank(accessToken)) {
stringRedisTemplate.opsForValue().set(ACCESS_TOKEN_KEY, accessToken, Math.max(expiresIn - 300, 300), TimeUnit.SECONDS);
return accessToken;
}
log.warn("微信 access_token 获取失败: {}", response.getBody());
} catch (Exception e) {
log.warn("微信 access_token 获取异常", e);
}
return null;
}
}

66
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeAliyunModerationResult.java

@ -0,0 +1,66 @@
package cc.hiver.mall.ie.vo;
import lombok.Data;
/**
* 阿里云内容安全审核结果图片同步 / 音视频异步任务
*/
@Data
public class IeAliyunModerationResult {
public static final int STATUS_PENDING = 0;
public static final int STATUS_PASS = 1;
public static final int STATUS_BLOCK = 2;
public static final int STATUS_FAILED = 3;
private int status;
private String taskId;
private String riskLevel;
private String detail;
public static IeAliyunModerationResult pending(String taskId) {
IeAliyunModerationResult result = new IeAliyunModerationResult();
result.setStatus(STATUS_PENDING);
result.setTaskId(taskId);
return result;
}
public static IeAliyunModerationResult pass(String riskLevel, String detail) {
IeAliyunModerationResult result = new IeAliyunModerationResult();
result.setStatus(STATUS_PASS);
result.setRiskLevel(riskLevel);
result.setDetail(detail);
return result;
}
public static IeAliyunModerationResult block(String riskLevel, String detail) {
IeAliyunModerationResult result = new IeAliyunModerationResult();
result.setStatus(STATUS_BLOCK);
result.setRiskLevel(riskLevel);
result.setDetail(detail);
return result;
}
public static IeAliyunModerationResult failed(String detail) {
IeAliyunModerationResult result = new IeAliyunModerationResult();
result.setStatus(STATUS_FAILED);
result.setDetail(detail);
return result;
}
public boolean isPass() {
return status == STATUS_PASS;
}
public boolean isBlock() {
return status == STATUS_BLOCK;
}
public boolean isPending() {
return status == STATUS_PENDING;
}
public boolean isFailed() {
return status == STATUS_FAILED;
}
}

3
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeMessageAckVO.java

@ -18,4 +18,7 @@ public class IeMessageAckVO {
private Long mediaSize;
private String mediaFormat;
private Integer mediaAuditStatus;
private String poster;
/** 给发送方展示的失败/审核说明,接收方可忽略 */
private String auditTip;
}

11
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMediaSecResult.java

@ -1,11 +0,0 @@
package cc.hiver.mall.ie.vo;
import lombok.Data;
@Data
public class IeWechatMediaSecResult {
private boolean submitted;
private Integer errcode;
private String errmsg;
private String traceId;
}

14
hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMsgSecResult.java

@ -1,14 +0,0 @@
package cc.hiver.mall.ie.vo;
import lombok.Data;
@Data
public class IeWechatMsgSecResult {
private boolean pass;
private boolean checked;
private Integer errcode;
private String errmsg;
private String suggest;
private String label;
private Integer strategy;
}

16
hiver-modules/hiver-mall/src/main/resources/db/ie_moment.sql

@ -0,0 +1,16 @@
-- i/e 个人空间动态表(类朋友圈:文字 + 最多5张图片 或 1个视频)
CREATE TABLE IF NOT EXISTS `ie_moment` (
`id` bigint NOT NULL AUTO_INCREMENT COMMENT '主键',
`user_id` bigint NOT NULL COMMENT '发布用户ID',
`content` varchar(1000) DEFAULT NULL COMMENT '文字内容(过滤后)',
`images` varchar(2500) DEFAULT NULL COMMENT '图片URL JSON数组,最多5张',
`video_url` varchar(500) DEFAULT NULL COMMENT '视频URL,最多1个',
`video_poster` varchar(500) DEFAULT NULL COMMENT '视频封面URL',
`status` int DEFAULT '1' COMMENT '0视频审核中 1已发布 2审核未通过',
`audit_task_id` varchar(128) DEFAULT NULL COMMENT '阿里云视频审核任务ID',
`create_time` datetime DEFAULT NULL,
`update_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
KEY `idx_user_time` (`user_id`,`create_time`),
KEY `idx_status` (`status`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='i/e个人空间动态';

9
hiver-modules/hiver-mall/src/main/resources/db/ie_report_admin_fields.sql

@ -0,0 +1,9 @@
ALTER TABLE ie_report
ADD COLUMN region_id VARCHAR(64) NULL COMMENT '校区/学校ID',
ADD COLUMN region_name VARCHAR(100) NULL COMMENT '校区/学校名称',
ADD COLUMN blocked_ie TINYINT DEFAULT 0 COMMENT '是否封禁被举报人的i/e身份',
ADD COLUMN handler_id BIGINT NULL COMMENT '处理人ID';
ALTER TABLE ie_report
ADD INDEX idx_ie_report_region_status (region_id, status, create_time),
ADD INDEX idx_ie_report_reporter_time (reporter_id, create_time);
Loading…
Cancel
Save