Преглед на файлове

新增飞书发课链路,前端以前的飞书改为非授权模式

吴树波 преди 1 ден
родител
ревизия
41cf8950bb
променени са 38 файла, в които са добавени 1749 реда и са изтрити 324 реда
  1. 2 2
      fs-admin/src/main/java/com/fs/FSApplication.java
  2. 3 0
      fs-admin/src/main/java/com/fs/chat/controller/ChatDatasetFileController.java
  3. 3 0
      fs-company/src/main/java/com/fs/chat/controller/ChatDatasetFileController.java
  4. 2 4
      fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java
  5. 529 174
      fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java
  6. 4 2
      fs-service/src/main/java/com/fs/company/service/impl/CompanyMoneyLogsServiceImpl.java
  7. 7 3
      fs-service/src/main/java/com/fs/core/service/impl/MsgTemeplateServiceImpl.java
  8. 4 0
      fs-service/src/main/java/com/fs/course/config/CourseConfig.java
  9. 4 4
      fs-service/src/main/java/com/fs/course/domain/FsCourseFinishTemp.java
  10. 2 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseFinishTempParent.java
  11. 15 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseFinishTempMapper.java
  12. 10 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseFinishTempService.java
  13. 25 2
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseFinishTempParentServiceImpl.java
  14. 16 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseFinishTempServiceImpl.java
  15. 12 4
      fs-service/src/main/java/com/fs/erp/service/impl/DfOrderServiceImpl.java
  16. 17 12
      fs-service/src/main/java/com/fs/fastgptApi/service/Impl/ChatServiceImpl.java
  17. 10 4
      fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java
  18. 5 0
      fs-service/src/main/java/com/fs/feishu/service/IFeishuAccountService.java
  19. 34 0
      fs-service/src/main/java/com/fs/feishu/service/impl/FeishuAccountServiceImpl.java
  20. 2 2
      fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java
  21. 3 1
      fs-service/src/main/java/com/fs/pay/service/impl/PayScrmServiceImpl.java
  22. 6 0
      fs-service/src/main/java/com/fs/sop/mapper/SopUserLogsInfoMapper.java
  23. 115 0
      fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java
  24. 9 0
      fs-service/src/main/resources/mapper/sop/SopUserLogsInfoMapper.xml
  25. 2 0
      fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java
  26. 20 0
      fs-user-app/src/main/java/com/fs/app/controller/CompanyUserController.java
  27. 2 1
      fs-user-app/src/main/java/com/fs/app/controller/FsUserComplaintController.java
  28. 18 2
      fs-user-app/src/main/java/com/fs/app/controller/HealthRecordController.java
  29. 8 0
      fs-user-app/src/main/java/com/fs/app/controller/HealthTongueController.java
  30. 5 0
      fs-user-app/src/main/java/com/fs/app/controller/UserController.java
  31. 13 1
      fs-user-app/src/main/java/com/fs/app/controller/UserInvitedController.java
  32. 760 98
      fs-user-app/src/main/java/com/fs/app/controller/course/CourseMpLoginController.java
  33. 8 0
      fs-user-app/src/main/java/com/fs/app/controller/live/LiveOrderController.java
  34. 3 1
      fs-user-app/src/main/java/com/fs/app/controller/store/CommonScrmController.java
  35. 10 1
      fs-user-app/src/main/java/com/fs/app/controller/store/FsStoreUserComplaintController.java
  36. 20 0
      fs-user-app/src/main/java/com/fs/app/param/FsUserClaimCourseMaterialParam.java
  37. 27 0
      fs-user-app/src/main/java/com/fs/app/param/FsUserLoginByMpOauthParam.java
  38. 14 6
      fs-user-app/src/main/java/com/fs/app/utils/JwtUtils.java

+ 2 - 2
fs-admin/src/main/java/com/fs/FSApplication.java

@@ -12,8 +12,8 @@ import org.springframework.transaction.annotation.Transactional;
  */
 @SpringBootApplication(exclude = {DataSourceAutoConfiguration.class})
 @Transactional
