2 Commits d51128850e ... 5abe326188

Autor SHA1 Mensaje Fecha
  yys 5abe326188 1、新增直播完课答题记录 hace 3 días
  yys f6d61400be 1、sop发送直播卡片 hace 3 días
Se han modificado 23 ficheros con 718 adiciones y 54 borrados
  1. 56 0
      fs-admin/src/main/java/com/fs/live/controller/LiveCompletionAnswerRecordController.java
  2. 17 15
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  3. 94 6
      fs-ipad-task/src/main/java/com/fs/app/task/SendAppMsg.java
  4. 38 2
      fs-service/src/main/java/com/fs/gtPush/service/impl/uniPush2ServiceImpl.java
  5. 1 1
      fs-service/src/main/java/com/fs/gtPush/service/uniPush2Service.java
  6. 3 0
      fs-service/src/main/java/com/fs/his/dto/PayloadDTO.java
  7. 1 1
      fs-service/src/main/java/com/fs/im/service/OpenIMService.java
  8. 33 5
      fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java
  9. 47 0
      fs-service/src/main/java/com/fs/live/domain/LiveCompletionAnswerRecord.java
  10. 20 0
      fs-service/src/main/java/com/fs/live/mapper/LiveCompletionAnswerRecordMapper.java
  11. 27 0
      fs-service/src/main/java/com/fs/live/param/LiveCompletionAnswerRecordParam.java
  12. 33 0
      fs-service/src/main/java/com/fs/live/service/ILiveCompletionAnswerRecordService.java
  13. 71 0
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionAnswerRecordServiceImpl.java
  14. 44 7
      fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionCouponServiceImpl.java
  15. 24 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerDetailVO.java
  16. 39 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerRecordListVO.java
  17. 3 0
      fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java
  18. 5 2
      fs-service/src/main/java/com/fs/qw/service/impl/AsyncSopTestService.java
  19. 94 13
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  20. 62 0
      fs-service/src/main/resources/mapper/live/LiveCompletionAnswerRecordMapper.xml
  21. 1 0
      fs-service/src/main/resources/mapper/live/LiveWatchLogMapper.xml
  22. 4 1
      fs-service/src/main/resources/mapper/sop/QwSopLogsMapper.xml
  23. 1 1
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java

+ 56 - 0
fs-admin/src/main/java/com/fs/live/controller/LiveCompletionAnswerRecordController.java

