diff --git a/hiver-admin/src/main/resources/application.yml b/hiver-admin/src/main/resources/application.yml index 92eb75a1..041e8a53 100644 --- a/hiver-admin/src/main/resources/application.yml +++ b/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/ diff --git a/hiver-admin/test-output/test-report.html b/hiver-admin/test-output/test-report.html index d30f7edf..99dab182 100644 --- a/hiver-admin/test-output/test-report.html +++ b/hiver-admin/test-output/test-report.html @@ -35,7 +35,7 @@ Hiver
  • - 11, 2026 19:44:28 + 13, 2026 16:59:20
  • @@ -84,7 +84,7 @@

    passTest

    -

    19:44:28 / 0.018 secs

    +

    16:59:20 / 0.022 secs

    @@ -92,9 +92,9 @@
    #test-id=1
    passTest
    -06.11.2026 19:44:28 -06.11.2026 19:44:28 -0.018 secs +06.13.2026 16:59:20 +06.13.2026 16:59:20 +0.022 secs
    @@ -104,7 +104,7 @@ Pass - 19:44:28 + 16:59:20 Test passed @@ -128,13 +128,13 @@

    Started

    -

    11, 2026 19:44:28

    +

    13, 2026 16:59:20

    Ended

    -

    11, 2026 19:44:28

    +

    13, 2026 16:59:20

    diff --git a/hiver-modules/hiver-file/src/main/java/cc/hiver/file/controller/UploadController.java b/hiver-modules/hiver-file/src/main/java/cc/hiver/file/controller/UploadController.java index bbf99430..cc484ca4 100644 --- a/hiver-modules/hiver-file/src/main/java/cc/hiver/file/controller/UploadController.java +++ b/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 diff --git a/hiver-modules/hiver-mall/pom.xml b/hiver-modules/hiver-mall/pom.xml index 6e1aead1..73d8c18b 100644 --- a/hiver-modules/hiver-mall/pom.xml +++ b/hiver-modules/hiver-mall/pom.xml @@ -44,6 +44,12 @@ sensitive-word 0.26.0 + + + com.aliyun + green20220302 + 3.3.3 + \ No newline at end of file diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/constant/IeConstants.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/constant/IeConstants.java index 337b0b3e..a1b189af 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/constant/IeConstants.java +++ b/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; diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeAdminController.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeAdminController.java index 381e6890..a82311f3 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeAdminController.java +++ b/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 finishExpiredRooms(Integer limit) { chatService.finishExpiredRooms(limit == null ? 100 : limit); return ResultUtil.success("处理完成"); } + + @RequestMapping(value = "/reports/page", method = RequestMethod.GET) + @ApiOperation("i/e投诉举报分页") + public Result 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 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().setData(chatService.handleReport(handlerId, reportId, status, handleResult, blockIe)); + } + + @RequestMapping(value = "/reports/delete", method = RequestMethod.POST) + @ApiOperation("删除i/e投诉举报") + public Result deleteReport(@RequestParam Long reportId) { + chatService.deleteReport(reportId); + return ResultUtil.success("删除成功"); + } } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeController.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeController.java index ffd3af1c..3ec228d3 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/controller/IeController.java +++ b/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 startMatch(@RequestBody IeMatchStartDTO dto) { @@ -334,143 +266,30 @@ public class IeController { return new ResultUtil>().setData(chatService.pageBlocks(currentUserId(), pageNumber, pageSize)); } + @RequestMapping(value = "/moments", method = RequestMethod.POST) + @ApiOperation("发布个人空间动态") + public Result publishMoment(@RequestBody IeMomentDTO dto) { + return new ResultUtil().setData(momentService.publish(currentUserId(), dto)); + } + + @RequestMapping(value = "/moments/page", method = RequestMethod.GET) + @ApiOperation("分页查询个人空间动态(不传userId看自己;roomId为聊天入口凭证)") + public Result> 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>().setData(momentService.pageMoments(currentUserId(), userId, roomId, pageNumber, pageSize)); + } + + @RequestMapping(value = "/moments/{momentId}", method = RequestMethod.DELETE) + @ApiOperation("删除自己的动态") + public Result 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 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 + ">(?:|(.*?))", 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; - } } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeMomentDTO.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeMomentDTO.java new file mode 100644 index 00000000..49df9e28 --- /dev/null +++ b/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 images; + private String videoUrl; + private String videoPoster; +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeReportDTO.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeReportDTO.java index c8092697..31cbc9e7 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeReportDTO.java +++ b/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; } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeStatusDTO.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeStatusDTO.java index 99639a04..bebc2b98 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/dto/IeStatusDTO.java +++ b/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; diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeMoment.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeMoment.java new file mode 100644 index 00000000..1eb6ae34 --- /dev/null +++ b/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 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; +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRecord.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRecord.java index 8ffd012a..4835f684 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRecord.java +++ b/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; } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeReport.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeReport.java index f36b5f91..5ed62de3 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeReport.java +++ b/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; } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRoomMessage.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRoomMessage.java index f9eda792..3f9deb91 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/entity/IeRoomMessage.java +++ b/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; } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mapper/IeMomentMapper.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mapper/IeMomentMapper.java new file mode 100644 index 00000000..44d0ab24 --- /dev/null +++ b/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 { +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatEventProducer.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatEventProducer.java index e687f266..08d92671 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatEventProducer.java +++ b/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))); diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatMqConfig.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatMqConfig.java index 1e52c1a0..fd287f14 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeChatMqConfig.java +++ b/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 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 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 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 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); diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeMediaAuditConsumer.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeMediaAuditConsumer.java index edeb02b8..e0499923 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/mq/IeMediaAuditConsumer.java +++ b/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); + } + } } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeAliyunModerationService.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeAliyunModerationService.java new file mode 100644 index 00000000..03e8d841 --- /dev/null +++ b/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); +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeChatService.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeChatService.java index 6a5c9620..d7092abf 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeChatService.java +++ b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeChatService.java @@ -52,11 +52,17 @@ public interface IeChatService { IPage pageReports(Long userId, Integer pageNumber, Integer pageSize); + IPage 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 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); } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeMomentService.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeMomentService.java new file mode 100644 index 00000000..985a3674 --- /dev/null +++ b/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 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); +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeRedisService.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeRedisService.java index 4fc58580..ed252976 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeRedisService.java +++ b/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); diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeSecurityAuditService.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeSecurityAuditService.java index f756b649..a03f3905 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeSecurityAuditService.java +++ b/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); } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeWechatSecurityService.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeWechatSecurityService.java deleted file mode 100644 index 5797a98b..00000000 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/IeWechatSecurityService.java +++ /dev/null @@ -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); -} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeAliyunModerationServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeAliyunModerationServiceImpl.java new file mode 100644 index 00000000..f834aa72 --- /dev/null +++ b/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; + } +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeChatServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeChatServiceImpl.java index 253fd464..7abdef17 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeChatServiceImpl.java +++ b/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() + .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() + .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() .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 items; - try { - items = buildMediaAuditItems(message); - } catch (Exception e) { - failMediaAudit(message, e.getMessage()); - return; - } - if (items.isEmpty()) { - failMediaAudit(message, "media audit material missing"); - return; - } - List 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() - .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() + .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 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 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 singletonAuditItem(String url, Integer type) { - if (CharSequenceUtil.isBlank(url) || type == null) { - return Collections.emptyList(); - } - return Collections.singletonList(new MediaAuditItem(url, type)); - } - - private List 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 items = new ArrayList<>(); - List frames; - try (java.util.stream.Stream 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 command) throws IOException, InterruptedException { - boolean success = runFfmpegQuiet(command); - if (!success) { - throw new RuntimeException("ffmpeg command failed: " + String.join(" ", command)); - } - } - - private boolean runFfmpegQuiet(List 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 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 calculateRoomStreak(IeRoom room, boolean rewardEnabled) { Calendar since = Calendar.getInstance(); since.add(Calendar.DAY_OF_YEAR, -STREAK_LOOKBACK_DAYS); List messages = roomMessageMapper.selectList(new LambdaQueryWrapper() .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 pageRecords(Long userId, Integer pageNumber, Integer pageSize) { - Page page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize)); - IPage result = recordMapper.selectPage(page, new LambdaQueryWrapper() + long current = safePageNumber(pageNumber); + long size = safePageSize(pageSize); + Page result = new Page<>(current, size); + List records = recordMapper.selectList(new LambdaQueryWrapper() .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 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 adminPageReports(String regionId, Integer status, Integer pageNumber, Integer pageSize) { + Page page = new Page<>(safePageNumber(pageNumber), safePageSize(pageSize)); + return reportMapper.selectPage(page, new LambdaQueryWrapper() + .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() + .eq(IeUserProfile::getUserId, userId) + .last("limit 1")); + } + @Override public IPage pageBlocks(Long userId, Integer pageNumber, Integer pageSize) { Page 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() .eq(IeRecord::getRoomId, room.getId()) diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMatchServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMatchServiceImpl.java index d62dd379..d40ab903 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMatchServiceImpl.java +++ b/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() .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() .eq(IeUserProfile::getUserId, userId) .last("limit 1")); } + private IeUserStatus findStatusByUserId(Long userId) { + if (userId == null) { + return null; + } + return userStatusMapper.selectOne(new LambdaQueryWrapper() + .eq(IeUserStatus::getUserId, userId) + .last("limit 1")); + } + private List candidateUsers(String targetMode, String selfMode, String mood, String regionId, boolean regionScope) { LinkedHashSet 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); diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMomentServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeMomentServiceImpl.java new file mode 100644 index 00000000..43879ed6 --- /dev/null +++ b/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 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 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 page = new Page<>(pageNumber == null || pageNumber < 1 ? 1 : pageNumber, + pageSize == null || pageSize < 1 || pageSize > 20 ? 10 : pageSize); + LambdaQueryWrapper wrapper = new LambdaQueryWrapper() + .eq(IeMoment::getUserId, ownerId) + .orderByDesc(IeMoment::getCreateTime) + .orderByDesc(IeMoment::getId); + if (!mineSpace) { + wrapper.eq(IeMoment::getStatus, IeMoment.STATUS_PUBLISHED); + } + IPage result = momentMapper.selectPage(page, wrapper); + Map 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() + .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 normalizeImages(List images) { + List 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() + .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 loadProfileMap(Long userId) { + Map map = new HashMap<>(); + IeUserProfile profile = userProfileMapper.selectOne(new LambdaQueryWrapper() + .eq(IeUserProfile::getUserId, userId) + .last("limit 1")); + if (profile != null) { + map.put(userId, profile); + } + return map; + } + + private void decorate(IeMoment moment, Long viewerId, Map 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(); + } + }); + } +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeQuizServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeQuizServiceImpl.java index b5de8def..36b178d2 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeQuizServiceImpl.java +++ b/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 buildView(JSONObject session, Long userId, IeRoom room) { Long otherId = userId.equals(room.getUserAId()) ? room.getUserBId() : room.getUserAId(); JSONObject answers = session.getJSONObject("answers"); diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeRedisServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeRedisServiceImpl.java index 92612af1..d88195a5 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeRedisServiceImpl.java +++ b/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; diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeSecurityAuditServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeSecurityAuditServiceImpl.java index 2b56ddb6..96357795 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeSecurityAuditServiceImpl.java +++ b/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) { diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeWechatSecurityServiceImpl.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeWechatSecurityServiceImpl.java deleted file mode 100644 index 8acc5f2c..00000000 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/service/impl/IeWechatSecurityServiceImpl.java +++ /dev/null @@ -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 body = new HashMap<>(); - body.put("content", content); - body.put("version", 2); - body.put("scene", 4); - body.put("openid", openid); - ResponseEntity 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 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 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 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; - } -} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeAliyunModerationResult.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeAliyunModerationResult.java new file mode 100644 index 00000000..d45ccdf7 --- /dev/null +++ b/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; + } +} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeMessageAckVO.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeMessageAckVO.java index 86c5965c..cee493e1 100644 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeMessageAckVO.java +++ b/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; } diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMediaSecResult.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMediaSecResult.java deleted file mode 100644 index 152af125..00000000 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMediaSecResult.java +++ /dev/null @@ -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; -} diff --git a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMsgSecResult.java b/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMsgSecResult.java deleted file mode 100644 index 737825dd..00000000 --- a/hiver-modules/hiver-mall/src/main/java/cc/hiver/mall/ie/vo/IeWechatMsgSecResult.java +++ /dev/null @@ -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; -} diff --git a/hiver-modules/hiver-mall/src/main/resources/db/ie_moment.sql b/hiver-modules/hiver-mall/src/main/resources/db/ie_moment.sql new file mode 100644 index 00000000..be6138d4 --- /dev/null +++ b/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个人空间动态'; diff --git a/hiver-modules/hiver-mall/src/main/resources/db/ie_report_admin_fields.sql b/hiver-modules/hiver-mall/src/main/resources/db/ie_report_admin_fields.sql new file mode 100644 index 00000000..a7172391 --- /dev/null +++ b/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);