Przeglądaj źródła

评论添加校验 小票审核 和 打卡审核

lutong 2 miesięcy temu
rodzic
commit
c520380d5f

+ 4 - 4
alien-entity/src/main/java/shop/alien/entity/storePlatform/vo/StoreOperationalActivityDTO.java

@@ -33,11 +33,11 @@ public class StoreOperationalActivityDTO {
     private String promotionalImage;
 
     @ApiModelProperty(value = "活动开始时间", required = true)
-    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private Date startTime;
 
     @ApiModelProperty(value = "活动结束时间", required = true)
-    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private Date endTime;
 
     @ApiModelProperty(value = "用户可参与次数,0表示不限制")
@@ -92,11 +92,11 @@ public class StoreOperationalActivityDTO {
     private String activityType;
 
     @ApiModelProperty(value = "报名开始时间")
-    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private Date signupStartTime;
 
     @ApiModelProperty(value = "报名结束时间")
-    @JsonFormat(pattern = "yyyy-MM-dd", timezone = "GMT+8")
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8")
     private Date signupEndTime;
 
     @ApiModelProperty(value = "活动限制人数")

+ 1 - 1
alien-store/src/main/java/shop/alien/store/controller/CommonRatingController.java

@@ -83,7 +83,7 @@ public class CommonRatingController {
             storeId = "#{#commonRating.businessId}",
             targetType = "STORE"
     )
-    @ApiOperation(value = "新增评价", notes = "0:成功, 1:失败, 2:文本内容异常, 3:图片内容异常")
+    @ApiOperation(value = "新增评价", notes = "0:成功(包括审核通过和审核不通过但允许创建的情况), 1:失败, 2:内容审核不通过(直接拒绝)。注意:小票审核和打卡审核不通过时,允许创建评论但审核状态设为不通过并记录审核原因")
     @PostMapping("/addRating")
     public R<Integer> add(@RequestBody CommonRating commonRating) {
         log.info("CommonRatingController.add?commonRating={}", commonRating);

+ 157 - 13
alien-store/src/main/java/shop/alien/store/service/impl/CommonRatingServiceImpl.java

@@ -27,6 +27,7 @@ import shop.alien.entity.store.vo.WebSocketVo;
 import shop.alien.entity.storePlatform.StoreOperationalActivity;
 import shop.alien.mapper.*;
 import shop.alien.mapper.storePlantform.StoreOperationalActivityMapper;
+import shop.alien.util.common.Constants;
 import shop.alien.store.config.WebSocketProcess;
 import shop.alien.store.service.CommonCommentService;
 import shop.alien.store.service.CommonRatingService;
@@ -34,6 +35,7 @@ import shop.alien.store.service.LifeDiscountCouponStoreFriendService;
 import shop.alien.store.util.CommonConstant;
 import shop.alien.store.util.ai.AiContentModerationUtil;
 import shop.alien.store.util.ai.AiVideoModerationUtil;
+import shop.alien.store.util.ai.ReceiptAuditUtil;
 import shop.alien.util.common.DateUtils;
 import shop.alien.util.common.constant.CommentSourceTypeEnum;
 import shop.alien.util.common.constant.RatingBusinessTypeEnum;
@@ -84,6 +86,8 @@ public class CommonRatingServiceImpl extends ServiceImpl<CommonRatingMapper, Com
     private final AiVideoModerationUtil aiVideoModerationUtil;
     private final LifeDiscountCouponStoreFriendService lifeDiscountCouponStoreFriendService;
     private final StoreOperationalActivityMapper storeOperationalActivityMapper;
+    private final ReceiptAuditUtil receiptAuditUtil;
+    private final StoreClockInMapper storeClockInMapper;
 
     public static final List<String> SERVICES_LIST = ImmutableList.of(
             TextReviewServiceEnum.COMMENT_DETECTION_PRO.getService(),
@@ -125,8 +129,7 @@ public class CommonRatingServiceImpl extends ServiceImpl<CommonRatingMapper, Com
                 commonRating.setScoreThree(parse.getDouble("scoreThree"));
             }
             
-            // 2. 先进行文本和图片审核(同步审核,必须通过才能保存)
-            // 处理imageUrls可能为null的情况
+            // 2. 处理图片URL列表
             String imageUrlsStr = commonRating.getImageUrls();
             List<String> imageUrlList = new ArrayList<>();
             if (StringUtils.isNotEmpty(imageUrlsStr)) {
@@ -135,18 +138,113 @@ public class CommonRatingServiceImpl extends ServiceImpl<CommonRatingMapper, Com
             
             // 一次遍历完成分类,避免多次流式处理
             Map<String, List<String>> urlCategoryMap = StoreRenovationRequirementServiceImpl.classifyUrls(imageUrlList);
+            List<String> imageUrls = urlCategoryMap.get("image");
+            List<String> videoUrls = urlCategoryMap.get("video");
+            
+            // 3. 内容审核(基础审核,必须通过)
+            // 文本、图片、视频必须符合法律法规要求
+            AiContentModerationUtil.AuditResult contentAuditResult = new AiContentModerationUtil.AuditResult(true, "");
+            // 只要有内容或图片,就必须进行内容审核
+            if (StringUtils.isNotEmpty(commonRating.getContent()) || !imageUrls.isEmpty()) {
+                contentAuditResult = aiContentModerationUtil.auditContent(commonRating.getContent(), imageUrls);
+            }
+            
+            // 内容审核不通过,直接判定为审核不通过
+            if (!contentAuditResult.isPassed()) {
+                log.warn("评论审核失败:内容审核不通过,不保存评论。原因:{}", contentAuditResult.getFailureReason());
+                return 2; // 返回2表示内容审核不通过
+            }
+            
+            log.info("评论审核:内容审核通过,继续检查其他条件");
+            
+            // 4. 内容审核通过后,检查是否满足以下条件之一(满足任意一个即可):
+            // 条件1:当前用户必须在这个店铺打过卡
+            // 条件2:当前用户上传的图片中包含该店铺的小票(调用AI审核)
+            // 注意:小票审核和打卡审核不通过时,允许创建评论,但审核状态设为不通过并记录审核原因
+            
+            boolean condition1Passed = false; // 条件1:打过卡
+            boolean condition2Passed = false; // 条件2:有小票
+            String auditReason = null; // 审核原因
+            
+            // 检查条件1:用户是否在该店铺打过卡
+            try {
+                LambdaQueryWrapper<StoreClockIn> clockInWrapper = new LambdaQueryWrapper<>();
+                clockInWrapper.eq(StoreClockIn::getUserId, commonRating.getUserId())
+                        .eq(StoreClockIn::getStoreId, commonRating.getBusinessId())
+                        .eq(StoreClockIn::getDeleteFlag, 0)
+                        .last("LIMIT 1");
+                Integer clockInCount = storeClockInMapper.selectCount(clockInWrapper);
+                condition1Passed = clockInCount != null && clockInCount > 0;
+                if (condition1Passed) {
+                    log.info("评论审核条件1通过:用户在该店铺打过卡,userId={}, storeId={}", 
+                            commonRating.getUserId(), commonRating.getBusinessId());
+                } else {
+                    // 打卡审核不通过,记录审核原因
+                    auditReason = "用户未在该店铺打过卡";
+                    log.info("评论审核条件1未通过:用户未在该店铺打过卡,userId={}, storeId={}", 
+                            commonRating.getUserId(), commonRating.getBusinessId());
+                }
+            } catch (Exception e) {
+                log.warn("检查用户打卡记录失败:userId={}, storeId={}", 
+                        commonRating.getUserId(), commonRating.getBusinessId(), e);
+                // 检查异常时,也记录为未通过
+                if (auditReason == null) {
+                    auditReason = "检查用户打卡记录失败";
+                }
+            }
             
-            // 3. 文本和图片审核(同步,必须通过)
-            AiContentModerationUtil.AuditResult auditResult = new AiContentModerationUtil.AuditResult(true, "");
-            // 只要有内容或图片,就必须审核
-            if (StringUtils.isNotEmpty(commonRating.getContent()) || !urlCategoryMap.get("image").isEmpty()) {
-                auditResult = aiContentModerationUtil.auditContent(commonRating.getContent(), urlCategoryMap.get("image"));
+            // 检查条件2:图片中包含该店铺的小票(如果有图片)
+            if (!condition1Passed && !imageUrls.isEmpty()) {
+                try {
+                    // 获取店铺名称用于小票审核
+                    String storeName = null;
+                    StoreInfo storeInfo = storeInfoMapper.selectById(commonRating.getBusinessId());
+                    if (storeInfo != null && StringUtils.isNotEmpty(storeInfo.getStoreName())) {
+                        storeName = storeInfo.getStoreName();
+                    }
+                    
+                    // 调用小票审核接口
+                    ReceiptAuditUtil.ReceiptAuditResult receiptAuditResult = receiptAuditUtil.auditReceipt(imageUrls, storeName);
+                    condition2Passed = receiptAuditResult.isPassed();
+                    
+                    if (condition2Passed) {
+                        log.info("评论审核条件2通过:图片中包含该店铺的小票,isValidProof={}, proofType={}", 
+                                receiptAuditResult.isValidProof(), receiptAuditResult.getProofType());
+                    } else {
+                        // 小票审核不通过,记录审核原因
+                        String receiptReason = receiptAuditResult.getFailureReason();
+                        if (StringUtils.isNotEmpty(receiptReason)) {
+                            auditReason = "小票审核不通过:" + receiptReason;
+                        } else {
+                            auditReason = "小票审核不通过:上传的图片不是有效的消费凭证(小票、支付记录等)";
+                        }
+                        log.info("评论审核条件2未通过:小票审核失败,reason={}", receiptReason);
+                    }
+                } catch (Exception e) {
+                    log.warn("小票审核异常:userId={}, storeId={}", 
+                            commonRating.getUserId(), commonRating.getBusinessId(), e);
+                    // 小票审核异常时,也记录为未通过
+                    if (auditReason == null) {
+                        auditReason = "小票审核异常";
+                    }
+                }
             }
             
-            // 4. 文本/图片审核不通过,直接返回,不保存
-            if (!auditResult.isPassed()) {
-                log.warn("评价审核不通过,不保存评论。原因:{}", auditResult.getFailureReason());
-                return 2; // 返回2表示文本内容异常
+            // 如果两个条件都不满足,设置审核状态为不通过并记录审核原因
+            if (!condition1Passed && !condition2Passed) {
+                // 如果还没有审核原因,设置默认原因
+                if (auditReason == null) {
+                    auditReason = "不满足审核条件:1) 用户未在该店铺打过卡;2) 上传的图片不包含该店铺的小票";
+                }
+                // 设置审核状态为不通过(2-驳回)
+                commonRating.setAuditStatus(2);
+                commonRating.setAuditReason(auditReason);
+                log.warn("评论审核不通过:内容审核通过,但不满足其他条件。条件1(打过卡)={}, 条件2(有小票)={}, 审核原因={}", 
+                        condition1Passed, condition2Passed, auditReason);
+            } else {
+                // 满足条件,审核状态保持为待审核(0),等待视频审核
+                log.info("评论审核通过:内容审核通过,且满足条件1(打过卡)={} 或 条件2(有小票)={}", 
+                        condition1Passed, condition2Passed);
             }
             
             // 5. 文本/图片审核通过,保存评论(状态为待审核,等待视频审核)
@@ -156,8 +254,54 @@ public class CommonRatingServiceImpl extends ServiceImpl<CommonRatingMapper, Com
                 return 1;
             }
             
-            // 6. 如果有视频,进行异步视频审核
-            List<String> videoUrls = urlCategoryMap.get("video");
+            // 6. 如果审核不通过(小票审核或打卡审核不通过),发送websocket通知给用户
+            if (commonRating.getAuditStatus() != null && commonRating.getAuditStatus() == 2 && StringUtils.isNotEmpty(auditReason)) {
+                try {
+                    // 获取用户信息
+                    LifeUser lifeUser = lifeUserMapper.selectById(commonRating.getUserId());
+                    if (lifeUser != null && StringUtils.isNotEmpty(lifeUser.getUserPhone())) {
+                        String receiverId = "user_" + lifeUser.getUserPhone();
+                        
+                        // 构建通知内容
+                        JSONObject contextJson = new JSONObject();
+                        contextJson.put("ratingId", commonRating.getId());
+                        contextJson.put("storeId", commonRating.getBusinessId());
+                        contextJson.put("message", "您的评价审核未通过:" + auditReason);
+                        contextJson.put("auditReason", auditReason);
+                        
+                        // 保存通知到数据库
+                        LifeNotice lifeNotice = new LifeNotice();
+                        lifeNotice.setSenderId("system");
+                        lifeNotice.setReceiverId(receiverId);
+                        lifeNotice.setBusinessId(commonRating.getBusinessId());
+                        lifeNotice.setTitle("评价审核通知");
+                        lifeNotice.setContext(contextJson.toJSONString());
+                        lifeNotice.setNoticeType(Constants.Notice.SYSTEM_NOTICE); // 系统通知
+                        lifeNotice.setIsRead(0);
+                        lifeNoticeMapper.insert(lifeNotice);
+                        
+                        // 通过WebSocket发送实时通知
+                        WebSocketVo webSocketVo = new WebSocketVo();
+                        webSocketVo.setSenderId("system");
+                        webSocketVo.setReceiverId(receiverId);
+                        webSocketVo.setCategory("notice");
+                        webSocketVo.setNoticeType("1");
+                        webSocketVo.setIsRead(0);
+                        webSocketVo.setText(JSONObject.from(lifeNotice).toJSONString());
+                        
+                        webSocketProcess.sendMessage(receiverId, JSONObject.from(webSocketVo).toJSONString());
+                        log.info("评价审核不通过通知发送成功,ratingId={}, receiverId={}, auditReason={}", 
+                                commonRating.getId(), receiverId, auditReason);
+                    } else {
+                        log.warn("无法发送评价审核通知:用户信息不存在或手机号为空,userId={}", commonRating.getUserId());
+                    }
+                } catch (Exception e) {
+                    log.error("发送评价审核不通过通知失败,ratingId={}, error={}", 
+                            commonRating.getId(), e.getMessage(), e);
+                }
+            }
+            
+            // 7. 如果有视频,进行异步视频审核
             if (!videoUrls.isEmpty()) {
                 CompletableFuture.runAsync(() -> {
                     AiVideoModerationUtil.VideoAuditResult videoAuditResult = null;

+ 227 - 0
alien-store/src/main/java/shop/alien/store/util/ai/ReceiptAuditUtil.java

@@ -0,0 +1,227 @@
+package shop.alien.store.util.ai;
+
+import com.alibaba.fastjson2.JSONObject;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.cloud.context.config.annotation.RefreshScope;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+import org.springframework.util.StringUtils;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * 小票审核工具类
+ * 调用消费凭证核验接口审核评论图片是否为真实消费凭证(小票、支付记录等)
+ */
+@Slf4j
+@Component
+@RefreshScope
+@RequiredArgsConstructor
+public class ReceiptAuditUtil {
+
+    private final RestTemplate restTemplate;
+    private final AiAuthTokenUtil aiAuthTokenUtil;
+
+    /**
+     * 小票审核接口地址
+     */
+    @Value("${ai.service.receipt-audit-url:http://124.93.18.180:9000/ai/multimodal-services/api/v1/consumption-proof/verify}")
+    private String receiptAuditUrl;
+
+    /**
+     * 审核结果类
+     */
+    public static class ReceiptAuditResult {
+        private boolean passed;
+        private String failureReason;
+        private boolean isValidProof;
+        private String proofType;
+
+        public ReceiptAuditResult(boolean passed, String failureReason) {
+            this.passed = passed;
+            this.failureReason = failureReason;
+        }
+
+        public ReceiptAuditResult(boolean passed, String failureReason, boolean isValidProof, String proofType) {
+            this.passed = passed;
+            this.failureReason = failureReason;
+            this.isValidProof = isValidProof;
+            this.proofType = proofType;
+        }
+
+        public boolean isPassed() {
+            return passed;
+        }
+
+        public String getFailureReason() {
+            return failureReason;
+        }
+
+        public boolean isValidProof() {
+            return isValidProof;
+        }
+
+        public String getProofType() {
+            return proofType;
+        }
+    }
+
+    /**
+     * 审核评论图片是否为真实消费凭证
+     *
+     * @param imageUrls 图片URL列表
+     * @param storeName 店铺名称(可选,用于核验凭证中的商户是否匹配)
+     * @return 审核结果
+     */
+    public ReceiptAuditResult auditReceipt(List<String> imageUrls, String storeName) {
+        log.info("开始小票审核:imageCount={}, storeName={}",
+                imageUrls != null ? imageUrls.size() : 0, storeName);
+
+        try {
+            // 如果没有图片,直接返回审核通过(不强制要求必须有小票)
+            if (imageUrls == null || imageUrls.isEmpty()) {
+                log.info("没有图片,跳过小票审核");
+                return new ReceiptAuditResult(true, null, false, null);
+            }
+
+            // 调用审核接口
+            return callReceiptAuditApi(imageUrls, storeName);
+
+        } catch (Exception e) {
+            log.error("小票审核异常", e);
+            // 审核异常时,为了不影响用户体验,默认通过(但记录日志)
+            return new ReceiptAuditResult(true, "小票审核异常,已通过", false, null);
+        }
+    }
+
+    /**
+     * 调用小票审核接口
+     *
+     * @param imageUrls 图片URL列表
+     * @param storeName 店铺名称
+     * @return 审核结果
+     */
+    private ReceiptAuditResult callReceiptAuditApi(List<String> imageUrls, String storeName) {
+        try {
+            // 获取访问令牌
+            String accessToken = aiAuthTokenUtil.getAccessToken();
+            if (!StringUtils.hasText(accessToken)) {
+                log.error("调用小票审核接口失败,获取accessToken失败");
+                return new ReceiptAuditResult(true, "获取访问令牌失败,已通过", false, null);
+            }
+            
+            // 构建请求头
+            HttpHeaders headers = new HttpHeaders();
+            headers.setContentType(MediaType.APPLICATION_JSON);
+            headers.set("Authorization", "Bearer " + accessToken);
+
+            // 构建请求体
+            JSONObject requestBody = new JSONObject();
+            requestBody.put("image_urls", imageUrls);
+            if (StringUtils.hasText(storeName)) {
+                requestBody.put("store_name", storeName);
+            }
+
+            String requestBodyStr = requestBody.toJSONString();
+            if (requestBodyStr == null || requestBodyStr.isEmpty()) {
+                log.error("构建小票审核请求体失败");
+                return new ReceiptAuditResult(true, "构建请求体失败,已通过", false, null);
+            }
+            // 确保 requestBodyStr 不为 null(用于消除 IDE 警告)
+            String nonNullRequestBody = Objects.requireNonNull(requestBodyStr);
+            HttpEntity<String> requestEntity = new HttpEntity<>(nonNullRequestBody, headers);
+
+            log.info("调用小票审核接口:url={}, imageCount={}, storeName={}",
+                    receiptAuditUrl, imageUrls.size(), storeName);
+
+            // 发送请求
+            ResponseEntity<String> response = restTemplate.postForEntity(receiptAuditUrl, requestEntity, String.class);
+
+            if (response.getStatusCode() == HttpStatus.OK) {
+                String responseBody = response.getBody();
+                log.info("小票审核接口响应:{}", responseBody);
+
+                if (StringUtils.hasText(responseBody)) {
+                    JSONObject jsonResponse = JSONObject.parseObject(responseBody);
+                    return parseReceiptAuditResult(jsonResponse);
+                } else {
+                    log.error("小票审核接口返回空响应");
+                    return new ReceiptAuditResult(true, "小票审核接口返回空响应,已通过", false, null);
+                }
+            } else {
+                log.error("小票审核接口调用失败,状态码:{}", response.getStatusCode());
+                return new ReceiptAuditResult(true, "小票审核接口调用失败,已通过", false, null);
+            }
+
+        } catch (Exception e) {
+            log.error("调用小票审核接口异常", e);
+            return new ReceiptAuditResult(true, "小票审核接口调用异常,已通过", false, null);
+        }
+    }
+
+    /**
+     * 解析小票审核结果
+     *
+     * @param jsonResponse API响应JSON
+     * @return 审核结果
+     */
+    private ReceiptAuditResult parseReceiptAuditResult(JSONObject jsonResponse) {
+        try {
+            // API返回格式:
+            // {
+            //     "code": 200,
+            //     "message": "操作成功",
+            //     "data": {
+            //         "can_comment": true/false,
+            //         "is_valid_proof": true/false,
+            //         "proof_type": "小票"/"支付记录"等,
+            //         "reason": "核验结论说明",
+            //         "image_results": [...]
+            //     },
+            //     "timestamp": 1704067200.123
+            // }
+
+            Integer code = jsonResponse.getInteger("code");
+            if (code == null || code != 200) {
+                String message = jsonResponse.getString("message");
+                log.warn("小票审核接口返回错误:code={}, message={}", code, message);
+                // 接口返回错误时,为了不影响用户体验,默认通过
+                return new ReceiptAuditResult(true, "小票审核接口返回错误,已通过", false, null);
+            }
+
+            JSONObject data = jsonResponse.getJSONObject("data");
+            if (data == null) {
+                log.warn("小票审核接口返回数据为空");
+                return new ReceiptAuditResult(true, "小票审核接口返回数据为空,已通过", false, null);
+            }
+
+            Boolean canComment = data.getBoolean("can_comment");
+            Boolean isValidProof = data.getBoolean("is_valid_proof");
+            String proofType = data.getString("proof_type");
+            String reason = data.getString("reason");
+
+            if (canComment != null && canComment) {
+                // 允许评论,审核通过
+                log.info("小票审核通过:isValidProof={}, proofType={}, reason={}", isValidProof, proofType, reason);
+                return new ReceiptAuditResult(true, null, isValidProof != null && isValidProof, proofType);
+            } else {
+                // 不允许评论,审核失败
+                String failureReason = StringUtils.hasText(reason) ? reason : "图片不是有效的消费凭证";
+                log.warn("小票审核失败:reason={}", failureReason);
+                return new ReceiptAuditResult(false, failureReason, isValidProof != null && isValidProof, proofType);
+            }
+
+        } catch (Exception e) {
+            log.error("解析小票审核结果异常", e);
+            return new ReceiptAuditResult(true, "解析小票审核结果异常,已通过", false, null);
+        }
+    }
+}