@@ -0,0 +1,56 @@
+package com.fs.live.controller;
+
+import com.fs.common.annotation.Log;
+import com.fs.common.core.controller.BaseController;
+import com.fs.common.core.domain.AjaxResult;
+import com.fs.common.core.page.TableDataInfo;
+import com.fs.common.enums.BusinessType;
+import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.service.ILiveCompletionAnswerRecordService;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Controller
+ */
+@RestController
+@RequestMapping("/live/liveCompletionAnswerRecord")
+public class LiveCompletionAnswerRecordController extends BaseController {
+
+    @Autowired
+    private ILiveCompletionAnswerRecordService liveCompletionAnswerRecordService;
+
+    @PreAuthorize("@ss.hasPermi('live:liveCompletionAnswerRecord:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(LiveCompletionAnswerRecordParam param) {
+        startPage();
+        List<LiveCompletionAnswerRecordListVO> list =
+                liveCompletionAnswerRecordService.selectLiveCompletionAnswerRecordList(param);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:liveCompletionAnswerRecord:export')")
+    @Log(title = "直播完课答题记录", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(LiveCompletionAnswerRecordParam param) {
+        List<LiveCompletionAnswerRecordListVO> list =
+                liveCompletionAnswerRecordService.selectLiveCompletionAnswerRecordList(param);
+        ExcelUtil<LiveCompletionAnswerRecordListVO> util =
+                new ExcelUtil<>(LiveCompletionAnswerRecordListVO.class);
+        return util.exportExcel(list, "直播完课答题记录");
+    }
+
+    @PreAuthorize("@ss.hasPermi('live:liveCompletionAnswerRecord:query')")
+    @GetMapping("/{id}")
+    public AjaxResult getInfo(@PathVariable("id") Long id) {
+        return AjaxResult.success(liveCompletionAnswerRecordService.selectLiveCompletionAnswerRecordById(id));
+    }
+}

+ 17 - 15
fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java

@@ -804,7 +804,7 @@ public class IpadSendServer {
                     sendAppShortLink(vo, content, miniMap);
                     break;
                 case "25":
-                    // APP直播卡片走 OpenIM WebSocket,企微侧跳过
+                    // APP直播卡片走 OpenIM IM 发送,企微侧跳过
                     content.setSendStatus(0);
                     content.setSendRemarks("APP待发送");
                     break;
@@ -1286,6 +1286,10 @@ public class IpadSendServer {
             log.info("不包含app课程:{}, LOGID: {}", qwUser.getQwUserName(), qwSopLogs.getId());
             return false;
         }
+        if (qwSopLogs.getSendStatus() != null && qwSopLogs.getSendStatus() != 3L) {
+            log.info("状态异常不发送APP:{}, LOGID: {}, sendStatus: {}", qwUser.getQwUserName(), qwSopLogs.getId(), qwSopLogs.getSendStatus());
+            return false;
+        }
         if(redisCache.getCacheObject("qw:user:id:" + qwUser.getId()) != null){
             log.info("频率异常不发送:{}", qwUser.getQwUserName());
             return false;
@@ -1345,20 +1349,18 @@ public class IpadSendServer {
                     }
                 }
             }
-            if (queryLiveId != null) {
-                LiveWatchLog liveWatchLog = liveWatchLogMapper.selectOneLogByLiveIdAndQwUserIdAndExternalId(
-                        queryLiveId, String.valueOf(qwUser.getId()), qwSopLogs.getExternalId());
-                if (liveWatchLog == null) {
-                    log.warn("SOP_LOG_ID:{}, APP直播卡片无观看记录,不发送", qwSopLogs.getId());
-                    qwSopLogsService.updateQwSopLogsByWatchLogType(qwSopLogs.getId(), "无观看记录,不发送");
-                    return false;
-                }
-                Integer courseType = setting.getCourseType();
-                if (courseType != null && courseType != 0
-                        && !QwSopLogsServiceImpl.isCourseTypeValid(courseType, liveWatchLog.getLogType())) {
-                    qwSopLogsService.updateQwSopLogsByWatchLogType(qwSopLogs.getId(), "看课状态未满足,不发送");
-                    return false;
-                }
+            LiveWatchLog liveWatchLog = liveWatchLogMapper.selectOneLogByLiveIdAndQwUserIdAndExternalId(
+                    queryLiveId, String.valueOf(qwUser.getId()), qwSopLogs.getExternalId());
+            if (liveWatchLog == null) {
+                log.warn("SOP_LOG_ID:{}, APP直播卡片无观看记录,不发送", qwSopLogs.getId());
+                qwSopLogsService.updateQwSopLogsByWatchLogType(qwSopLogs.getId(), "无观看记录,不发送");
+                return false;
+            }
+            Integer courseType = setting.getCourseType();
+            if (courseType != null && courseType != 0
+                    && !QwSopLogsServiceImpl.isCourseTypeValid(courseType, liveWatchLog.getLogType())) {
+                qwSopLogsService.updateQwSopLogsByWatchLogType(qwSopLogs.getId(), "看课状态未满足,不发送");
+                return false;
             }
             return true;
         }

+ 94 - 6
fs-ipad-task/src/main/java/com/fs/app/task/SendAppMsg.java

@@ -8,6 +8,9 @@ import com.fs.app.service.IpadSendServer;
 import com.fs.common.core.redis.RedisCacheT;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.PubFun;
+import com.fs.live.domain.LiveWatchLog;
+import com.fs.live.mapper.LiveWatchLogMapper;
+import com.fs.qw.domain.QwExternalContact;
 import com.fs.qw.domain.QwIpadServer;
 import com.fs.qw.domain.QwUser;
 import com.fs.qw.mapper.*;
@@ -48,6 +51,8 @@ public class SendAppMsg {
     private final QwIpadServerMapper qwIpadServerMapper;
     private final RedisCacheT<Long> redisCache;
     private final QwPushCountMapper qwPushCountMapper;
+    private final QwExternalContactMapper qwExternalContactMapper;
+    private final LiveWatchLogMapper liveWatchLogMapper;
     @Value("${group-no}")
     private String groupNo;
     private final List<QwUser> qwUserList = Collections.synchronizedList(new ArrayList<>());
@@ -58,7 +63,7 @@ public class SendAppMsg {
     private ThreadPoolTaskExecutor customThreadPool;
 
 
-    public SendAppMsg(QwUserMapper qwUserMapper, QwSopLogsMapper qwSopLogsMapper, IpadSendServer sendServer, SysConfigMapper sysConfigMapper, IQwSopLogsService qwSopLogsService, AsyncSopTestService asyncSopTestService, QwIpadServerMapper qwIpadServerMapper, RedisCacheT<Long> redisCache, QwPushCountMapper qwPushCountMapper) {
+    public SendAppMsg(QwUserMapper qwUserMapper, QwSopLogsMapper qwSopLogsMapper, IpadSendServer sendServer, SysConfigMapper sysConfigMapper, IQwSopLogsService qwSopLogsService, AsyncSopTestService asyncSopTestService, QwIpadServerMapper qwIpadServerMapper, RedisCacheT<Long> redisCache, QwPushCountMapper qwPushCountMapper, QwExternalContactMapper qwExternalContactMapper, LiveWatchLogMapper liveWatchLogMapper) {
         this.qwUserMapper = qwUserMapper;
         this.qwSopLogsMapper = qwSopLogsMapper;
         this.sendServer = sendServer;
@@ -68,6 +73,76 @@ public class SendAppMsg {
         this.qwIpadServerMapper = qwIpadServerMapper;
         this.redisCache = redisCache;
         this.qwPushCountMapper = qwPushCountMapper;
+        this.qwExternalContactMapper = qwExternalContactMapper;
+        this.liveWatchLogMapper = liveWatchLogMapper;
+    }
+
+    private Long resolveLiveId(QwSopCourseFinishTempSetting setting) {
+        if (setting == null) {
+            return null;
+        }
+        if (setting.getLiveId() != null) {
+            return setting.getLiveId();
+        }
+        if (setting.getSetting() == null) {
+            return null;
+        }
+        for (QwSopCourseFinishTempSetting.Setting st : setting.getSetting()) {
+            if (StringUtils.hasText(st.getLiveId())) {
+                return Long.valueOf(st.getLiveId());
+            }
+        }
+        return null;
+    }
+
+    private QwExternalContact loadExternalContact(QwSopLogs qwSopLogs) {
+        if (qwSopLogs.getExternalId() == null) {
+            return null;
+        }
+        return qwExternalContactMapper.selectQwExternalContactById(qwSopLogs.getExternalId());
+    }
+
+    private LiveWatchLog loadLiveWatchLog(QwSopLogs qwSopLogs, QwUser qwUser, QwSopCourseFinishTempSetting setting) {
+        Long liveId = resolveLiveId(setting);
+        if (liveId == null || qwSopLogs.getExternalId() == null || qwUser == null) {
+            return null;
+        }
+        return liveWatchLogMapper.selectOneLogByLiveIdAndQwUserIdAndExternalId(
+                liveId, String.valueOf(qwUser.getId()), qwSopLogs.getExternalId());
+    }
+
+    /**
+     * 优先发送记录 fsUserId,其次客户 fs_user_id,再次 live_watch_log.user_id
+     */
+    private Long resolveFsUserId(QwSopLogs qwSopLogs, QwUser qwUser, QwSopCourseFinishTempSetting setting) {
+        Long fsUserId = qwSopLogs.getFsUserId();
+        if (fsUserId != null && fsUserId > 0) {
+            return fsUserId;
+        }
+        QwExternalContact contact = loadExternalContact(qwSopLogs);
+        if (contact != null && contact.getFsUserId() != null && contact.getFsUserId() > 0) {
+            return contact.getFsUserId();
+        }
+        LiveWatchLog watchLog = loadLiveWatchLog(qwSopLogs, qwUser, setting);
+        if (watchLog != null && watchLog.getUserId() != null && watchLog.getUserId() > 0) {
+            return watchLog.getUserId();
+        }
+        return null;
+    }
+
+    private Long resolveCompanyUserId(QwUser qwUser, QwSopLogs qwSopLogs, QwSopCourseFinishTempSetting setting) {
+        if (qwUser != null && qwUser.getCompanyUserId() != null) {
+            return qwUser.getCompanyUserId();
+        }
+        QwExternalContact contact = loadExternalContact(qwSopLogs);
+        if (contact != null && contact.getCompanyUserId() != null) {
+            return contact.getCompanyUserId();
+        }
+        LiveWatchLog watchLog = loadLiveWatchLog(qwSopLogs, qwUser, setting);
+        if (watchLog != null && watchLog.getCompanyUserId() != null) {
+            return watchLog.getCompanyUserId();
+        }
+        return null;
     }
 
     private List<QwUser> getQwUserList() {
@@ -148,6 +223,9 @@ public class SendAppMsg {
         // 循环待发送消息
         for (QwSopLogs qwSopLogs : qwSopLogList) {
             long start2 = System.currentTimeMillis();
+            if (qwSopLogs.getSendStatus() != null && qwSopLogs.getSendStatus() != 3L) {
+                continue;
+            }
             QwSopCourseFinishTempSetting setting = JSON.parseObject(qwSopLogs.getContentJson(), QwSopCourseFinishTempSetting.class);
             // 判断消息状态是否满足发送条件
             if (!sendServer.isSendAppLogs(qwSopLogs, setting, user)) {
@@ -166,12 +244,22 @@ public class SendAppMsg {
             if (setting.getType() != 4 && !CollectionUtils.isEmpty(settings) && settings.stream().anyMatch(e -> typeList.contains(e.getContentType()))) {
                 // 发送前次数限制校验
                 Long qwUserId = qwUser.getId();
-                Long customerId = qwSopLogs.getFsUserId();
+                Long fsUserId = resolveFsUserId(qwSopLogs, user, setting);
+                Long resolvedCompanyUserId = resolveCompanyUserId(user, qwSopLogs, setting);
                 Long companyId = qwSopLogs.getCompanyId();
+                if (fsUserId == null || resolvedCompanyUserId == null) {
+                    log.warn("SendAppMsg-缺少IM发送参数, logId={}, fsUserId={}, companyUserId={}, externalId={}",
+                            qwSopLogs.getId(), fsUserId, resolvedCompanyUserId, qwSopLogs.getExternalId());
+                    continue;
+                }
                 boolean txtSendStatus = true;
                 boolean mp3SendStatus = true;
                 boolean courseSendStatus = true;
                 boolean liveCardSendStatus = true;
+                boolean hasLiveCard = allContent.stream().anyMatch(e -> "25".equals(e.getContentType()));
+                if (hasLiveCard) {
+                    liveCardSendStatus = false;
+                }
 //                for (QwSopCourseFinishTempSetting.Setting content : allContent) {
 //                    String contentType = content.getContentType();
 //                    if (!typeList.contains(contentType)) {
@@ -212,24 +300,24 @@ public class SendAppMsg {
                         List<QwSopCourseFinishTempSetting.Setting> linkList = allContent.stream().filter(e -> "9".equals(e.getContentType())).collect(Collectors.toList());
 
                         if (!linkList.isEmpty()) {
-                            courseSendStatus = asyncSopTestService.asyncSendMsgBySopAppLinkNormalIM(linkList, qwSopLogs.getCorpId(), user.getCompanyUserId(), qwSopLogs.getFsUserId(), qwSopLogs.getId());
+                            courseSendStatus = asyncSopTestService.asyncSendMsgBySopAppLinkNormalIM(linkList, qwSopLogs.getCorpId(), resolvedCompanyUserId, fsUserId, qwSopLogs.getId());
                         }
                         //app文本消息
                         log.info("开始发送app文本消息消息开始,消息{},用户{}", com.alibaba.fastjson.JSONObject.toJSONString(allContent), user.getQwUserName());
                         List<QwSopCourseFinishTempSetting.Setting> txtList = allContent.stream().filter(e -> "15".equals(e.getContentType())).collect(Collectors.toList());
 
                         if (!txtList.isEmpty()) {
-                            txtSendStatus = asyncSopTestService.asyncSendMsgBySopAppTxtNormalIM(txtList, qwSopLogs.getCorpId(), qwUser.getCompanyUserId(), qwSopLogs.getFsUserId(), qwSopLogs.getId());
+                            txtSendStatus = asyncSopTestService.asyncSendMsgBySopAppTxtNormalIM(txtList, qwSopLogs.getCorpId(), resolvedCompanyUserId, fsUserId, qwSopLogs.getId());
                         }
                         //app语音消息
                         log.info("开始发送app语音消息消息开始,消息{},用户{}", com.alibaba.fastjson.JSONObject.toJSONString(allContent), user.getQwUserName());
                         List<QwSopCourseFinishTempSetting.Setting> voiceList = allContent.stream().filter(e -> "16".equals(e.getContentType())).collect(Collectors.toList());
                         if (!voiceList.isEmpty()) {
-                            mp3SendStatus = asyncSopTestService.asyncSendMsgBySopAppMP3NormalIM(voiceList, qwSopLogs.getCorpId(), qwUser.getCompanyUserId(), qwSopLogs.getFsUserId(), qwSopLogs.getId());
+                            mp3SendStatus = asyncSopTestService.asyncSendMsgBySopAppMP3NormalIM(voiceList, qwSopLogs.getCorpId(), resolvedCompanyUserId, fsUserId, qwSopLogs.getId());
                         }
                         List<QwSopCourseFinishTempSetting.Setting> liveCardList = allContent.stream().filter(e -> "25".equals(e.getContentType())).collect(Collectors.toList());
                         if (!liveCardList.isEmpty()) {
-                            liveCardSendStatus = asyncSopTestService.asyncSendMsgBySopAppLiveCardNormalIM(liveCardList, qwSopLogs.getCorpId(), user.getCompanyUserId(), qwSopLogs.getFsUserId(), qwSopLogs.getId());
+                            liveCardSendStatus = asyncSopTestService.asyncSendMsgBySopAppLiveCardNormalIM(liveCardList, qwSopLogs.getCorpId(), resolvedCompanyUserId, fsUserId, companyId, qwSopLogs.getExternalId(), user.getId(), qwSopLogs.getId());
                         }
                         // 发送成功后记录次数
                         // 发送成功后记录次数(只记录真正发送的 content)

+ 38 - 2
fs-service/src/main/java/com/fs/gtPush/service/impl/uniPush2ServiceImpl.java

@@ -29,6 +29,10 @@ import org.springframework.beans.factory.annotation.Autowired;
 import com.fs.gtPush.utils.PushUtils;
 import com.fs.his.domain.FsUser;
 import com.fs.his.service.IFsUserService;
+import com.fs.live.domain.LiveWatchLog;
+import com.fs.live.mapper.LiveWatchLogMapper;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.mapper.QwExternalContactMapper;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
@@ -60,6 +64,12 @@ public class uniPush2ServiceImpl implements uniPush2Service {
     @Autowired
     private IMConfig imConfig;
 
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+
+    @Autowired
+    private LiveWatchLogMapper liveWatchLogMapper;
+
     @Override
     public PushResult pushMessage(PushReqBean push) {
         SysConfig config = iSysConfigService.selectConfigByConfigKey("his.config");
@@ -105,7 +115,26 @@ public class uniPush2ServiceImpl implements uniPush2Service {
     }
 
     @Override
-    public OpenImResponseDTO pushSopAppLiveCardMsgByExternalIM(String cropId, String title, String linkImageUrl, String link, Long companyUserId, Long fsUserId) throws JsonProcessingException {
+    public OpenImResponseDTO pushSopAppLiveCardMsgByExternalIM(String cropId, String title, String appRealLink, Long liveId, Long companyUserId, Long fsUserId, Long companyId, Long externalId, Long qwUserKey) throws JsonProcessingException {
+        if ((fsUserId == null || fsUserId == 0) && externalId != null) {
+            QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactById(externalId);
+            if (contact != null && contact.getFsUserId() != null && contact.getFsUserId() > 0) {
+                fsUserId = contact.getFsUserId();
+            }
+            if (companyUserId == null && contact != null && contact.getCompanyUserId() != null) {
+                companyUserId = contact.getCompanyUserId();
+            }
+        }
+        if ((fsUserId == null || fsUserId == 0) && liveId != null && externalId != null && qwUserKey != null) {
+            LiveWatchLog watchLog = liveWatchLogMapper.selectOneLogByLiveIdAndQwUserIdAndExternalId(
+                    liveId, String.valueOf(qwUserKey), externalId);
+            if (watchLog != null && watchLog.getUserId() != null && watchLog.getUserId() > 0) {
+                fsUserId = watchLog.getUserId();
+            }
+            if (companyUserId == null && watchLog != null && watchLog.getCompanyUserId() != null) {
+                companyUserId = watchLog.getCompanyUserId();
+            }
+        }
         if (companyUserId == null || fsUserId == null || fsUserId == 0) {
             OpenImResponseDTO errorResponse = new OpenImResponseDTO();
             errorResponse.setErrCode(-1);
@@ -113,6 +142,13 @@ public class uniPush2ServiceImpl implements uniPush2Service {
             errorResponse.setErrDlt("缺少必要参数");
             return errorResponse;
         }
+        if (liveId == null) {
+            OpenImResponseDTO errorResponse = new OpenImResponseDTO();
+            errorResponse.setErrCode(-1);
+            errorResponse.setErrMsg("参数错误:直播间ID为空");
+            errorResponse.setErrDlt("缺少liveId");
+            return errorResponse;
+        }
 
         FsUser fsUser = userService.selectFsUserByUserId(fsUserId);
         if (fsUser == null) {
@@ -123,7 +159,7 @@ public class uniPush2ServiceImpl implements uniPush2Service {
             return errorResponse;
         }
 
-        return openIMService.sendLive(fsUserId, companyUserId, link, title, linkImageUrl, cropId);
+        return openIMService.sendLive(fsUserId, companyUserId, appRealLink, title, liveId, cropId, companyId);
     }
 
     /**

+ 1 - 1
fs-service/src/main/java/com/fs/gtPush/service/uniPush2Service.java

@@ -14,6 +14,6 @@ public interface uniPush2Service {
     PushReqBean getParam(Long userId,String purl,String title,String content,Float type,Integer desType,String imJsonString);
 //    void pushSopAppLinkMsgByExternalIM(String cropId,String linkTile,String linkDescribe,String linkImageUrl,String link,Long companyUserId,Long fsUserId) throws JsonProcessingException;
     OpenImResponseDTO pushSopAppLinkMsgByExternalIM(String cropId, String linkTile, String linkDescribe, String linkImageUrl, String link, Long companyUserId, Long fsUserId) throws JsonProcessingException;
-    OpenImResponseDTO pushSopAppLiveCardMsgByExternalIM(String cropId, String title, String linkImageUrl, String link, Long companyUserId, Long fsUserId) throws JsonProcessingException;
+    OpenImResponseDTO pushSopAppLiveCardMsgByExternalIM(String cropId, String title, String appRealLink, Long liveId, Long companyUserId, Long fsUserId, Long companyId, Long externalId, Long qwUserKey) throws JsonProcessingException;
     void pushIm(Long userId, Long businessId, String purl, String title, String content, Float type, Integer desType,String imJsonString);
 }

+ 3 - 0
fs-service/src/main/java/com/fs/his/dto/PayloadDTO.java

@@ -1,6 +1,7 @@
 package com.fs.his.dto;
 
 import com.fasterxml.jackson.annotation.JsonInclude;
+import com.fs.live.domain.Live;
 import lombok.Data;
 
 import java.io.Serializable;
@@ -36,6 +37,8 @@ public class PayloadDTO implements Serializable {
         private Long companyUserId;
         private Long doctorId;
         private Long userInformationId;
+        private Long liveId;
+        private Live live;
     }
 
 }

+ 1 - 1
fs-service/src/main/java/com/fs/im/service/OpenIMService.java

@@ -34,7 +34,7 @@ public interface OpenIMService {
     R accountCheck(String userId, String type);
     void checkAndImportFriend(Long companyUserId,String fsUserId);
     OpenImResponseDTO sendCourse(Long userId,Long companyUserId,String url,String title,String linkImageUrl,String cropId) throws JsonProcessingException;
-    OpenImResponseDTO sendLive(Long userId, Long companyUserId, String url, String title, String linkImageUrl, String cropId) throws JsonProcessingException;
+    OpenImResponseDTO sendLive(Long userId, Long companyUserId, String appRealLink, String title, Long liveId, String cropId, Long companyId) throws JsonProcessingException;
     void checkAndImportFriendByDianBo(Long companyUserId,String fsUserId,String cropId, boolean isUpdate);
 
     OpenImResponseDTO updateUserInfo(CompanyUser companyUser);

+ 33 - 5
fs-service/src/main/java/com/fs/im/service/impl/OpenIMServiceImpl.java

@@ -44,6 +44,7 @@ import com.fs.im.mapper.ImSendLogMapper;
 import com.fs.im.service.OpenIMService;
 import com.fs.im.vo.OpenImMsgCallBackVO;
 import com.fs.im.vo.OpenImResponseDTOTest;
+import com.fs.live.domain.Live;
 import com.fs.qw.mapper.QwExternalContactMapper;
 import com.github.pagehelper.util.StringUtil;
 import com.google.common.base.Joiner;
@@ -560,7 +561,9 @@ public class OpenIMServiceImpl implements OpenIMService {
     }
 
     @Override
-    public OpenImResponseDTO sendLive(Long userId, Long companyUserId, String url, String title, String linkImageUrl, String cropId) throws JsonProcessingException {
+    public OpenImResponseDTO sendLive(Long userId, Long companyUserId, String appRealLink, String title, Long liveId, String cropId, Long companyId) throws JsonProcessingException {
+        Company company = companyId != null ? companyMapper.selectCompanyById(companyId) : null;
+        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
         ObjectMapper objectMapper = new ObjectMapper();
         objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
         checkAndImportFriendByDianBo(companyUserId, userId.toString(), cropId, true);
@@ -569,10 +572,18 @@ public class OpenIMServiceImpl implements OpenIMService {
         PayloadDTO payload = new PayloadDTO();
         PayloadDTO.Extension extension = new PayloadDTO.Extension();
         payload.setData("live");
+        Live live = buildLiveCardPayload(liveId, title, appRealLink, companyId, companyUserId);
         extension.setTitle(title);
-        extension.setAppRealLink(url);
+        extension.setAppRealLink(appRealLink);
         extension.setSendTime(new Date());
-        extension.setCourseUrl(linkImageUrl);
+        extension.setLiveId(liveId);
+        if (company != null) {
+            extension.setCompanyId(company.getCompanyId());
+        } else if (companyId != null) {
+            extension.setCompanyId(companyId);
+        }
+        extension.setCompanyUserId(companyUserId);
+        extension.setLive(live);
         payload.setExtension(extension);
         imData.setPayload(payload);
         String imJson = objectMapper.writeValueAsString(imData);
@@ -580,8 +591,9 @@ public class OpenIMServiceImpl implements OpenIMService {
 
         OpenImMsgDTO.OfflinePushInfo offlinePushInfo = new OpenImMsgDTO.OfflinePushInfo();
         offlinePushInfo.setDesc(title);
-        CompanyUser companyUser = companyUserMapper.selectCompanyUserById(companyUserId);
-        offlinePushInfo.setTitle(StringUtils.isNotEmpty(companyUser.getImNickName()) ? companyUser.getImNickName() : companyUser.getNickName());
+        if (companyUser != null) {
+            offlinePushInfo.setTitle(StringUtils.isNotEmpty(companyUser.getImNickName()) ? companyUser.getImNickName() : companyUser.getNickName());
+        }
         offlinePushInfo.setIOSBadgeCount(true);
         offlinePushInfo.setIOSPushSound("");
 
@@ -596,6 +608,22 @@ public class OpenIMServiceImpl implements OpenIMService {
         return openIMSendMsg(openImMsgDTO);
     }
 
+    /**
+     * 组装 IM 直播卡片 payload,避免 fs-ipad-task 等模块查询 SLAVE 库 live 表
+     */
+    private Live buildLiveCardPayload(Long liveId, String title, String appRealLink, Long companyId, Long companyUserId) {
+        if (liveId == null) {
+            return null;
+        }
+        Live live = new Live();
+        live.setLiveId(liveId);
+        live.setLiveName(title);
+        live.setLiveImgUrl(appRealLink);
+        live.setCompanyId(companyId);
+        live.setCompanyUserId(companyUserId);
+        return live;
+    }
+
     @Override
     public OpenImResponseDTO sendPackageUtil(String sendID, String recvID, Integer contentType, String payloadData,String packageName,String packageId){
         try {

+ 47 - 0
fs-service/src/main/java/com/fs/live/domain/LiveCompletionAnswerRecord.java

@@ -0,0 +1,47 @@
+package com.fs.live.domain;
+
+import com.baomidou.mybatisplus.annotation.IdType;
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.Date;
+
+/**
+ * 直播完课答题记录对象 live_completion_answer_record
+ */
+@Data
+@TableName("live_completion_answer_record")
+public class LiveCompletionAnswerRecord implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @TableId(type = IdType.AUTO)
+    private Long id;
+
+    @Excel(name = "直播ID")
+    private Long liveId;
+
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    @Excel(name = "用户名称")
+    private String userName;
+
+    /** 是否全部正确 0否 1是 */
+    @Excel(name = "是否全部正确", readConverterExp = "0=否,1=是")
+    private Integer isRight;
+
+    /** 用户作答原始 JSON */
+    private String answerJson;
+
+    /** 题目及作答明细 JSON */
+    private String questionJson;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "答题时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 20 - 0
fs-service/src/main/java/com/fs/live/mapper/LiveCompletionAnswerRecordMapper.java

@@ -0,0 +1,20 @@
+package com.fs.live.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.live.domain.LiveCompletionAnswerRecord;
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import org.apache.ibatis.annotations.Mapper;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Mapper
+ */
+@Mapper
+public interface LiveCompletionAnswerRecordMapper extends BaseMapper<LiveCompletionAnswerRecord> {
+
+    List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param);
+
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id);
+}

+ 27 - 0
fs-service/src/main/java/com/fs/live/param/LiveCompletionAnswerRecordParam.java

@@ -0,0 +1,27 @@
+package com.fs.live.param;
+
+import lombok.Data;
+
+/**
+ * 直播完课答题记录查询参数
+ */
+@Data
+public class LiveCompletionAnswerRecordParam {
+
+    private Integer pageNum;
+
+    private Integer pageSize;
+
+    private Long liveId;
+
+    private Long userId;
+
+    private String userName;
+
+    /** 是否全部正确 0否 1是 */
+    private Integer isRight;
+
+    private String beginTime;
+
+    private String endTime;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/live/service/ILiveCompletionAnswerRecordService.java

@@ -0,0 +1,33 @@
+package com.fs.live.service;
+
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Service
+ */
+public interface ILiveCompletionAnswerRecordService {
+
+    /**
+     * 查询完课答题记录列表
+     */
+    List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param);
+
+    /**
+     * 查询完课答题记录详情
+     */
+    LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id);
+
+    /**
+     * 保存完课答题记录(/app/live/completion/coupon/answer 提交时调用)
+     *
+     * @return 记录主键ID
+     */
+    Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
+                          List<LiveCompletionCouponAnswerItem> answers,
+                          List<LiveCompletionAnswerDetailVO> questionDetails);
+}

+ 71 - 0
fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionAnswerRecordServiceImpl.java

@@ -0,0 +1,71 @@
+package com.fs.live.service.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.his.domain.FsUser;
+import com.fs.his.mapper.FsUserMapper;
+import com.fs.live.domain.LiveCompletionAnswerRecord;
+import com.fs.live.mapper.LiveCompletionAnswerRecordMapper;
+import com.fs.live.param.LiveCompletionAnswerRecordParam;
+import com.fs.live.param.LiveCompletionCouponAnswerItem;
+import com.fs.live.service.ILiveCompletionAnswerRecordService;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
+import com.fs.live.vo.LiveCompletionAnswerRecordListVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+/**
+ * 直播完课答题记录Service业务层
+ */
+@Service
+public class LiveCompletionAnswerRecordServiceImpl implements ILiveCompletionAnswerRecordService {
+
+    @Autowired
+    private LiveCompletionAnswerRecordMapper liveCompletionAnswerRecordMapper;
+
+    @Autowired
+    private FsUserMapper fsUserMapper;
+
+    @Override
+    public List<LiveCompletionAnswerRecordListVO> selectLiveCompletionAnswerRecordList(LiveCompletionAnswerRecordParam param) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordList(param);
+    }
+
+    @Override
+    public LiveCompletionAnswerRecordListVO selectLiveCompletionAnswerRecordById(Long id) {
+        return liveCompletionAnswerRecordMapper.selectLiveCompletionAnswerRecordById(id);
+    }
+
+    @Override
+    public Long saveAnswerRecord(Long liveId, Long userId, boolean allCorrect,
+                                 List<LiveCompletionCouponAnswerItem> answers,
+                                 List<LiveCompletionAnswerDetailVO> questionDetails) {
+        LiveCompletionAnswerRecord record = new LiveCompletionAnswerRecord();
+        record.setLiveId(liveId);
+        record.setUserId(userId);
+        record.setUserName(resolveUserName(userId));
+        record.setIsRight(allCorrect ? 1 : 0);
+        record.setAnswerJson(answers == null ? null : JSON.toJSONString(answers));
+        record.setQuestionJson(questionDetails == null ? null : JSON.toJSONString(questionDetails));
+        record.setCreateTime(DateUtils.getNowDate());
+        liveCompletionAnswerRecordMapper.insert(record);
+        return record.getId();
+    }
+
+    private String resolveUserName(Long userId) {
+        if (userId == null) {
+            return null;
+        }
+        FsUser user = fsUserMapper.selectFsUserByUserId(userId);
+        if (user == null) {
+            return null;
+        }
+        if (StringUtils.isNotEmpty(user.getNickName())) {
+            return user.getNickName();
+        }
+        return user.getPhone();
+    }
+}

+ 44 - 7
fs-service/src/main/java/com/fs/live/service/impl/LiveCompletionCouponServiceImpl.java

@@ -11,6 +11,7 @@ import com.fs.live.param.LiveCompletionCouponAnswerItem;
 import com.fs.live.param.LiveCompletionCouponAnswerParam;
 import com.fs.live.param.LiveCompletionCouponClaimParam;
 import com.fs.live.service.*;
+import com.fs.live.vo.LiveCompletionAnswerDetailVO;
 import com.fs.live.vo.LiveCompletionCouponAnswerResult;
 import com.fs.live.vo.LiveCompletionCouponConfigVO;
 import com.fs.live.vo.LiveCompletionCouponInfoVO;
@@ -72,6 +73,9 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
     @Autowired
     private ILiveConsoleOpLogService liveConsoleOpLogService;
 
+    @Autowired
+    private ILiveCompletionAnswerRecordService liveCompletionAnswerRecordService;
+
     @Override
     public LiveCompletionCouponConfigVO parseCompletionCouponConfig(Live live) {
         LiveCompletionCouponConfigVO vo = new LiveCompletionCouponConfigVO();
@@ -213,11 +217,14 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
         }
 
         List<LiveCompletionCouponAnswerItem> normalizedAnswers = normalizeUserAnswers(param.getAnswers());
-        boolean allCorrect = evaluateAnswers(normalizedAnswers, configuredQuestionIds);
-        saveAnswerRecordToday(liveId, userId, allCorrect);
+        AnswerEvaluation evaluation = evaluateAnswersWithDetails(normalizedAnswers, configuredQuestionIds);
+        saveAnswerRecordToday(liveId, userId, evaluation.isAllCorrect());
+        Long recordId = liveCompletionAnswerRecordService.saveAnswerRecord(
+                liveId, userId, evaluation.isAllCorrect(), normalizedAnswers, evaluation.getDetails());
 
         LiveCompletionCouponAnswerResult result = new LiveCompletionCouponAnswerResult();
-        result.setAllCorrect(allCorrect);
+        result.setAllCorrect(evaluation.isAllCorrect());
+        result.setRecordId(recordId);
         return result;
     }
 
@@ -332,9 +339,10 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
     }
 
     /**
-     * 校验是否答完全部题目,并返回是否全部答对(答错不阻断记录)
+     * 校验是否答完全部题目,并返回是否全部答对及每题明细(答错不阻断记录)
      */
-    private boolean evaluateAnswers(List<LiveCompletionCouponAnswerItem> userAnswers, List<Long> configuredQuestionIds) {
+    private AnswerEvaluation evaluateAnswersWithDetails(List<LiveCompletionCouponAnswerItem> userAnswers,
+                                                        List<Long> configuredQuestionIds) {
         Set<Long> submittedIds = userAnswers.stream()
                 .map(LiveCompletionCouponAnswerItem::getQuestionId)
                 .filter(Objects::nonNull)
@@ -350,16 +358,27 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
                 .collect(Collectors.toMap(LiveQuestionBank::getId, q -> q));
 
         boolean allCorrect = true;
+        List<LiveCompletionAnswerDetailVO> details = new ArrayList<>();
         for (LiveCompletionCouponAnswerItem userAnswer : userAnswers) {
             LiveQuestionBank correctAnswer = correctAnswersMap.get(userAnswer.getQuestionId());
             if (correctAnswer == null || correctAnswer.getStatus() == null || correctAnswer.getStatus() == 0) {
                 throw new BaseException("题目不存在或已停用");
             }
-            if (!isAnswerCorrect(userAnswer.getAnswer(), correctAnswer)) {
+            boolean questionCorrect = isAnswerCorrect(userAnswer.getAnswer(), correctAnswer);
+            if (!questionCorrect) {
                 allCorrect = false;
             }
+
+            LiveCompletionAnswerDetailVO detail = new LiveCompletionAnswerDetailVO();
+            detail.setQuestionId(userAnswer.getQuestionId());
+            detail.setTitle(correctAnswer.getTitle());
+            detail.setType(correctAnswer.getType());
+            detail.setUserAnswer(userAnswer.getAnswer());
+            detail.setCorrectAnswer(correctAnswer.getAnswer());
+            detail.setIsRight(questionCorrect ? 1 : 0);
+            details.add(detail);
         }
-        return allCorrect;
+        return new AnswerEvaluation(allCorrect, details);
     }
 
     private boolean isAnswerCorrect(String userAnswerValue, LiveQuestionBank correctAnswer) {
@@ -698,4 +717,22 @@ public class LiveCompletionCouponServiceImpl implements ILiveCompletionCouponSer
             this.allCorrect = allCorrect;
         }
     }
+
+    private static class AnswerEvaluation {
+        private final boolean allCorrect;
+        private final List<LiveCompletionAnswerDetailVO> details;
+
+        private AnswerEvaluation(boolean allCorrect, List<LiveCompletionAnswerDetailVO> details) {
+            this.allCorrect = allCorrect;
+            this.details = details;
+        }
+
+        public boolean isAllCorrect() {
+            return allCorrect;
+        }
+
+        public List<LiveCompletionAnswerDetailVO> getDetails() {
+            return details;
+        }
+    }
 }

+ 24 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerDetailVO.java

@@ -0,0 +1,24 @@
+package com.fs.live.vo;
+
+import lombok.Data;
+
+/**
+ * 完课答题单题明细(落库快照)
+ */
+@Data
+public class LiveCompletionAnswerDetailVO {
+
+    private Long questionId;
+
+    private String title;
+
+    /** 题型 1单选 2多选 */
+    private Long type;
+
+    private String userAnswer;
+
+    private String correctAnswer;
+
+    /** 本题是否答对 0否 1是 */
+    private Integer isRight;
+}

+ 39 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionAnswerRecordListVO.java

@@ -0,0 +1,39 @@
+package com.fs.live.vo;
+
+import com.fasterxml.jackson.annotation.JsonFormat;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+import java.util.Date;
+
+/**
+ * 直播完课答题记录列表 VO
+ */
+@Data
+public class LiveCompletionAnswerRecordListVO {
+
+    private Long id;
+
+    @Excel(name = "直播ID")
+    private Long liveId;
+
+    @Excel(name = "直播名称")
+    private String liveName;
+
+    @Excel(name = "用户ID")
+    private Long userId;
+
+    @Excel(name = "用户名称")
+    private String userName;
+
+    @Excel(name = "是否全部正确", readConverterExp = "0=否,1=是")
+    private Integer isRight;
+
+    private String answerJson;
+
+    private String questionJson;
+
+    @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
+    @Excel(name = "答题时间", width = 30, dateFormat = "yyyy-MM-dd HH:mm:ss")
+    private Date createTime;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/live/vo/LiveCompletionCouponAnswerResult.java

@@ -10,4 +10,7 @@ public class LiveCompletionCouponAnswerResult {
 
     /** 是否全部答对 */
     private boolean allCorrect;
+
+    /** 答题记录ID(写入 live_completion_answer_record) */
+    private Long recordId;
 }

+ 5 - 2
fs-service/src/main/java/com/fs/qw/service/impl/AsyncSopTestService.java

@@ -567,6 +567,9 @@ public class AsyncSopTestService {
             String cropId,
             Long companyUserId,
             Long fsUserId,
+            Long companyId,
+            Long externalId,
+            Long qwUserKey,
             String logId) {
 
         boolean success = true;
@@ -577,11 +580,11 @@ public class AsyncSopTestService {
             item.setSendRemarks("APP直播卡片发送失败");
 
             try {
-                String liveLink = StringUtils.isNotEmpty(item.getAppLinkUrl()) ? item.getAppLinkUrl() : item.getMiniprogramPage();
                 String title = item.getMiniprogramTitle();
                 String coverUrl = item.getMiniprogramPicUrl();
+                Long liveId = StringUtils.isNotEmpty(item.getLiveId()) ? Long.valueOf(item.getLiveId()) : null;
                 OpenImResponseDTO resp = push2Service.pushSopAppLiveCardMsgByExternalIM(
-                        cropId, title, coverUrl, liveLink, companyUserId, fsUserId);
+                        cropId, title, coverUrl, liveId, companyUserId, fsUserId, companyId, externalId, qwUserKey);
                 if (resp != null && resp.getErrCode() != null && resp.getErrCode() == 0) {
                     item.setSendStatus(1);
                     item.setSendRemarks("发送成功");

+ 94 - 13
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -490,6 +490,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
     }
     @Override
     public R sendUserLogsInfoMsg(SendUserLogsInfoMsgParam param) {
+        Long resolvedLiveId = resolveLiveIdFromParam(param);
+        if (resolvedLiveId != null) {
+            param.setLiveId(resolvedLiveId);
+        }
         Boolean sendLiveMsg = Boolean.FALSE;
         if(null != param.getLiveId()){
             sendLiveMsg = Boolean.TRUE;
@@ -709,7 +713,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 //todo 发个人看课记录处理
                                 try {
                                     if (vo != null && vo.getId() != null) {
-                                        createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),vo.getId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUser.getId().toString(),param.getCorpId());
+                                        createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),vo.getId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUser.getId().toString(),param.getCorpId(), vo.getFsUserId());
                                     }
                                 } catch (Exception e) {
                                     log.error("群聊创建直播看课记录失败!", e);
@@ -815,7 +819,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                             sysConfigLive.getAppId(),
                                             2,
                                             String.valueOf(qwUser.getId()),
-                                            param.getCorpId());
+                                            param.getCorpId(),
+                                            StringUtils.isNotEmpty(groupUser.getFsUserId())
+                                                    ? Long.valueOf(groupUser.getFsUserId()) : null);
                                 } catch (Exception e) {
                                     log.error("APP直播模板解析失败:" + e);
                                 }
@@ -825,10 +831,11 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 if (vo == null || vo.getId() == null || StringUtil.strIsNullOrEmpty(st.getLiveId())) {
                                     break;
                                 }
+                                sopLogs.setSendType(20);
                                 String appLiveLink = createAppLiveShortLink(st, param.getCorpId(), qwUser.getId(),
                                         companyUserId, companyId, vo.getId(), createTime);
                                 st.setMiniprogramPage(appLiveLink);
-                                fillAppLiveSetting(st, param.getCorpId(), qwUser, vo.getId());
+                                fillAppLiveSetting(st, param.getCorpId(), qwUser, vo.getId(), vo.getFsUserId());
                                 if ("25".equals(st.getContentType())) {
                                     st.setAppLinkUrl(appLiveLink);
                                     sopLogs.setIsHaveApp(1);
@@ -992,7 +999,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                             Map<String, GroupUserExternalVo> userMap = PubFun.listToMapByGroupObject(e.getUserList(), GroupUserExternalVo::getUserId);
                                             GroupUserExternalVo vo = userMap.get(groupChat.getOwner());
                                             if (vo != null && vo.getId() != null) {
-                                                createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),vo.getId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUser.getId().toString(),param.getCorpId());
+                                                createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),vo.getId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUser.getId().toString(),param.getCorpId(), vo.getFsUserId());
                                             }
                                         });
                                     });
@@ -1123,7 +1130,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 sopLogs.setReceivingStatus(0L);
                 sopLogs.setSopId(param.getSopId());
                 sopLogs.setCorpId(item.getCorpId());
-                sopLogs.setFsUserId(item.getFsUserId());
+                sopLogs.setFsUserId(resolveFsUserIdForLiveWatch(item.getExternalId(), item.getFsUserId()));
                 sopLogs.setSort(30000000);
                 sopLogs.setSendType(5);
                 sopLogs.setExternalUserName(item.getExternalUserName());
@@ -1270,7 +1277,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             FSSysConfig sysConfig= JSON.parseObject(js,FSSysConfig.class);
                             //发个人看课记录处理
                             try {
-                                    createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),item.getExternalId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUserId,param.getCorpId());
+                                    createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),item.getExternalId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, qwUserId,param.getCorpId(), item.getFsUserId());
 
                             } catch (Exception e) {
                                 log.error("群聊创建直播看课记录失败!", e);
@@ -1443,21 +1450,23 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                             if (StringUtil.strIsNullOrEmpty(st.getLiveId())) {
                                 throw new BaseException("APP直播短链配置缺少直播间");
                             }
+                            sopLogs.setSendType(20);
                             String appLiveLink = createAppLiveShortLink(st, param.getCorpId(), qwUser.getId(),
                                     companyUserId, companyId, item.getExternalId(), createTime);
                             st.setMiniprogramPage(appLiveLink);
-                            fillAppLiveSetting(st, param.getCorpId(), qwUser, item.getExternalId());
+                            fillAppLiveSetting(st, param.getCorpId(), qwUser, item);
                             break;
                         }
                         case "25": {
                             if (StringUtil.strIsNullOrEmpty(st.getLiveId())) {
                                 throw new BaseException("APP直播卡片配置缺少直播间");
                             }
+                            sopLogs.setSendType(20);
                             String appLiveLink = createAppLiveShortLink(st, param.getCorpId(), qwUser.getId(),
                                     companyUserId, companyId, item.getExternalId(), createTime);
                             st.setMiniprogramPage(appLiveLink);
                             st.setAppLinkUrl(appLiveLink);
-                            fillAppLiveSetting(st, param.getCorpId(), qwUser, item.getExternalId());
+                            fillAppLiveSetting(st, param.getCorpId(), qwUser, item);
                             sopLogs.setIsHaveApp(1);
                             sopLogs.setAppSendStatus(0);
                             break;
@@ -1540,6 +1549,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
     @Override
     public R sendUserLogsInfoMsgSop(SendUserLogsInfoMsgParam param) {
+        Long resolvedLiveId = resolveLiveIdFromParam(param);
+        if (resolvedLiveId != null) {
+            param.setLiveId(resolvedLiveId);
+        }
         List<String> sopUserLogsIds = sopUserLogsMapper.getSopUserLogsIds(param);
         if(sopUserLogsIds == null || sopUserLogsIds.isEmpty()){
             throw new BaseException("SOP没有可发送的营期数据");
@@ -1634,8 +1647,10 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
         // 遍历分组
         int finalSort = sort;
-        if(null != param.getLiveId()){
+        Long resolvedLiveId = resolveLiveIdFromParam(param);
+        if (resolvedLiveId != null) {
             sendType = 20;
+            param.setLiveId(resolvedLiveId);
         }
         int finalSendType = sendType;
         groupedLogs.forEach((key, logs) -> {
@@ -1713,7 +1728,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             sopLogs.setReceivingStatus(0L);
             sopLogs.setSopId(item.getSopId());
             sopLogs.setCorpId(item.getCorpId());
-            sopLogs.setFsUserId(item.getFsUserId());
+            sopLogs.setFsUserId(resolveFsUserIdForLiveWatch(item.getExternalId(), item.getFsUserId()));
 
             sopLogs.setSort(finalSort);
             sopLogs.setSendType(finalSendType);
@@ -1952,7 +1967,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     FSSysConfig sysConfig= JSON.parseObject(json,FSSysConfig.class);
                     //todo 发个人看课记录处理
                     try {
-                        createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),item.getExternalId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, String.valueOf(qwUser.getId()),param.getCorpId());
+                        createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),item.getExternalId().toString(),Long.valueOf(st.getLiveId()),sysConfig.getAppId(),2, String.valueOf(qwUser.getId()),param.getCorpId(), item.getFsUserId());
                     } catch (Exception e) {
                         log.error("群聊创建直播看课记录失败!", e);
                     }
@@ -2131,6 +2146,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     if (StringUtil.strIsNullOrEmpty(st.getLiveId())) {
                         throw new BaseException("APP直播短链配置缺少直播间");
                     }
+                    sopLogs.setSendType(20);
                     String appLiveLink = createAppLiveShortLink(st, param.getCorpId(), qwUser.getId(),
                             companyUserId, companyId, item.getExternalId(), dataTime);
                     st.setMiniprogramPage(appLiveLink);
@@ -2141,6 +2157,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                     if (StringUtil.strIsNullOrEmpty(st.getLiveId())) {
                         throw new BaseException("APP直播卡片配置缺少直播间");
                     }
+                    sopLogs.setSendType(20);
                     String appLiveLink = createAppLiveShortLink(st, param.getCorpId(), qwUser.getId(),
                             companyUserId, companyId, item.getExternalId(), dataTime);
                     st.setMiniprogramPage(appLiveLink);
@@ -2421,11 +2438,18 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
     private void fillAppLiveSetting(QwSopCourseFinishTempSetting.Setting st, String corpId, QwUser qwUser,
                                     SopUserLogsInfo item) {
-        fillAppLiveSetting(st, corpId, qwUser, item != null ? item.getExternalId() : null);
+        fillAppLiveSetting(st, corpId, qwUser,
+                item != null ? item.getExternalId() : null,
+                item != null ? item.getFsUserId() : null);
     }
 
     private void fillAppLiveSetting(QwSopCourseFinishTempSetting.Setting st, String corpId, QwUser qwUser,
                                     Long externalId) {
+        fillAppLiveSetting(st, corpId, qwUser, externalId, null);
+    }
+
+    private void fillAppLiveSetting(QwSopCourseFinishTempSetting.Setting st, String corpId, QwUser qwUser,
+                                    Long externalId, Long fsUserId) {
         String liveConfigJson = configService.selectConfigByKey("his.config");
         FSSysConfig liveSysConfig = JSON.parseObject(liveConfigJson, FSSysConfig.class);
         if (liveSysConfig != null && !StringUtil.strIsNullOrEmpty(liveSysConfig.getAppId())) {
@@ -2441,7 +2465,7 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             try {
                 createLiveWatchLogAndInsert(qwUser.getCompanyId().toString(), qwUser.getCompanyUserId().toString(),
                         externalId.toString(), Long.valueOf(st.getLiveId()), liveSysConfig.getAppId(), 2,
-                        String.valueOf(qwUser.getId()), corpId);
+                        String.valueOf(qwUser.getId()), corpId, fsUserId);
             } catch (Exception e) {
                 log.error("APP直播一键群发创建看课记录失败", e);
             }
@@ -2451,6 +2475,38 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                 : st.getMiniprogramPicUrl());
     }
 
+    /**
+     * 从请求参数或规则配置中解析直播间ID
+     */
+    private Long resolveLiveIdFromParam(SendUserLogsInfoMsgParam param) {
+        if (param.getLiveId() != null) {
+            return param.getLiveId();
+        }
+        if (StringUtil.strIsNullOrEmpty(param.getSetting())) {
+            return null;
+        }
+        try {
+            List<QwSopCourseFinishTempSetting.Setting> list = JSONArray.parseArray(
+                    param.getSetting(), QwSopCourseFinishTempSetting.Setting.class);
+            if (list == null) {
+                return null;
+            }
+            for (QwSopCourseFinishTempSetting.Setting st : list) {
+                if (st.getContentType() == null || StringUtil.strIsNullOrEmpty(st.getLiveId())) {
+                    continue;
+                }
+                String contentType = st.getContentType();
+                if ("12".equals(contentType) || "18".equals(contentType) || "19".equals(contentType)
+                        || "24".equals(contentType) || "25".equals(contentType)) {
+                    return Long.valueOf(st.getLiveId());
+                }
+            }
+        } catch (Exception e) {
+            log.error("解析直播间ID失败", e);
+        }
+        return null;
+    }
+
     private String createAppLiveShortLink(QwSopCourseFinishTempSetting.Setting st, String corpId, Long qwUserId,
                                           String companyUserId, String companyId, Long externalId, Date sendTime) {
         FsCourseLink link = new FsCourseLink();
@@ -2541,7 +2597,12 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
      * @param corpId
      */
     public void createLiveWatchLogAndInsert(String companyId,String companyUserId,String externalId,Long liveId,String appId,Integer logSource,String qwUserId,String corpId){
+        createLiveWatchLogAndInsert(companyId, companyUserId, externalId, liveId, appId, logSource, qwUserId, corpId, null);
+    }
+
+    public void createLiveWatchLogAndInsert(String companyId,String companyUserId,String externalId,Long liveId,String appId,Integer logSource,String qwUserId,String corpId, Long fsUserId){
         try{
+            Long resolvedUserId = resolveFsUserIdForLiveWatch(Long.valueOf(externalId), fsUserId);
             // 写入对应数据源的记录表
             LiveWatchLog itemLiveWatchLog = new LiveWatchLog();
             itemLiveWatchLog.setLiveId(liveId);
@@ -2554,6 +2615,9 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
             itemLiveWatchLog.setQwUserId(qwUserId);
             itemLiveWatchLog.setExternalContactId(Long.valueOf(externalId));
             itemLiveWatchLog.setCorpId(corpId);
+            if (resolvedUserId != null) {
+                itemLiveWatchLog.setUserId(resolvedUserId);
+            }
             if(liveWatchLogMapper.updateLiveWatchLogCondition(itemLiveWatchLog) > 0){
 
             }else{
@@ -2568,6 +2632,23 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
 
     }
 
+    /**
+     * 优先使用营期 fsUserId,为空时从 qw_external_contact.fs_user_id 回填
+     */
+    private Long resolveFsUserIdForLiveWatch(Long externalId, Long fsUserId) {
+        if (fsUserId != null && fsUserId > 0) {
+            return fsUserId;
+        }
+        if (externalId == null) {
+            return null;
+        }
+        QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactById(externalId);
+        if (contact != null && contact.getFsUserId() != null && contact.getFsUserId() > 0) {
+            return contact.getFsUserId();
+        }
+        return null;
+    }
+
     /**
      * 构建福袋记录对象
      */

+ 62 - 0
fs-service/src/main/resources/mapper/live/LiveCompletionAnswerRecordMapper.xml

@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="UTF-8" ?>
+<!DOCTYPE mapper
+        PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+        "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
+<mapper namespace="com.fs.live.mapper.LiveCompletionAnswerRecordMapper">
+
+    <select id="selectLiveCompletionAnswerRecordList"
+            parameterType="com.fs.live.param.LiveCompletionAnswerRecordParam"
+            resultType="com.fs.live.vo.LiveCompletionAnswerRecordListVO">
+        SELECT
+            r.id,
+            r.live_id AS liveId,
+            l.live_name AS liveName,
+            r.user_id AS userId,
+            r.user_name AS userName,
+            r.is_right AS isRight,
+            r.answer_json AS answerJson,
+            r.question_json AS questionJson,
+            r.create_time AS createTime
+        FROM live_completion_answer_record r
+        LEFT JOIN live l ON l.live_id = r.live_id
+        <where>
+            <if test="liveId != null">
+                AND r.live_id = #{liveId}
+            </if>
+            <if test="userId != null">
+                AND r.user_id = #{userId}
+            </if>
+            <if test="userName != null and userName != ''">
+                AND r.user_name LIKE CONCAT('%', #{userName}, '%')
+            </if>
+            <if test="isRight != null">
+                AND r.is_right = #{isRight}
+            </if>
+            <if test="beginTime != null and beginTime != ''">
+                AND r.create_time &gt;= #{beginTime}
+            </if>
+            <if test="endTime != null and endTime != ''">
+                AND r.create_time &lt;= #{endTime}
+            </if>
+        </where>
+        ORDER BY r.create_time DESC
+    </select>
+
+    <select id="selectLiveCompletionAnswerRecordById" parameterType="Long"
+            resultType="com.fs.live.vo.LiveCompletionAnswerRecordListVO">
+        SELECT
+            r.id,
+            r.live_id AS liveId,
+            l.live_name AS liveName,
+            r.user_id AS userId,
+            r.user_name AS userName,
+            r.is_right AS isRight,
+            r.answer_json AS answerJson,
+            r.question_json AS questionJson,
+            r.create_time AS createTime
+        FROM live_completion_answer_record r
+        LEFT JOIN live l ON l.live_id = r.live_id
+        WHERE r.id = #{id}
+    </select>
+
+</mapper>

+ 1 - 0
fs-service/src/main/resources/mapper/live/LiveWatchLogMapper.xml

@@ -198,6 +198,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             sop_create_time = NOW(),
             send_app_id = #{liveWatchLog.sendAppId},
             log_source = #{liveWatchLog.logSource}
+            <if test="liveWatchLog.userId != null">, user_id = #{liveWatchLog.userId}</if>
         where external_contact_id = #{liveWatchLog.externalContactId}
             and live_id = #{liveWatchLog.liveId}
             and qw_user_id = #{liveWatchLog.qwUserId}

+ 4 - 1
fs-service/src/main/resources/mapper/sop/QwSopLogsMapper.xml

@@ -230,7 +230,9 @@
             send_status = 5,
             receiving_status = 4,
             remark = #{remark},
-            real_send_time = NOW()
+            real_send_time = NOW(),
+            app_send_status = CASE WHEN is_have_app = 1 THEN 2 ELSE app_send_status END,
+            app_send_remark = CASE WHEN is_have_app = 1 THEN #{remark} ELSE app_send_remark END
         WHERE
             id = #{id}
     </update>
@@ -893,6 +895,7 @@
           AND ql.send_type > 1
           AND ql.is_have_app = 1
           AND ql.app_send_status = 0
+          AND ql.send_status = 3
         <![CDATA[
           AND ql.send_time <= now()
         ]]>

+ 1 - 1
fs-user-app/src/main/java/com/fs/app/controller/live/LiveCompletionCouponController.java

@@ -34,7 +34,7 @@ public class LiveCompletionCouponController extends AppBaseController {
     }
 
     /**
-     * 提交今日问题(仅记录答题结果,不发券)
+     * 提交今日问题(校验答题结果并写入 live_completion_answer_record,不发券)
      */
     @PostMapping("/answer")
     @RepeatSubmit