-//@EnableAsync
-//@EnableScheduling
+@EnableAsync
+@EnableScheduling
 public class FSApplication {
     public static void main(String[] args) {
          System.setProperty("spring.devtools.restart.enabled", "false");

+ 3 - 0
fs-admin/src/main/java/com/fs/chat/controller/ChatDatasetFileController.java

@@ -15,6 +15,7 @@ import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.exception.file.OssException;
 import com.fs.common.utils.file.FileUploadUtils;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.common.utils.poi.ExcelUtil;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -84,6 +85,8 @@ public class ChatDatasetFileController extends BaseController
     @PostMapping("/add")
     public R add(@RequestParam("file") MultipartFile file,@RequestParam Long datasetId) throws Exception {
         ChatDataset dataset=chatDatasetService.selectChatDatasetByDatasetId(datasetId);
+        // 仅校验后缀白名单与文件名,不做大小限制
+        OssUploadSecurityUtils.validateAndGetSafeFilename(file);
         if (file.isEmpty())
         {
             throw new OssException("上传文件不能为空");

+ 3 - 0
fs-company/src/main/java/com/fs/chat/controller/ChatDatasetFileController.java

@@ -14,6 +14,7 @@ import com.fs.common.core.domain.R;
 import com.fs.common.exception.file.OssException;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.file.FileUploadUtils;
+import com.fs.common.utils.security.OssUploadSecurityUtils;
 import com.fs.framework.security.LoginUser;
 import com.fs.framework.service.TokenService;
 import com.fs.his.dto.StoreOrderExpressExportDTO;
@@ -95,6 +96,8 @@ public class ChatDatasetFileController extends BaseController
     @PostMapping("/add")
     public R add(@RequestParam("file") MultipartFile file,@RequestParam Long datasetId) throws Exception {
         ChatDataset dataset=chatDatasetService.selectChatDatasetByDatasetId(datasetId);
+        // 仅校验后缀白名单与文件名,不做大小限制
+        OssUploadSecurityUtils.validateAndGetSafeFilename(file);
         if (file.isEmpty())
         {
             throw new OssException("上传文件不能为空");

+ 2 - 4
fs-ipad-task/src/main/java/com/fs/app/service/IpadSendServer.java

@@ -850,11 +850,9 @@ public class IpadSendServer {
                     sendImg(vo, content);
                     break;
                 case "3":
-                    // 链接
-                    sendLink(vo, content);
-                    break;
                 case "17":
-                    // 飞书看课
+                case "18":
+                    // 链接卡片:3=H5,17=飞书看课,18=飞书NEW
                     sendLink(vo, content);
                     break;
                 case "4":

Файловите разлики са ограничени, защото са твърде много
+ 529 - 174
fs-qw-task/src/main/java/com/fs/app/taskService/impl/SopLogsTaskServiceImpl.java


+ 4 - 2
fs-service/src/main/java/com/fs/company/service/impl/CompanyMoneyLogsServiceImpl.java

@@ -236,7 +236,8 @@ public class CompanyMoneyLogsServiceImpl implements ICompanyMoneyLogsService
             FsExportTask task=fsExportTaskMapper.selectFsExportTaskByTaskId(param.getTaskId());
             task.setFinishTime(new Date());
             task.setStatus(1);
-            task.setFileUrl(result.get("msg").toString());
+            Object msgObj = result.get("msg");
+            task.setFileUrl(msgObj != null ? msgObj.toString() : null);
             fsExportTaskMapper.updateFsExportTask(task);
         }
         else if(param.getType()==1){
@@ -271,7 +272,8 @@ public class CompanyMoneyLogsServiceImpl implements ICompanyMoneyLogsService
             FsExportTask task=fsExportTaskMapper.selectFsExportTaskByTaskId(param.getTaskId());
             task.setFinishTime(new Date());
             task.setStatus(1);
-            task.setFileUrl(result.get("msg").toString());
+            Object msgObj = result.get("msg");
+            task.setFileUrl(msgObj != null ? msgObj.toString() : null);
             fsExportTaskMapper.updateFsExportTask(task);
         }
     }

+ 7 - 3
fs-service/src/main/java/com/fs/core/service/impl/MsgTemeplateServiceImpl.java

@@ -35,18 +35,22 @@ public class MsgTemeplateServiceImpl implements IMsgTemeplateService
 
         WxMaSubscribeMessage.MsgData wxMaSubscribeData1 = new WxMaSubscribeMessage.MsgData();
         wxMaSubscribeData1.setName("thing1");
-        wxMaSubscribeData1.setValue(data.get("title").toString());
+        // 模板字段缺失时用空串,避免 NPE
+        Object titleObj = data.get("title");
+        wxMaSubscribeData1.setValue(titleObj != null ? titleObj.toString() : "");
         wxMaSubscribeData.add(wxMaSubscribeData1);
 
         WxMaSubscribeMessage.MsgData wxMaSubscribeData2 = new WxMaSubscribeMessage.MsgData();
         wxMaSubscribeData2.setName("thing4");
-        wxMaSubscribeData2.setValue(data.get("content").toString());
+        Object contentObj = data.get("content");
+        wxMaSubscribeData2.setValue(contentObj != null ? contentObj.toString() : "");
         wxMaSubscribeData.add(wxMaSubscribeData2);
 
         SimpleDateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd HH:mm");
         WxMaSubscribeMessage.MsgData wxMaSubscribeData3 = new WxMaSubscribeMessage.MsgData();
         wxMaSubscribeData3.setName("time3");
-        wxMaSubscribeData3.setValue(dateFormat.format(data.get("time")));
+        Object timeObj = data.get("time");
+        wxMaSubscribeData3.setValue(timeObj != null ? dateFormat.format(timeObj) : "");
         wxMaSubscribeData.add(wxMaSubscribeData3);
 
 

+ 4 - 0
fs-service/src/main/java/com/fs/course/config/CourseConfig.java

@@ -26,6 +26,10 @@ public class CourseConfig implements Serializable {
     private String realLinkGjDomainName;//外网通用看课链接域名
     private String authDomainName;//网页授权域名
     private String mpAppId;//看课公众号APPID
+    /**
+     * 飞书NEW(contentType=18)领取页服务号 AppID,与通用 mpAppId 分开配置
+     */
+    private String feishuMpAppId;
     private String registerDomainName;//注册域名
     private String courseDomainName;//链接域名
     private String miniprogramAppid;//链接域名

+ 4 - 4
fs-service/src/main/java/com/fs/course/domain/FsCourseFinishTemp.java

@@ -30,12 +30,12 @@ public class FsCourseFinishTemp extends BaseEntity
     @Excel(name = "状态 1正常 0停用")
     private Long status;
 
-    /** 规则 */
-    @Excel(name = "规则")
+    /** 看课规则(完课后私聊发送) */
+    @Excel(name = "看课规则")
     private String setting;
 
-    /** 规则 */
-    @Excel(name = "规则")
+    /** 群聊恭喜规则(完课后群聊发送) */
+    @Excel(name = "群聊恭喜规则")
     private String chatSetting;
 
     /** 公司id */

+ 2 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseFinishTempParent.java

@@ -39,8 +39,10 @@ public class FsCourseFinishTempParent extends BaseEntity{
 
     @TableField(exist = false)
     private Long status;
+    /** 看课规则(完课后私聊发送,存于子模板) */
     @TableField(exist = false)
     private String setting;
+    /** 群聊恭喜规则(完课后群聊发送,存于子模板) */
     @TableField(exist = false)
     private String chatSetting;
     @TableField(exist = false)

+ 15 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseFinishTempMapper.java

@@ -119,6 +119,21 @@ public interface FsCourseFinishTempMapper
 
     void deleteByParentIds(@Param("ids") Long[] ids);
 
+    /**
+     * 按父模板取一条子模板(用于回显看课/群聊规则)
+     */
+    @Select("SELECT * FROM fs_course_finish_temp WHERE parent_id = #{parentId} AND (is_del = 0 OR is_del IS NULL) ORDER BY id ASC LIMIT 1")
+    FsCourseFinishTemp selectOneByParentId(@Param("parentId") Long parentId);
+
+    /**
+     * 父模板修改时,同步更新其下所有子模板的看课规则与群聊恭喜规则
+     */
+    @Update("UPDATE fs_course_finish_temp SET setting = #{setting}, chat_setting = #{chatSetting}, update_time = NOW() " +
+            "WHERE parent_id = #{parentId} AND (is_del = 0 OR is_del IS NULL)")
+    int updateSettingByParentId(@Param("parentId") Long parentId,
+                                @Param("setting") String setting,
+                                @Param("chatSetting") String chatSetting);
+
     @Update({"<script> " +
             " update fs_course_finish_temp " +
             " set status = #{data.status} ," +

+ 10 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseFinishTempService.java

@@ -68,6 +68,16 @@ public interface IFsCourseFinishTempService
 
     void deleteByParentIds(Long[] ids);
 
+    /**
+     * 按父模板取一条子模板(回显看课/群聊规则)
+     */
+    FsCourseFinishTemp selectOneByParentId(Long parentId);
+
+    /**
+     * 父模板修改时同步子模板看课规则与群聊恭喜规则
+     */
+    int updateSettingByParentId(Long parentId, String setting, String chatSetting);
+
     /**
      * 将所有的CompanyUserId更新为 企业微信账号
      */

+ 25 - 2
fs-service/src/main/java/com/fs/course/service/impl/FsCourseFinishTempParentServiceImpl.java

@@ -52,7 +52,20 @@ public class FsCourseFinishTempParentServiceImpl extends ServiceImpl<FsCourseFin
     @Override
     public FsCourseFinishTempParent selectFsCourseFinishTempParentById(Long id)
     {
-        return baseMapper.selectFsCourseFinishTempParentById(id);
+        FsCourseFinishTempParent parent = baseMapper.selectFsCourseFinishTempParentById(id);
+        if (parent == null || parent.getId() == null) {
+            return parent;
+        }
+        // 父表不存规则,从子模板回显看课规则 / 群聊恭喜规则,供编辑使用
+        FsCourseFinishTemp child = fsCourseFinishTempService.selectOneByParentId(parent.getId());
+        if (child != null) {
+            parent.setSetting(child.getSetting());
+            parent.setChatSetting(child.getChatSetting());
+            parent.setStatus(child.getStatus());
+            parent.setCompanyUserIds(child.getCompanyUserIds());
+            parent.setIsAllCompanyUser(child.getIsAllCompanyUser());
+        }
+        return parent;
     }
 
     /**
@@ -111,7 +124,17 @@ public class FsCourseFinishTempParentServiceImpl extends ServiceImpl<FsCourseFin
     public int updateFsCourseFinishTempParent(FsCourseFinishTempParent fsCourseFinishTempParent)
     {
         fsCourseFinishTempParent.setUpdateTime(DateUtils.getNowDate());
-        return baseMapper.updateById(fsCourseFinishTempParent);
+        int rows = baseMapper.updateById(fsCourseFinishTempParent);
+        // 同步看课规则 / 群聊恭喜规则到所有子课节模板
+        if (fsCourseFinishTempParent.getId() != null
+                && (StringUtils.isNotEmpty(fsCourseFinishTempParent.getSetting())
+                || StringUtils.isNotEmpty(fsCourseFinishTempParent.getChatSetting()))) {
+            fsCourseFinishTempService.updateSettingByParentId(
+                    fsCourseFinishTempParent.getId(),
+                    fsCourseFinishTempParent.getSetting(),
+                    fsCourseFinishTempParent.getChatSetting());
+        }
+        return rows;
     }
 
     /**

+ 16 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseFinishTempServiceImpl.java

@@ -218,6 +218,22 @@ public class FsCourseFinishTempServiceImpl implements IFsCourseFinishTempService
         fsCourseFinishTempMapper.deleteByParentIds(ids);
     }
 
+    @Override
+    public FsCourseFinishTemp selectOneByParentId(Long parentId) {
+        if (parentId == null) {
+            return null;
+        }
+        return fsCourseFinishTempMapper.selectOneByParentId(parentId);
+    }
+
+    @Override
+    public int updateSettingByParentId(Long parentId, String setting, String chatSetting) {
+        if (parentId == null) {
+            return 0;
+        }
+        return fsCourseFinishTempMapper.updateSettingByParentId(parentId, setting, chatSetting);
+    }
+
     /**
      * 完课用户 打备注
      */

+ 12 - 4
fs-service/src/main/java/com/fs/erp/service/impl/DfOrderServiceImpl.java

@@ -1729,7 +1729,9 @@ public class DfOrderServiceImpl implements IErpOrderService {
 
     private void getOrderResult(Map<String, Object> map, Long dfAccountId) {
         try {
-            String status = map.get("exInterfaceType").toString();
+            // 上游回调可能缺字段,避免直接 toString 触发 NPE
+            Object exInterfaceType = map.get("exInterfaceType");
+            String status = exInterfaceType != null ? exInterfaceType.toString() : "";
             log.info("开始查询订单结果,参数为: {}", JSON.toJSONString(map));
             String response = client.execute(RequestUrlEnum.ORDER_RESULT, map, dfAccountId);
             if (StringUtils.isBlank(response)) {
@@ -1785,7 +1787,9 @@ public class DfOrderServiceImpl implements IErpOrderService {
 
     private void getIntegralOrderResult(Map<String, Object> map, Long dfAccountId){
         try {
-            String status = map.get("exInterfaceType").toString();
+            // 上游回调可能缺字段,避免直接 toString 触发 NPE
+            Object exInterfaceType = map.get("exInterfaceType");
+            String status = exInterfaceType != null ? exInterfaceType.toString() : "";
             log.info("开始查询订单结果,参数为: {}", JSON.toJSONString(map));
             String response = client.execute(RequestUrlEnum.ORDER_RESULT, map, dfAccountId);
             if (StringUtils.isBlank(response)) {
@@ -1841,7 +1845,9 @@ public class DfOrderServiceImpl implements IErpOrderService {
 
     private void getOrderScrmResult(Map<String, Object> map, Long dfAccountId) {
         try {
-            String status = map.get("exInterfaceType").toString();
+            // 上游回调可能缺字段,避免直接 toString 触发 NPE
+            Object exInterfaceType = map.get("exInterfaceType");
+            String status = exInterfaceType != null ? exInterfaceType.toString() : "";
             log.info("开始查询订单结果,参数为: {}", JSON.toJSONString(map));
             String response = client.execute(RequestUrlEnum.ORDER_RESULT, map, dfAccountId);
             if (StringUtils.isBlank(response)) {
@@ -2254,7 +2260,9 @@ public class DfOrderServiceImpl implements IErpOrderService {
 
     private void getOrderLiveResult(Map<String, Object> map, Long dfAccountId) {
         try {
-            String status = map.get("exInterfaceType").toString();
+            // 上游回调可能缺字段,避免直接 toString 触发 NPE
+            Object exInterfaceType = map.get("exInterfaceType");
+            String status = exInterfaceType != null ? exInterfaceType.toString() : "";
             log.info("开始查询订单结果,参数为: {}", JSON.toJSONString(map));
             String response = client.execute(RequestUrlEnum.ORDER_RESULT, map, dfAccountId);
             if (StringUtils.isBlank(response)) {

+ 17 - 12
fs-service/src/main/java/com/fs/fastgptApi/service/Impl/ChatServiceImpl.java

@@ -39,16 +39,19 @@ public class ChatServiceImpl implements ChatService {
             // 获取消息列表
             JSONArray choices = jsonObject.getJSONArray("choices");
             // 回复消息不为空
-            if(!choices.isEmpty()){
-                // 判断是否转人工
-                if(JSON.parseObject(choices.get(0).toString()).getJSONObject("message").getString("content").contains("【转人工】")){
-                    jsonObject.put("artificial", true);
-                }
-                if(JSON.parseObject(choices.get(0).toString()).getJSONObject("message").getString("content").contains("FunctionCallBegin")){
-                    jsonObject.put("artificial", true);
-                }
-                if(JSON.parseObject(choices.get(0).toString()).getJSONObject("message").getString("content").contains("【长对话】")){
-                    jsonObject.put("longText", true);
+            if(choices != null && !choices.isEmpty()){
+                // 安全读取首条回复内容,避免 message/content 为空时 NPE
+                JSONObject firstChoice = JSON.parseObject(choices.get(0).toString());
+                JSONObject message = firstChoice != null ? firstChoice.getJSONObject("message") : null;
+                String content = message != null ? message.getString("content") : null;
+                if (StringUtils.hasText(content)) {
+                    // 判断是否转人工
+                    if (content.contains("【转人工】") || content.contains("FunctionCallBegin")) {
+                        jsonObject.put("artificial", true);
+                    }
+                    if (content.contains("【长对话】")) {
+                        jsonObject.put("longText", true);
+                    }
                 }
 
 
@@ -56,8 +59,10 @@ public class ChatServiceImpl implements ChatService {
                 // 替换AI回复里面的所有的【】包括里面的字符串
                 List<JSONObject> list = choices.stream().map(e -> {
                     JSONObject result = JSON.parseObject(e.toString());
-                    JSONObject message = result.getJSONObject("message");
-                    message.put("content", message.getString("content"));
+                    JSONObject msg = result.getJSONObject("message");
+                    if (msg != null) {
+                        msg.put("content", msg.getString("content"));
+                    }
                     return result;
                 }).collect(Collectors.toList());
                 jsonObject.put("choices", list);

+ 10 - 4
fs-service/src/main/java/com/fs/feishu/service/FeiShuService.java

@@ -191,7 +191,9 @@ public class FeiShuService {
         if (companyUserId == null) {
             throw new CustomException("销售ID不能为空");
         }
-        feishuAccountId = feishuAccountService.resolveSendAccountId(companyUserId, feishuAccountId);
+        // 指定账号若已禁用,改选最新启用账号(避免被封 appId 无法建链)
+        FeishuAccount activeAccount = feishuAccountService.resolveEnabledOrLatestAccount(companyUserId, feishuAccountId);
+        feishuAccountId = activeAccount.getId();
 
         FsUserCourseVideo userCourseVideo = videoMapper.selectFsUserCourseVideoByVideoId(videoId);
         if (userCourseVideo == null) {
@@ -338,9 +340,13 @@ public class FeiShuService {
         if (!isAuthRequired(courseLink.getFeishuNeedAuth())) {
             throw new CustomException("免授权链接无需调用此接口");
         }
-        Long feishuAccountId = courseLink.getFeishuAccountId();
-        if (feishuAccountId == null) {
-            feishuAccountId = feishuAccountService.resolveSendAccountId(param.getCompanyUserId(), null);
+        // 指定账号若已禁用,改选该销售最新启用账号并回写短链,避免继续用被封 appId
+        FeishuAccount activeAccount = feishuAccountService.resolveEnabledOrLatestAccount(
+                param.getCompanyUserId(), courseLink.getFeishuAccountId());
+        Long feishuAccountId = activeAccount.getId();
+        if (courseLink.getFeishuAccountId() == null || !feishuAccountId.equals(courseLink.getFeishuAccountId())) {
+            log.info("飞书看课账号切换: link={}, oldAccountId={}, newAccountId={}, appId={}",
+                    param.getLink(), courseLink.getFeishuAccountId(), feishuAccountId, activeAccount.getAppId());
             updateCourseLinkFeishuAccount(param.getLink(), feishuAccountId);
         }
 

+ 5 - 0
fs-service/src/main/java/com/fs/feishu/service/IFeishuAccountService.java

@@ -36,4 +36,9 @@ public interface IFeishuAccountService {
      */
     Long resolveSendAccountId(Long companyUserId, Long feishuAccountId);
 
+    /**
+     * 优先使用指定账号(须启用且属于该销售);若已禁用/不存在则改选该销售最新一条启用账号
+     */
+    FeishuAccount resolveEnabledOrLatestAccount(Long companyUserId, Long preferredAccountId);
+
 }

+ 34 - 0
fs-service/src/main/java/com/fs/feishu/service/impl/FeishuAccountServiceImpl.java

@@ -150,6 +150,40 @@ public class FeishuAccountServiceImpl implements IFeishuAccountService {
         return pollSendAccountId(companyUserId);
     }
 
+    @Override
+    public FeishuAccount resolveEnabledOrLatestAccount(Long companyUserId, Long preferredAccountId) {
+        if (companyUserId == null) {
+            throw new CustomException("用户ID不能为空");
+        }
+        if (preferredAccountId != null) {
+            FeishuAccount preferred = feishuAccountMapper.selectById(preferredAccountId);
+            if (preferred != null
+                    && companyUserId.equals(preferred.getCompanyUserId())
+                    && Integer.valueOf(1).equals(preferred.getStatus())
+                    && StringUtils.isNotBlank(preferred.getAppId())) {
+                return preferred;
+            }
+        }
+        List<FeishuAccount> enabled = feishuAccountMapper.selectEnabledByCompanyUserId(companyUserId);
+        if (enabled == null || enabled.isEmpty()) {
+            throw new CustomException("当前销售没有可用的飞书账号,请先配置或更换账号");
+        }
+        // 选最新启用账号(id 最大);旧账号被封后应换新号重建飞书链接
+        FeishuAccount latest = null;
+        for (FeishuAccount account : enabled) {
+            if (account == null || account.getId() == null || StringUtils.isBlank(account.getAppId())) {
+                continue;
+            }
+            if (latest == null || account.getId() > latest.getId()) {
+                latest = account;
+            }
+        }
+        if (latest == null) {
+            throw new CustomException("当前销售没有可用的飞书账号,请先配置或更换账号");
+        }
+        return latest;
+    }
+
     private FeishuAccount requireEnabledAccount(Long feishuAccountId, Long companyUserId) {
         FeishuAccount account = feishuAccountMapper.selectById(feishuAccountId);
         if (account == null) {

+ 2 - 2
fs-service/src/main/java/com/fs/live/service/impl/LiveWatchUserServiceImpl.java

@@ -510,7 +510,7 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
     @Override
     public List<LiveWatchUserVO> selectWatchUserList(Map<String, Object> params) {
         // 先查缓存,获取liveFlag和replayFlag
-        if (params != null && params.containsKey("liveId")) {
+        if (params != null && params.get("liveId") != null) {
             Long liveId = Long.valueOf(params.get("liveId").toString());
             Map<String, Integer> flagMap = getLiveFlagWithCache(liveId);
             params.put("liveFlag", flagMap.get("liveFlag"));
@@ -653,7 +653,7 @@ public class LiveWatchUserServiceImpl implements ILiveWatchUserService {
     @Override
     public List<LiveWatchUserVO> dashBoardWatchUserList(Map<String, Object> param) {
         // 先查缓存,获取liveFlag和replayFlag
-        if (param != null && param.containsKey("liveId")) {
+        if (param != null && param.get("liveId") != null) {
             Long liveId = Long.valueOf(param.get("liveId").toString());
             Map<String, Integer> flagMap = getLiveFlagWithCache(liveId);
             param.put("liveFlag", flagMap.get("liveFlag"));

+ 3 - 1
fs-service/src/main/java/com/fs/pay/service/impl/PayScrmServiceImpl.java

@@ -304,7 +304,9 @@ public class PayScrmServiceImpl implements IPayService {
         boolean isPass = hcClient.verificationSign(responseStr,sysConfig.getPayKey());
         if(isPass) {
             JSONObject responseObject = JSON.parseObject(responseStr);
-            String code=responseObject.get("orderStatus").toString();
+            // 支付回包可能缺 orderStatus,避免 NPE
+            Object orderStatusObj = responseObject.get("orderStatus");
+            String code = orderStatusObj != null ? orderStatusObj.toString() : "";
             if(code!=null&&code.equals("PROCESSING")) {
                 return R.ok();
             }

+ 6 - 0
fs-service/src/main/java/com/fs/sop/mapper/SopUserLogsInfoMapper.java

@@ -149,6 +149,12 @@ public interface SopUserLogsInfoMapper {
     @DataSource(DataSourceType.SOP)
     List<SopUserLogsInfo> selectSopUserLogsInfoListBySopId(@Param("sopId") String sopId, @Param("userLogsId") String userLogsId);
 
+    /**
+     * 按 SOP 一次拉取全部营期客户明细(生成任务批量预加载用)
+     */
+    @DataSource(DataSourceType.SOP)
+    List<SopUserLogsInfo> selectSopUserLogsInfoListBySopIdOnly(@Param("sopId") String sopId);
+
     @DataSource(DataSourceType.SOP)
     List<SopUserLogsInfo> selectRestoreByIsDaysNotStudy(@Param("sopId") String sopId, @Param("userLogsId") String userLogsId);
 

+ 115 - 0
fs-service/src/main/java/com/fs/sop/service/impl/SopUserLogsInfoServiceImpl.java

@@ -798,6 +798,18 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     st.setLinkUrl(feishuLink);
                                 }
                                 break;
+                            //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
+                            case "18":
+                                Integer videoIdFeishuNew = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                Integer courseIdFeishuNew = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                // 与飞书需授权(17)一致:发送时写入看课记录
+                                addWatchLogIfNeeded(param.getSopId(), param.getVideoId(), param.getCourseId(),
+                                        vo.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId,
+                                        String.valueOf(qwUser.getCompanyId()), vo.getId(),
+                                        param.getStartTime(), createTime, 2);
+                                st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNew, videoIdFeishuNew,
+                                        qwUser.getId(), companyUserId, String.valueOf(qwUser.getCompanyId()), vo.getId(), config, null));
+                                break;
                             //群公告
                             case "11":
                                 sopLogs.setSendType(21); // 设置为群公告类型
@@ -1027,6 +1039,14 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                     st.setLinkUrl(feishuLinkGroup);
                                 }
                                 break;
+                            //飞书NEW(发群:挂 chatId)
+                            case "18":
+                                Integer videoIdFeishuNewGroup = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                                Integer courseIdFeishuNewGroup = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                                st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNewGroup, videoIdFeishuNewGroup,
+                                        qwUser.getId(), String.valueOf(qwUser.getCompanyUserId()), String.valueOf(qwUser.getCompanyId()),
+                                        null, config, groupChat.getChatId()));
+                                break;
                             //群公告
                             case "11":
                                 sopLogs.setSendType(21); // 设置为群公告类型
@@ -1372,6 +1392,29 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                                 st.setLinkUrl(feishuLink);
                             }
                             break;
+                        //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
+                        case "18":
+                            Integer videoIdFeishuNewPrivate = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                            Integer courseIdFeishuNewPrivate = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                            // 与飞书需授权(17)一致:发送时写入看课记录
+                            addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                                    item.getFsUserId(), qwUserId, companyUserId, companyId,
+                                    item.getExternalId(), param.getStartTime(), createTime, 2);
+                            String txtFeishuNew = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
+                            String customerTitleFeishuNew = StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus();
+                            if (StringUtils.isNotEmpty(st.getLinkTitle())) {
+                                st.setLinkTitle(st.getLinkTitle()
+                                        .replaceAll("#销售称呼#", txtFeishuNew)
+                                        .replaceAll("#客户称呼#", customerTitleFeishuNew));
+                            }
+                            if (StringUtils.isNotEmpty(st.getLinkDescribe())) {
+                                st.setLinkDescribe(st.getLinkDescribe()
+                                        .replaceAll("#销售称呼#", txtFeishuNew)
+                                        .replaceAll("#客户称呼#", customerTitleFeishuNew));
+                            }
+                            st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), createTime, courseIdFeishuNewPrivate, videoIdFeishuNewPrivate,
+                                    Long.valueOf(qwUserId), companyUserId, companyId, item.getExternalId(), config, null));
+                            break;
                         //群公告(仅用于一键群发,个人不应该有群公告)
                         case "11":
                             log.warn("群公告不能发给个人,跳过处理,sopId:{}, externalId:{}", param.getSopId(), item.getExternalId());
@@ -2134,6 +2177,35 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
                         st.setLinkUrl(feishuLink);
                     }
                     break;
+                //飞书NEW:走需授权链路(发时写看课记录 + 入库短链),发出授权页 URL
+                case "18":
+                    Integer videoIdFeishuNewPs = param.getVideoId() != null ? param.getVideoId().intValue() : null;
+                    Integer courseIdFeishuNewPs = param.getCourseId() != null ? param.getCourseId().intValue() : null;
+                    Long externalIdFeishuNew = item.getExternalId();
+                    if (externalIdFeishuNew == null) {
+                        log.error("飞书NEW链接生成失败:外部ID为空,sopId={}", item.getSopId());
+                        break;
+                    }
+                    // 与飞书需授权(17)一致:发送时写入看课记录
+                    addWatchLogIfNeeded(item.getSopId(), param.getVideoId(), param.getCourseId(),
+                            item.getFsUserId(), String.valueOf(qwUser.getId()), companyUserId, companyId,
+                            item.getExternalId(), item.getStartTime(), dataTime, 2);
+                    String txtFeishuNewPs = StringUtil.strIsNullOrEmpty(qwUser.getWelcomeText()) ? "" : qwUser.getWelcomeText();
+                    String customerTitleFeishuNewPs = contact == null ? "同学" :
+                            (StringUtil.strIsNullOrEmpty(contact.getStageStatus()) || "0".equals(contact.getStageStatus()) ? "同学" : contact.getStageStatus());
+                    if (StringUtils.isNotEmpty(st.getLinkTitle())) {
+                        st.setLinkTitle(st.getLinkTitle()
+                                .replaceAll("#销售称呼#", txtFeishuNewPs)
+                                .replaceAll("#客户称呼#", customerTitleFeishuNewPs));
+                    }
+                    if (StringUtils.isNotEmpty(st.getLinkDescribe())) {
+                        st.setLinkDescribe(st.getLinkDescribe()
+                                .replaceAll("#销售称呼#", txtFeishuNewPs)
+                                .replaceAll("#客户称呼#", customerTitleFeishuNewPs));
+                    }
+                    st.setLinkUrl(createFeishuNewSendUrl(st, param.getCorpId(), dataTime, courseIdFeishuNewPs, videoIdFeishuNewPs,
+                            qwUser.getId(), companyUserId, companyId, externalIdFeishuNew, config, null));
+                    break;
 
                 default:
                     break;
@@ -2144,6 +2216,49 @@ public class SopUserLogsInfoServiceImpl implements ISopUserLogsInfoService {
         return  list;
     }
 
+    /**
+     * 飞书NEW(contentType=18):按需授权链路处理——
+     * 1)发送侧须调用 addWatchLogIfNeeded 写入看课记录;
+     * 2)短链入库标记 feishuNeedAuth=1,领取后走 getFeishuCourseNewLink;
+     * 3)发出 URL 为 {realLinkDomainName}/?uuid={link}&appid={feishuMpAppId}(不在发课瞬间调飞书建文档)。
+     */
+    private String createFeishuNewSendUrl(QwSopCourseFinishTempSetting.Setting setting, String corpId, Date sendTime,
+                                          Integer courseId, Integer videoId, Long qwUserId,
+                                          String companyUserId, String companyId, Long externalId,
+                                          CourseConfig config, String chatId) {
+        if (config == null || StringUtils.isEmpty(config.getRealLinkDomainName())) {
+            throw new BaseException("飞书NEW链接生成失败:未配置通用看课域名");
+        }
+        if (StringUtils.isEmpty(config.getFeishuMpAppId())) {
+            throw new BaseException("飞书NEW链接生成失败:未配置飞书服务号AppID");
+        }
+        if (videoId == null) {
+            throw new BaseException("飞书NEW链接生成失败:视频ID为空");
+        }
+        if (courseId == null) {
+            throw new BaseException("飞书NEW链接生成失败:课程ID为空");
+        }
+        FsCourseLink link = createFsCourseLink(corpId, sendTime, courseId, videoId, qwUserId,
+                companyUserId, companyId, externalId, 0, chatId);
+        // 固定需授权,对齐飞书授权看课链路(领取时走 getFeishuCourseNewLink)
+        link.setFeishuNeedAuth(1);
+        if (setting != null && setting.getFeishuAccountId() != null) {
+            link.setFeishuAccountId(setting.getFeishuAccountId());
+        }
+        FsCourseRealLink courseMap = new FsCourseRealLink();
+        BeanUtils.copyProperties(link, courseMap);
+        String courseJson = JSON.toJSONString(courseMap);
+        link.setRealLink(REAL_LINK_PREFIX + courseJson);
+        link.setUpdateTime(createUpdateTime(setting, sendTime, config));
+        fsCourseLinkMapper.insertFsCourseLink(link);
+
+        String domain = config.getRealLinkDomainName().trim();
+        if (!domain.endsWith("/")) {
+            domain = domain + "/";
+        }
+        return domain + "?uuid=" + link.getLink() + "&appid=" + config.getFeishuMpAppId().trim();
+    }
+
     /**
      * 生成飞书看课课程短链(私聊:传 externalId;发群:传 chatId)
      */

+ 9 - 0
fs-service/src/main/resources/mapper/sop/SopUserLogsInfoMapper.xml

@@ -236,6 +236,15 @@
         from sop_user_logs_info where sop_id = #{sopId} and user_logs_id=#{userLogsId}
     </select>
 
+    <!-- 按 SOP 批量拉取营期客户,避免生成任务对每个 user_logs 单独查询 -->
+    <select id="selectSopUserLogsInfoListBySopIdOnly" resultMap="SopUserLogsInfoResult">
+        select
+            id, sop_id, user_logs_id, external_contact_id, qw_user_id, corp_id, external_id,
+            fs_user_id, external_user_name, create_time, crt_Time, update_time, is_days_not_study, grade
+        from sop_user_logs_info
+        where sop_id = #{sopId}
+    </select>
+
     <select id="selectRestoreByIsDaysNotStudy" parameterType="String" resultMap="SopUserLogsInfoResult">
         select
             id,external_id

+ 2 - 0
fs-user-app/src/main/java/com/fs/app/controller/AppLoginController.java

@@ -252,6 +252,8 @@ public class AppLoginController extends AppBaseController{
         if(!code.equals(param.getCode())){
             return R.error("验证码不正确");
         }
+        // 验证成功后立即删除,防止验证码重放
+        redisCache.deleteObject(user.getPhone());
         FsUser userMap=new FsUser();
         userMap.setUserId(user.getUserId());
         userMap.setPassword(Md5Utils.hash(param.getPassword()));

+ 20 - 0
fs-user-app/src/main/java/com/fs/app/controller/CompanyUserController.java

@@ -482,8 +482,18 @@ public class CompanyUserController extends AppBaseController {
     /**
      * 获取用户信息采集详细信息
      */
+    @Login
     @GetMapping(value = "/informationCollection/{id}")
     public R getInformationCollectionInfo(@PathVariable("id") Long id) {
+        FsUserInformationCollection info = fsUserInformationCollectionService.selectFsUserInformationCollectionById(id);
+        if (info == null) {
+            return R.error("记录不存在");
+        }
+        // 仅允许查看归属当前销售的采集记录
+        Long companyUserId = getCompanyUserId();
+        if (info.getCompanyUserId() == null || !info.getCompanyUserId().equals(companyUserId)) {
+            return R.error("无权查看该记录");
+        }
         return R.ok().put("data", fsUserInformationCollectionService.selectFsUserInformationCollectionVoById(id));
     }
 
@@ -527,8 +537,18 @@ public class CompanyUserController extends AppBaseController {
     /**
      * 删除用户信息采集
      */
+    @Login
     @DeleteMapping("/informationCollection/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids) {
+        Long companyUserId = getCompanyUserId();
+        if (ids != null) {
+            for (Long id : ids) {
+                FsUserInformationCollection info = fsUserInformationCollectionService.selectFsUserInformationCollectionById(id);
+                if (info == null || info.getCompanyUserId() == null || !info.getCompanyUserId().equals(companyUserId)) {
+                    return AjaxResult.error("无权删除所选记录");
+                }
+            }
+        }
         return toAjax(fsUserInformationCollectionService.deleteFsUserInformationCollectionByIds(ids));
     }
 

+ 2 - 1
fs-user-app/src/main/java/com/fs/app/controller/FsUserComplaintController.java

@@ -13,6 +13,7 @@ import com.fs.his.vo.FsComplaintTemplateVO;
 import com.fs.his.vo.FsUserComplaintVo;
 import com.google.common.collect.Lists;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
 import java.util.List;
@@ -67,7 +68,7 @@ public class FsUserComplaintController extends AppBaseController
     /**
      * 查询用户投诉列表
      */
-//    @PreAuthorize("@ss.hasPermi('his:complaint:list')")
+    @PreAuthorize("@ss.hasPermi('his:complaint:list')")
     @GetMapping("/list")
     public TableDataInfo list(FsHisComplaint fsHisComplaint)
     {

+ 18 - 2
fs-user-app/src/main/java/com/fs/app/controller/HealthRecordController.java

@@ -166,7 +166,15 @@ public class HealthRecordController extends AppBaseController
     @GetMapping(value = "/getHealthLife/{id}")
     public AjaxResult getHealthLife(@PathVariable("id") Long id)
     {
-        return AjaxResult.success(fsHealthLifeService.selectFsHealthLifeById(id));
+        FsHealthLife life = fsHealthLifeService.selectFsHealthLifeById(id);
+        if (life == null) {
+            return AjaxResult.error("记录不存在");
+        }
+        // 仅允许查看本人健康生活记录
+        if (life.getUserId() == null || !life.getUserId().equals(Long.parseLong(getUserId()))) {
+            return AjaxResult.error("无权查看该记录");
+        }
+        return AjaxResult.success(life);
     }
 
     /**
@@ -218,7 +226,15 @@ public class HealthRecordController extends AppBaseController
     @GetMapping(value = "/HealthData/{id}")
     public AjaxResult HealthData(@PathVariable("id") Long id)
     {
-        return AjaxResult.success(fsHealthDataService.selectFsHealthDataById(id));
+        FsHealthData data = fsHealthDataService.selectFsHealthDataById(id);
+        if (data == null) {
+            return AjaxResult.error("记录不存在");
+        }
+        // 仅允许查看本人健康数据
+        if (data.getUserId() == null || !data.getUserId().equals(Long.parseLong(getUserId()))) {
+            return AjaxResult.error("无权查看该记录");
+        }
+        return AjaxResult.success(data);
     }
     @Login
     @ApiOperation("新增健康数据")

+ 8 - 0
fs-user-app/src/main/java/com/fs/app/controller/HealthTongueController.java

@@ -96,11 +96,19 @@ public class HealthTongueController extends AppBaseController
     }
 
 
+    @Login
     @ApiOperation("AI舌诊报告")
     @GetMapping(value = "/getHealthTongueById/{id}")
     public R getInfo(@PathVariable("id") Long id)
     {
         FsHealthTongueUVO fsHealthTongue = fsHealthTongueService.selectFsHealthTongueUVOById(id);
+        if (fsHealthTongue == null) {
+            return R.error("记录不存在");
+        }
+        // 仅允许查看本人舌诊报告,防止越权
+        if (fsHealthTongue.getUserId() == null || !fsHealthTongue.getUserId().equals(Long.parseLong(getUserId()))) {
+            return R.error("无权查看该报告");
+        }
         FsDoctorArticle fsDoctorArticle = fsDoctorArticleMapper.selectFsDoctorArticleByArticleByNameBodyUrl(fsHealthTongue.getTypeName());
         String url="";
         String imageUrl="";

+ 5 - 0
fs-user-app/src/main/java/com/fs/app/controller/UserController.java

@@ -381,6 +381,11 @@ public class UserController extends  AppBaseController {
     @ApiOperation("删除用户(逻辑删除)")
     @PostMapping("/deleteUser/{id}")
     public R deleteUser(@PathVariable("id") Long id ){
+        // 仅允许删除当前登录用户自身,防止越权删除他人账号
+        Long currentUserId = Long.parseLong(getUserId());
+        if (id == null || !id.equals(currentUserId)) {
+            return R.error("无权删除该用户");
+        }
         return userService.updateStatus(id);
     }
 

+ 13 - 1
fs-user-app/src/main/java/com/fs/app/controller/UserInvitedController.java

@@ -52,10 +52,22 @@ public class UserInvitedController extends AppBaseController {
     /**
      * 获取用户填写邀请码记录详细信息
      */
+    @Login
     @GetMapping(value = "/{id}")
     public AjaxResult getInfo(@PathVariable("id") Long id)
     {
-        return AjaxResult.success(fsUserInvitedService.selectFsUserInvitedById(id));
+        FsUserInvited invited = fsUserInvitedService.selectFsUserInvitedById(id);
+        if (invited == null) {
+            return AjaxResult.error("记录不存在");
+        }
+        Long currentUserId = Long.parseLong(getUserId());
+        // 邀请人或被邀请人可查看
+        boolean owner = (invited.getUserId() != null && invited.getUserId().equals(currentUserId))
+                || (invited.getInvitedUserId() != null && invited.getInvitedUserId().equals(currentUserId));
+        if (!owner) {
+            return AjaxResult.error("无权查看该记录");
+        }
+        return AjaxResult.success(invited);
     }
 
     /**

+ 760 - 98
fs-user-app/src/main/java/com/fs/app/controller/course/CourseMpLoginController.java

@@ -1,132 +1,794 @@
 package com.fs.app.controller.course;
 
 import cn.hutool.core.date.DateTime;
+import cn.hutool.json.JSONUtil;
+import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
 import com.fs.app.annotation.UserOperationLog;
+import com.fs.app.controller.AppBaseController;
+import com.fs.app.param.FsUserClaimCourseMaterialParam;
+import com.fs.app.param.FsUserLoginByMpOauthParam;
 import com.fs.app.param.FsUserLoginByMpParam;
-import com.fs.app.utils.JwtUtils;
+import com.fs.app.utils.WxUtil;
 import com.fs.common.core.domain.R;
-import com.fs.common.core.redis.RedisCache;
-import com.fs.course.mapper.FsCourseSopLogsMapper;
+import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.course.config.CourseConfig;
+import com.fs.course.domain.FsCourseLink;
+import com.fs.course.domain.FsCoursePlaySourceConfig;
+import com.fs.course.domain.FsCourseWatchLog;
+import com.fs.course.mapper.FsCourseLinkMapper;
 import com.fs.course.mapper.FsCourseWatchLogMapper;
+import com.fs.course.service.IFsCoursePlaySourceConfigService;
+import com.fs.course.utils.FsCoursePlaySourceConfigQuery;
+import com.fs.common.exception.CustomException;
+import com.fs.feishu.domain.FeishuAccount;
+import com.fs.feishu.model.FeishuLinkParam;
+import com.fs.feishu.service.FeiShuService;
+import com.fs.feishu.service.IFeishuAccountService;
 import com.fs.his.domain.FsUser;
 import com.fs.his.enums.FsUserOperationEnum;
 import com.fs.his.service.IFsUserService;
-import com.fs.his.utils.ConfigUtil;
+import com.fs.qw.domain.QwExternalContact;
+import com.fs.qw.domain.QwGroupChat;
+import com.fs.qw.domain.QwGroupChatUser;
 import com.fs.qw.mapper.QwExternalContactMapper;
-import com.fs.sop.params.QwSopSettingTimeParam;
-import com.fs.system.mapper.SysConfigMapper;
-import com.fs.wx.mp.WxEeventType;
-import com.fs.wx.mp.WxMessageType;
-import com.fs.wx.mp.WxServiceMsgDto;
+import com.fs.qw.mapper.QwGroupChatMapper;
+import com.fs.qw.mapper.QwGroupChatUserMapper;
+import com.fs.sop.domain.SopUserLogsInfo;
+import com.fs.sop.service.ISopUserLogsInfoService;
+import com.fs.system.service.ISysConfigService;
+import io.jsonwebtoken.Claims;
 import io.swagger.annotations.Api;
 import io.swagger.annotations.ApiOperation;
-import lombok.Synchronized;
-import me.chanjar.weixin.common.bean.WxJsapiSignature;
 import me.chanjar.weixin.common.bean.WxOAuth2UserInfo;
-import me.chanjar.weixin.common.bean.menu.WxMenu;
 import me.chanjar.weixin.common.bean.oauth2.WxOAuth2AccessToken;
 import me.chanjar.weixin.common.error.WxErrorException;
-import me.chanjar.weixin.mp.api.WxMpMenuService;
 import me.chanjar.weixin.mp.api.WxMpService;
-import me.chanjar.weixin.mp.api.WxMpUserService;
-import me.chanjar.weixin.mp.bean.result.WxMpUser;
 import org.apache.commons.lang3.StringUtils;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.transaction.annotation.Transactional;
-import org.springframework.web.bind.annotation.*;
+import org.springframework.validation.annotation.Validated;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
 
-import java.io.UnsupportedEncodingException;
 import java.net.URLDecoder;
-import java.text.SimpleDateFormat;
-import java.util.Calendar;
+import java.util.Arrays;
 import java.util.Date;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
-
+/**
+ * 看课服务号授权登录 / H5 公众号 OAuth 登录与领取资料
+ */
 @Api("看课服务号授权登录接口")
 @RestController
 @RequestMapping("/app/wx/mp")
-public class CourseMpLoginController {
-  Logger logger= LoggerFactory.getLogger(getClass());
-  @Autowired
-  private WxMpService wxMpService;
-  @Autowired
-  private IFsUserService userService;
-
-  @Autowired
-  JwtUtils jwtUtils;
-  @Autowired
-  RedisCache redisCache;
-
-  @Autowired
-  FsCourseWatchLogMapper fsCourseWatchLogMapper;
-  @Autowired
-  QwExternalContactMapper qwExternalContactMapper;
-
-
-  @ApiOperation("课程短链公众号登录")
-  @PostMapping("/loginByMp")
-  @Transactional
-  @UserOperationLog(operationType = FsUserOperationEnum.H5LOGIN)
-  public R loginByMp( @RequestBody FsUserLoginByMpParam param) {
-
-    if (StringUtils.isBlank(param.getCode())) {
-      return R.error("code不存在");
-    }
-    try{
-      WxOAuth2AccessToken wxMpOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(param.getCode());
-      WxOAuth2UserInfo wxMpUser = wxMpService.getOAuth2Service().getUserInfo(wxMpOAuth2AccessToken, null);
-      if(StringUtils.isBlank(wxMpUser.getUnionId())){
-        return R.error("请使用微信扫码登录");
-      }
-      FsUser user=userService.selectFsUserByUnionid(wxMpUser.getUnionId());
-      if(user!=null){
-        FsUser userMap=new FsUser();
-        userMap.setUserId(user.getUserId());
-        userMap.setNickName(wxMpUser.getNickname());
-        userMap.setAvatar(wxMpUser.getHeadImgUrl());
-        userMap.setMpOpenId(wxMpUser.getOpenid());
-        userMap.setUpdateTime(new DateTime());
-        userService.updateFsUser(userMap);
-      }
-      else{
-        //写入
-        user=new FsUser();
-        user.setNickName(wxMpUser.getNickname());
-        user.setAvatar(wxMpUser.getHeadImgUrl());
-        user.setStatus(1);
-        user.setSex(wxMpUser.getSex());
-        user.setMpOpenId(wxMpUser.getOpenid());
-        user.setUnionId(wxMpUser.getUnionId());
-        // 新用户 - 添加 appId
-        if (StringUtils.isNotEmpty(param.getAppId())) {
-          user.setAppId(param.getAppId());
-        }
-        user.setCreateTime(new Date());
-        userService.insertFsUser(user);
-      }
-      System.out.println("user:id:"+user.getUserId());
-      String token = jwtUtils.generateToken(user.getUserId());
-      redisCache.setCacheObject("token:"+user.getUserId(),token,604800, TimeUnit.SECONDS);
-      Map<String,Object> map=new HashMap<>();
-      map.put("token",token);
-      map.put("user",user);
-      logger.info("zyp \n 【点播公众号登录】:{}",user.getUserId());
-      return R.ok(map);
-
-    }
-    catch (WxErrorException e){
-      if(e.getError().getErrorCode()==40163){
-        return R.error(40163,e.getError().getErrorMsg());
-      }
-      else{
-        return R.error("授权失败,"+e.getMessage());
-      }
-    }
-  }
+public class CourseMpLoginController extends AppBaseController {
+
+    private static final Logger logger = LoggerFactory.getLogger(CourseMpLoginController.class);
+
+    /** H5 公众号登录 token 有效期:3 天 */
+    private static final long MP_OAUTH_TOKEN_TTL_SECONDS = 3L * 24 * 60 * 60;
+
+    /** 领取资料:按短链 link 缓存飞书 docx(一个 link 一条,含群链接) */
+    private static final String FEISHU_CLAIM_LINK_CACHE_PREFIX = "feishu:h5_claim_link:";
+    private static final int FEISHU_CLAIM_LINK_CACHE_HOURS = 24;
+
+    /** 与 isAddKf 群/独属提示对齐 */
+    private static final String TIP_NO_REGISTER = "由于您还未完成注册,请联系伴学助手完成注册即可观看!";
+    private static final String TIP_NO_MEMBER = "此链接已被绑定,请联系学习助理~";
+    private static final String TIP_GROUP_PARAM = "群参数异常!";
+    private static final String TIP_RATE_LIMIT = "当前学习通道访问限流,暂时无法观看!";
+    private static final String TIP_EXPIRED = "资料链接已过期,请联系伴学助手";
+    private static final String TIP_LINK_MISSING = "未找到资料链接,请联系伴学助手";
+    private static final String TIP_FEISHU_APP = "资料生成失败,请联系伴学助手";
+    private static final String TIP_SYSTEM = "系统繁忙,请稍后再试;若持续失败请联系伴学助手";
+
+    @Autowired
+    private WxMpService wxMpService;
+    @Autowired
+    private IFsUserService userService;
+    @Autowired
+    private QwExternalContactMapper qwExternalContactMapper;
+    @Autowired
+    private QwGroupChatMapper qwGroupChatMapper;
+    @Autowired
+    private QwGroupChatUserMapper qwGroupChatUserMapper;
+    @Autowired
+    private IFsCoursePlaySourceConfigService fsCoursePlaySourceConfigService;
+    @Autowired
+    private FsCourseLinkMapper fsCourseLinkMapper;
+    @Autowired
+    private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+    @Autowired
+    private CompanyUserMapper companyUserMapper;
+    @Autowired
+    private ISysConfigService configService;
+    @Autowired
+    private FeiShuService feiShuService;
+    @Autowired
+    private IFeishuAccountService feishuAccountService;
+    @Autowired
+    private ISopUserLogsInfoService iSopUserLogsInfoService;
+
+    @ApiOperation("课程短链公众号登录")
+    @PostMapping("/loginByMp")
+    @Transactional
+    @UserOperationLog(operationType = FsUserOperationEnum.H5LOGIN)
+    public R loginByMp(@RequestBody FsUserLoginByMpParam param) {
+
+        if (StringUtils.isBlank(param.getCode())) {
+            return R.error("code不存在");
+        }
+        try {
+            WxOAuth2AccessToken wxMpOAuth2AccessToken = wxMpService.getOAuth2Service().getAccessToken(param.getCode());
+            WxOAuth2UserInfo wxMpUser = wxMpService.getOAuth2Service().getUserInfo(wxMpOAuth2AccessToken, null);
+            if (StringUtils.isBlank(wxMpUser.getUnionId())) {
+                return R.error("请使用微信扫码登录");
+            }
+            FsUser user = userService.selectFsUserByUnionid(wxMpUser.getUnionId());
+            if (user != null) {
+                FsUser userMap = new FsUser();
+                userMap.setUserId(user.getUserId());
+                userMap.setNickName(wxMpUser.getNickname());
+                userMap.setAvatar(wxMpUser.getHeadImgUrl());
+                userMap.setMpOpenId(wxMpUser.getOpenid());
+                userMap.setUpdateTime(new DateTime());
+                userService.updateFsUser(userMap);
+            } else {
+                // 写入新用户
+                user = new FsUser();
+                user.setNickName(wxMpUser.getNickname());
+                user.setAvatar(wxMpUser.getHeadImgUrl());
+                user.setStatus(1);
+                user.setSex(wxMpUser.getSex());
+                user.setMpOpenId(wxMpUser.getOpenid());
+                user.setUnionId(wxMpUser.getUnionId());
+                if (StringUtils.isNotEmpty(param.getAppId())) {
+                    user.setAppId(param.getAppId());
+                }
+                user.setCreateTime(new Date());
+                userService.insertFsUser(user);
+            }
+            String token = jwtUtils.generateToken(user.getUserId());
+            redisCache.setCacheObject("token:" + user.getUserId(), token, 604800, TimeUnit.SECONDS);
+            Map<String, Object> map = new HashMap<>();
+            map.put("token", token);
+            map.put("user", user);
+            logger.info("【点播公众号登录】:{}", user.getUserId());
+            return R.ok(map);
+
+        } catch (WxErrorException e) {
+            if (e.getError().getErrorCode() == 40163) {
+                return R.error(40163, e.getError().getErrorMsg());
+            } else {
+                return R.error("授权失败," + e.getMessage());
+            }
+        }
+    }
+
+    /**
+     * H5 消防科普页等场景:前端传 code + appId,按播放源配置换票登录,token 缓存 3 天
+     */
+    @ApiOperation("H5公众号OAuth登录(按appId查配置,返回token、userId、昵称、头像)")
+    @PostMapping("/loginByMpOauth")
+    @Transactional
+    @UserOperationLog(operationType = FsUserOperationEnum.H5LOGIN)
+    public R loginByMpOauth(@Validated @RequestBody FsUserLoginByMpOauthParam param) {
+        long startMs = System.currentTimeMillis();
+        String codePreview = param != null && StringUtils.isNotBlank(param.getCode())
+                ? param.getCode().substring(0, Math.min(8, param.getCode().length())) + "***"
+                : "";
+        logger.info("[H5登录] 开始 appId={} uuid={} codePreview={}",
+                param != null ? param.getAppId() : null,
+                param != null ? param.getUuid() : null,
+                codePreview);
+        try {
+            if (StringUtils.isBlank(param.getCode())) {
+                logger.warn("[H5登录] 失败 reason=code为空 appId={} uuid={}", param.getAppId(), param.getUuid());
+                return R.error("code不存在");
+            }
+            if (StringUtils.isBlank(param.getAppId())) {
+                logger.warn("[H5登录] 失败 reason=appId为空 uuid={}", param.getUuid());
+                return R.error("appId不存在");
+            }
+
+            QueryWrapper<FsCoursePlaySourceConfig> appIdQuery = new QueryWrapper<FsCoursePlaySourceConfig>()
+                    .eq("appid", param.getAppId())
+                    .eq("is_del", 0);
+            FsCoursePlaySourceConfigQuery.excludeBanned(appIdQuery);
+            FsCoursePlaySourceConfig playSourceConfig = fsCoursePlaySourceConfigService.getOne(appIdQuery);
+            if (playSourceConfig == null
+                    || StringUtils.isBlank(playSourceConfig.getAppid())
+                    || StringUtils.isBlank(playSourceConfig.getSecret())) {
+                logger.warn("[H5登录] 失败 reason=公众号配置不存在或未启用 appId={} uuid={}",
+                        param.getAppId(), param.getUuid());
+                return R.error("公众号配置不存在或未启用");
+            }
 
+            String appId = playSourceConfig.getAppid();
+            String appSecret = playSourceConfig.getSecret();
+            logger.info("[H5登录] 已匹配播放源配置 appId={} configId={} name={}",
+                    appId, playSourceConfig.getId(), playSourceConfig.getName());
+
+            Map<?, ?> tokenResult = WxUtil.getAccessToken(param.getCode(), appId, appSecret);
+            Object unionidObj = tokenResult.get("unionid");
+            Object openidObj = tokenResult.get("openid");
+            Object accessTokenObj = tokenResult.get("access_token");
+            if (unionidObj == null || StringUtils.isBlank(String.valueOf(unionidObj))) {
+                logger.warn("[H5登录] 失败 reason=无unionid appId={} uuid={} wxKeys={}",
+                        appId, param.getUuid(), tokenResult != null ? tokenResult.keySet() : null);
+                return R.error("未绑定开放平台,无法获取unionid");
+            }
+            if (openidObj == null || accessTokenObj == null) {
+                logger.warn("[H5登录] 失败 reason=无openid或access_token appId={} uuid={} wxKeys={}",
+                        appId, param.getUuid(), tokenResult != null ? tokenResult.keySet() : null);
+                return R.error("授权失败,未获取到openid");
+            }
+
+            String unionid = String.valueOf(unionidObj);
+            String openid = String.valueOf(openidObj);
+            String accessToken = String.valueOf(accessTokenObj);
+            logger.info("[H5登录] 微信换票成功 appId={} openid={} unionid={}", appId, openid, unionid);
+
+            String nickname = "微信用户";
+            String avatar = "";
+            Integer sex = 0;
+            try {
+                Map<?, ?> userInfo = WxUtil.getUserInfo(accessToken, openid);
+                if (userInfo != null) {
+                    if (userInfo.get("nickname") != null) {
+                        nickname = String.valueOf(userInfo.get("nickname"));
+                    }
+                    if (userInfo.get("headimgurl") != null) {
+                        avatar = String.valueOf(userInfo.get("headimgurl"));
+                    }
+                    if (userInfo.get("sex") instanceof Integer) {
+                        sex = (Integer) userInfo.get("sex");
+                    }
+                }
+                logger.info("[H5登录] 拉取微信资料 nickname={} hasAvatar={}", nickname, StringUtils.isNotBlank(avatar));
+            } catch (Exception e) {
+                logger.warn("[H5登录] 获取微信用户资料失败,使用默认昵称 openid={} err={}", openid, e.getMessage());
+            }
+
+            FsUser user = userService.selectFsUserByUnionid(unionid);
+            boolean isNewUser = false;
+            if (user != null) {
+                if (user.getStatus() != null && user.getStatus().equals(0)) {
+                    logger.warn("[H5登录] 失败 reason=会员已禁用 userId={} unionid={}", user.getUserId(), unionid);
+                    return R.error("此会员已禁用");
+                }
+                FsUser userMap = new FsUser();
+                userMap.setUserId(user.getUserId());
+                userMap.setNickName(nickname);
+                userMap.setAvatar(avatar);
+                userMap.setMpOpenId(openid);
+                userMap.setUpdateTime(new DateTime());
+                userService.updateFsUser(userMap);
+                user.setNickName(nickname);
+                user.setAvatar(avatar);
+                user.setMpOpenId(openid);
+                logger.info("[H5登录] 更新已有用户 userId={} mpOpenId={}", user.getUserId(), openid);
+            } else {
+                isNewUser = true;
+                user = new FsUser();
+                user.setNickName(nickname);
+                user.setAvatar(avatar);
+                user.setStatus(1);
+                if (sex != null && sex != 0) {
+                    user.setSex(sex);
+                }
+                user.setMpOpenId(openid);
+                user.setUnionId(unionid);
+                user.setAppId(appId);
+                user.setCreateTime(new Date());
+                userService.insertFsUser(user);
+                logger.info("[H5登录] 注册新用户 userId={} unionid={} appId={}", user.getUserId(), unionid, appId);
+            }
+
+            String token = jwtUtils.generateToken(user.getUserId(), MP_OAUTH_TOKEN_TTL_SECONDS);
+            redisCache.setCacheObject("token:" + user.getUserId(), token,
+                    Integer.valueOf((int) MP_OAUTH_TOKEN_TTL_SECONDS), TimeUnit.SECONDS);
+
+            Map<String, Object> map = new HashMap<>();
+            // H5:token、userId、昵称、头像(不回传 openid/unionid/完整 user)
+            map.put("token", token);
+            map.put("userId", user.getUserId());
+            map.put("nickname", nickname);
+            map.put("headimgurl", avatar);
+            logger.info("[H5登录] 成功 isNew={} userId={} appId={} uuid={} nickname={} hasAvatar={} costMs={}",
+                    isNewUser, user.getUserId(), appId, param.getUuid(), nickname,
+                    StringUtils.isNotBlank(avatar), System.currentTimeMillis() - startMs);
+            return R.ok(map);
+        } catch (Exception e) {
+            logger.error("[H5登录] 异常 appId={} uuid={} costMs={} err={}",
+                    param != null ? param.getAppId() : null,
+                    param != null ? param.getUuid() : null,
+                    System.currentTimeMillis() - startMs,
+                    e.getMessage(), e);
+            return R.error("登录失败");
+        }
+    }
+
+    /**
+     * 领取资料:Header 带 APPToken,Body 只传 uuid(= fs_course_link.link)
+     * 先校验短链归属,通过后再按 getFeishuCourseNewLink 逻辑返回飞书 docx
+     */
+    @ApiOperation("H5领取资料(先归属校验,再返回飞书看课链接)")
+    @PostMapping("/claimCourseMaterial")
+    public R claimCourseMaterial(@Validated @RequestBody FsUserClaimCourseMaterialParam param) {
+        long startMs = System.currentTimeMillis();
+        String uuid = param != null ? param.getUuid() : null;
+        logger.info("[H5领取] 开始 uuid={}", uuid);
+
+        String userIdStr = getUserId();
+        if (StringUtils.isBlank(userIdStr)) {
+            logger.warn("[H5领取] 失败 reason=APPToken无法解析用户 uuid={}", uuid);
+            return R.error(401, "未登录");
+        }
+        Long userId;
+        try {
+            userId = Long.valueOf(userIdStr);
+        } catch (NumberFormatException e) {
+            logger.warn("[H5领取] 失败 reason=userId非法 userIdStr={} uuid={}", userIdStr, uuid);
+            return R.error(401, "未登录");
+        }
+
+        // 校验 Redis 中 token 是否仍有效(与 3 天缓存对齐)
+        String headerToken = com.fs.common.utils.ServletUtils.getRequest().getHeader("APPToken");
+        if (StringUtils.isBlank(headerToken)) {
+            logger.warn("[H5领取] 失败 reason=缺少APPToken userId={} uuid={}", userId, uuid);
+            return R.error(401, "未登录");
+        }
+        Claims claims = jwtUtils.getClaimByToken(headerToken);
+        if (claims == null || jwtUtils.isTokenExpired(claims.getExpiration())) {
+            logger.warn("[H5领取] 失败 reason=JWT无效或过期 userId={} uuid={}", userId, uuid);
+            return R.error(401, "未登录");
+        }
+        Object cachedToken = redisCache.getCacheObject("token:" + userId);
+        if (cachedToken == null || !headerToken.equals(String.valueOf(cachedToken))) {
+            logger.warn("[H5领取] 失败 reason=Redis token不匹配或已失效 userId={} uuid={} hasRedisToken={}",
+                    userId, uuid, cachedToken != null);
+            return R.error(401, "未登录");
+        }
+
+        FsUser currentUser = userService.selectFsUserByUserId(userId);
+        if (currentUser == null) {
+            logger.warn("[H5领取] 失败 reason=用户不存在 userId={} uuid={}", userId, uuid);
+            return R.error(401, "未登录");
+        }
+        if (currentUser.getStatus() != null && currentUser.getStatus().equals(0)) {
+            logger.warn("[H5领取] 失败 reason=会员已禁用 userId={} uuid={}", userId, uuid);
+            return R.error("此会员已禁用");
+        }
+
+        logger.info("[H5领取] 鉴权通过 userId={} unionId={} uuid={}",
+                userId, currentUser.getUnionId(), uuid);
+        R result = resolveMaterialJump(uuid, currentUser);
+        logger.info("[H5领取] 结束 userId={} uuid={} hasMaterial={} msg={} costMs={}",
+                userId,
+                uuid,
+                result.get("hasMaterial"),
+                result.get("msg"),
+                System.currentTimeMillis() - startMs);
+        return result;
+    }
+
+    /**
+     * 按 uuid 查短链:先归属门禁(群对齐 isAddKf),通过后再取飞书看课文档
+     */
+    private R resolveMaterialJump(String uuid, FsUser currentUser) {
+        if (StringUtils.isBlank(uuid)) {
+            logger.info("[H5领取] 失败 reason=uuid为空 userId={}", currentUser.getUserId());
+            return claimFail("link_missing", TIP_LINK_MISSING);
+        }
+
+        try {
+            String linkCode = URLDecoder.decode(uuid, "UTF-8");
+            FsCourseLink courseLink = fsCourseLinkMapper.selectFsCourseLinkByLink(linkCode);
+            if (courseLink == null) {
+                logger.info("[H5领取] 失败 reason=短链不存在 userId={} link={}", currentUser.getUserId(), linkCode);
+                return claimFail("link_missing", TIP_LINK_MISSING);
+            }
+            logger.info("[H5领取] 查到短链 userId={} linkId={} link={} chatId={} qwExternalId={} companyUserId={} "
+                            + "feishuNeedAuth={} videoId={} courseId={} expireAt={}",
+                    currentUser.getUserId(),
+                    courseLink.getLinkId(),
+                    courseLink.getLink(),
+                    courseLink.getChatId(),
+                    courseLink.getQwExternalId(),
+                    courseLink.getCompanyUserId(),
+                    courseLink.getFeishuNeedAuth(),
+                    courseLink.getVideoId(),
+                    courseLink.getCourseId(),
+                    courseLink.getUpdateTime());
+
+            if (courseLink.getUpdateTime() != null && new Date().after(courseLink.getUpdateTime())) {
+                logger.info("[H5领取] 失败 reason=短链已过期 userId={} linkId={} expireAt={}",
+                        currentUser.getUserId(), courseLink.getLinkId(), courseLink.getUpdateTime());
+                return claimFail("expired", TIP_EXPIRED);
+            }
+
+            // 归属门禁:群链走 isAddKf/handleQwRoom;私聊走独属校验。未通过不调飞书
+            R ownershipFail = checkLinkOwnership(courseLink, currentUser);
+            if (ownershipFail != null) {
+                return ownershipFail;
+            }
+
+            return resolveJumpUrlAfterOwned(courseLink, currentUser);
+        } catch (Exception e) {
+            logger.error("[H5领取] 校验异常 userId={} uuid={} err={}",
+                    currentUser.getUserId(), uuid, e.getMessage(), e);
+            return claimFail("system", TIP_SYSTEM);
+        }
+    }
+
+    /**
+     * 归属校验。返回 null 表示通过;否则返回带具体提示的失败结果
+     */
+    private R checkLinkOwnership(FsCourseLink courseLink, FsUser currentUser) {
+        if (StringUtils.isNotBlank(courseLink.getChatId())) {
+            return checkGroupOwnership(courseLink, currentUser);
+        }
+        return checkPrivateOwnership(courseLink, currentUser);
+    }
+
+    /**
+     * 群看课归属:对齐 isAddKf → handleQwRoom(在群、已注册)
+     */
+    private R checkGroupOwnership(FsCourseLink courseLink, FsUser currentUser) {
+        String chatId = courseLink.getChatId();
+        QwGroupChat qwGroupChat = qwGroupChatMapper.selectQwGroupChatByChatId(chatId);
+        if (qwGroupChat == null) {
+            logger.info("[H5领取] 群归属失败 reason=群不存在 userId={} chatId={}", currentUser.getUserId(), chatId);
+            return claimFail("ownership_group", TIP_GROUP_PARAM);
+        }
+        SopUserLogsInfo sopQuery = new SopUserLogsInfo();
+        sopQuery.setChatId(chatId);
+        List<QwGroupChatUser> qwGroupChatUsers = qwGroupChatUserMapper.selectByChatId(sopQuery);
+        if (qwGroupChatUsers == null || qwGroupChatUsers.isEmpty()) {
+            logger.info("[H5领取] 群归属失败 reason=群成员为空 userId={} chatId={}", currentUser.getUserId(), chatId);
+            return claimFail("ownership_group", TIP_GROUP_PARAM);
+        }
+
+        String corpId = StringUtils.isNotBlank(courseLink.getCorpId())
+                ? courseLink.getCorpId() : qwGroupChat.getCorpId();
+        QwExternalContact contact = findGroupExternalContact(qwGroupChat.getOwner(), currentUser, corpId);
+        if (contact == null) {
+            logger.info("[H5领取] 群归属失败 reason=未匹配外部联系人 userId={} chatId={} owner={}",
+                    currentUser.getUserId(), chatId, qwGroupChat.getOwner());
+            return claimFail("ownership_group", TIP_NO_REGISTER);
+        }
+        Integer contactStatus = contact.getStatus();
+        if (contactStatus == null || !Arrays.asList(0, 1, 2).contains(contactStatus)) {
+            logger.info("[H5领取] 群归属失败 reason=客户状态异常 userId={} status={}",
+                    currentUser.getUserId(), contactStatus);
+            return claimFail("ownership_group", TIP_RATE_LIMIT);
+        }
+        final String externalUserId = contact.getExternalUserId();
+        boolean inGroup = qwGroupChatUsers.stream()
+                .anyMatch(e -> e != null && externalUserId != null && externalUserId.equals(e.getUserId()));
+        if (!inGroup) {
+            logger.info("[H5领取] 群归属失败 reason=客户不在群 userId={} chatId={} externalUserId={}",
+                    currentUser.getUserId(), chatId, externalUserId);
+            return claimFail("ownership_group", TIP_NO_REGISTER);
+        }
+        // 对齐 handleQwRoom:已绑他人则拒绝;未绑则绑定当前用户
+        R bindFail = ensureExternalContactBound(contact, currentUser, courseLink, true);
+        if (bindFail != null) {
+            return bindFail;
+        }
+        logger.info("[H5领取] 群归属通过 userId={} chatId={} qwExternalId={}",
+                currentUser.getUserId(), chatId, contact.getId());
+        return null;
+    }
+
+    /**
+     * 群主下匹配外部联系人:优先 fsUserId,其次昵称(对齐 handleQwRoom)
+     */
+    private QwExternalContact findGroupExternalContact(String ownerUserId, FsUser currentUser, String corpId) {
+        if (StringUtils.isBlank(ownerUserId) || currentUser == null || currentUser.getUserId() == null) {
+            return null;
+        }
+        QueryWrapper<QwExternalContact> byFsUser = new QueryWrapper<QwExternalContact>()
+                .eq("user_id", ownerUserId)
+                .eq("fs_user_id", currentUser.getUserId())
+                .in("status", Arrays.asList(0, 1, 2))
+                .orderByAsc("status")
+                .orderByDesc("create_time");
+        if (StringUtils.isNotBlank(corpId)) {
+            byFsUser.eq("corp_id", corpId);
+        }
+        List<QwExternalContact> list = qwExternalContactMapper.selectList(byFsUser);
+        if (list != null && !list.isEmpty()) {
+            return list.get(0);
+        }
+        if (StringUtils.isBlank(currentUser.getNickName())) {
+            return null;
+        }
+        QueryWrapper<QwExternalContact> byName = new QueryWrapper<QwExternalContact>()
+                .eq("user_id", ownerUserId)
+                .eq("name", currentUser.getNickName())
+                .in("status", Arrays.asList(0, 1, 2))
+                .orderByAsc("status")
+                .orderByDesc("create_time");
+        if (StringUtils.isNotBlank(corpId)) {
+            byName.eq("corp_id", corpId);
+        }
+        list = qwExternalContactMapper.selectList(byName);
+        return (list == null || list.isEmpty()) ? null : list.get(0);
+    }
+
+    /**
+     * 私聊独属短链归属:对齐 isAddKf/handleExt——
+     * 已绑他人则拒绝;未绑则绑定当前用户后放行;已绑本人则补齐看课/SOP 关联后放行。
+     */
+    private R checkPrivateOwnership(FsCourseLink courseLink, FsUser currentUser) {
+        if (courseLink.getQwExternalId() == null) {
+            logger.info("[H5领取] 私聊归属失败 reason=短链无qwExternalId userId={} linkId={}",
+                    currentUser.getUserId(), courseLink.getLinkId());
+            return claimFail("ownership", TIP_NO_MEMBER);
+        }
+        QwExternalContact contact = qwExternalContactMapper.selectQwExternalContactById(courseLink.getQwExternalId());
+        if (contact == null) {
+            logger.info("[H5领取] 私聊归属失败 reason=企微客户不存在 userId={} qwExternalId={}",
+                    currentUser.getUserId(), courseLink.getQwExternalId());
+            return claimFail("ownership", TIP_NO_REGISTER);
+        }
+        logger.info("[H5领取] 私聊归属比对 userId={} contactFsUserId={} contactUnionid={} userUnionId={}",
+                currentUser.getUserId(),
+                contact.getFsUserId(),
+                contact.getUnionid(),
+                currentUser.getUnionId());
+        return ensureExternalContactBound(contact, currentUser, courseLink, false);
+    }
+
+    /**
+     * 对齐 handleExt / handleQwRoom 的绑定门禁:
+     * - 已绑其他 fsUser → 拒绝
+     * - 已绑本人 → 补齐看课记录 userId / SOP 关联后放行
+     * - 未绑 → 写入 fsUserId 等绑定字段后放行(独属链以短链上的 qwExternalId 为准)
+     *
+     * @param groupScene true 时失败文案用群未注册提示
+     */
+    private R ensureExternalContactBound(QwExternalContact contact, FsUser currentUser,
+                                         FsCourseLink courseLink, boolean groupScene) {
+        String failTip = groupScene ? TIP_NO_REGISTER : TIP_NO_MEMBER;
+        String failReason = groupScene ? "ownership_group" : "ownership";
+
+        if (contact.getFsUserId() != null) {
+            if (!contact.getFsUserId().equals(currentUser.getUserId())) {
+                logger.info("[H5领取] 归属失败 reason=已绑其他用户 userId={} contactFsUserId={} qwExternalId={} group={}",
+                        currentUser.getUserId(), contact.getFsUserId(), contact.getId(), groupScene);
+                return claimFail(failReason, failTip);
+            }
+            syncClaimBindSideEffects(courseLink, contact, currentUser);
+            logger.info("[H5领取] 归属通过(已绑定本人) userId={} qwExternalId={} group={}",
+                    currentUser.getUserId(), contact.getId(), groupScene);
+            return null;
+        }
+
+        // 未绑定:对齐 handleExt,把当前用户绑到该外部联系人后放行(独属链以 qwExternalId 为准)
+        logger.info("[H5领取] 外部联系人未绑定,执行绑定 userId={} qwExternalId={} group={}",
+                currentUser.getUserId(), contact.getId(), groupScene);
+        bindUnboundExternalContact(contact, currentUser);
+        syncClaimBindSideEffects(courseLink, contact, currentUser);
+        logger.info("[H5领取] 归属通过(新绑定) userId={} qwExternalId={} group={}",
+                currentUser.getUserId(), contact.getId(), groupScene);
+        return null;
+    }
+
+    /**
+     * 未绑定外部联系人时写入 fsUserId(对齐 handleExt)
+     */
+    private void bindUnboundExternalContact(QwExternalContact contact, FsUser currentUser) {
+        QwExternalContact update = new QwExternalContact();
+        update.setId(contact.getId());
+        update.setFsUserId(currentUser.getUserId());
+        update.setRegisterTime(new Date());
+        qwExternalContactMapper.updateQwExternalContact(update);
+        contact.setFsUserId(currentUser.getUserId());
+
+        FsUser userUpdate = new FsUser();
+        userUpdate.setUserId(currentUser.getUserId());
+        userUpdate.setIsAddQw(1);
+        userUpdate.setQwExtId(contact.getId());
+        userService.updateFsUser(userUpdate);
+    }
+
+    /**
+     * 绑定后补齐 SOP 用户关联、看课记录 userId(对齐 handleExt)
+     */
+    private void syncClaimBindSideEffects(FsCourseLink courseLink, QwExternalContact contact, FsUser currentUser) {
+        Long qwExternalId = contact.getId();
+        try {
+            iSopUserLogsInfoService.updateSopUserInfoByExternalId(qwExternalId, currentUser.getUserId());
+        } catch (Exception e) {
+            logger.warn("[H5领取] 更新SOP用户关联失败 qwExternalId={} userId={} err={}",
+                    qwExternalId, currentUser.getUserId(), e.getMessage());
+        }
+        if (courseLink.getVideoId() == null || contact.getQwUserId() == null) {
+            return;
+        }
+        try {
+            FsCourseWatchLog watchLog = fsCourseWatchLogMapper.getWatchCourseVideoByExt(
+                    qwExternalId, courseLink.getVideoId(), String.valueOf(contact.getQwUserId()));
+            if (watchLog == null) {
+                return;
+            }
+            if (watchLog.getUserId() == null || watchLog.getUserId().equals(0L)
+                    || !watchLog.getUserId().equals(currentUser.getUserId())) {
+                watchLog.setUserId(currentUser.getUserId());
+                watchLog.setUpdateTime(new Date());
+                fsCourseWatchLogMapper.updateFsCourseWatchLog(watchLog);
+            }
+        } catch (Exception e) {
+            logger.warn("[H5领取] 更新看课记录失败 qwExternalId={} videoId={} userId={} err={}",
+                    qwExternalId, courseLink.getVideoId(), currentUser.getUserId(), e.getMessage());
+        }
+    }
+
+    /**
+     * 归属通过后解析跳转地址:优先按 link 缓存的飞书 docx;缺字段才走 H5 兜底
+     */
+    private R resolveJumpUrlAfterOwned(FsCourseLink courseLink, FsUser currentUser) {
+        if (canBuildFeishuLink(courseLink)) {
+            try {
+                String feishuUrl = buildFeishuJumpUrlCachedByLink(courseLink, currentUser);
+                if (StringUtils.isNotBlank(feishuUrl)) {
+                    logger.info("[H5领取] 成功 userId={} linkId={} jumpUrl={}",
+                            currentUser.getUserId(), courseLink.getLinkId(), feishuUrl);
+                    return R.ok()
+                            .put("hasMaterial", true)
+                            .put("jumpUrl", feishuUrl)
+                            .put("msg", "ok");
+                }
+                logger.warn("[H5领取] 飞书建链返回空 userId={} linkId={}",
+                        currentUser.getUserId(), courseLink.getLinkId());
+                return claimFail("feishu_app", TIP_FEISHU_APP);
+            } catch (CustomException e) {
+                logger.error("[H5领取] 飞书建链业务失败 userId={} linkId={} err={}",
+                        currentUser.getUserId(), courseLink.getLinkId(), e.getMessage(), e);
+                return claimFail(mapFeishuFailReason(e.getMessage()), mapFeishuFailTip(e.getMessage()));
+            } catch (Exception e) {
+                logger.error("[H5领取] 飞书建链异常 userId={} linkId={} err={}",
+                        currentUser.getUserId(), courseLink.getLinkId(), e.getMessage(), e);
+                return claimFail("system", TIP_SYSTEM);
+            }
+        }
+        logger.info("[H5领取] 短链不具备飞书建链字段,走 H5 兜底 userId={} linkId={}",
+                currentUser.getUserId(), courseLink.getLinkId());
+        if (StringUtils.isBlank(courseLink.getRealLink())) {
+            return claimFail("system", "资料地址未配置,请联系伴学助手");
+        }
+        String h5Url = buildH5JumpUrl(courseLink);
+        if (StringUtils.isBlank(h5Url)) {
+            return claimFail("system", "资料域名未配置,请联系伴学助手");
+        }
+        return R.ok()
+                .put("hasMaterial", true)
+                .put("jumpUrl", h5Url)
+                .put("msg", "ok");
+    }
+
+    private boolean canBuildFeishuLink(FsCourseLink courseLink) {
+        return courseLink != null
+                && courseLink.getCompanyId() != null
+                && courseLink.getCompanyUserId() != null
+                && courseLink.getCourseId() != null
+                && courseLink.getVideoId() != null
+                && StringUtils.isNotBlank(courseLink.getLink());
+    }
+
+    /**
+     * 按 link + 销售飞书 appId 缓存;账号被封换新号后 appId 变,自动未命中并重建
+     */
+    private String buildFeishuJumpUrlCachedByLink(FsCourseLink courseLink, FsUser currentUser) {
+        String linkCode = courseLink.getLink();
+        FeishuAccount activeAccount = feishuAccountService.resolveEnabledOrLatestAccount(
+                courseLink.getCompanyUserId(), courseLink.getFeishuAccountId());
+        if (courseLink.getFeishuAccountId() == null
+                || !activeAccount.getId().equals(courseLink.getFeishuAccountId())) {
+            logger.info("[H5领取] 飞书账号已切换 link={} oldAccountId={} newAccountId={} appId={}",
+                    linkCode, courseLink.getFeishuAccountId(), activeAccount.getId(), activeAccount.getAppId());
+            courseLink.setFeishuAccountId(activeAccount.getId());
+            fsCourseLinkMapper.updateFsCourseLink(courseLink);
+        }
+        String cacheKey = FEISHU_CLAIM_LINK_CACHE_PREFIX + linkCode + ":" + activeAccount.getAppId();
+        Object cached = redisCache.getCacheObject(cacheKey);
+        if (cached != null && StringUtils.isNotBlank(String.valueOf(cached))) {
+            logger.info("[H5领取] 命中飞书缓存 link={} appId={} userId={}",
+                    linkCode, activeAccount.getAppId(), currentUser.getUserId());
+            return String.valueOf(cached);
+        }
+        String feishuUrl = createFeishuJumpUrl(courseLink, currentUser);
+        if (StringUtils.isNotBlank(feishuUrl)) {
+            redisCache.setCacheObject(cacheKey, feishuUrl, FEISHU_CLAIM_LINK_CACHE_HOURS, TimeUnit.HOURS);
+            logger.info("[H5领取] 写入飞书缓存 link={} appId={}", linkCode, activeAccount.getAppId());
+        }
+        return feishuUrl;
+    }
+
+    /**
+     * 调用飞书建链(销售自己的飞书 AppID 账号)
+     */
+    private String createFeishuJumpUrl(FsCourseLink courseLink, FsUser currentUser) {
+        String linkCode = courseLink.getLink();
+        if (FeiShuService.isAuthRequired(courseLink.getFeishuNeedAuth())) {
+            FeishuLinkParam param = new FeishuLinkParam();
+            param.setCompanyId(courseLink.getCompanyId());
+            param.setCompanyUserId(courseLink.getCompanyUserId());
+            param.setCourseId(courseLink.getCourseId());
+            param.setVideoId(courseLink.getVideoId());
+            param.setUserId(currentUser.getUserId());
+            param.setLink(linkCode);
+            logger.info("[H5领取] 调用需授权飞书建链 userId={} link={} companyUserId={} videoId={}",
+                    currentUser.getUserId(), linkCode, courseLink.getCompanyUserId(), courseLink.getVideoId());
+            return feiShuService.getFeishuCourseNewLink(param);
+        }
+        logger.info("[H5领取] 调用免授权飞书建链 userId={} link={} companyUserId={} videoId={}",
+                currentUser.getUserId(), linkCode, courseLink.getCompanyUserId(), courseLink.getVideoId());
+        return feiShuService.getFeishuDirectCourseLink(
+                courseLink.getVideoId(),
+                courseLink.getCompanyId(),
+                courseLink.getCourseId(),
+                courseLink.getCompanyUserId(),
+                linkCode,
+                courseLink.getFeishuAccountId());
+    }
+
+    private String buildH5JumpUrl(FsCourseLink courseLink) {
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        String domainName = companyUserMapper.selectDomainByUserId(courseLink.getCompanyUserId());
+        if (StringUtils.isEmpty(domainName) && config != null) {
+            domainName = config.getRealLinkDomainName();
+        }
+        if (StringUtils.isEmpty(domainName)) {
+            return null;
+        }
+        return domainName + courseLink.getRealLink();
+    }
+
+    /**
+     * 领取失败:带 reason 便于排查,msg 给前端直接展示
+     */
+    private R claimFail(String reason, String tip) {
+        return R.ok(tip)
+                .put("hasMaterial", false)
+                .put("msg", tip)
+                .put("reason", reason);
+    }
+
+    private String mapFeishuFailReason(String errMsg) {
+        if (StringUtils.isBlank(errMsg)) {
+            return "system";
+        }
+        if (errMsg.contains("未开启飞书") || errMsg.contains("飞书账号") || errMsg.contains("没有可用")
+                || errMsg.contains("账号不可用") || errMsg.contains("更换账号")) {
+            return "feishu_app";
+        }
+        return "system";
+    }
+
+    private String mapFeishuFailTip(String errMsg) {
+        if (StringUtils.isBlank(errMsg)) {
+            return TIP_SYSTEM;
+        }
+        if (errMsg.contains("未开启飞书") || errMsg.contains("飞书账号") || errMsg.contains("没有可用")
+                || errMsg.contains("账号不可用") || errMsg.contains("更换账号")) {
+            return TIP_FEISHU_APP;
+        }
+        // 业务异常文案本身已是中文,可直接给客户看
+        if (errMsg.length() <= 80 && !errMsg.contains("Exception") && !errMsg.contains("null")) {
+            return errMsg;
+        }
+        return TIP_SYSTEM;
+    }
 }

+ 8 - 0
fs-user-app/src/main/java/com/fs/app/controller/live/LiveOrderController.java

@@ -398,6 +398,14 @@ public class LiveOrderController extends AppBaseController
     {
         log.info("获取订单详细信息 参数: {}",orderId);
         LiveOrder liveOrder = orderService.selectLiveOrderByOrderId(orderId);
+        if (liveOrder == null) {
+            return AjaxResult.error("订单不存在");
+        }
+        // 仅允许查看本人订单,防止越权
+        String currentUserId = getUserId();
+        if (StringUtils.isEmpty(liveOrder.getUserId()) || !liveOrder.getUserId().equals(currentUserId)) {
+            return AjaxResult.error("无权查看该订单");
+        }
         //订单总价 临时处理为 商品金额(商品支付金额=应付金额-快递费+优惠券)
         if(ObjectUtil.isNotEmpty(liveOrder)) {
             liveOrder.setTotalPrice(liveOrder.getPayPrice().subtract(liveOrder.getPayPostage()).add(liveOrder.getDiscountMoney()));

+ 3 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/CommonScrmController.java

@@ -321,13 +321,15 @@ public class CommonScrmController extends AppBaseController {
     @ApiOperation("上传证件")
     @PostMapping("upload")
     public R upload(@RequestParam("file") MultipartFile file) throws Exception {
-        String fileName = file.getOriginalFilename();
+        // 仅校验后缀白名单与文件名,不做大小限制
+        String safeFileName = OssUploadSecurityUtils.validateAndGetSafeFilename(file);
         byte[] fileByte = null;
         try {
             fileByte = file.getBytes();
         } catch (IOException e) {
             return R.error(e.getMessage());
         }
+        String fileName = safeFileName;
         String voiceBase64 = Base64.getEncoder().encodeToString(fileByte);
         UploadDTO dto = new UploadDTO();
         dto.setFileName(fileName);

+ 10 - 1
fs-user-app/src/main/java/com/fs/app/controller/store/FsStoreUserComplaintController.java

@@ -111,10 +111,19 @@ public class FsStoreUserComplaintController extends AppBaseController
     /**
      * 获取投诉消息记录详细信息
      */
+    @Login
     @GetMapping(value = "/msg/{id}")
     public AjaxResult getMsgInfo(@PathVariable("id") Long id)
     {
-        return AjaxResult.success(fsUserComplaintMsgService.selectFsUserComplaintMsgById(id));
+        FsUserComplaintMsg msg = fsUserComplaintMsgService.selectFsUserComplaintMsgById(id);
+        if (msg == null) {
+            return AjaxResult.error("记录不存在");
+        }
+        // 仅允许查看本人投诉消息
+        if (msg.getUserId() == null || !msg.getUserId().equals(Long.valueOf(getUserId()))) {
+            return AjaxResult.error("无权查看该消息");
+        }
+        return AjaxResult.success(msg);
     }
 
     /**

+ 20 - 0
fs-user-app/src/main/java/com/fs/app/param/FsUserClaimCourseMaterialParam.java

@@ -0,0 +1,20 @@
+package com.fs.app.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+
+/**
+ * H5 领取资料入参:仅传 uuid(fs_course_link.link),用户身份从 APPToken 解析
+ */
+@Data
+public class FsUserClaimCourseMaterialParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @NotBlank(message = "uuid参数缺失")
+    @ApiModelProperty(value = "短链码,对应 fs_course_link.link", required = true)
+    private String uuid;
+}

+ 27 - 0
fs-user-app/src/main/java/com/fs/app/param/FsUserLoginByMpOauthParam.java

@@ -0,0 +1,27 @@
+package com.fs.app.param;
+
+import io.swagger.annotations.ApiModelProperty;
+import lombok.Data;
+
+import javax.validation.constraints.NotBlank;
+import java.io.Serializable;
+
+/**
+ * H5 公众号 OAuth 登录入参(按 appId 查播放源配置换票)
+ */
+@Data
+public class FsUserLoginByMpOauthParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    @NotBlank(message = "code参数缺失")
+    @ApiModelProperty(value = "微信授权 code", required = true)
+    private String code;
+
+    @NotBlank(message = "appId参数缺失")
+    @ApiModelProperty(value = "公众号 AppID", required = true)
+    private String appId;
+
+    @ApiModelProperty(value = "短链码,对应 fs_course_link.link(登录阶段可选)")
+    private String uuid;
+}

+ 14 - 6
fs-user-app/src/main/java/com/fs/app/utils/JwtUtils.java

@@ -25,17 +25,25 @@ public class JwtUtils {
     private String header;
 
     /**
-     * 生成jwt token
+     * 生成jwt token(使用配置的默认过期秒数)
      */
     public String generateToken(long userId) {
+        return generateToken(userId, expire);
+    }
+
+    /**
+     * 生成jwt token,可指定过期秒数(如 H5 公众号登录缓存 3 天)
+     *
+     * @param userId        用户ID
+     * @param expireSeconds 过期秒数;小于等于 0 时回退为配置默认值
+     */
+    public String generateToken(long userId, long expireSeconds) {
         Date nowDate = new Date();
-        //过期时间
-        Date expireDate = new Date(nowDate.getTime() + expire * 1000);
-//        System.out.println("==============================="+secret);
-//        logger.info("secret:{}",secret);
+        long ttl = expireSeconds > 0 ? expireSeconds : expire;
+        Date expireDate = new Date(nowDate.getTime() + ttl * 1000);
         return Jwts.builder()
                 .setHeaderParam("typ", "JWT")
-                .setSubject(userId+"")
+                .setSubject(userId + "")
                 .setIssuedAt(nowDate)
                 .setExpiration(expireDate)
                 .signWith(SignatureAlgorithm.HS512, secret)

Някои файлове не бяха показани, защото твърде много файлове са промени