Browse Source

Merge remote-tracking branch 'origin/master'

zyy 1 day ago
parent
commit
bae3f872ed
100 changed files with 3299 additions and 84 deletions
  1. 78 3
      fs-admin/src/main/java/com/fs/course/controller/FsUserCourseCategoryController.java
  2. 39 2
      fs-admin/src/main/java/com/fs/course/controller/FsUserCourseController.java
  3. 26 0
      fs-admin/src/main/java/com/fs/course/controller/FsUserCourseVideoController.java
  4. 41 0
      fs-admin/src/main/java/com/fs/course/controller/FsVideoResourceController.java
  5. 67 0
      fs-admin/src/main/java/com/fs/course/controller/PublicCourseSearchKeywordStatController.java
  6. 17 0
      fs-admin/src/main/java/com/fs/his/task/Task.java
  7. 12 0
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java
  8. 3 1
      fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductPackageScrmController.java
  9. 31 0
      fs-common/src/main/java/com/fs/common/annotation/CallbackIpCheck.java
  10. 34 0
      fs-common/src/main/java/com/fs/common/utils/RedisUtil.java
  11. 14 0
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  12. 109 0
      fs-company/src/main/java/com/fs/company/aspectj/CallbackIpCheckAspect.java
  13. 104 2
      fs-company/src/main/java/com/fs/company/controller/company/CompanyInboundCallManageController.java
  14. 16 5
      fs-company/src/main/java/com/fs/company/controller/company/GeneralCustomerEntryController.java
  15. 1 1
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  16. 31 0
      fs-service/src/main/java/com/fs/company/domain/CompanyInboundBind.java
  17. 111 0
      fs-service/src/main/java/com/fs/company/domain/EasyCallInboundCdrVO.java
  18. 66 0
      fs-service/src/main/java/com/fs/company/mapper/CompanyInboundBindMapper.java
  19. 11 1
      fs-service/src/main/java/com/fs/company/mapper/EasyCallInboundLlmMapper.java
  20. 4 0
      fs-service/src/main/java/com/fs/company/mapper/EasyCallMapper.java
  21. 1 1
      fs-service/src/main/java/com/fs/company/param/EntryCustomerParam.java
  22. 18 0
      fs-service/src/main/java/com/fs/company/param/InboundCallbackParam.java
  23. 61 0
      fs-service/src/main/java/com/fs/company/service/ICompanyInboundBindService.java
  24. 10 1
      fs-service/src/main/java/com/fs/company/service/ICompanyInboundCallManageService.java
  25. 3 0
      fs-service/src/main/java/com/fs/company/service/IGeneralCustomerEntryService.java
  26. 93 0
      fs-service/src/main/java/com/fs/company/service/impl/CompanyInboundBindServiceImpl.java
  27. 55 3
      fs-service/src/main/java/com/fs/company/service/impl/CompanyInboundCallManageServiceImpl.java
  28. 28 0
      fs-service/src/main/java/com/fs/company/service/impl/GeneralCustomerEntryServiceImpl.java
  29. 202 0
      fs-service/src/main/java/com/fs/company/util/IpCheckUtil.java
  30. 4 0
      fs-service/src/main/java/com/fs/company/vo/CidConfigVO.java
  31. 43 0
      fs-service/src/main/java/com/fs/company/vo/InboundCallInfo.java
  32. 22 0
      fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallInboundLlmVO.java
  33. 17 0
      fs-service/src/main/java/com/fs/course/cache/PublicCourseAppCacheNames.java
  34. 12 0
      fs-service/src/main/java/com/fs/course/constant/SearchRedisConstants.java
  35. 24 0
      fs-service/src/main/java/com/fs/course/domain/FsCompanyUserCourseFavorite.java
  36. 19 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseKeywordSearchLog.java
  37. 27 0
      fs-service/src/main/java/com/fs/course/domain/FsPublicCourseSearchKeywordStat.java
  38. 6 0
      fs-service/src/main/java/com/fs/course/domain/FsUserCourseVideo.java
  39. 17 0
      fs-service/src/main/java/com/fs/course/mapper/FsCompanyUserCourseFavoriteMapper.java
  40. 13 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java
  41. 35 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseKeywordSearchLogMapper.java
  42. 19 0
      fs-service/src/main/java/com/fs/course/mapper/FsPublicCourseSearchKeywordStatMapper.java
  43. 14 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseCategoryMapper.java
  44. 10 2
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseMapper.java
  45. 5 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseStudyLogMapper.java
  46. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseStudyMapper.java
  47. 31 2
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java
  48. 11 0
      fs-service/src/main/java/com/fs/course/mapper/FsVideoResourceMapper.java
  49. 28 0
      fs-service/src/main/java/com/fs/course/param/FsCourseAnswerStatusQueryParam.java
  50. 22 0
      fs-service/src/main/java/com/fs/course/param/FsUserCourseCategoryAppQueryParam.java
  51. 15 0
      fs-service/src/main/java/com/fs/course/param/FsUserCoursePublicAppQueryParam.java
  52. 23 0
      fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoBatchWatchIntegralParam.java
  53. 3 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseListParam.java
  54. 11 0
      fs-service/src/main/java/com/fs/course/service/IFsCompanyUserCourseFavoriteService.java
  55. 20 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseKeywordSearchLogService.java
  56. 13 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java
  57. 22 0
      fs-service/src/main/java/com/fs/course/service/IFsPublicCourseSearchKeywordStatService.java
  58. 12 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseCategoryService.java
  59. 6 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  60. 5 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  61. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsVideoResourceService.java
  62. 12 0
      fs-service/src/main/java/com/fs/course/service/SearchStatSyncService.java
  63. 39 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCompanyUserCourseFavoriteServiceImpl.java
  64. 48 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseKeywordSearchLogServiceImpl.java
  65. 362 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java
  66. 1 1
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  67. 74 0
      fs-service/src/main/java/com/fs/course/service/impl/FsPublicCourseSearchKeywordStatServiceImpl.java
  68. 29 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseCategoryServiceImpl.java
  69. 19 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  70. 16 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  71. 5 0
      fs-service/src/main/java/com/fs/course/service/impl/FsVideoResourceServiceImpl.java
  72. 186 0
      fs-service/src/main/java/com/fs/course/service/impl/SearchStatSyncServiceImpl.java
  73. 20 0
      fs-service/src/main/java/com/fs/course/vo/CompanyUserCourseFavoriteToggleVO.java
  74. 33 0
      fs-service/src/main/java/com/fs/course/vo/FsCourseAnswerStatusVO.java
  75. 3 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCoursePublicAppVO.java
  76. 3 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseStudyListUVO.java
  77. 5 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoListUVO.java
  78. 6 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoQVO.java
  79. 26 0
      fs-service/src/main/java/com/fs/course/vo/PublicCourseSearchKeywordStatExportVO.java
  80. 3 0
      fs-service/src/main/java/com/fs/course/vo/newfs/FsUserCourseListVO.java
  81. 3 0
      fs-service/src/main/java/com/fs/course/vo/newfs/FsUserCourseVideoPageListVO.java
  82. 7 0
      fs-service/src/main/java/com/fs/his/config/CidPhoneConfig.java
  83. 10 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralLogsMapper.java
  84. 7 0
      fs-service/src/main/java/com/fs/his/utils/RedisCacheUtil.java
  85. 15 0
      fs-service/src/main/java/com/fs/hisStore/dto/FsStoreOrderPayPostageEditDTO.java
  86. 26 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsUserAddressScrmMapper.java
  87. 6 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreOrderScrmService.java
  88. 5 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsUserAddressScrmService.java
  89. 60 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  90. 5 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsUserAddressScrmServiceImpl.java
  91. 20 16
      fs-service/src/main/java/com/fs/sensitive/ProductionWordFilter.java
  92. 77 0
      fs-service/src/main/resources/mapper/company/CompanyInboundBindMapper.xml
  93. 2 40
      fs-service/src/main/resources/mapper/company/CompanyWithdrawDetailMapper.xml
  94. 111 2
      fs-service/src/main/resources/mapper/company/EasyCallInboundLlmMapper.xml
  95. 13 0
      fs-service/src/main/resources/mapper/company/EasyCallMapper.xml
  96. 32 0
      fs-service/src/main/resources/mapper/course/FsCompanyUserCourseFavoriteMapper.xml
  97. 81 0
      fs-service/src/main/resources/mapper/course/FsCourseKeywordSearchLogMapper.xml
  98. 1 0
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  99. 57 0
      fs-service/src/main/resources/mapper/course/FsPublicCourseSearchKeywordStatMapper.xml
  100. 73 0
      fs-service/src/main/resources/mapper/course/FsUserCourseCategoryMapper.xml

+ 78 - 3
fs-admin/src/main/java/com/fs/course/controller/FsUserCourseCategoryController.java

@@ -12,8 +12,13 @@ import com.fs.his.vo.FsStoreProductCategoryVO;
 
 import cn.hutool.core.util.ObjectUtil;
 import cn.hutool.json.JSONUtil;
+import com.fs.config.cloud.CloudHostProper;
+import com.fs.course.cache.PublicCourseAppCacheNames;
 import com.fs.course.config.CourseConfig;
+import com.fs.course.mapper.FsUserCourseMapper;
+import com.fs.course.mapper.FsVideoResourceMapper;
 
+import com.fs.his.utils.RedisCacheUtil;
 import com.fs.his.vo.OptionsVO;
 import com.fs.system.service.ISysConfigService;
 import org.springframework.security.access.prepost.PreAuthorize;
@@ -55,6 +60,18 @@ public class FsUserCourseCategoryController extends BaseController
     @Autowired
     private ISysConfigService configService;
 
+    @Autowired
+    private RedisCacheUtil redisCacheUtil;
+
+    @Autowired
+    private CloudHostProper cloudHostProper;
+
+    @Autowired
+    private FsUserCourseMapper fsUserCourseMapper;
+
+    @Autowired
+    private FsVideoResourceMapper fsVideoResourceMapper;
+
     /**
      * 查询课堂分类列表
      */
@@ -118,7 +135,11 @@ public class FsUserCourseCategoryController extends BaseController
         if (ObjectUtil.isNotEmpty(config.getIsBound())&&config.getIsBound()){
             fsUserCourseCategory.setUserId(userId);
         }
-        return toAjax(fsUserCourseCategoryService.insertFsUserCourseCategory(fsUserCourseCategory));
+        int rows = fsUserCourseCategoryService.insertFsUserCourseCategory(fsUserCourseCategory);
+        if (rows > 0 && Integer.valueOf(1).equals(fsUserCourseCategory.getCateType())) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.CATEGORY_APP_LIST);
+        }
+        return toAjax(rows);
     }
 
     /**
@@ -129,7 +150,18 @@ public class FsUserCourseCategoryController extends BaseController
     @PutMapping
     public AjaxResult edit(@RequestBody FsUserCourseCategory fsUserCourseCategory)
     {
-        return toAjax(fsUserCourseCategoryService.updateFsUserCourseCategory(fsUserCourseCategory));
+        FsUserCourseCategory old = fsUserCourseCategory.getCateId() != null
+                ? fsUserCourseCategoryService.selectFsUserCourseCategoryByCateId(fsUserCourseCategory.getCateId())
+                : null;
+        int rows = fsUserCourseCategoryService.updateFsUserCourseCategory(fsUserCourseCategory);
+        if (rows > 0) {
+            boolean wasPublic = old != null && Integer.valueOf(1).equals(old.getCateType());
+            boolean nowPublic = Integer.valueOf(1).equals(fsUserCourseCategory.getCateType());
+            if (wasPublic || nowPublic) {
+                redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.CATEGORY_APP_LIST);
+            }
+        }
+        return toAjax(rows);
     }
 
     /**
@@ -140,7 +172,32 @@ public class FsUserCourseCategoryController extends BaseController
 	@DeleteMapping("/{cateIds}")
     public AjaxResult remove(@PathVariable Long[] cateIds)
     {
-        return toAjax(fsUserCourseCategoryService.deleteFsUserCourseCategoryByCateIds(cateIds));
+        if ("北京卓美".equals(cloudHostProper.getCompanyName())) {
+            for (Long cateId : cateIds) {
+                if (cateId == null) {
+                    continue;
+                }
+                if (fsUserCourseMapper.countCourseByCategoryId(cateId) > 0) {
+                    return AjaxResult.error("该分类下有关联课程,不能删除");
+                }
+                if (fsVideoResourceMapper.countVideoResourceByCourseCategoryId(cateId) > 0) {
+                    return AjaxResult.error("该分类下有关联课程,不能删除");
+                }
+            }
+        }
+        boolean evictCategoryApp = false;
+        for (Long cateId : cateIds) {
+            FsUserCourseCategory one = fsUserCourseCategoryService.selectFsUserCourseCategoryByCateId(cateId);
+            if (one != null && Integer.valueOf(1).equals(one.getCateType())) {
+                evictCategoryApp = true;
+                break;
+            }
+        }
+        int rows = fsUserCourseCategoryService.deleteFsUserCourseCategoryByCateIds(cateIds);
+        if (rows > 0 && evictCategoryApp) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.CATEGORY_APP_LIST);
+        }
+        return toAjax(rows);
     }
 
 
@@ -177,6 +234,24 @@ public class FsUserCourseCategoryController extends BaseController
         return R.ok().put("data", list);
     }
 
+    /**
+     * 公域课:一级分类(管理端-公域视频素材等场景,仅 cateType=1)
+     */
+    @GetMapping("/getPublicCatePidList")
+    public R getPublicCatePidList() {
+        List<OptionsVO> list = fsUserCourseCategoryService.selectPublicUserCourseCategoryPidList();
+        return R.ok().put("data", list);
+    }
+
+    /**
+     * 公域课:某一级下的二级分类
+     */
+    @GetMapping("/getPublicCateListByPid/{pid}")
+    public R getPublicCateListByPid(@PathVariable("pid") Long pid) {
+        List<OptionsVO> list = fsUserCourseCategoryService.selectPublicCateListByPid(pid);
+        return R.ok().put("data", list);
+    }
+
     // 下载模板
     @GetMapping("/importTemplate")
     public AjaxResult importTemplate() {

+ 39 - 2
fs-admin/src/main/java/com/fs/course/controller/FsUserCourseController.java

@@ -12,6 +12,7 @@ import com.fs.common.core.page.TableDataInfo;
 import com.fs.common.enums.BusinessType;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.poi.ExcelUtil;
+import com.fs.course.cache.PublicCourseAppCacheNames;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsUserCourse;
 import com.fs.course.params.FsUserCourseConfigParam;
@@ -168,7 +169,10 @@ public class FsUserCourseController extends BaseController {
         }
         fsUserCourseService.insertFsUserCourse(fsUserCourse);
         redisCacheUtil.delRedisKey("getCourseList");
-
+        if (fsUserCourse.getIsPrivate() != null && fsUserCourse.getIsPrivate() == 0) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+        }
+        
         return toAjax(1);
     }
 
@@ -191,7 +195,9 @@ public class FsUserCourseController extends BaseController {
         }
         fsUserCourseService.insertFsUserCourse(fsUserCourse);
         redisCacheUtil.delRedisKey("getCourseList");
-
+        if (fsUserCourse.getIsPrivate() != null && fsUserCourse.getIsPrivate() == 0) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+        }
         return toAjax(1);
     }
 
@@ -204,6 +210,9 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult edit(@RequestBody FsUserCourse fsUserCourse) {
         fsUserCourseService.updateFsUserCourse(fsUserCourse);
         redisCacheUtil.delRedisKey("getCourseList");
+        if (fsUserCourse.getIsPrivate() != null && fsUserCourse.getIsPrivate() == 0) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+        }
         return toAjax(1);
     }
 
@@ -227,6 +236,9 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult publicEdit(@RequestBody FsUserCourse fsUserCourse) {
         fsUserCourseService.updateFsUserCourse(fsUserCourse);
         redisCacheUtil.delRedisKey("getCourseList");
+        if (fsUserCourse.getIsPrivate() != null && fsUserCourse.getIsPrivate() == 0) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+        }
         return toAjax(1);
     }
 
@@ -250,6 +262,8 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult remove(@PathVariable Long[] courseIds) {
         fsUserCourseService.deleteFsUserCourseByCourseIds(courseIds);
         redisCacheUtil.delRedisKey("getCourseList");
+        redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+        
         return toAjax(1);
     }
 
@@ -262,6 +276,7 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult publicRemove(@PathVariable Long[] courseIds) {
         fsUserCourseService.deleteFsUserCourseByCourseIds(courseIds);
         redisCacheUtil.delRedisKey("getCourseList");
+        redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
         return toAjax(1);
     }
 
@@ -278,6 +293,9 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult updateIsShow(@RequestBody FsUserCourse fsUserCourse) {
         fsUserCourseService.updateFsUserCourse(fsUserCourse);
         redisCacheUtil.delRedisKey("getCourseList");
+        if (fsUserCourse.getIsPrivate() != null && fsUserCourse.getIsPrivate() == 0) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+        }
         return toAjax(1);
     }
 
@@ -287,6 +305,9 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult publicUpdateIsShow(@RequestBody FsUserCourse fsUserCourse) {
         fsUserCourseService.updateFsUserCourse(fsUserCourse);
         redisCacheUtil.delRedisKey("getCourseList");
+        if (fsUserCourse.getIsPrivate() != null && fsUserCourse.getIsPrivate() == 0) {
+            redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+        }
         return toAjax(1);
     }
 
@@ -296,6 +317,9 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult putOn(@PathVariable Long[] courseIds) {
         fsUserCourseService.updateFsUserCourseIsShow(courseIds, 1);
         redisCacheUtil.delRedisKey("getCourseList");
+
+        redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+
         return toAjax(1);
     }
 
@@ -305,6 +329,9 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult publicPutOn(@PathVariable Long[] courseIds) {
         fsUserCourseService.updateFsUserCourseIsShow(courseIds, 1);
         redisCacheUtil.delRedisKey("getCourseList");
+
+        redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+
         return toAjax(1);
     }
 
@@ -314,6 +341,9 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult pullOff(@PathVariable Long[] courseIds) {
         fsUserCourseService.updateFsUserCourseIsShow(courseIds, 0);
         redisCacheUtil.delRedisKey("getCourseList");
+
+        redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+
         return toAjax(1);
     }
 
@@ -323,6 +353,10 @@ public class FsUserCourseController extends BaseController {
     public AjaxResult publicPutOff(@PathVariable Long[] courseIds) {
         fsUserCourseService.updateFsUserCourseIsShow(courseIds, 0);
         redisCacheUtil.delRedisKey("getCourseList");
+
+        redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+
+        
         return toAjax(1);
     }
 
@@ -338,6 +372,9 @@ public class FsUserCourseController extends BaseController {
         redisCacheUtil.delRedisKey("h5user:course:video:list:all");
         redisCacheUtil.delRedisKey("h5user:course:list:all");
         redisCacheUtil.delRedisKey("cache:video");
+
+        redisCacheUtil.delSpringCacheAllByName(PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST);
+
         return R.ok();
     }
 

+ 26 - 0
fs-admin/src/main/java/com/fs/course/controller/FsUserCourseVideoController.java

@@ -195,6 +195,26 @@ public class FsUserCourseVideoController extends BaseController
         return toAjax(fsUserCourseVideoService.updateFsUserCourseVideo(fsUserCourseVideo));
     }
 
+    /**
+     * 批量修改课节:观看时长(分钟)、积分奖励(限同一课程下勾选的 videoId)
+     */
+    @PreAuthorize("@ss.hasPermi('course:userCourseVideo:edit')")
+    @Log(title = "课堂视频", businessType = BusinessType.UPDATE)
+    @PostMapping("/batchUpdateWatchIntegral")
+    public AjaxResult batchUpdateWatchIntegral(@RequestBody FsUserCourseVideoBatchWatchIntegralParam param) {
+        if (param == null || param.getCourseId() == null) {
+            return AjaxResult.error("课程ID不能为空");
+        }
+        if (param.getVideoIds() == null || param.getVideoIds().isEmpty()) {
+            return AjaxResult.error("请选择要修改的课节");
+        }
+        if (param.getWatchDurationMinutes() == null || param.getIntegralReward() == null) {
+            return AjaxResult.error("请填写观看时长与积分奖励");
+        }
+        int rows = fsUserCourseVideoService.batchUpdateWatchIntegral(param);
+        return rows > 0 ? AjaxResult.success() : AjaxResult.error("未更新任何数据,请确认课节属于当前课程");
+    }
+
     /**
      * 删除课堂视频
      */
@@ -203,6 +223,12 @@ public class FsUserCourseVideoController extends BaseController
 	@DeleteMapping("/{videoIds}")
     public AjaxResult remove(@PathVariable String[] videoIds)
     {
+        if ("北京卓美".equals(companyName) && videoIds != null && videoIds.length > 0) {
+            int onShelf = fsUserCourseVideoMapper.countOnShelfCourseByVideoIds(videoIds);
+            if (onShelf > 0) {
+                return AjaxResult.error("内容有上架状态,请先修改启用状态");
+            }
+        }
         return toAjax(fsUserCourseVideoService.deleteFsUserCourseVideoByVideoIds(videoIds));
     }
 

+ 41 - 0
fs-admin/src/main/java/com/fs/course/controller/FsVideoResourceController.java

@@ -17,6 +17,7 @@ import com.fs.config.cloud.CloudHostProper;
 import com.fs.course.business.FsVideoResourceBusinessService;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.FsVideoResource;
+import com.fs.course.mapper.FsUserCourseVideoMapper;
 import com.fs.course.service.IFsUserCourseVideoService;
 import com.fs.course.service.IFsUserVideoService;
 import com.fs.course.service.IFsVideoResourceService;
@@ -57,6 +58,9 @@ public class FsVideoResourceController extends BaseController {
     @Autowired
     private IFsUserCourseVideoService fsUserCourseVideoService;
 
+    @Autowired
+    private FsUserCourseVideoMapper fsUserCourseVideoMapper;
+
     /**
      * 查询视频素材库列表
      */
@@ -89,6 +93,37 @@ public class FsVideoResourceController extends BaseController {
         return getDataTable(list);
     }
 
+    /**
+     * 公域视频素材库列表(仅打标在公域分类 cateType=1 上的素材,与 /list 查询条件相同但过滤范围不同)
+     */
+    @PreAuthorize("@ss.hasPermi('course:videoResource:list')")
+    @GetMapping("/publicList")
+    public TableDataInfo publicList(@RequestParam(required = false) String resourceName,
+                                    @RequestParam(required = false) String fileName,
+                                    @RequestParam(required = false) Integer typeId,
+                                    @RequestParam(required = false) Integer typeSubId,
+                                    @RequestParam(required = false) Integer videoType,
+                                    @RequestParam(required = false, defaultValue = "1") Integer pageNum,
+                                    @RequestParam(required = false, defaultValue = "10") Integer pageSize) {
+        Map<String, Object> params = new HashMap<>();
+        params.put("resourceName", resourceName);
+        params.put("fileName", fileName);
+        params.put("typeId", typeId);
+        params.put("typeSubId", typeSubId);
+        if (videoType == null) {
+            videoType = 1;
+        }
+        params.put("videoType", videoType);
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        String json = configService.selectConfigByKey("course.config");
+        CourseConfig config = JSONUtil.toBean(json, CourseConfig.class);
+        if (ObjectUtil.isNotEmpty(config.getIsBound()) && config.getIsBound()) {
+            params.put("userId", loginUser.getUser().getUserId());
+        }
+        PageHelper.startPage(pageNum, pageSize);
+        List<FsVideoResourceVO> list = fsVideoResourceService.selectPublicVideoResourceListByMap(params);
+        return getDataTable(list);
+    }
 
     /**
      * 获取视频素材库详细信息
@@ -161,6 +196,12 @@ public class FsVideoResourceController extends BaseController {
     @Log(title = "视频素材库", businessType = BusinessType.DELETE)
     @DeleteMapping("/{ids}")
     public AjaxResult remove(@PathVariable Long[] ids) {
+        if ("北京卓美".equals(cloudHostProper.getCompanyName()) && ids != null && ids.length > 0) {
+            int linked = fsUserCourseVideoMapper.countCourseVideoByVideoResourceIds(ids);
+            if (linked > 0) {
+                return AjaxResult.error("素材有关联课程,不能删除");
+            }
+        }
         Wrapper<FsVideoResource> updateWrapper = Wrappers.<FsVideoResource>lambdaUpdate()
                 .set(FsVideoResource::getIsDel, 1)
                 .in(FsVideoResource::getId, Arrays.asList(ids));

+ 67 - 0
fs-admin/src/main/java/com/fs/course/controller/PublicCourseSearchKeywordStatController.java

@@ -0,0 +1,67 @@
+package com.fs.course.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.course.domain.FsPublicCourseSearchKeywordStat;
+import com.fs.course.service.IFsPublicCourseSearchKeywordStatService;
+import com.fs.course.service.SearchStatSyncService;
+import com.fs.course.vo.PublicCourseSearchKeywordStatExportVO;
+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.PostMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import java.util.List;
+
+/**
+ * 总后台:课程搜索管理(公域搜索词 PV/UV 等聚合)
+ */
+@RestController
+@RequestMapping("/course/publicCourse/courseLink")
+public class PublicCourseSearchKeywordStatController extends BaseController {
+
+    @Autowired
+    private IFsPublicCourseSearchKeywordStatService fsPublicCourseSearchKeywordStatService;
+
+    @Autowired
+    private SearchStatSyncService searchStatSyncService;
+
+    /**
+     * 手动刷新搜索统计(按钮点击触发)
+     */
+    @PostMapping("/refresh")
+    public AjaxResult refresh() {
+        try {
+            searchStatSyncService.refreshSearchStat();
+            return AjaxResult.success("刷新成功");
+        } catch (Exception e) {
+            return AjaxResult.error("刷新失败:" + e.getMessage());
+        }
+    }
+
+    @PreAuthorize("@ss.hasPermi('course:publicCourseSearch:list')")
+    @GetMapping("/list")
+    public TableDataInfo list(FsPublicCourseSearchKeywordStat query) {
+        startPage();
+        List<FsPublicCourseSearchKeywordStat> list =
+                fsPublicCourseSearchKeywordStatService.selectFsPublicCourseSearchKeywordStatList(query);
+        return getDataTable(list);
+    }
+
+    @PreAuthorize("@ss.hasPermi('course:publicCourseSearch:export')")
+    @Log(title = "课程搜索管理", businessType = BusinessType.EXPORT)
+    @GetMapping("/export")
+    public AjaxResult export(FsPublicCourseSearchKeywordStat query) {
+        List<PublicCourseSearchKeywordStatExportVO> list =
+                fsPublicCourseSearchKeywordStatService.selectExportList(query);
+        ExcelUtil<PublicCourseSearchKeywordStatExportVO> util =
+                new ExcelUtil<>(PublicCourseSearchKeywordStatExportVO.class);
+        return util.exportExcel(list, "课程搜索结果");
+    }
+}

+ 17 - 0
fs-admin/src/main/java/com/fs/his/task/Task.java

@@ -26,6 +26,7 @@ import com.fs.course.mapper.FsCourseRedPacketLogMapper;
 import com.fs.course.mapper.FsUserCompanyUserMapper;
 import com.fs.course.service.IFsCourseWatchLogService;
 import com.fs.course.service.ITencentCloudCosService;
+import com.fs.course.service.SearchStatSyncService;
 import com.fs.erp.domain.ErpDeliverys;
 import com.fs.erp.domain.ErpOrderQuery;
 import com.fs.erp.dto.ErpOrderQueryRequert;
@@ -1867,6 +1868,22 @@ public class Task {
         log.info("定时删除行为轨迹记录 {} 条", deleteCount);
     }
 
+    @Autowired
+    private SearchStatSyncService searchStatSyncService;
+
+    //定时删除行为轨迹记录 (数据量太大 默认保留一天的)
+    @Scheduled(cron = "0 0 * * * ?")
+    //@Scheduled(cron = "0 * * * * ?") //测试每分钟执行一次
+    public void refreshSearchStat(){
+        log.info("===== 定时任务:开始刷新搜索统计 =====");
+        try {
+            searchStatSyncService.refreshSearchStat();
+        } catch (Exception e) {
+            log.error("定时刷新搜索统计异常", e);
+        }
+        log.info("===== 定时任务:搜索统计刷新完成 =====");
+    }
+
     //同步支付状态
     public void synchronizePayStatus(){
         fsStorePaymentService.synchronizePayStatus();

+ 12 - 0
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreOrderScrmController.java

@@ -62,6 +62,7 @@ import com.fs.hisStore.domain.FsStorePaymentScrm;
 import com.fs.hisStore.domain.FsStoreAfterSalesScrm;
 import com.fs.his.dto.ExpressInfoDTO;
 import com.fs.hisStore.dto.FsStoreOrderPayDeliveryDTO;
+import com.fs.hisStore.dto.FsStoreOrderPayPostageEditDTO;
 import com.fs.hisStore.dto.StoreOrderExpressExportDTO;
 import com.fs.hisStore.dto.StoreOrderProductDTO;
 import com.fs.hisStore.enums.ShipperCodeEnum;
@@ -795,6 +796,17 @@ public class FsStoreOrderScrmController extends BaseController {
     public AjaxResult edit(@RequestBody FsStoreOrderScrm fsStoreOrder) {
         return toAjax(fsStoreOrderService.updateFsStoreOrder(fsStoreOrder));
     }
+
+    /**
+     * 待支付订单仅修改运费(同步调整应付金额等,见服务实现)
+     */
+    @PreAuthorize("@ss.hasPermi('store:storeOrder:edit')")
+    @Log(title = "订单-修改运费", businessType = BusinessType.UPDATE)
+    @PutMapping("/editPayPostage")
+    public R editPayPostage(@RequestBody FsStoreOrderPayPostageEditDTO dto) {
+        return fsStoreOrderService.updateUnpaidOrderPayPostage(dto);
+    }
+
     /**
      * 修改订单itemJson
      */

+ 3 - 1
fs-admin/src/main/java/com/fs/hisStore/controller/FsStoreProductPackageScrmController.java

@@ -153,7 +153,9 @@ public class FsStoreProductPackageScrmController extends BaseController
         JSONArray jsonArray=JSONUtil.parseArray(fsStoreProductPackage.getProductList());
         List<StorePackageProductDTO> goodsList=JSONUtil.toList(jsonArray, StorePackageProductDTO.class);
         fsStoreProductPackage.setProducts(JSONUtil.toJsonStr(goodsList));
-        fsStoreProductPackage.setCompanyId(0l);
+        if (fsStoreProductPackage.getCompanyId() == null) {
+            fsStoreProductPackage.setCompanyId(0l);
+        }
         return toAjax(fsStoreProductPackageService.insertFsStoreProductPackage(fsStoreProductPackage));
     }
 

+ 31 - 0
fs-common/src/main/java/com/fs/common/annotation/CallbackIpCheck.java

@@ -0,0 +1,31 @@
+package com.fs.common.annotation;
+
+import java.lang.annotation.*;
+
+/**
+ * 回调来源IP校验注解
+ * <p>
+ * 标注在Controller方法上,自动校验请求来源IP是否在配置的合法IP列表中。
+ * 切面会从方法参数中提取companyId,查询company_config表获取配置,解析出legalIPs后进行校验。
+ * </p>
+ * <pre>
+ * 使用示例:
+ *   @PostMapping("/inboundCallback")
+ *   @CallbackIpCheck
+ *   public String inboundCallback(@RequestBody InboundCallbackParam param) {
+ *       // 业务逻辑 - IP校验已由切面自动完成
+ *   }
+ * </pre>
+ *
+ * @author MixLiu
+ */
+@Target({ElementType.METHOD})
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface CallbackIpCheck {
+
+    /**
+     * 公司配置的键名,用于从company_config表查询配置
+     */
+    String configKey() default "cId.config";
+}

+ 34 - 0
fs-common/src/main/java/com/fs/common/utils/RedisUtil.java

@@ -4,6 +4,7 @@ import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.data.redis.core.RedisTemplate;
 import org.springframework.stereotype.Component;
 
+import java.util.Set;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -120,5 +121,38 @@ public class RedisUtil {
     public Boolean setIfAbsent(String key, String number, int i, TimeUnit timeUnit) {
         return redisTemplate.opsForValue().setIfAbsent(key, number, i, timeUnit);
     }
+
+    public Long addToSet(String key, String... values) {
+        return redisTemplate.opsForSet().add(key, values);
+    }
+
+    /**
+     * 获取Set所有成员
+     */
+    @SuppressWarnings("unchecked")
+    public Set<String> getSetMembers(String key) {
+        return (Set<String>) (Set<?>) redisTemplate.opsForSet().members(key);
+    }
+
+    /**
+     * 获取Set大小
+     */
+    public Long getSetSize(String key) {
+        return redisTemplate.opsForSet().size(key);
+    }
+
+    /**
+     * 设置值(带过期时间)
+     */
+    public void setString(String key, String value) {
+        redisTemplate.opsForValue().set(key, value);
+    }
+
+    /**
+     * 获取值
+     */
+    public String getString(String key) {
+        return (String) redisTemplate.opsForValue().get(key);
+    }
 }
 

+ 14 - 0
fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java

@@ -27,6 +27,7 @@ import com.fs.course.param.newfs.FsCourseWatchAppParam;
 import com.fs.course.param.newfs.FsUserCourseListParam;
 import com.fs.course.param.newfs.UserCourseVideoPageParam;
 import com.fs.course.service.*;
+import com.fs.course.vo.CompanyUserCourseFavoriteToggleVO;
 import com.fs.course.vo.FsCourseWatchLogListVO;
 import com.fs.course.vo.FsUserCourseParticipationRecordVO;
 import com.fs.course.vo.newfs.*;
@@ -98,6 +99,9 @@ public class FsUserCourseVideoController extends AppBaseController {
     @Autowired
     private CloudHostProper cloudHostProper;
 
+    @Autowired
+    private IFsCompanyUserCourseFavoriteService companyUserCourseFavoriteService;
+
     @Login
     @GetMapping("/pageList")
     @ApiOperation("课程分页列表")
@@ -135,11 +139,21 @@ public class FsUserCourseVideoController extends AppBaseController {
     public ResponseResult<PageInfo<FsUserCourseListVO>> getAllCourseList(FsUserCourseListParam param) {
         PageHelper.startPage(param.getPageNum(), param.getPageSize());
         param.setCompanyId(getCompanyId());
+        param.setCompanyUserId(getCompanyUserId());
         List<FsUserCourseListVO> fsUserCourseList = fsUserCourseService.getFsUserCourseList(param);
         PageInfo<FsUserCourseListVO> pageInfo = new PageInfo<>(fsUserCourseList);
         return ResponseResult.ok(pageInfo);
     }
 
+    @Login
+    @PostMapping("/courseFavorite/toggle")
+    @ApiOperation("销售收藏/取消收藏营期(无记录则收藏,有记录则取消)")
+    public ResponseResult<CompanyUserCourseFavoriteToggleVO> toggleCourseFavorite(@RequestParam("periodId") Long periodId) {
+        CompanyUserCourseFavoriteToggleVO vo = companyUserCourseFavoriteService.toggle(
+                getCompanyId(), getCompanyUserId(), periodId);
+        return ResponseResult.ok(vo);
+    }
+
     @Login
     @GetMapping("/videoList")
     @ApiOperation("获取视频下拉列表")

+ 109 - 0
fs-company/src/main/java/com/fs/company/aspectj/CallbackIpCheckAspect.java

@@ -0,0 +1,109 @@
+package com.fs.company.aspectj;
+
+import com.alibaba.fastjson.JSONObject;
+import com.fs.aicall.utils.StringUtils;
+import com.fs.common.annotation.CallbackIpCheck;
+import com.fs.company.util.IpCheckUtil;
+import com.fs.common.utils.IpUtil;
+import com.fs.company.domain.CompanyConfig;
+import com.fs.company.mapper.CompanyConfigMapper;
+import com.fs.company.vo.CidConfigVO;
+import com.fs.his.config.CidPhoneConfig;
+import com.fs.system.domain.SysConfig;
+import com.fs.system.mapper.SysConfigMapper;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.annotation.Around;
+import org.aspectj.lang.annotation.Aspect;
+import org.aspectj.lang.annotation.Pointcut;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Component;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import javax.servlet.http.HttpServletRequest;
+import java.lang.reflect.Method;
+
+/**
+ * 回调IP校验切面
+ * <p>
+ * 拦截所有标注了 @CallbackIpCheck 的方法,自动完成:
+ * 1. 从方法参数中提取 companyId
+ * 2. 查询 company_config 获取配置(如 cId.config)
+ * 3. 解析 CidPhoneConfig 获取 legalIPs
+ * 4. 校验请求来源IP是否在合法列表中
+ * 5. 不合法则阻断请求
+ * </p>
+ *
+ * @author MixLiu
+ */
+@Aspect
+@Component
+public class CallbackIpCheckAspect {
+
+    private static final Logger log = LoggerFactory.getLogger(CallbackIpCheckAspect.class);
+
+    @Autowired
+    private CompanyConfigMapper companyConfigMapper;
+    @Autowired
+    private SysConfigMapper sysConfigMapper;
+
+    @Pointcut("@annotation(com.fs.common.annotation.CallbackIpCheck)")
+    public void ipCheckPointCut() {
+    }
+
+    @Around("ipCheckPointCut()")
+    public Object around(ProceedingJoinPoint joinPoint) throws Throwable {
+        // 获取注解
+        MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+        Method method = signature.getMethod();
+        CallbackIpCheck annotation = method.getAnnotation(CallbackIpCheck.class);
+
+        // 获取当前请求
+        ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+        if (attributes == null) {
+            throw new IllegalStateException("CallbackIpCheck: 无法获取当前请求上下文");
+        }
+        HttpServletRequest request = attributes.getRequest();
+        String clientIp = IpUtil.getRequestIp(request);
+
+        // 查询配置
+        String configKey = annotation.configKey();
+        SysConfig sysConfig = sysConfigMapper.selectConfigByConfigKey(configKey);
+        if(null == sysConfig || StringUtils.isBlank(sysConfig.getConfigValue())){
+            log.error("CallbackIpCheck: 未找到配置,  configKey={}, 请求IP: {}",
+                    configKey, clientIp);
+            throw new IllegalArgumentException("CallbackIpCheck: 未找到公司配置");
+        }
+
+        CidConfigVO cidConf;
+        try {
+            cidConf = JSONObject.parseObject(sysConfig.getConfigValue(), CidConfigVO.class);
+        } catch (Exception e) {
+            log.error("CallbackIpCheck: 配置JSON解析失败,  configValue={}",
+                    sysConfig.getConfigValue(), e);
+            throw new IllegalArgumentException("CallbackIpCheck: 配置解析异常");
+        }
+
+        String legalIPs = cidConf.getLegalIPs();
+
+        // 校验IP
+        if (!IpCheckUtil.isIpInList(clientIp, legalIPs)) {
+            log.warn("非法回调来源IP: {}, legalIPs: {}", clientIp, legalIPs);
+            // 根据目标方法的返回类型返回对应的错误响应
+            Class<?> returnType = method.getReturnType();
+            if (returnType == String.class) {
+                return "illegal IP";
+            }
+            // 非String返回类型则抛异常,由全局异常处理器处理
+            throw new SecurityException("非法IP来源,请求IP: " + clientIp);
+        }
+
+        // IP校验通过,放行
+        return joinPoint.proceed();
+    }
+
+
+}

+ 104 - 2
fs-company/src/main/java/com/fs/company/controller/company/CompanyInboundCallManageController.java

@@ -1,11 +1,17 @@
 package com.fs.company.controller.company;
 
+import com.fs.aicall.domain.CcLlmAgentAccount;
+import com.fs.aicall.service.ICompanyBindAiModelService;
 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.ServletUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.company.domain.CompanyInboundBind;
+import com.fs.company.domain.EasyCallInboundCdrVO;
+import com.fs.company.mapper.CompanyInboundBindMapper;
 import com.fs.company.mapper.EasyCallInboundLlmMapper;
 import com.fs.company.service.ICompanyInboundCallManageService;
 import com.fs.company.vo.easycall.EasyCallBizGroupVO;
@@ -14,12 +20,17 @@ import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
 import com.fs.company.vo.easycall.EasyCallIvrVO;
 import com.fs.company.vo.easycall.EasyCallLlmAccountVO;
 import com.fs.company.vo.easycall.EasyCallVoiceCodeVO;
+import com.fs.framework.security.LoginUser;
+import com.fs.framework.service.TokenService;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.security.access.prepost.PreAuthorize;
 import org.springframework.web.bind.annotation.*;
 
+import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.stream.Collectors;
 
 /**
  * 呼入大模型配置 Controller
@@ -36,11 +47,28 @@ public class CompanyInboundCallManageController extends BaseController {
     @Autowired
     private EasyCallInboundLlmMapper inboundLlmMapper;
 
+    @Autowired
+    private TokenService tokenService;
+
+    @Autowired
+    CompanyInboundBindMapper companyInboundBindMapper;
+
+    @Autowired
+    private ICompanyBindAiModelService companyBindAiModelService;
+
     /**
      * 查询呼入大模型配置列表
      */
+    @PreAuthorize("@ss.hasPermi('inboundCallManage:list')")
     @GetMapping("/list")
     public TableDataInfo list(EasyCallInboundLlmVO vo) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        List<CompanyInboundBind> companyInboundBinds = companyInboundBindMapper.selectIdsByCompanyId(loginUser.getUser().getCompanyId());
+        if (null == companyInboundBinds || companyInboundBinds.isEmpty()) {
+            return getDataTable(null);
+        }
+        List<Long> ids = companyInboundBinds.stream().map(companyInboundBind -> companyInboundBind.getInboundLlmAccountId()).collect(Collectors.toList());
+        vo.setVisibleIds(ids);
         startPage();
         List<EasyCallInboundLlmVO> list = inboundCallManageService.selectInboundLlmList(vo);
         TableDataInfo rspData = getDataTable(list);
@@ -59,7 +87,19 @@ public class CompanyInboundCallManageController extends BaseController {
      */
     @GetMapping("/llmAccountList")
     public AjaxResult getLlmAccountList() {
-        List<EasyCallLlmAccountVO> list = inboundLlmMapper.selectLlmAccountList();
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        // 获取当前登录的公司ID
+        Long companyId = loginUser.getUser().getCompanyId();
+        List<Long> modelsId = new ArrayList<>();
+        if (companyId != null) {
+            List<Long> modelIds = companyBindAiModelService.selectModelIdsByCompanyId(companyId);
+            if (!modelIds.isEmpty()) {
+                modelsId = modelIds;
+            } else {
+                return AjaxResult.success();
+            }
+        }
+        List<EasyCallLlmAccountVO> list = inboundLlmMapper.selectLlmAccountList(modelsId);
         return AjaxResult.success(list);
     }
 
@@ -74,15 +114,19 @@ public class CompanyInboundCallManageController extends BaseController {
     /**
      * 新增呼入大模型配置
      */
+    @PreAuthorize("@ss.hasPermi('inboundCallManage:add')")
     @Log(title = "呼入大模型配置", businessType = BusinessType.INSERT)
     @PostMapping
     public AjaxResult add(@RequestBody EasyCallInboundLlmVO vo) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        vo.setCompanyId(loginUser.getUser().getCompanyId());
         return toAjax(inboundCallManageService.insertInboundLlm(vo));
     }
 
     /**
      * 修改呼入大模型配置
      */
+    @PreAuthorize("@ss.hasPermi('inboundCallManage:edit')")
     @Log(title = "呼入大模型配置", businessType = BusinessType.UPDATE)
     @PutMapping
     public AjaxResult edit(@RequestBody EasyCallInboundLlmVO vo) {
@@ -94,8 +138,11 @@ public class CompanyInboundCallManageController extends BaseController {
      */
     @Log(title = "呼入大模型配置", businessType = BusinessType.DELETE)
     @DeleteMapping("/{ids}")
+    @PreAuthorize("@ss.hasPermi('inboundCallManage:delete')")
     public AjaxResult remove(@PathVariable String ids) {
-        return toAjax(inboundCallManageService.deleteInboundLlmByIds(ids));
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+        return toAjax(inboundCallManageService.deleteInboundLlmByIds(ids,companyId));
     }
 
     /**
@@ -197,10 +244,65 @@ public class CompanyInboundCallManageController extends BaseController {
         return AjaxResult.success(list);
     }
 
+    /**
+     * 查询呼入通话记录列表
+     */
+    @PreAuthorize("@ss.hasPermi('inboundCallManage:inboundCallRecord:list')")
+    @GetMapping("/inboundCdrList")
+    public TableDataInfo inboundCdrList(EasyCallInboundCdrVO vo) {
+        LoginUser loginUser = tokenService.getLoginUser(ServletUtils.getRequest());
+        Long companyId = loginUser.getUser().getCompanyId();
+
+        // 通过CompanyInboundBind查询该公司绑定的呼入配置
+        List<CompanyInboundBind> binds = companyInboundBindMapper.selectIdsByCompanyId(companyId);
+        if (binds == null || binds.isEmpty()) {
+            return getDataTable(new ArrayList<>());
+        }
+
+        // 获取绑定配置的ID列表
+        List<Long> llmAccountIds = binds.stream()
+                .map(CompanyInboundBind::getInboundLlmAccountId)
+                .collect(Collectors.toList());
+
+        // 通过llmAccountIds查询对应的callee列表
+        EasyCallInboundLlmVO query = new EasyCallInboundLlmVO();
+        query.setVisibleIds(llmAccountIds);
+        List<EasyCallInboundLlmVO> llmConfigs = inboundCallManageService.selectInboundLlmList(query);
+        List<String> calleeList = llmConfigs.stream()
+                .map(EasyCallInboundLlmVO::getCallee)
+                .filter(StringUtils::isNotEmpty)
+                .distinct()
+                .collect(Collectors.toList());
+
+        if (calleeList.isEmpty()) {
+            return getDataTable(new ArrayList<>());
+        }
+
+        // 设置visibleCallees用于数据隔离
+        vo.setVisibleCallees(calleeList);
+
+        // 分页查询
+        startPage();
+        List<EasyCallInboundCdrVO> list = inboundCallManageService.selectInboundCdrList(vo);
+        TableDataInfo rspData = getDataTable(list);
+
+        // 处理录音URL
+        @SuppressWarnings("unchecked")
+        List<EasyCallInboundCdrVO> records = (List<EasyCallInboundCdrVO>) rspData.getRows();
+        for (EasyCallInboundCdrVO cdr : records) {
+            if (StringUtils.isNotEmpty(cdr.getWavFile())) {
+                cdr.setWavFileUrl("http://129.28.164.235:8899/recordings/files?filename=" + cdr.getWavFile());
+            }
+        }
+        rspData.setRows(records);
+        return rspData;
+    }
+
     /**
      * 填充关联数据
      */
     private void fillRelationData(EasyCallInboundLlmVO data) {
+        data.setAiTransferGroupId(data.getAiTransferData());
         // 填充大模型账户名称
         if (data.getLlmAccountId() != null && data.getLlmAccountId() > 0) {
             EasyCallLlmAccountVO llmAccount = inboundLlmMapper.selectLlmAccountById(data.getLlmAccountId());

+ 16 - 5
fs-company/src/main/java/com/fs/company/controller/company/GeneralCustomerEntryController.java

@@ -1,8 +1,11 @@
 package com.fs.company.controller.company;
 
+import com.fs.common.annotation.CallbackIpCheck;
 import com.fs.common.core.domain.R;
 import com.fs.company.param.EntryCustomerParam;
+import com.fs.company.param.InboundCallbackParam;
 import com.fs.company.service.IGeneralCustomerEntryService;
+import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.web.bind.annotation.*;
 
@@ -13,16 +16,24 @@ import org.springframework.web.bind.annotation.*;
  */
 @RestController
 @RequestMapping("/company/general/customer")
+@Slf4j
 public class GeneralCustomerEntryController {
 
     @Autowired
     IGeneralCustomerEntryService iGeneralCustomerEntryService;
 
-    @PostMapping("/entryCustomer")
-    public R entryCustomer(@RequestBody EntryCustomerParam param){
-        iGeneralCustomerEntryService.entryCustomer(param);
-       return R.ok("success");
-    }
+//    @PostMapping("/entryCustomer")
+//    public R entryCustomer(@RequestBody EntryCustomerParam param){
+//       iGeneralCustomerEntryService.entryCustomer(param);
+//       return R.ok("success");
+//    }
 
+    @PostMapping("/inboundCallback")
+    @CallbackIpCheck
+    public String inboundCallback(@RequestBody InboundCallbackParam param){
+        log.info("呼入回调:{}", param);
+        iGeneralCustomerEntryService.inboundCallback(param);
+        return "success";
+    }
 
 }

+ 1 - 1
fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java

@@ -13,7 +13,7 @@ import com.fs.his.domain.FsUser;
 import com.fs.his.service.IFsUserService;
 import com.fs.hisStore.domain.FsUserScrm;
 import com.fs.hisStore.service.IFsUserScrmService;
-import com.fs.live.config.ProductionWordFilter;
+import com.fs.sensitive.ProductionWordFilter;
 import com.fs.live.mapper.LiveCouponMapper;
 import com.fs.live.vo.LiveWatchUserEntry;
 import com.fs.live.domain.LiveWatchLog;

+ 31 - 0
fs-service/src/main/java/com/fs/company/domain/CompanyInboundBind.java

@@ -0,0 +1,31 @@
+package com.fs.company.domain;
+
+import com.baomidou.mybatisplus.annotation.TableId;
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+import com.fs.common.core.domain.BaseEntity;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 呼入线路模型绑定关系对象 company_inbound_bind
+ *
+ * @author fs
+ * @date 2026-04-27
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class CompanyInboundBind extends BaseEntity{
+
+    /** id */
+    private Long id;
+
+    /** 公司id */
+    @Excel(name = "公司id")
+    private Long companyId;
+
+    /** 呼入线路模型id */
+    @Excel(name = "呼入线路模型id")
+    private Long inboundLlmAccountId;
+
+
+}

+ 111 - 0
fs-service/src/main/java/com/fs/company/domain/EasyCallInboundCdrVO.java

@@ -0,0 +1,111 @@
+package com.fs.company.domain;
+
+import com.fasterxml.jackson.annotation.JsonInclude;
+import lombok.Data;
+import lombok.experimental.Accessors;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 呼入通话记录对象 cc_inbound_cdr
+ *
+ * @author fs
+ */
+@Data
+@Accessors(chain = true)
+public class EasyCallInboundCdrVO implements Serializable {
+    private static final long serialVersionUID = 1L;
+
+    /** 主键id */
+    private String id;
+
+    /** 主叫号码 */
+    private String caller;
+
+    /** 被叫号码 */
+    private String callee;
+
+    /** 呼入时间 */
+    private Long inboundTime;
+
+    /** 请求转接的坐席组 */
+    private String groupId;
+
+    /** 电话被接听时间 */
+    private Long answeredTime;
+
+    /** 接听电话的分机号码 */
+    private String extnum;
+
+    /** 接听电话的员工工号 */
+    private String opnum;
+
+    /** 挂机时间 */
+    private Long hangupTime;
+
+    /** 服务时长 */
+    private Long answeredTimeLen;
+
+    /** 通话总时长 */
+    private Long timeLen;
+
+    /** 通话uuid */
+    private String uuid;
+
+    /** 录音文件名 */
+    private String wavFile;
+
+    /** 录音文件url访问地址 */
+    private String wavFileUrl;
+
+    /** AI客服对话内容 */
+    private String chatContent;
+
+    /** asr时长(秒) */
+    private Integer asrSeconds;
+
+    /** tts调用次数(次) */
+    private Integer ttsTimes;
+
+    /** 大模型tts的字符数(字符) */
+    private Integer ttsFlowTokens;
+
+    /** 总输入token数 */
+    private Integer inputTokens;
+
+    /** 总输出token数 */
+    private Integer outputTokens;
+
+    /** 总调用费用(asr+tts+大模型) */
+    private BigDecimal totalCost;
+
+    /** 计费状态(1:已计费、0:未计费) */
+    private Integer billingStatus;
+
+    /** 整个ivr通话中的有效按键 */
+    private String ivrDtmfDigits;
+
+    /** 挂机原因 */
+    private String hangupCause;
+
+    /** 人工接听时间 */
+    private String manualAnsweredTime;
+
+    /** 人工接听时长 */
+    private String manualAnsweredTimeLen;
+
+    /************ 以下不是表结构字段 ************/
+
+    /** 业务组名称 */
+    private String groupName;
+
+    /** 请求参数(时间范围、时长范围等) */
+    @JsonInclude(JsonInclude.Include.NON_EMPTY)
+    private Map<String, Object> params;
+
+    /** 可见被叫号码列表(多租户隔离用) */
+    private List<String> visibleCallees;
+}

+ 66 - 0
fs-service/src/main/java/com/fs/company/mapper/CompanyInboundBindMapper.java

@@ -0,0 +1,66 @@
+package com.fs.company.mapper;
+
+import java.util.List;
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.fs.company.domain.CompanyInboundBind;
+import org.apache.ibatis.annotations.Param;
+
+/**
+ * 呼入线路模型绑定关系Mapper接口
+ * 
+ * @author fs
+ * @date 2026-04-27
+ */
+public interface CompanyInboundBindMapper extends BaseMapper<CompanyInboundBind>{
+    /**
+     * 查询呼入线路模型绑定关系
+     * 
+     * @param id 呼入线路模型绑定关系主键
+     * @return 呼入线路模型绑定关系
+     */
+    CompanyInboundBind selectCompanyInboundBindById(Long id);
+
+    /**
+     * 查询呼入线路模型绑定关系列表
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 呼入线路模型绑定关系集合
+     */
+    List<CompanyInboundBind> selectCompanyInboundBindList(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 新增呼入线路模型绑定关系
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    int insertCompanyInboundBind(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 修改呼入线路模型绑定关系
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    int updateCompanyInboundBind(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 删除呼入线路模型绑定关系
+     * 
+     * @param id 呼入线路模型绑定关系主键
+     * @return 结果
+     */
+    int deleteCompanyInboundBindById(Long id);
+
+    /**
+     * 批量删除呼入线路模型绑定关系
+     * 
+     * @param ids 需要删除的数据主键集合
+     * @return 结果
+     */
+    int deleteCompanyInboundBindByIds(Long[] ids);
+
+    List<CompanyInboundBind> selectIdsByCompanyId(@Param("companyId") Long companyId);
+
+    int deleteByCompanyIdAndInboundLlmAccountIds(@Param("companyId") Long companyId, @Param("inboundLlmAccountIds") List<Long> inboundLlmAccountIds);
+}

+ 11 - 1
fs-service/src/main/java/com/fs/company/mapper/EasyCallInboundLlmMapper.java

@@ -2,6 +2,7 @@ package com.fs.company.mapper;
 
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
+import com.fs.company.domain.EasyCallInboundCdrVO;
 import com.fs.company.vo.easycall.EasyCallBizGroupVO;
 import com.fs.company.vo.easycall.EasyCallGatewayVO;
 import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
@@ -91,7 +92,7 @@ public interface EasyCallInboundLlmMapper {
      * @return 大模型账户列表
      */
     @DataSource(DataSourceType.EASYCALL)
-    List<EasyCallLlmAccountVO> selectLlmAccountList();
+    List<EasyCallLlmAccountVO> selectLlmAccountList(@Param("modelsId") List<Long> modelsId);
 
     /**
      * 根据ID查询大模型账户
@@ -159,4 +160,13 @@ public interface EasyCallInboundLlmMapper {
      */
     @DataSource(DataSourceType.EASYCALL)
     List<EasyCallIvrVO> selectIvrList();
+
+    /**
+     * 查询呼入通话记录列表
+     *
+     * @param vo 查询条件
+     * @return 通话记录列表
+     */
+    @DataSource(DataSourceType.EASYCALL)
+    List<EasyCallInboundCdrVO> selectInboundCdrList(EasyCallInboundCdrVO vo);
 }

+ 4 - 0
fs-service/src/main/java/com/fs/company/mapper/EasyCallMapper.java

@@ -2,6 +2,7 @@ package com.fs.company.mapper;
 
 import com.fs.common.annotation.DataSource;
 import com.fs.common.enums.DataSourceType;
+import com.fs.company.vo.InboundCallInfo;
 import com.fs.company.vo.easycall.EasyCallCallPhoneVO;
 import com.fs.company.vo.easycall.EasyCallOutBoundVO;
 import org.apache.ibatis.annotations.Param;
@@ -22,4 +23,7 @@ public interface EasyCallMapper {
     @DataSource(DataSourceType.EASYCALL)
     EasyCallOutBoundVO getOutBoundInfoByUuid(@Param("uuid") String uuid);
 
+    @DataSource(DataSourceType.EASYCALL)
+    InboundCallInfo selectInboundCallbackInfoByUuid(@Param("uuid") String uuid);
+
 }

+ 1 - 1
fs-service/src/main/java/com/fs/company/param/EntryCustomerParam.java

@@ -162,7 +162,7 @@ public class EntryCustomerParam {
     private Integer sceneType;
     //对话图
     private String dialogue;
-    //投流id
+    //投流id ||在呼入场景下 此id存储值为呼入cdr的uuid
     private String traceId;
 
     private String remark;

+ 18 - 0
fs-service/src/main/java/com/fs/company/param/InboundCallbackParam.java

@@ -0,0 +1,18 @@
+package com.fs.company.param;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/4/28 09:59
+ * @description
+ */
+
+@Data
+public class InboundCallbackParam {
+
+    /**
+     * 数据uuid
+     */
+    private String uuid;
+}

+ 61 - 0
fs-service/src/main/java/com/fs/company/service/ICompanyInboundBindService.java

@@ -0,0 +1,61 @@
+package com.fs.company.service;
+
+import java.util.List;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.fs.company.domain.CompanyInboundBind;
+
+/**
+ * 呼入线路模型绑定关系Service接口
+ * 
+ * @author fs
+ * @date 2026-04-27
+ */
+public interface ICompanyInboundBindService extends IService<CompanyInboundBind>{
+    /**
+     * 查询呼入线路模型绑定关系
+     * 
+     * @param id 呼入线路模型绑定关系主键
+     * @return 呼入线路模型绑定关系
+     */
+    CompanyInboundBind selectCompanyInboundBindById(Long id);
+
+    /**
+     * 查询呼入线路模型绑定关系列表
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 呼入线路模型绑定关系集合
+     */
+    List<CompanyInboundBind> selectCompanyInboundBindList(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 新增呼入线路模型绑定关系
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    int insertCompanyInboundBind(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 修改呼入线路模型绑定关系
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    int updateCompanyInboundBind(CompanyInboundBind companyInboundBind);
+
+    /**
+     * 批量删除呼入线路模型绑定关系
+     * 
+     * @param ids 需要删除的呼入线路模型绑定关系主键集合
+     * @return 结果
+     */
+    int deleteCompanyInboundBindByIds(Long[] ids);
+
+    /**
+     * 删除呼入线路模型绑定关系信息
+     * 
+     * @param id 呼入线路模型绑定关系主键
+     * @return 结果
+     */
+    int deleteCompanyInboundBindById(Long id);
+}

+ 10 - 1
fs-service/src/main/java/com/fs/company/service/ICompanyInboundCallManageService.java

@@ -1,5 +1,6 @@
 package com.fs.company.service;
 
+import com.fs.company.domain.EasyCallInboundCdrVO;
 import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
 
 import java.util.List;
@@ -65,5 +66,13 @@ public interface ICompanyInboundCallManageService {
      * @param ids ID字符串,逗号分隔
      * @return 影响行数
      */
-    int deleteInboundLlmByIds(String ids);
+    int deleteInboundLlmByIds(String ids,Long companyId);
+
+    /**
+     * 查询呼入通话记录列表
+     *
+     * @param vo 查询条件
+     * @return 通话记录列表
+     */
+    List<EasyCallInboundCdrVO> selectInboundCdrList(EasyCallInboundCdrVO vo);
 }

+ 3 - 0
fs-service/src/main/java/com/fs/company/service/IGeneralCustomerEntryService.java

@@ -2,6 +2,7 @@ package com.fs.company.service;
 
 import com.fs.common.core.domain.R;
 import com.fs.company.param.EntryCustomerParam;
+import com.fs.company.param.InboundCallbackParam;
 
 /**
  * @author MixLiu
@@ -13,4 +14,6 @@ public interface IGeneralCustomerEntryService {
 //    R entryCustomer(String param);
 
     void entryCustomer(EntryCustomerParam param);
+
+    void inboundCallback(InboundCallbackParam param);
 }

+ 93 - 0
fs-service/src/main/java/com/fs/company/service/impl/CompanyInboundBindServiceImpl.java

@@ -0,0 +1,93 @@
+package com.fs.company.service.impl;
+
+import java.util.List;
+import com.fs.common.utils.DateUtils;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import com.fs.company.mapper.CompanyInboundBindMapper;
+import com.fs.company.domain.CompanyInboundBind;
+import com.fs.company.service.ICompanyInboundBindService;
+
+/**
+ * 呼入线路模型绑定关系Service业务层处理
+ * 
+ * @author fs
+ * @date 2026-04-27
+ */
+@Service
+public class CompanyInboundBindServiceImpl extends ServiceImpl<CompanyInboundBindMapper, CompanyInboundBind> implements ICompanyInboundBindService {
+
+    /**
+     * 查询呼入线路模型绑定关系
+     * 
+     * @param id 呼入线路模型绑定关系主键
+     * @return 呼入线路模型绑定关系
+     */
+    @Override
+    public CompanyInboundBind selectCompanyInboundBindById(Long id)
+    {
+        return baseMapper.selectCompanyInboundBindById(id);
+    }
+
+    /**
+     * 查询呼入线路模型绑定关系列表
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 呼入线路模型绑定关系
+     */
+    @Override
+    public List<CompanyInboundBind> selectCompanyInboundBindList(CompanyInboundBind companyInboundBind)
+    {
+        return baseMapper.selectCompanyInboundBindList(companyInboundBind);
+    }
+
+    /**
+     * 新增呼入线路模型绑定关系
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    @Override
+    public int insertCompanyInboundBind(CompanyInboundBind companyInboundBind)
+    {
+        companyInboundBind.setCreateTime(DateUtils.getNowDate());
+        return baseMapper.insertCompanyInboundBind(companyInboundBind);
+    }
+
+    /**
+     * 修改呼入线路模型绑定关系
+     * 
+     * @param companyInboundBind 呼入线路模型绑定关系
+     * @return 结果
+     */
+    @Override
+    public int updateCompanyInboundBind(CompanyInboundBind companyInboundBind)
+    {
+        return baseMapper.updateCompanyInboundBind(companyInboundBind);
+    }
+
+    /**
+     * 批量删除呼入线路模型绑定关系
+     * 
+     * @param ids 需要删除的呼入线路模型绑定关系主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanyInboundBindByIds(Long[] ids)
+    {
+        return baseMapper.deleteCompanyInboundBindByIds(ids);
+    }
+
+    /**
+     * 删除呼入线路模型绑定关系信息
+     * 
+     * @param id 呼入线路模型绑定关系主键
+     * @return 结果
+     */
+    @Override
+    public int deleteCompanyInboundBindById(Long id)
+    {
+        return baseMapper.deleteCompanyInboundBindById(id);
+    }
+}

+ 55 - 3
fs-service/src/main/java/com/fs/company/service/impl/CompanyInboundCallManageServiceImpl.java

@@ -1,13 +1,20 @@
 package com.fs.company.service.impl;
 
 import com.fs.common.core.text.Convert;
+import com.fs.company.domain.CompanyInboundBind;
+import com.fs.company.domain.EasyCallInboundCdrVO;
+import com.fs.company.mapper.CompanyInboundBindMapper;
 import com.fs.company.mapper.EasyCallInboundLlmMapper;
 import com.fs.company.service.ICompanyInboundCallManageService;
 import com.fs.company.vo.easycall.EasyCallInboundLlmVO;
+import org.apache.commons.lang.StringUtils;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Service;
 
+import java.util.Arrays;
+import java.util.Date;
 import java.util.List;
+import java.util.stream.Collectors;
 
 /**
  * 呼入大模型配置 Service业务层处理
@@ -20,6 +27,9 @@ public class CompanyInboundCallManageServiceImpl implements ICompanyInboundCallM
     @Autowired
     private EasyCallInboundLlmMapper inboundLlmMapper;
 
+    @Autowired
+    CompanyInboundBindMapper companyInboundBindMapper;
+
     /**
      * 查询呼入大模型配置
      *
@@ -61,7 +71,19 @@ public class CompanyInboundCallManageServiceImpl implements ICompanyInboundCallM
      */
     @Override
     public int insertInboundLlm(EasyCallInboundLlmVO vo) {
-        return inboundLlmMapper.insertInboundLlm(vo);
+        Boolean b = checkCalleeInboundLlm(vo.getCallee());
+        if(b){
+          throw new RuntimeException("被叫号码已存在,不能重复插入");
+        }
+        int i = inboundLlmMapper.insertInboundLlm(vo);
+        if(i >0 && vo.getId()!= null) {
+            CompanyInboundBind bind = new CompanyInboundBind();
+            bind.setInboundLlmAccountId(Long.valueOf(vo.getId()));
+            bind.setCompanyId(vo.getCompanyId());
+            bind.setCreateTime(new Date());
+            companyInboundBindMapper.insertCompanyInboundBind( bind);
+        }
+        return i;
     }
 
     /**
@@ -72,6 +94,10 @@ public class CompanyInboundCallManageServiceImpl implements ICompanyInboundCallM
      */
     @Override
     public int updateInboundLlm(EasyCallInboundLlmVO vo) {
+        List<EasyCallInboundLlmVO> list = inboundLlmMapper.selectInboundLlmByCallee(vo.getCallee());
+        if(list != null && list.size() > 0 && !list.get(0).getId().equals(vo.getId())){
+            throw new RuntimeException("被叫号码已存在,不能重复");
+        }
         return inboundLlmMapper.updateInboundLlm(vo);
     }
 
@@ -93,7 +119,33 @@ public class CompanyInboundCallManageServiceImpl implements ICompanyInboundCallM
      * @return 影响行数
      */
     @Override
-    public int deleteInboundLlmByIds(String ids) {
-        return inboundLlmMapper.deleteInboundLlmByIds(Convert.toStrArray(ids));
+    public int deleteInboundLlmByIds(String ids,Long companyId) {
+        int i = inboundLlmMapper.deleteInboundLlmByIds(Convert.toStrArray(ids));
+        if(StringUtils.isNotBlank(ids) && null != companyId ){
+            List<Long> collect = Arrays.stream(ids.split(",")).map(id -> Long.valueOf(id)).collect(Collectors.toList());
+            companyInboundBindMapper.deleteByCompanyIdAndInboundLlmAccountIds(companyId,collect );
+        }
+        return i;
+    }
+
+    /**
+     * 查询呼入通话记录列表
+     *
+     * @param vo 查询条件
+     * @return 通话记录列表
+     */
+    @Override
+    public List<EasyCallInboundCdrVO> selectInboundCdrList(EasyCallInboundCdrVO vo) {
+        return inboundLlmMapper.selectInboundCdrList(vo);
+    }
+
+    /**
+     * 检查被叫号码是否在呼入大模型配置中
+     * @param callee
+     * @return
+     */
+    public Boolean checkCalleeInboundLlm(String callee) {
+        List<EasyCallInboundLlmVO> list = inboundLlmMapper.selectInboundLlmByCallee(callee);
+        return list != null && list.size() > 0;
     }
 }

+ 28 - 0
fs-service/src/main/java/com/fs/company/service/impl/GeneralCustomerEntryServiceImpl.java

@@ -15,12 +15,15 @@ import com.fs.common.utils.spring.SpringUtils;
 import com.fs.company.domain.CompanyConfig;
 import com.fs.company.domain.CompanyVoiceRobotic;
 import com.fs.company.mapper.CompanyVoiceRoboticMapper;
+import com.fs.company.mapper.EasyCallMapper;
 import com.fs.company.param.EntryCustomerParam;
+import com.fs.company.param.InboundCallbackParam;
 import com.fs.company.service.ICompanyConfigService;
 import com.fs.company.service.ICompanyVoiceRoboticService;
 import com.fs.company.service.IGeneralCustomerEntryService;
 import com.fs.company.util.PhoneNumberUtil;
 import com.fs.company.vo.DictVO;
+import com.fs.company.vo.InboundCallInfo;
 import com.fs.config.ai.AiHostProper;
 import com.fs.crm.domain.CrmCustomer;
 import com.fs.crm.domain.CrmCustomerProperty;
@@ -80,6 +83,8 @@ public class GeneralCustomerEntryServiceImpl implements IGeneralCustomerEntrySer
     CompanyVoiceRoboticServiceImpl companyVoiceRoboticServiceImpl;
     @Autowired
     CrmCustomerPropertyServiceImpl crmCustomerPropertyService;
+    @Autowired
+    EasyCallMapper easyCallMapper;
     /**
      * 录入客户
      *
@@ -405,4 +410,27 @@ public class GeneralCustomerEntryServiceImpl implements IGeneralCustomerEntrySer
         return !now.isBefore(start) || !now.isAfter(end);
     }
 
+    /**
+     * 呼入回调
+     * @param param
+     */
+    @Override
+    public void inboundCallback(InboundCallbackParam param){
+        try {
+            Thread.sleep(5000L);
+        } catch (InterruptedException e) {
+            throw new RuntimeException(e);
+        }
+        if (param == null || StringUtils.isBlank(param.getUuid())){
+            return;
+        }
+        InboundCallInfo info  = easyCallMapper.selectInboundCallbackInfoByUuid(param.getUuid());
+        EntryCustomerParam entry = new EntryCustomerParam();
+        entry.setTraceId(param.getUuid());
+        entry.setCompanyId(info.getFsCompanyId());
+        entry.setSceneType(info.getFsSceneType());
+        entry.setMobile(info.getCaller());
+        entryCustomer(entry);
+    }
+
 }

+ 202 - 0
fs-service/src/main/java/com/fs/company/util/IpCheckUtil.java

@@ -0,0 +1,202 @@
+package com.fs.company.util;
+
+import cn.hutool.core.util.StrUtil;
+import com.fs.common.utils.IpUtil;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import javax.servlet.http.HttpServletRequest;
+
+/**
+ * IP校验工具类
+ * 用于校验请求来源IP是否在合法IP列表中
+ * 支持:精确IP匹配、通配符(*)匹配、CIDR格式(192.168.1.0/24)匹配
+ *
+ * @author MixLiu
+ */
+public class IpCheckUtil {
+
+    private static final Logger log = LoggerFactory.getLogger(IpCheckUtil.class);
+
+    private IpCheckUtil() {
+        throw new AssertionError();
+    }
+
+    /**
+     * 校验请求来源IP是否在合法IP列表中(不合法则抛异常)
+     *
+     * @param request  HTTP请求
+     * @param legalIPs 逗号分隔的合法IP列表
+     * @throws SecurityException 当IP不合法时抛出
+     */
+    public static void checkRequestIpOrThrow(HttpServletRequest request, String legalIPs) {
+        if (!isRequestIpLegal(request, legalIPs)) {
+            String clientIp = IpUtil.getRequestIp(request);
+            log.warn("IP校验未通过, 来源IP: {}, 合法IP列表: {}", clientIp, legalIPs);
+            throw new SecurityException("非法IP来源: " + clientIp);
+        }
+    }
+
+    /**
+     * 校验请求来源IP是否在合法IP列表中
+     *
+     * @param request  HTTP请求
+     * @param legalIPs 逗号分隔的合法IP列表,支持精确IP、通配符(*)和CIDR格式
+     * @return true=合法, false=不合法
+     */
+    public static boolean isRequestIpLegal(HttpServletRequest request, String legalIPs) {
+        if (request == null) {
+            return false;
+        }
+        if (StrUtil.isBlank(legalIPs)) {
+            log.warn("legalIPs未配置,拒绝所有请求");
+            return false;
+        }
+        String clientIp = IpUtil.getRequestIp(request);
+        return isIpInList(clientIp, legalIPs);
+    }
+
+    /**
+     * 判断指定IP是否在IP列表中
+     *
+     * @param ip      待检查的IP (如 "192.168.1.100")
+     * @param ipList  逗号分隔的合法IP列表
+     *                <pre>
+     * 支持格式:
+     *   - 精确IP:      192.168.1.100
+     *   - 通配符:      192.168.1.* , 192.168.*.* , *
+     *   - CIDR:       192.168.1.0/24
+     *   - 混合逗号分隔: 192.168.1.100,10.0.0.*,172.16.0.0/12
+     *                </pre>
+     * @return true=在列表中
+     */
+    public static boolean isIpInList(String ip, String ipList) {
+        if (StrUtil.isBlank(ip) || StrUtil.isBlank(ipList)) {
+            return false;
+        }
+
+        String[] ipArr = ipList.split(",");
+        for (String allowedIp : ipArr) {
+            allowedIp = allowedIp.trim();
+            if (matchIp(ip, allowedIp)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * 单个IP与单个规则匹配
+     *
+     * @param ip      待检查的IP
+     * @param pattern 匹配规则:精确IP / 通配符(*) / CIDR格式
+     */
+    public static boolean matchIp(String ip, String pattern) {
+        if (StrUtil.isBlank(ip) || StrUtil.isBlank(pattern)) {
+            return false;
+        }
+        pattern = pattern.trim();
+
+        // 通配所有
+        if ("*".equals(pattern)) {
+            return true;
+        }
+
+        // 精确匹配
+        if (ip.equals(pattern)) {
+            return true;
+        }
+
+        // CIDR格式匹配 (如 192.168.1.0/24)
+        if (pattern.contains("/")) {
+            return matchCidr(ip, pattern);
+        }
+
+        // 通配符匹配 (如 192.168.1.* 或 192.168.*)
+        if (pattern.contains("*")) {
+            return matchWildcard(ip, pattern);
+        }
+
+        return false;
+    }
+
+    /**
+     * 通配符匹配,如 192.168.1.* 或 192.168.*.* 或 192.*
+     */
+    private static boolean matchWildcard(String ip, String pattern) {
+        String[] ipParts = ip.split("\\.");
+        String[] patternParts = pattern.split("\\.");
+
+        // 如果模式段数不足4段,补全为通配符
+        if (patternParts.length < 4) {
+            String[] full = new String[4];
+            System.arraycopy(patternParts, 0, full, 0, patternParts.length);
+            for (int i = patternParts.length; i < 4; i++) {
+                full[i] = "*";
+            }
+            patternParts = full;
+        }
+
+        if (ipParts.length != 4) {
+            return false;
+        }
+
+        for (int i = 0; i < 4; i++) {
+            if ("*".equals(patternParts[i])) {
+                continue;
+            }
+            if (!ipParts[i].equals(patternParts[i])) {
+                return false;
+            }
+        }
+        return true;
+    }
+
+    /**
+     * CIDR格式匹配,如 192.168.1.0/24
+     */
+    private static boolean matchCidr(String ip, String cidr) {
+        try {
+            String[] parts = cidr.split("/");
+            if (parts.length != 2) {
+                return false;
+            }
+            String subnet = parts[0];
+            int prefixLength = Integer.parseInt(parts[1]);
+
+            int ipInt = ipToInt(ip);
+            if (ipInt == -1) {
+                return false;
+            }
+
+            int subnetInt = ipToInt(subnet);
+            if (subnetInt == -1) {
+                return false;
+            }
+
+            int mask = prefixLength == 0 ? 0 : (0xFFFFFFFF << (32 - prefixLength));
+            return (ipInt & mask) == (subnetInt & mask);
+        } catch (Exception e) {
+            log.debug("CIDR匹配异常, ip={}, cidr={}", ip, cidr, e);
+            return false;
+        }
+    }
+
+    /**
+     * IP字符串转32位整数
+     */
+    private static int ipToInt(String ip) {
+        try {
+            String[] parts = ip.split("\\.");
+            if (parts.length != 4) {
+                return -1;
+            }
+            return (Integer.parseInt(parts[0]) << 24)
+                    | (Integer.parseInt(parts[1]) << 16)
+                    | (Integer.parseInt(parts[2]) << 8)
+                    | Integer.parseInt(parts[3]);
+        } catch (NumberFormatException e) {
+            return -1;
+        }
+    }
+}

+ 4 - 0
fs-service/src/main/java/com/fs/company/vo/CidConfigVO.java

@@ -10,4 +10,8 @@ public class CidConfigVO {
     private BigDecimal callCharge;
 
     private BigDecimal smsCharge;
+    /**
+     * 允许的IPs 逗号分隔的字符串
+     */
+    private String legalIPs;
 }

+ 43 - 0
fs-service/src/main/java/com/fs/company/vo/InboundCallInfo.java

@@ -0,0 +1,43 @@
+package com.fs.company.vo;
+
+import lombok.Data;
+
+/**
+ * @author MixLiu
+ * @date 2026/4/28 16:43
+ * @description
+ */
+@Data
+public class InboundCallInfo {
+
+    /**
+     * uuid
+     */
+    private String uuid;
+
+    /**
+     * 回调地址
+     */
+    private String callBackUrl;
+
+    /**
+     * 公司id
+     */
+    private Long fsCompanyId;
+
+    /**
+     * 呼入场景
+     */
+    private Integer fsSceneType;
+
+    /**
+     * 对话内容
+     */
+    private String chatContent;
+
+    /**
+     * 主叫号码
+     */
+    private String caller;
+
+}

+ 22 - 0
fs-service/src/main/java/com/fs/company/vo/easycall/EasyCallInboundLlmVO.java

@@ -4,6 +4,7 @@ import lombok.Data;
 import lombok.experimental.Accessors;
 
 import java.io.Serializable;
+import java.util.List;
 
 /**
  * 呼入大模型配置对象 cc_inbound_llm_account
@@ -72,4 +73,25 @@ public class EasyCallInboundLlmVO implements Serializable {
 
     /** AI转接分机号 */
     private String aiTransferExtNumber;
+    /**
+     * 公司可见的ID列表
+     */
+    private List<Long> visibleIds;
+
+    /**
+     * 公司id
+     */
+    private Long companyId;
+
+    /**
+     * 呼入回调地址
+     */
+    private String callBackUrl;
+
+    /**
+     * 场景类型
+     */
+    private Integer fsSceneType;
+
+
 }

+ 17 - 0
fs-service/src/main/java/com/fs/course/cache/PublicCourseAppCacheNames.java

@@ -0,0 +1,17 @@
+package com.fs.course.cache;
+
+/**
+ * 用户端(小程序等)公域课程/分类查询 Spring Cache 名称,与 Redis 中
+ * "cacheName::key" 前缀一致,管理端需按名批量删除时与此常量对齐。
+ */
+public final class PublicCourseAppCacheNames {
+
+    /** 公域/小程序课程分类 App 列表 */
+    public static final String CATEGORY_APP_LIST = "publicCourseCategoryApp";
+
+    /** 公域课程 App 列表 */
+    public static final String COURSE_PUBLIC_APP_LIST = "publicCoursePublicApp";
+
+    private PublicCourseAppCacheNames() {
+    }
+}

+ 12 - 0
fs-service/src/main/java/com/fs/course/constant/SearchRedisConstants.java

@@ -0,0 +1,12 @@
+package com.fs.course.constant;
+
+public class SearchRedisConstants {
+    /** 上次同步时间戳 */
+    public static final String SEARCH_STAT_LAST_SYNC_TIME = "search:stat:last_sync_time";
+    /** 同步锁 */
+    public static final String SEARCH_STAT_LAST_SYNC_STATUS = "search:stat:status";
+    /** 已写入聚合表 UV 的用户集合(按 keyword),仅由 SearchStatSyncServiceImpl 同步成功后写入;勿在实时搜索里 SADD */
+    public static final String SEARCH_STAT_SYNCED_USERS = "search:stat:synced_users:";
+    /** PV增量计数(按keyword分) */
+    public static final String SEARCH_STAT_PV_INCREMENT = "search:stat:pv_increment:";
+}

+ 24 - 0
fs-service/src/main/java/com/fs/course/domain/FsCompanyUserCourseFavorite.java

@@ -0,0 +1,24 @@
+package com.fs.course.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+
+/**
+ * 销售端营期收藏 fs_company_user_course_favorite
+ */
+@Data
+@EqualsAndHashCode(callSuper = true)
+public class FsCompanyUserCourseFavorite extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private Long companyId;
+
+    private Long companyUserId;
+
+    /** 营期ID */
+    private Long periodId;
+}

+ 19 - 0
fs-service/src/main/java/com/fs/course/domain/FsCourseKeywordSearchLog.java

@@ -0,0 +1,19 @@
+package com.fs.course.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 关键词搜索日志 course_keywork_search_log
+ */
+@Data
+public class FsCourseKeywordSearchLog extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long id;
+
+    private String keyword;
+
+    private Long userId;
+}

+ 27 - 0
fs-service/src/main/java/com/fs/course/domain/FsPublicCourseSearchKeywordStat.java

@@ -0,0 +1,27 @@
+package com.fs.course.domain;
+
+import com.fs.common.core.domain.BaseEntity;
+import lombok.Data;
+
+/**
+ * 公域课程搜索关键词统计 fs_public_course_search_keyword_stat
+ */
+@Data
+public class FsPublicCourseSearchKeywordStat extends BaseEntity {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long statId;
+
+    /** 搜索词 */
+    private String keyword;
+
+    /** 搜索次数 PV */
+    private Long searchPv;
+
+    /** 搜索人数 UV */
+    private Long searchUv;
+
+    /** 关联课程数量 */
+    private Integer relatedCourseCount;
+}

+ 6 - 0
fs-service/src/main/java/com/fs/course/domain/FsUserCourseVideo.java

@@ -64,6 +64,12 @@ public class FsUserCourseVideo extends BaseEntity
     @Excel(name = "课程排序")
     private Long courseSort;
 
+    /** 观看时长(分钟),用户在页面需停留时间 */
+    private Integer watchDurationMinutes;
+
+    /** 积分奖励 */
+    private Integer integralReward;
+
     private String fileName;
 
     @TableLogic(value = "0", delval = "1")

+ 17 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCompanyUserCourseFavoriteMapper.java

@@ -0,0 +1,17 @@
+package com.fs.course.mapper;
+
+import com.fs.course.domain.FsCompanyUserCourseFavorite;
+import org.apache.ibatis.annotations.Param;
+
+public interface FsCompanyUserCourseFavoriteMapper {
+
+    FsCompanyUserCourseFavorite selectByCompanyUserAndPeriod(
+            @Param("companyUserId") Long companyUserId,
+            @Param("periodId") Long periodId);
+
+    int insertFsCompanyUserCourseFavorite(FsCompanyUserCourseFavorite row);
+
+    int deleteByCompanyUserAndPeriod(
+            @Param("companyUserId") Long companyUserId,
+            @Param("periodId") Long periodId);
+}

+ 13 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java

@@ -159,6 +159,19 @@ public interface FsCourseAnswerLogsMapper
             "</script>"})
     int selectErrorCountByCourseVideo(@Param("videoId") Long videoId, @Param("userId") Long userId, @Param("qwUserId") String qwUserId,@Param("project") Long project);
 
+    /** 当日错题次数(跨日清零重试) */
+    @Select({"<script> " +
+            "select count(0) from fs_course_answer_logs where video_id = #{videoId} and user_id = #{userId} and is_right = 0 " +
+            "and DATE(create_time) = CURDATE() " +
+            "<if test = 'qwUserId !=null '> " +
+            "and qw_user_id = #{qwUserId} " +
+            "</if>" +
+            "<if test = 'project !=null '> " +
+            "and project = #{project} " +
+            "</if>" +
+            "</script>"})
+    int selectErrorCountByCourseVideoToday(@Param("videoId") Long videoId, @Param("userId") Long userId, @Param("qwUserId") String qwUserId, @Param("project") Long project);
+
     Long selectRedStatus(@Param("userId") Long userId, @Param("videoId") Long videoId, @Param("periodId") Long periodId);
     Integer selectRedStatus2(@Param("userId") Long userId, @Param("videoId") Long videoId, @Param("periodId") Long periodId);
     List<FsCourseAnswerLogsListVO> selectFsCourseAnswerLogsListVONew(FsCourseAnswerLogsParam param);

+ 35 - 0
fs-service/src/main/java/com/fs/course/mapper/FsCourseKeywordSearchLogMapper.java

@@ -0,0 +1,35 @@
+package com.fs.course.mapper;
+
+import com.fs.course.domain.FsCourseKeywordSearchLog;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+public interface FsCourseKeywordSearchLogMapper {
+
+    FsCourseKeywordSearchLog selectFsCourseKeywordSearchLogById(Long id);
+
+    List<FsCourseKeywordSearchLog> selectFsCourseKeywordSearchLogList(FsCourseKeywordSearchLog query);
+
+    int insertFsCourseKeywordSearchLog(FsCourseKeywordSearchLog data);
+
+    int updateFsCourseKeywordSearchLog(FsCourseKeywordSearchLog data);
+
+    int deleteFsCourseKeywordSearchLogById(Long id);
+
+    int deleteFsCourseKeywordSearchLogByIds(Long[] ids);
+
+    /**
+     * 按关键词分组统计增量PV
+     */
+    List<Map<String, Object>> selectPvGroupByKeyword(@Param("startTime") Date startTime,
+                                                     @Param("endTime") Date endTime);
+
+    /**
+     * 按关键词分组查询增量中的去重用户ID
+     */
+    List<Map<String, Object>> selectDistinctUserByKeyword(@Param("startTime") Date startTime,
+                                                          @Param("endTime") Date endTime);
+}

+ 19 - 0
fs-service/src/main/java/com/fs/course/mapper/FsPublicCourseSearchKeywordStatMapper.java

@@ -0,0 +1,19 @@
+package com.fs.course.mapper;
+
+import com.fs.course.domain.FsPublicCourseSearchKeywordStat;
+
+import java.util.List;
+
+/**
+ * 公域课程搜索关键词统计
+ */
+public interface FsPublicCourseSearchKeywordStatMapper {
+
+    List<FsPublicCourseSearchKeywordStat> selectFsPublicCourseSearchKeywordStatList(FsPublicCourseSearchKeywordStat query);
+
+    FsPublicCourseSearchKeywordStat selectByKeyword(String keyword);
+
+    int insertFsPublicCourseSearchKeywordStat(FsPublicCourseSearchKeywordStat stat);
+
+    int updateFsPublicCourseSearchKeywordStat(FsPublicCourseSearchKeywordStat stat);
+}

+ 14 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseCategoryMapper.java

@@ -83,6 +83,20 @@ public interface FsUserCourseCategoryMapper
     @Select("select cate_id dict_value, cate_name dict_label  from fs_user_course_category WHERE pid =#{pid} and is_del=0 ")
     List<OptionsVO> selectCateListByPid(Long pid);
 
+    /**
+     * 公域课一级分类(cateType=1,pid=0)
+     */
+    @Select("select cate_id dict_value, cate_name dict_label from fs_user_course_category "
+            + "WHERE pid = 0 and is_del = 0 and is_show = 1 and cate_type = 1 order by sort asc")
+    List<OptionsVO> selectPublicUserCourseCategoryPidList();
+
+    /**
+     * 公域课二级分类
+     */
+    @Select("select cate_id dict_value, cate_name dict_label from fs_user_course_category "
+            + "WHERE pid = #{pid} and is_del = 0 and is_show = 1 and cate_type = 1 order by sort asc")
+    List<OptionsVO> selectPublicCateListByPid(@Param("pid") Long pid);
+
     /**
      * 根据名称查询分类
      * @param name  名称

+ 10 - 2
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseMapper.java

@@ -261,11 +261,13 @@ public interface FsUserCourseMapper
     @Select({"<script> " +
             "SELECT DISTINCT\n" +
             "        fcp.period_id,\n" +
-            "        fcp.period_name\n" +
+            "        fcp.period_name,\n" +
+            "        if(fav.id is null, 0, 1) as is_favorite\n" +
             "        FROM\n" +
             "        fs_user_course_period fcp\n" +
             "        LEFT JOIN fs_user_course_period_days fcpd ON fcpd.period_id = fcp.period_id\n" +
             "        LEFT JOIN fs_user_course c ON c.course_id = fcpd.course_id\n" +
+            "        LEFT JOIN fs_company_user_course_favorite fav ON fav.period_id = fcp.period_id AND fav.company_user_id = #{companyUserId}\n" +
             "        WHERE\n" +
             "        c.is_del = 0 and fcp.del_flag = '0'\n" +
             "        AND FIND_IN_SET(#{companyId}, fcp.company_id)\n" +
@@ -274,7 +276,7 @@ public interface FsUserCourseMapper
             "            )\n" +
             "        </if>\n" +
             "        ORDER BY\n" +
-            "        fcp.create_time desc, fcp.period_status asc"+
+            "        is_favorite DESC, fcp.create_time desc, fcp.period_status asc"+
             "</script>"})
     List<FsUserCourseListVO> getFsUserCourseList(FsUserCourseListParam param);
 
@@ -362,4 +364,10 @@ public interface FsUserCourseMapper
             "    GROUP BY course_id, course_name")
     List<OptionsVO> selectCourseOptionsList();
 
+    /**
+     * 分类是否仍被课程引用(一级 cate_id 或二级 sub_cate_id)
+     */
+    @Select("SELECT COUNT(1) FROM fs_user_course WHERE IFNULL(is_del,0) = 0 AND (cate_id = #{cateId} OR sub_cate_id = #{cateId})")
+    int countCourseByCategoryId(@Param("cateId") Long cateId);
+
 }

+ 5 - 0
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseStudyLogMapper.java

@@ -74,6 +74,11 @@ public interface FsUserCourseStudyLogMapper
     @Select("select * from fs_user_course_study_log where user_id = #{userId} and video_id = #{videoId} ")
     FsUserCourseStudyLog selectFsUserCourseStudyLogByVideo(@Param("userId")Long userId, @Param("videoId")Long videoId);
 
+    /** 当日小节学习记录(按日期重置答题前置条件) */
+    @Select("select * from fs_user_course_study_log where user_id = #{userId} and video_id = #{videoId} "
+            + "and DATE(create_time) = CURDATE() order by create_time desc limit 1")
+    FsUserCourseStudyLog selectFsUserCourseStudyLogByVideoToday(@Param("userId") Long userId, @Param("videoId") Long videoId);
+
     @Select("select count(0) from fs_user_course_study_log where user_id = #{userId} and course_id = #{courseId} and status = 1 ")
     Long selectStudyLogFinishCount(@Param("courseId") Long courseId,@Param("userId") Long userId);
 }

+ 1 - 1
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseStudyMapper.java

@@ -66,7 +66,7 @@ public interface FsUserCourseStudyMapper
 
 
     @Select({"<script> " +
-            "select s.*,c.img_url  from fs_user_course_study s left join fs_user_course c on c.course_id = s.course_id " +
+            "select s.*,c.img_url,c.description as courseDesc  from fs_user_course_study s left join fs_user_course c on c.course_id = s.course_id " +
             "where c.is_del = 0   " +
             "<if test = ' maps.userId  '> " +
             "and s.user_id = #{maps.userId} " +

+ 31 - 2
fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java

@@ -67,6 +67,11 @@ public interface FsUserCourseVideoMapper extends BaseMapper<FsUserCourseVideo> {
 
     int batchUpdateByVideoId(@Param("list") List<Map<String, Object>> list);
 
+    int batchUpdateWatchIntegralByVideoIds(@Param("courseId") Long courseId,
+                                           @Param("videoIds") List<Long> videoIds,
+                                           @Param("watchDurationMinutes") Integer watchDurationMinutes,
+                                           @Param("integralReward") Integer integralReward);
+
     /**
      * 删除课堂视频
      *
@@ -106,7 +111,7 @@ public interface FsUserCourseVideoMapper extends BaseMapper<FsUserCourseVideo> {
             " v.status, v.course_sort,v.line_one,v.line_two,v.line_three,v.upload_type,1 as is_vip,CASE \n" +
             "        WHEN l.log_id IS NOT NULL THEN 1\n" +
             "        ELSE 0\n" +
-            "    END AS is_buy  from fs_user_course_video v  " +
+            "    END AS is_buy,v.watch_duration_minutes,v.integral_reward  from fs_user_course_video v  " +
             " left join fs_user_course_study_log l on l.video_id = v.video_id and l.user_id = #{maps.userId} and l.is_buy = 1 " +
             " where v.is_del = 0 and  v.course_id = #{maps.courseId} " +
             "<if test = ' maps.keyword!=null and maps.keyword != \"\" '> " +
@@ -118,7 +123,7 @@ public interface FsUserCourseVideoMapper extends BaseMapper<FsUserCourseVideo> {
 
     @Select({"<script> " +
             "select v.video_id, v.title,v.package_json, v.video_url, v.thumbnail,v.duration as seconds, SEC_TO_TIME(v.duration) as duration,v.create_time, v.talent_id, v.course_id, " +
-            " v.status, v.course_sort,v.line_one,v.line_two,v.line_three,v.upload_type,1 as is_vip,0 as is_buy  from fs_user_course_video v  " +
+            " v.status, v.course_sort,v.line_one,v.line_two,v.line_three,v.upload_type,1 as is_vip,0 as is_buy,v.watch_duration_minutes,v.integral_reward  from fs_user_course_video v  " +
             " where v.is_del = 0 and  v.course_id = #{maps.courseId}   " +
             "<if test = ' maps.keyword!=null and maps.keyword != \"\" '> " +
             "and v.title like CONCAT('%',#{maps.keyword},'%') " +
@@ -317,4 +322,28 @@ public interface FsUserCourseVideoMapper extends BaseMapper<FsUserCourseVideo> {
             "      AND course_id = #{courseId}")
     List<OptionsVO> selectVideoOptionsByCourseId(@Param("courseId") Long courseId);
 
+    /**
+     * 素材库资源 id 是否已被未删除的课程小节引用(file_key 关联,且课程未删除)
+     */
+    @Select("<script>" +
+            "SELECT COUNT(1) FROM fs_user_course_video v " +
+            "INNER JOIN fs_video_resource r ON r.file_key = v.file_key AND IFNULL(TRIM(r.file_key),'') != '' " +
+            "INNER JOIN fs_user_course c ON c.course_id = v.course_id AND IFNULL(c.is_del,0) = 0 " +
+            "WHERE IFNULL(v.is_del,0) = 0 AND r.id IN " +
+            "<foreach collection='resourceIds' item='id' open='(' separator=',' close=')'>#{id}</foreach>" +
+            "</script>")
+    int countCourseVideoByVideoResourceIds(@Param("resourceIds") Long[] resourceIds);
+
+    /**
+     * 待删课节所属课程是否存在已上架(is_show=1)且未删除
+     */
+    @Select("<script>" +
+            "SELECT COUNT(1) FROM fs_user_course_video v " +
+            "INNER JOIN fs_user_course c ON c.course_id = v.course_id " +
+            "WHERE IFNULL(v.is_del,0) = 0 AND IFNULL(c.is_del,0) = 0 AND IFNULL(c.is_show,0) = 1 " +
+            "AND v.video_id IN " +
+            "<foreach collection='videoIds' item='vid' open='(' separator=',' close=')'>#{vid}</foreach>" +
+            "</script>")
+    int countOnShelfCourseByVideoIds(@Param("videoIds") String[] videoIds);
+
 }

+ 11 - 0
fs-service/src/main/java/com/fs/course/mapper/FsVideoResourceMapper.java

@@ -19,6 +19,11 @@ public interface FsVideoResourceMapper extends BaseMapper<FsVideoResource> {
      */
     List<FsVideoResourceVO> selectVideoResourceListByMap(@Param("params") Map<String, Object> params);
 
+    /**
+     * 公域:仅 cateType=1 下的分类所打标的素材
+     */
+    List<FsVideoResourceVO> selectPublicVideoResourceListByMap(@Param("params") Map<String, Object> params);
+
     @Select("select * from fs_video_resource where line1 is not null and is_transcode = 0 and is_del = 0")
     List<FsVideoResource> selectVideoNotTranscode();
 
@@ -37,4 +42,10 @@ public interface FsVideoResourceMapper extends BaseMapper<FsVideoResource> {
     FsVideoResource selectByFileKey(String fileKey);
 
     List<FsVideoResource> selectByIds(@Param("ids") long[] ids);
+
+    /**
+     * 素材库是否仍绑定该课堂分类(一级 type_id 或二级 type_sub_id)
+     */
+    @Select("SELECT COUNT(1) FROM fs_video_resource WHERE IFNULL(is_del,0) = 0 AND (type_id = #{cateId} OR type_sub_id = #{cateId})")
+    int countVideoResourceByCourseCategoryId(@Param("cateId") Long cateId);
 }

+ 28 - 0
fs-service/src/main/java/com/fs/course/param/FsCourseAnswerStatusQueryParam.java

@@ -0,0 +1,28 @@
+package com.fs.course.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 用户端答题状态查询(与 {@link FsCourseQuestionAnswerUParam} 维度字段对齐)。
+ */
+@Data
+public class FsCourseAnswerStatusQueryParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /** 当前登录用户,可由 Controller 写入 */
+    private Long userId;
+
+    private Long videoId;
+
+    private String qwUserId;
+
+    private Long companyUserId;
+
+    /** 短链等特殊场景与答题提交一致 */
+    private Integer linkType;
+
+    private Long periodId;
+}

+ 22 - 0
fs-service/src/main/java/com/fs/course/param/FsUserCourseCategoryAppQueryParam.java

@@ -24,6 +24,9 @@ public class FsUserCourseCategoryAppQueryParam implements Serializable {
     @ApiModelProperty(value = "分类名称(模糊)")
     private String cateName;
 
+    @ApiModelProperty(value = "搜索关键词")
+    private String keyword;
+
     @ApiModelProperty(value = "一级分类ID(父级 pid);只返回该一级下的二级;不传则返回所有「有公域课使用」的二级")
     private Long pid;
 
@@ -32,4 +35,23 @@ public class FsUserCourseCategoryAppQueryParam implements Serializable {
 
     @ApiModelProperty(value = "分类类型:1=公域课程分类,0=普通;不传时接口默认按1(公域)查询")
     private Integer cateType;
+
+    @ApiModelProperty(value = "原乡行标签:不传或0=只统计/展示下挂课程中无「原乡行」标签的;1=只统计/展示含「原乡行」标签的课程", example = "0")
+    private Integer yxxTag;
+
+    /**
+     * 与 {@link com.fs.course.service.impl.FsUserCourseCategoryServiceImpl#selectFsUserCourseCategoryAppList}
+     * 中默认入参(null 补全)一致,供缓存 key 使用。
+     */
+    public String appListCacheKey() {
+        int pn = pageNum != null ? pageNum : 1;
+        int ps = pageSize != null ? pageSize : 10;
+        int ct = cateType != null ? cateType : 1;
+        int yxx = yxxTag != null ? yxxTag : 0;
+        return pn + "|" + ps + "|" + n(cateName) + "|" + n(pid) + "|" + n(isShow) + "|" + ct + "|" + yxx;
+    }
+
+    private static String n(Object o) {
+        return o == null ? "null" : String.valueOf(o);
+    }
 }

+ 15 - 0
fs-service/src/main/java/com/fs/course/param/FsUserCoursePublicAppQueryParam.java

@@ -35,4 +35,19 @@ public class FsUserCoursePublicAppQueryParam implements Serializable {
      */
     @ApiModelProperty(value = "推荐位:1首页顶部 2商城首页 3首页长视频瀑布流;不传则不限")
     private Integer recommendSlot;
+
+    @ApiModelProperty(value = "原乡行标签:不传或0=只查无「原乡行」标签的公域课;1=只查带「原乡行」标签的", example = "0")
+    private Integer yxxTag;
+
+    /** 公域课列表缓存 key,与业务默认分页一致 */
+    public String appListCacheKey() {
+        int pn = pageNum != null ? pageNum : 1;
+        int ps = pageSize != null ? pageSize : 10;
+        int yxx = yxxTag != null ? yxxTag : 0;
+        return pn + "|" + ps + "|" + n(cateId) + "|" + n(subCateId) + "|" + n(keyword) + "|" + n(recommendSlot) + "|" + yxx;
+    }
+
+    private static String n(Object o) {
+        return o == null ? "null" : String.valueOf(o);
+    }
 }

+ 23 - 0
fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoBatchWatchIntegralParam.java

@@ -0,0 +1,23 @@
+package com.fs.course.param;
+
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+/**
+ * 批量更新课节:观看时长(分钟)、积分奖励
+ */
+@Data
+public class FsUserCourseVideoBatchWatchIntegralParam implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    private Long courseId;
+
+    private List<Long> videoIds;
+
+    private Integer watchDurationMinutes;
+
+    private Integer integralReward;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseListParam.java

@@ -18,6 +18,9 @@ public class FsUserCourseListParam {
     @ApiModelProperty(value = "公司id,不用传")
     private Long companyId;
 
+    @ApiModelProperty(value = "销售ID,不用传")
+    private Long companyUserId;
+
     private String corpId;
 
     private String qwUserId;

+ 11 - 0
fs-service/src/main/java/com/fs/course/service/IFsCompanyUserCourseFavoriteService.java

@@ -0,0 +1,11 @@
+package com.fs.course.service;
+
+import com.fs.course.vo.CompanyUserCourseFavoriteToggleVO;
+
+public interface IFsCompanyUserCourseFavoriteService {
+
+    /**
+     * 切换收藏:无记录则新增并 favorited=true;有记录则删除并 favorited=false
+     */
+    CompanyUserCourseFavoriteToggleVO toggle(Long companyId, Long companyUserId, Long periodId);
+}

+ 20 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseKeywordSearchLogService.java

@@ -0,0 +1,20 @@
+package com.fs.course.service;
+
+import com.fs.course.domain.FsCourseKeywordSearchLog;
+
+import java.util.List;
+
+public interface IFsCourseKeywordSearchLogService {
+
+    FsCourseKeywordSearchLog selectFsCourseKeywordSearchLogById(Long id);
+
+    List<FsCourseKeywordSearchLog> selectFsCourseKeywordSearchLogList(FsCourseKeywordSearchLog query);
+
+    int insertFsCourseKeywordSearchLog(FsCourseKeywordSearchLog data);
+
+    int updateFsCourseKeywordSearchLog(FsCourseKeywordSearchLog data);
+
+    int deleteFsCourseKeywordSearchLogById(Long id);
+
+    int deleteFsCourseKeywordSearchLogByIds(Long[] ids);
+}

+ 13 - 0
fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java

@@ -4,6 +4,7 @@ import com.fs.common.core.domain.R;
 import com.fs.course.domain.FsCourseQuestionBank;
 import com.fs.course.dto.FsCourseQuestionBankImportDTO;
 import com.fs.course.dto.ImportResultDTO;
+import com.fs.course.param.FsCourseAnswerStatusQueryParam;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
 import com.fs.course.param.LiveQuizSubmitUParam;
 
@@ -99,4 +100,16 @@ public interface IFsCourseQuestionBankService
      * 直播答题提交:校验直播间-题目关联、题库是否存在,按 course.config 决定是否跳过对错校验(与课程答题 submit 逻辑一致)。
      */
     R submitLiveQuiz(LiveQuizSubmitUParam param);
+
+    R courseAnswerForUserApp(FsCourseQuestionAnswerUParam param);
+
+    /**
+     * 查询用户端答题状态(与学习记录、看课记录、错题次数一致);内置 Redis 短锁。
+     */
+    R getCourseAnswerStatusForUserApp(FsCourseAnswerStatusQueryParam param);
+
+    /**
+     * 用户端:答题正确后领取本节配置的 integralReward 积分(与答题维度一致,Redis 唯一锁、幂等)。
+     */
+    R claimPublicCourseAnswerIntegralReward(FsCourseAnswerStatusQueryParam param);
 }

+ 22 - 0
fs-service/src/main/java/com/fs/course/service/IFsPublicCourseSearchKeywordStatService.java

@@ -0,0 +1,22 @@
+package com.fs.course.service;
+
+import com.fs.course.domain.FsPublicCourseSearchKeywordStat;
+import com.fs.course.vo.PublicCourseSearchKeywordStatExportVO;
+
+import java.util.List;
+
+/**
+ * 公域课程搜索关键词统计
+ */
+public interface IFsPublicCourseSearchKeywordStatService {
+
+    List<FsPublicCourseSearchKeywordStat> selectFsPublicCourseSearchKeywordStatList(FsPublicCourseSearchKeywordStat query);
+
+    List<PublicCourseSearchKeywordStatExportVO> selectExportList(FsPublicCourseSearchKeywordStat query);
+
+    /**
+     * 记录公域课程搜索关键词统计:
+     * 关键词存在则累加搜索次数/人数并更新关联课程数,不存在则新增。
+     */
+    void recordKeywordSearch(String keyword, int relatedCourseCount, Long userId);
+}

+ 12 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseCategoryService.java

@@ -7,6 +7,7 @@ import com.fs.course.domain.FsUserCourseCategory;
 import com.fs.course.dto.FsCourseCategoryImportDTO;
 import com.fs.course.param.FsUserCourseCategoryAppQueryParam;
 import com.fs.his.vo.OptionsVO;
+import com.github.pagehelper.PageInfo;
 
 /**
  * 课堂分类Service接口
@@ -37,6 +38,11 @@ public interface IFsUserCourseCategoryService
      */
     List<FsUserCourseCategory> selectFsUserCourseCategoryAppList(FsUserCourseCategoryAppQueryParam param);
 
+    /**
+     * 小程序端:课程分类分页(返回完整 PageInfo 以便缓存完整分页元数据)
+     */
+    PageInfo<FsUserCourseCategory> selectFsUserCourseCategoryAppPage(FsUserCourseCategoryAppQueryParam param);
+
     /**
      * 新增课堂分类
      *
@@ -78,6 +84,12 @@ public interface IFsUserCourseCategoryService
 
     List<OptionsVO> selectCateListByPid(Long pid);
 
+    /** 公域课一级分类(下拉,仅 cateType=1) */
+    List<OptionsVO> selectPublicUserCourseCategoryPidList();
+
+    /** 公域课某一级下的二级分类 */
+    List<OptionsVO> selectPublicCateListByPid(Long pid);
+
     /**
      * 课堂分类导入
      */

+ 6 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java

@@ -8,6 +8,7 @@ import com.fs.course.param.newfs.FsUserCourseListParam;
 import com.fs.course.vo.*;
 import com.fs.course.vo.newfs.FsUserCourseListVO;
 import com.fs.his.vo.OptionsVO;
+import com.github.pagehelper.PageInfo;
 
 import javax.validation.constraints.NotNull;
 import java.io.IOException;
@@ -90,6 +91,11 @@ public interface IFsUserCourseService {
      */
     List<FsUserCoursePublicAppVO> selectFsUserCoursePublicAppList(FsUserCoursePublicAppQueryParam param);
 
+    /**
+     * 小程序:公域课程分页(返回完整 PageInfo 以便缓存完整分页元数据)
+     */
+    PageInfo<FsUserCoursePublicAppVO> selectFsUserCoursePublicAppPage(FsUserCoursePublicAppQueryParam param);
+
     List<OptionsVO> selectFsUserCourseAllList();
 
     List<FsUserCourseListPVO> selectFsUserCourseListPVO(FsUserCourse param);

+ 5 - 0
fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java

@@ -61,6 +61,11 @@ public interface IFsUserCourseVideoService extends IService<FsUserCourseVideo> {
      */
     public int updateFsUserCourseVideo(FsUserCourseVideo fsUserCourseVideo);
 
+    /**
+     * 批量更新指定课节:观看时长(分钟)、积分奖励(需 courseId+videoIds 与库一致)
+     */
+    int batchUpdateWatchIntegral(FsUserCourseVideoBatchWatchIntegralParam param);
+
     public int updateFsUserCourseRedPage(FsUserCourseRedPageParam userCourseRedPageParam);
 
     public void sortCourseVideo(List<FsUserCourseVideo> list);

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

@@ -17,6 +17,8 @@ public interface IFsVideoResourceService extends IService<FsVideoResource> {
      */
     List<FsVideoResourceVO> selectVideoResourceListByMap(Map<String, Object> params);
 
+    List<FsVideoResourceVO> selectPublicVideoResourceListByMap(Map<String, Object> params);
+
     /**
      * 通过filekey查询数据信息
      *

+ 12 - 0
fs-service/src/main/java/com/fs/course/service/SearchStatSyncService.java

@@ -0,0 +1,12 @@
+package com.fs.course.service;
+
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+
+public interface SearchStatSyncService {
+
+    void refreshSearchStat();
+
+}

+ 39 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCompanyUserCourseFavoriteServiceImpl.java

@@ -0,0 +1,39 @@
+package com.fs.course.service.impl;
+
+import com.fs.common.exception.CustomException;
+import com.fs.common.utils.DateUtils;
+import com.fs.course.domain.FsCompanyUserCourseFavorite;
+import com.fs.course.mapper.FsCompanyUserCourseFavoriteMapper;
+import com.fs.course.service.IFsCompanyUserCourseFavoriteService;
+import com.fs.course.vo.CompanyUserCourseFavoriteToggleVO;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+@Service
+public class FsCompanyUserCourseFavoriteServiceImpl implements IFsCompanyUserCourseFavoriteService {
+
+    @Autowired
+    private FsCompanyUserCourseFavoriteMapper fsCompanyUserCourseFavoriteMapper;
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public CompanyUserCourseFavoriteToggleVO toggle(Long companyId, Long companyUserId, Long periodId) {
+        if (periodId == null) {
+            throw new CustomException("营期ID不能为空");
+        }
+        FsCompanyUserCourseFavorite exist =
+                fsCompanyUserCourseFavoriteMapper.selectByCompanyUserAndPeriod(companyUserId, periodId);
+        if (exist != null) {
+            fsCompanyUserCourseFavoriteMapper.deleteByCompanyUserAndPeriod(companyUserId, periodId);
+            return new CompanyUserCourseFavoriteToggleVO(false);
+        }
+        FsCompanyUserCourseFavorite row = new FsCompanyUserCourseFavorite();
+        row.setCompanyId(companyId);
+        row.setCompanyUserId(companyUserId);
+        row.setPeriodId(periodId);
+        row.setCreateTime(DateUtils.getNowDate());
+        fsCompanyUserCourseFavoriteMapper.insertFsCompanyUserCourseFavorite(row);
+        return new CompanyUserCourseFavoriteToggleVO(true);
+    }
+}

+ 48 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseKeywordSearchLogServiceImpl.java

@@ -0,0 +1,48 @@
+package com.fs.course.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.course.domain.FsCourseKeywordSearchLog;
+import com.fs.course.mapper.FsCourseKeywordSearchLogMapper;
+import com.fs.course.service.IFsCourseKeywordSearchLogService;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+
+@Service
+public class FsCourseKeywordSearchLogServiceImpl implements IFsCourseKeywordSearchLogService {
+
+    @Autowired
+    private FsCourseKeywordSearchLogMapper fsCourseKeywordSearchLogMapper;
+
+    @Override
+    public FsCourseKeywordSearchLog selectFsCourseKeywordSearchLogById(Long id) {
+        return fsCourseKeywordSearchLogMapper.selectFsCourseKeywordSearchLogById(id);
+    }
+
+    @Override
+    public List<FsCourseKeywordSearchLog> selectFsCourseKeywordSearchLogList(FsCourseKeywordSearchLog query) {
+        return fsCourseKeywordSearchLogMapper.selectFsCourseKeywordSearchLogList(query);
+    }
+
+    @Override
+    public int insertFsCourseKeywordSearchLog(FsCourseKeywordSearchLog data) {
+        data.setCreateTime(DateUtils.getNowDate());
+        return fsCourseKeywordSearchLogMapper.insertFsCourseKeywordSearchLog(data);
+    }
+
+    @Override
+    public int updateFsCourseKeywordSearchLog(FsCourseKeywordSearchLog data) {
+        return fsCourseKeywordSearchLogMapper.updateFsCourseKeywordSearchLog(data);
+    }
+
+    @Override
+    public int deleteFsCourseKeywordSearchLogById(Long id) {
+        return fsCourseKeywordSearchLogMapper.deleteFsCourseKeywordSearchLogById(id);
+    }
+
+    @Override
+    public int deleteFsCourseKeywordSearchLogByIds(Long[] ids) {
+        return fsCourseKeywordSearchLogMapper.deleteFsCourseKeywordSearchLogByIds(ids);
+    }
+}

+ 362 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java

@@ -5,6 +5,7 @@ import com.alibaba.fastjson.JSON;
 import com.alibaba.fastjson.JSONArray;
 import com.alibaba.fastjson.JSONObject;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.redis.RedisCache;
 import com.fs.common.exception.ServiceException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
@@ -13,14 +14,20 @@ import com.fs.course.domain.*;
 import com.fs.course.dto.FsCourseQuestionBankImportDTO;
 import com.fs.course.dto.ImportResultDTO;
 import com.fs.course.mapper.*;
+import com.fs.course.param.FsCourseAnswerStatusQueryParam;
 import com.fs.course.param.FsCourseQuestionAnswerUParam;
 import com.fs.course.param.LiveQuizSubmitUParam;
 import com.fs.course.service.IFsCourseQuestionBankService;
+import com.fs.course.vo.FsCourseAnswerStatusVO;
 import com.fs.course.util.CourseConfigUserAnswerExpose;
 import com.fs.live.mapper.LiveCourseQuestionRelMapper;
 import com.fs.his.domain.FsUser;
+import com.fs.his.domain.FsUserIntegralLogs;
+import com.fs.his.enums.FsUserIntegralLogTypeEnum;
+import com.fs.his.mapper.FsUserIntegralLogsMapper;
 import com.fs.his.mapper.FsUserMapper;
 import com.fs.his.service.IFsStorePaymentService;
+import com.fs.his.service.IFsUserIntegralLogsService;
 import com.fs.system.service.ISysConfigService;
 import lombok.Getter;
 import org.springframework.beans.factory.annotation.Autowired;
@@ -28,6 +35,7 @@ import org.springframework.beans.factory.annotation.Value;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 
+import java.util.concurrent.TimeUnit;
 import java.util.*;
 import java.util.function.BiConsumer;
 import java.util.function.Function;
@@ -64,8 +72,25 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
     private LiveCourseQuestionRelMapper liveCourseQuestionRelMapper;
     @Autowired
     private FsUserCourseCategoryMapper courseCategoryMapper;
+    @Autowired
+    private FsUserCourseStudyLogMapper fsUserCourseStudyLogMapper;
+    @Autowired
+    private FsUserIntegralLogsMapper fsUserIntegralLogsMapper;
+    @Autowired
+    private IFsUserIntegralLogsService fsUserIntegralLogsService;
+    @Autowired
+    private RedisCache redisCache;
     @Value("${cloud_host.company_name}")
     private String signProjectName;
+
+    /** 用户端(APP)答题:允许答错的上限次数(与 course.config 无关,固定写死) */
+    private static final int APP_USER_ANSWER_MAX_ATTEMPTS = 3;
+
+    private static final String REDIS_LOCK_APP_COURSE_ANSWER = "course:answer:userApp:lock:";
+
+    private static final String REDIS_LOCK_APP_COURSE_ANSWER_STATUS = "course:answer:userApp:status:lock:";
+
+    private static final String REDIS_LOCK_APP_COURSE_ANSWER_CLAIM = "course:answer:userApp:claim:lock:";
     /**
      * 查询题库
      *
@@ -558,6 +583,343 @@ public class FsCourseQuestionBankServiceImpl implements IFsCourseQuestionBankSer
         return R.ok("回答错误").put("correct", false);
     }
 
+    @Override
+    @Transactional
+    public R courseAnswerForUserApp(FsCourseQuestionAnswerUParam param) {
+        if (param == null || param.getUserId() == null || param.getVideoId() == null) {
+            return R.error("参数不完整");
+        }
+        String lockKey = REDIS_LOCK_APP_COURSE_ANSWER + param.getUserId() + ":" + param.getVideoId()
+                + (param.getPeriodId() != null ? ":" + param.getPeriodId() : "");
+        boolean locked = redisCache.setIfAbsent(lockKey, "1", 45L, TimeUnit.SECONDS);
+        if (!locked) {
+            return R.error("操作过于频繁,请稍后重试");
+        }
+        try {
+            return courseAnswerForUserAppLocked(param);
+        } finally {
+            redisCache.deleteObject(lockKey);
+        }
+    }
+
+    /**
+     * 历史上是否曾有答对记录(营期 / 短链维度与答题接口一致)。
+     */
+    private FsCourseAnswerLogs selectEverSucceededAnswerLogForApp(Long videoId, Long userId, String qwUserId, Long periodId, Integer linkType) {
+        if (linkType != null && linkType == 1) {
+            return courseAnswerLogsMapper.selectRightLogByCourseVideo(videoId, userId, null);
+        }
+        if (periodId != null) {
+            return courseAnswerLogsMapper.selectRightLogByCourseVideoWithPeriodId(videoId, userId, qwUserId, periodId);
+        }
+        return courseAnswerLogsMapper.selectRightLogByCourseVideo(videoId, userId, qwUserId);
+    }
+
+    private FsCourseWatchLog resolveCourseWatchLogForApp(Long videoId, Long userId, Long companyUserId, Long periodId) {
+        if (periodId != null) {
+            return courseWatchLogMapper.getWatchLogByFsUserAndPeriodId(videoId, userId, companyUserId, periodId);
+        }
+        return courseWatchLogMapper.getWatchLogByFsUser(videoId, userId, companyUserId);
+    }
+
+    /**
+     * 用户端答题核心逻辑(已由外层 Redis 唯一锁串行)。依赖 {@link FsUserCourseServiceImpl#addStudyCourse} 写入的学习小节记录。
+     */
+    private R courseAnswerForUserAppLocked(FsCourseQuestionAnswerUParam param) {
+        FsCourseAnswerLogs everRight = selectEverSucceededAnswerLogForApp(
+                param.getVideoId(), param.getUserId(), param.getQwUserId(), param.getPeriodId(), param.getLinkType());
+        if (everRight != null) {
+            return R.ok("你已完成过当前课程的答题");
+        }
+
+        FsUserCourseStudyLog studyRow = fsUserCourseStudyLogMapper.selectFsUserCourseStudyLogByVideoToday(param.getUserId(), param.getVideoId());
+        if (studyRow == null) {
+            return R.error("检测学习数据丢失,请联系管理处理");
+        }
+
+        int thisRightCount = 0;
+        int errorCount;
+        List<FsCourseQuestionBank> incorrectQuestions = new ArrayList<>();
+        Long logId = null;
+
+        if (param.getLinkType() != null && param.getLinkType() == 1) {
+            errorCount = courseAnswerLogsMapper.selectErrorCountByCourseVideoToday(param.getVideoId(), param.getUserId(), null, null);
+        } else {
+            FsCourseWatchLog courseWatchLog = resolveCourseWatchLogForApp(
+                    param.getVideoId(), param.getUserId(), param.getCompanyUserId(), param.getPeriodId());
+            if (courseWatchLog == null) {
+                return R.error("无看课记录");
+            }
+            if (courseWatchLog.getLogType() == null || courseWatchLog.getLogType() != 2) {
+                return R.error("未完课");
+            }
+            logId = courseWatchLog.getLogId();
+            errorCount = courseAnswerLogsMapper.selectErrorCountByCourseVideoToday(
+                    param.getVideoId(), param.getUserId(), param.getQwUserId(), courseWatchLog.getProject());
+        }
+
+        if (errorCount >= APP_USER_ANSWER_MAX_ATTEMPTS) {
+            return R.error("已达答题次数上限,最多可尝试" + APP_USER_ANSWER_MAX_ATTEMPTS + "次");
+        }
+        int remainCount = APP_USER_ANSWER_MAX_ATTEMPTS - errorCount - 1;
+
+        List<FsCourseQuestionBank> questions = param.getQuestions();
+        if (questions != null && !questions.isEmpty()) {
+//            if (skipAnswerValidation) {
+//                thisRightCount = questions.size();
+//            } else {
+                Map<Long, FsCourseQuestionBank> correctAnswersMap = fsCourseQuestionBankMapper.selectFsCourseQuestionBankByIds(
+                        questions.stream().map(FsCourseQuestionBank::getId).collect(Collectors.toList())
+                ).stream().collect(Collectors.toMap(FsCourseQuestionBank::getId, question -> question));
+
+                for (FsCourseQuestionBank questionBank : questions) {
+                    FsCourseQuestionBank correctAnswer = correctAnswersMap.get(questionBank.getId());
+                    if (correctAnswer == null || correctAnswer.getType() == null) {
+                        correctAnswer = new FsCourseQuestionBank();
+                        correctAnswer.setId(questionBank.getId());
+                        incorrectQuestions.add(correctAnswer);
+                        continue;
+                    }
+                    if (correctAnswer.getType() == 1) {
+                        if (questionBank.getAnswer() != null && questionBank.getAnswer().equals(correctAnswer.getAnswer())) {
+                            thisRightCount++;
+                        } else {
+                            correctAnswer.setAnswer(null);
+                            incorrectQuestions.add(correctAnswer);
+                        }
+                    } else if (correctAnswer.getType() == 2) {
+                        String[] userAnswers = convertStringToArray(questionBank.getAnswer());
+                        String[] correctAnswers = convertStringToArray(correctAnswer.getAnswer());
+
+                        Arrays.sort(userAnswers);
+                        Arrays.sort(correctAnswers);
+
+                        if (Arrays.equals(userAnswers, correctAnswers)) {
+                            thisRightCount++;
+                        } else {
+                            correctAnswer.setAnswer(null);
+                            incorrectQuestions.add(correctAnswer);
+                        }
+                    }
+                }
+//            }
+        }
+
+        FsCourseAnswerLogs logs = new FsCourseAnswerLogs();
+        logs.setWatchLogId(logId);
+        logs.setUserId(param.getUserId());
+        logs.setVideoId(param.getVideoId());
+        logs.setCourseId(param.getCourseId());
+        logs.setCompanyId(param.getCompanyId());
+        logs.setCompanyUserId(param.getCompanyUserId());
+        logs.setQwUserId(param.getQwUserId() != null ? param.getQwUserId() : null);
+        logs.setQuestionJson(JSONObject.toJSONString(param.getQuestions()));
+        logs.setCreateTime(new Date());
+        logs.setPeriodId(param.getPeriodId());
+
+        int questionCount = questions == null ? 0 : questions.size();
+        if (thisRightCount == questionCount) {
+            logs.setIsRight(1);
+            courseAnswerLogsMapper.insertFsCourseAnswerLogs(logs);
+            return R.ok("答题成功");
+        }
+        logs.setIsRight(0);
+        courseAnswerLogsMapper.insertFsCourseAnswerLogs(logs);
+        return R.ok("答题失败").put("incorrectQuestions", incorrectQuestions).put("remain", remainCount);
+    }
+
+    @Override
+    public R getCourseAnswerStatusForUserApp(FsCourseAnswerStatusQueryParam param) {
+        if (param == null || param.getUserId() == null || param.getVideoId() == null) {
+            return R.error("参数不完整");
+        }
+        String lockKey = REDIS_LOCK_APP_COURSE_ANSWER_STATUS + param.getUserId() + ":" + param.getVideoId()
+                + (param.getPeriodId() != null ? ":" + param.getPeriodId() : "");
+        boolean locked = redisCache.setIfAbsent(lockKey, "1", 30L, TimeUnit.SECONDS);
+        if (!locked) {
+            return R.error("操作过于频繁,请稍后重试");
+        }
+        try {
+            FsCourseAnswerStatusVO vo = buildCourseAnswerStatusForUserApp(param);
+            return R.ok().put("data", vo);
+        } finally {
+            redisCache.deleteObject(lockKey);
+        }
+    }
+
+    @Override
+    @Transactional(rollbackFor = Exception.class)
+    public R claimPublicCourseAnswerIntegralReward(FsCourseAnswerStatusQueryParam param) {
+        if (param == null || param.getUserId() == null || param.getVideoId() == null) {
+            return R.error("参数不完整");
+        }
+        String lockKey = REDIS_LOCK_APP_COURSE_ANSWER_CLAIM + param.getUserId() + ":" + param.getVideoId()
+                + (param.getPeriodId() != null ? ":" + param.getPeriodId() : "");
+        boolean locked = redisCache.setIfAbsent(lockKey, "1", 45L, TimeUnit.SECONDS);
+        if (!locked) {
+            return R.error("操作过于频繁,请稍后重试");
+        }
+        try {
+            FsCourseAnswerLogs everRight = selectEverSucceededAnswerLogForApp(
+                    param.getVideoId(), param.getUserId(), param.getQwUserId(), param.getPeriodId(), param.getLinkType());
+            if (everRight != null) {
+                return R.ok("你已完成过当前课程的答题");
+            }
+
+            FsUserCourseStudyLog studyRow = fsUserCourseStudyLogMapper.selectFsUserCourseStudyLogByVideoToday(param.getUserId(), param.getVideoId());
+            if (studyRow == null) {
+                return R.error("检测学习数据丢失,请联系管理处理");
+            }
+
+            FsCourseAnswerLogs rightLog;
+            if (param.getLinkType() != null && param.getLinkType() == 1) {
+                rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideo(param.getVideoId(), param.getUserId(), null);
+            } else {
+                FsCourseWatchLog watchLog = resolveCourseWatchLogForApp(
+                        param.getVideoId(), param.getUserId(), param.getCompanyUserId(), param.getPeriodId());
+                if (watchLog == null) {
+                    return R.error("无看课记录");
+                }
+                if (watchLog.getLogType() == null || watchLog.getLogType() != 2) {
+                    return R.error("未完课");
+                }
+                if (param.getPeriodId() != null) {
+                    rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideoWithPeriodId(
+                            param.getVideoId(), param.getUserId(), param.getQwUserId(), param.getPeriodId());
+                } else {
+                    rightLog = courseAnswerLogsMapper.selectRightLogByCourseVideo(param.getVideoId(), param.getUserId(), param.getQwUserId());
+                }
+            }
+
+            if (rightLog == null) {
+                return R.error("尚未答题正确,无法领取积分");
+            }
+
+            FsUserCourseVideo video = courseVideoMapper.selectFsUserCourseVideoByVideoId(param.getVideoId());
+            if (video == null) {
+                return R.error("视频不存在");
+            }
+            Integer rewardPoints = video.getIntegralReward();
+            if (rewardPoints == null || rewardPoints <= 0) {
+                return R.error("本节未配置答题积分或未开启");
+            }
+
+            String businessId = resolveAnswerIntegralBusinessId(param.getVideoId(), param.getPeriodId());
+            FsUserIntegralLogs existed = fsUserIntegralLogsMapper.selectAnswerRewardIntegralLog(param.getUserId(), businessId);
+            if (existed != null) {
+                return R.error("答题积分已领取");
+            }
+
+            FsUser user = fsUserMapper.selectFsUserByUserId(param.getUserId());
+            if (user == null) {
+                return R.error("用户不存在");
+            }
+
+            long baseIntegral = user.getIntegral() != null ? user.getIntegral() : 0L;
+            long reward = rewardPoints.longValue();
+            long newBalance = baseIntegral + reward;
+
+            FsUser upd = new FsUser();
+            upd.setUserId(param.getUserId());
+            upd.setIntegral(newBalance);
+            fsUserMapper.updateFsUser(upd);
+
+            FsUserIntegralLogs integralLogs = new FsUserIntegralLogs();
+            integralLogs.setIntegral(reward);
+            integralLogs.setUserId(param.getUserId());
+            integralLogs.setBalance(newBalance);
+            integralLogs.setLogType(FsUserIntegralLogTypeEnum.TYPE_17.getValue());
+            integralLogs.setBusinessId(businessId);
+            integralLogs.setRemark(FsUserIntegralLogTypeEnum.TYPE_17.getDesc());
+            integralLogs.setCreateTime(new Date());
+            fsUserIntegralLogsService.insertFsUserIntegralLogs(integralLogs);
+
+            return R.ok("领取成功").put("integral", rewardPoints).put("balance", newBalance);
+        } finally {
+            redisCache.deleteObject(lockKey);
+        }
+    }
+
+    private static String resolveAnswerIntegralBusinessId(Long videoId, Long periodId) {
+        if (periodId != null) {
+            return videoId + ":p:" + periodId;
+        }
+        return String.valueOf(videoId);
+    }
+
+    /**
+     * 与 {@link #courseAnswerForUserAppLocked} 前置条件、错题统计、是否已有正确记录判定保持一致。
+     */
+    private FsCourseAnswerStatusVO buildCourseAnswerStatusForUserApp(FsCourseAnswerStatusQueryParam param) {
+        FsCourseAnswerStatusVO vo = new FsCourseAnswerStatusVO();
+        vo.setMaxAttempts(APP_USER_ANSWER_MAX_ATTEMPTS);
+
+        FsCourseAnswerLogs everRight = selectEverSucceededAnswerLogForApp(
+                param.getVideoId(), param.getUserId(), param.getQwUserId(), param.getPeriodId(), param.getLinkType());
+        if (everRight != null) {
+            fillCompleted(vo);
+            return vo;
+        }
+
+        FsUserCourseStudyLog studyRow = fsUserCourseStudyLogMapper.selectFsUserCourseStudyLogByVideoToday(param.getUserId(), param.getVideoId());
+        if (studyRow == null) {
+            vo.setStatus(-1);
+            vo.setStatusText("检测学习数据丢失,请联系管理处理");
+            return vo;
+        }
+
+        if (param.getLinkType() != null && param.getLinkType() == 1) {
+            int errorCount = courseAnswerLogsMapper.selectErrorCountByCourseVideoToday(param.getVideoId(), param.getUserId(), null, null);
+            vo.setErrorCount(errorCount);
+            fillProgressByErrorCount(vo, errorCount);
+            return vo;
+        }
+
+        FsCourseWatchLog watchLog = resolveCourseWatchLogForApp(
+                param.getVideoId(), param.getUserId(), param.getCompanyUserId(), param.getPeriodId());
+        if (watchLog == null) {
+            vo.setStatus(-2);
+            vo.setStatusText("无看课记录");
+            return vo;
+        }
+        if (watchLog.getLogType() == null || watchLog.getLogType() != 2) {
+            vo.setStatus(-3);
+            vo.setStatusText("未完课");
+            return vo;
+        }
+
+        int errorCount = courseAnswerLogsMapper.selectErrorCountByCourseVideoToday(
+                param.getVideoId(), param.getUserId(), param.getQwUserId(), watchLog.getProject());
+        vo.setErrorCount(errorCount);
+        fillProgressByErrorCount(vo, errorCount);
+        return vo;
+    }
+
+    private static void fillCompleted(FsCourseAnswerStatusVO vo) {
+        vo.setStatus(4);
+        vo.setStatusText("已完成答题");
+        vo.setErrorCount(null);
+        vo.setRemainWrongAttempts(0);
+    }
+
+    private void fillProgressByErrorCount(FsCourseAnswerStatusVO vo, int errorCount) {
+        if (errorCount >= APP_USER_ANSWER_MAX_ATTEMPTS) {
+            vo.setStatus(3);
+            vo.setStatusText("答题达到上限");
+            vo.setRemainWrongAttempts(0);
+            return;
+        }
+        if (errorCount == 0) {
+            vo.setStatus(1);
+            vo.setStatusText("未答题");
+            vo.setRemainWrongAttempts(APP_USER_ANSWER_MAX_ATTEMPTS);
+            return;
+        }
+        vo.setStatus(2);
+        vo.setStatusText("可以答题");
+        vo.setRemainWrongAttempts(APP_USER_ANSWER_MAX_ATTEMPTS - errorCount);
+    }
+
     /**
      * 与 {@link #courseAnswer} 中单选/多选判分规则一致。
      */

+ 1 - 1
fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java

@@ -1875,7 +1875,7 @@ public class FsCourseWatchLogServiceImpl extends ServiceImpl<FsCourseWatchLogMap
                 vo.setPaidConversionRate20Min(BigDecimal.valueOf(paidUserCount * 100.0 / vo.getTotalCompleteCount()).setScale(2, RoundingMode.HALF_UP));
             }
             if (vo.getTotalCompleteCount() != null && vo.getTotalCompleteCount() > 0 && gmv != null && gmv.compareTo(BigDecimal.ZERO) > 0) {
-                vo.setCompleteRValue(gmv.divide(BigDecimal.valueOf(vo.getTotalCompleteCount()), 2, RoundingMode.HALF_UP));
+                vo.setCompleteRValue(gmv.divide(BigDecimal.valueOf(vo.getFsActualCompletionVO().getCompletedCount()), 2, RoundingMode.HALF_UP));
             }
 
             Long answerCount = fsCourseAnswerLogsMapper.countDistinctUsersByVideoAndPeriod(videoId, periodId,companyId,companyUserId);

+ 74 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsPublicCourseSearchKeywordStatServiceImpl.java

@@ -0,0 +1,74 @@
+package com.fs.course.service.impl;
+
+import com.fs.common.utils.RedisUtil;
+import com.fs.course.constant.SearchRedisConstants;
+import com.fs.course.domain.FsPublicCourseSearchKeywordStat;
+import com.fs.course.mapper.FsPublicCourseSearchKeywordStatMapper;
+import com.fs.course.service.IFsCourseKeywordSearchLogService;
+import com.fs.course.service.IFsPublicCourseSearchKeywordStatService;
+import com.fs.course.vo.PublicCourseSearchKeywordStatExportVO;
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.domain.FsCourseKeywordSearchLog;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Service
+public class FsPublicCourseSearchKeywordStatServiceImpl implements IFsPublicCourseSearchKeywordStatService {
+
+    @Autowired
+    private FsPublicCourseSearchKeywordStatMapper fsPublicCourseSearchKeywordStatMapper;
+    @Autowired
+    private IFsCourseKeywordSearchLogService fsCourseKeywordSearchLogService;
+
+    @Autowired
+    private RedisUtil redisCache;
+
+    @Override
+    public List<FsPublicCourseSearchKeywordStat> selectFsPublicCourseSearchKeywordStatList(FsPublicCourseSearchKeywordStat query) {
+        return fsPublicCourseSearchKeywordStatMapper.selectFsPublicCourseSearchKeywordStatList(query);
+    }
+
+    @Override
+    public List<PublicCourseSearchKeywordStatExportVO> selectExportList(FsPublicCourseSearchKeywordStat query) {
+        List<FsPublicCourseSearchKeywordStat> rows =
+                fsPublicCourseSearchKeywordStatMapper.selectFsPublicCourseSearchKeywordStatList(query);
+        List<PublicCourseSearchKeywordStatExportVO> out = new ArrayList<>(rows.size());
+        int n = 1;
+        for (FsPublicCourseSearchKeywordStat r : rows) {
+            PublicCourseSearchKeywordStatExportVO vo = new PublicCourseSearchKeywordStatExportVO();
+            vo.setRowNum(n++);
+            vo.setKeyword(r.getKeyword());
+            vo.setSearchPv(r.getSearchPv());
+            vo.setSearchUv(r.getSearchUv());
+            vo.setRelatedCourseCount(r.getRelatedCourseCount());
+            out.add(vo);
+        }
+        return out;
+    }
+
+    @Override
+    public void recordKeywordSearch(String keyword, int relatedCourseCount, Long userId) {
+        if (StringUtils.isEmpty(keyword)) {
+            return;
+        }
+        String keywordTrimmed = keyword.trim();
+        if (keywordTrimmed.isEmpty()) {
+            return;
+        }
+
+        // 1. 写搜索日志表(保留,这是数据源)
+        FsCourseKeywordSearchLog log = new FsCourseKeywordSearchLog();
+        log.setKeyword(keywordTrimmed);
+        log.setUserId(userId);
+        log.setCreateTime(DateUtils.getNowDate());
+        fsCourseKeywordSearchLogService.insertFsCourseKeywordSearchLog(log);
+
+        // 2. Redis PV 自增(供可选实时读取);UV 仅按日志由定时/手动同步写入聚合表 + SEARCH_STAT_SYNCED_USERS,避免与同步任务共用同一 Set 导致 newUv 恒为 0
+        String pvKey = SearchRedisConstants.SEARCH_STAT_PV_INCREMENT + keywordTrimmed;
+        redisCache.increment(pvKey);
+    }
+}

+ 29 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseCategoryServiceImpl.java

@@ -6,10 +6,14 @@ import java.util.stream.Collectors;
 import com.fs.common.exception.CustomException;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.StringUtils;
+import com.fs.course.cache.PublicCourseAppCacheNames;
 import com.fs.course.dto.FsCourseCategoryImportDTO;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
 import com.fs.his.vo.OptionsVO;
 import lombok.extern.slf4j.Slf4j;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.Cacheable;
 import org.springframework.stereotype.Service;
 import com.fs.course.mapper.FsUserCourseCategoryMapper;
 import com.fs.course.domain.FsUserCourseCategory;
@@ -65,6 +69,21 @@ public class FsUserCourseCategoryServiceImpl implements IFsUserCourseCategorySer
         return fsUserCourseCategoryMapper.selectFsUserCourseCategoryAppList(param);
     }
 
+    @Override
+    @Cacheable(
+            value = PublicCourseAppCacheNames.CATEGORY_APP_LIST,
+            key = "#param == null ? 'default' : #param.appListCacheKey()")
+    public PageInfo<FsUserCourseCategory> selectFsUserCourseCategoryAppPage(FsUserCourseCategoryAppQueryParam param) {
+        if (param == null) {
+            param = new FsUserCourseCategoryAppQueryParam();
+        }
+        int pageNum = param.getPageNum() == null || param.getPageNum() < 1 ? 1 : param.getPageNum();
+        int pageSize = param.getPageSize() == null || param.getPageSize() < 1 ? 10 : param.getPageSize();
+        PageHelper.startPage(pageNum, pageSize, false);
+        List<FsUserCourseCategory> list = this.selectFsUserCourseCategoryAppList(param);
+        return new PageInfo<>(list);
+    }
+
     /**
      * 新增课堂分类
      *
@@ -139,6 +158,16 @@ public class FsUserCourseCategoryServiceImpl implements IFsUserCourseCategorySer
         return fsUserCourseCategoryMapper.selectCateListByPid(pid);
     }
 
+    @Override
+    public List<OptionsVO> selectPublicUserCourseCategoryPidList() {
+        return fsUserCourseCategoryMapper.selectPublicUserCourseCategoryPidList();
+    }
+
+    @Override
+    public List<OptionsVO> selectPublicCateListByPid(Long pid) {
+        return fsUserCourseCategoryMapper.selectPublicCateListByPid(pid);
+    }
+
     /**
      * 课堂分类导入
      */

+ 19 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java

@@ -23,6 +23,7 @@ import com.fs.company.domain.CompanyTag;
 import com.fs.company.domain.CompanyUser;
 import com.fs.company.mapper.CompanyTagMapper;
 import com.fs.company.mapper.CompanyUserMapper;
+import com.fs.course.cache.PublicCourseAppCacheNames;
 import com.fs.course.config.CourseConfig;
 import com.fs.course.domain.*;
 import com.fs.course.dto.BatchSendCourseDTO;
@@ -57,10 +58,13 @@ import com.google.zxing.EncodeHintType;
 import com.google.zxing.client.j2se.MatrixToImageWriter;
 import com.google.zxing.common.BitMatrix;
 import com.google.zxing.qrcode.QRCodeWriter;
+import com.github.pagehelper.PageHelper;
+import com.github.pagehelper.PageInfo;
 import lombok.extern.slf4j.Slf4j;
 import org.checkerframework.checker.units.qual.A;
 import org.springframework.beans.BeanUtils;
 import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.cache.annotation.Cacheable;
 import org.springframework.context.annotation.Lazy;
 import org.springframework.stereotype.Service;
 import com.fs.course.service.IFsUserCourseService;
@@ -289,6 +293,21 @@ public class FsUserCourseServiceImpl implements IFsUserCourseService
         return fsUserCourseMapper.selectFsUserCoursePublicAppList(param);
     }
 
+    @Override
+    @Cacheable(
+            value = PublicCourseAppCacheNames.COURSE_PUBLIC_APP_LIST,
+            key = "#param == null ? 'default' : #param.appListCacheKey()")
+    public PageInfo<FsUserCoursePublicAppVO> selectFsUserCoursePublicAppPage(FsUserCoursePublicAppQueryParam param) {
+        if (param == null) {
+            param = new FsUserCoursePublicAppQueryParam();
+        }
+        int pageNum = param.getPageNum() == null || param.getPageNum() < 1 ? 1 : param.getPageNum();
+        int pageSize = param.getPageSize() == null || param.getPageSize() < 1 ? 10 : param.getPageSize();
+        PageHelper.startPage(pageNum, pageSize);
+        List<FsUserCoursePublicAppVO> list = this.selectFsUserCoursePublicAppList(param);
+        return new PageInfo<>(list);
+    }
+
     @Override
     public List<FsUserCourseListUVO> selectFsUserCourseCommentListUVO(FsUserCourseListUParam param) {
         return fsUserCourseMapper.selectFsUserCourseCommentListUVO(param);

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

@@ -335,6 +335,22 @@ public class FsUserCourseVideoServiceImpl extends ServiceImpl<FsUserCourseVideoM
         return fsUserCourseVideoMapper.updateFsUserCourseVideo(fsUserCourseVideo);
     }
 
+    @Override
+    public int batchUpdateWatchIntegral(FsUserCourseVideoBatchWatchIntegralParam param) {
+        if (param == null || param.getCourseId() == null
+                || param.getVideoIds() == null || param.getVideoIds().isEmpty()) {
+            return 0;
+        }
+        if (param.getWatchDurationMinutes() == null || param.getIntegralReward() == null) {
+            return 0;
+        }
+        return fsUserCourseVideoMapper.batchUpdateWatchIntegralByVideoIds(
+                param.getCourseId(),
+                param.getVideoIds(),
+                param.getWatchDurationMinutes(),
+                param.getIntegralReward());
+    }
+
     @Override
     public int updateFsUserCourseRedPage(FsUserCourseRedPageParam userCourseRedPageParam) {
 

+ 5 - 0
fs-service/src/main/java/com/fs/course/service/impl/FsVideoResourceServiceImpl.java

@@ -25,6 +25,11 @@ public class FsVideoResourceServiceImpl extends ServiceImpl<FsVideoResourceMappe
         return baseMapper.selectVideoResourceListByMap(params);
     }
 
+    @Override
+    public List<FsVideoResourceVO> selectPublicVideoResourceListByMap(Map<String, Object> params) {
+        return baseMapper.selectPublicVideoResourceListByMap(params);
+    }
+
     /**
      * 通过fileKey查询数据信息
      *

+ 186 - 0
fs-service/src/main/java/com/fs/course/service/impl/SearchStatSyncServiceImpl.java

@@ -0,0 +1,186 @@
+package com.fs.course.service.impl;
+
+import com.fs.common.utils.DateUtils;
+import com.fs.common.utils.RedisUtil;
+import com.fs.common.utils.StringUtils;
+import com.fs.course.constant.SearchRedisConstants;
+import com.fs.course.domain.FsPublicCourseSearchKeywordStat;
+import com.fs.course.domain.FsUserCourseCategory;
+import com.fs.course.mapper.FsCourseKeywordSearchLogMapper;
+import com.fs.course.mapper.FsPublicCourseSearchKeywordStatMapper;
+import com.fs.course.param.FsUserCourseCategoryAppQueryParam;
+import com.fs.course.param.FsUserCoursePublicAppQueryParam;
+import com.fs.course.service.IFsUserCourseCategoryService;
+import com.fs.course.service.IFsUserCourseService;
+import com.fs.course.service.SearchStatSyncService;
+import com.fs.course.vo.FsUserCoursePublicAppVO;
+import com.github.pagehelper.PageInfo;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+@Service
+@Slf4j
+public class SearchStatSyncServiceImpl implements SearchStatSyncService {
+    @Autowired
+    private RedisUtil redisCache;
+
+    @Autowired
+    private FsCourseKeywordSearchLogMapper fsCourseKeywordSearchLogMapper;
+
+    @Autowired
+    private FsPublicCourseSearchKeywordStatMapper fsPublicCourseSearchKeywordStatMapper;
+
+
+    @Autowired
+    private IFsUserCourseService courseService;
+
+    /**
+     * 与小程序 {@code CourseController#listPublicCourseCategory} 一致:按 {@link FsUserCourseCategoryAppQueryParam#setCateName}
+     * 查询公域分类分页,取 {@link PageInfo#getTotal()} 作为关联维度总数写入 {@link FsPublicCourseSearchKeywordStat#setRelatedCourseCount}。
+     */
+    private int resolveRelatedCourseCountLikePublicCourseCategoryList(String keyword) {
+        if (StringUtils.isEmpty(keyword)) {
+            return 0;
+        }
+        FsUserCoursePublicAppQueryParam q = new FsUserCoursePublicAppQueryParam();
+        q.setKeyword(keyword.trim());
+        q.setPageNum(1);
+        q.setPageSize(1);
+        try {
+            PageInfo<FsUserCoursePublicAppVO> pageInfo = courseService.selectFsUserCoursePublicAppPage(q);
+            long total = pageInfo != null ? pageInfo.getTotal() : 0L;
+            if (total > Integer.MAX_VALUE) {
+                return Integer.MAX_VALUE;
+            }
+            return (int) total;
+        } catch (Exception e) {
+            log.warn("解析关键词关联公域分类总数失败 keyword={}", keyword, e);
+            return 0;
+        }
+    }
+
+    /**
+     * 刷新搜索统计数据(定时任务 & 手动按钮 共用)
+     */
+    @Transactional(rollbackFor = Exception.class)
+    public synchronized void refreshSearchStat() {
+        Date now = DateUtils.getNowDate();
+
+        Boolean lockKey = redisCache.setIfAbsent(SearchRedisConstants.SEARCH_STAT_LAST_SYNC_STATUS, "1", 30, TimeUnit.SECONDS);
+        if (!lockKey) {
+            return;
+        }
+
+        // 1. 获取上次同步时间
+        String lastSyncTimeStr = redisCache.getString(SearchRedisConstants.SEARCH_STAT_LAST_SYNC_TIME);
+        Date lastSyncTime;
+        if (StringUtils.isEmpty(lastSyncTimeStr)) {
+            // 首次同步,默认查最近1小时(可根据业务调整)
+            lastSyncTime = new Date(now.getTime() - 3600_000L);
+        } else {
+            lastSyncTime = new Date(Long.parseLong(lastSyncTimeStr));
+        }
+
+        log.info("开始刷新搜索统计,时间范围:{} ~ {}", lastSyncTime, now);
+
+        // 2. 查询增量PV(按keyword分组)
+        List<Map<String, Object>> pvList = fsCourseKeywordSearchLogMapper
+                .selectPvGroupByKeyword(lastSyncTime, now);
+
+        if (pvList == null || pvList.isEmpty()) {
+            log.info("该时间段无新增搜索记录,跳过同步");
+            // 即使没有数据也要更新同步时间
+            redisCache.setString(SearchRedisConstants.SEARCH_STAT_LAST_SYNC_TIME,
+                    String.valueOf(now.getTime()));
+            return;
+        }
+
+        // 3. 查询增量用户(按keyword + userId分组)
+        List<Map<String, Object>> uvList = fsCourseKeywordSearchLogMapper
+                .selectDistinctUserByKeyword(lastSyncTime, now);
+
+        // 构建 keyword -> Set<userId> 的映射(增量中的去重用户)
+        Map<String, Set<String>> incrementalUvMap = new HashMap<>();
+        if (uvList != null) {
+            for (Map<String, Object> map : uvList) {
+                String kwRaw = (String) map.get("keyword");
+                String kw = kwRaw != null ? kwRaw.trim() : "";
+                String userId = String.valueOf(map.get("user_id"));
+                incrementalUvMap.computeIfAbsent(kw, k -> new HashSet<>()).add(userId);
+            }
+        }
+
+        // 4. 逐个keyword处理
+        for (Map<String, Object> pvMap : pvList) {
+            String kwRaw = (String) pvMap.get("keyword");
+            String keyword = kwRaw != null ? kwRaw.trim() : "";
+            if (keyword.isEmpty()) {
+                continue;
+            }
+            long incrementalPv = ((Number) pvMap.get("pv")).longValue();
+
+            // 获取增量中去重的用户集合
+            Set<String> incrementalUsers = incrementalUvMap.getOrDefault(keyword, Collections.emptySet());
+
+            // 从Redis获取该keyword已写入聚合 UV 的用户集合(仅同步任务写入,与实时搜索分离)
+            String syncedUsersKey = SearchRedisConstants.SEARCH_STAT_SYNCED_USERS + keyword;
+            Set<String> alreadySyncedUsers = redisCache.getSetMembers(syncedUsersKey);
+            if (alreadySyncedUsers == null) {
+                alreadySyncedUsers = Collections.emptySet();
+            }
+
+            // 计算新增UV:在增量中但不在已统计集合中的用户
+            Set<String> newUsers = new HashSet<>(incrementalUsers);
+            newUsers.removeAll(alreadySyncedUsers);
+            long newUv = newUsers.size();
+
+            // 更新统计表
+            FsPublicCourseSearchKeywordStat old = fsPublicCourseSearchKeywordStatMapper
+                    .selectByKeyword(keyword);
+
+            int relatedCourseCount = resolveRelatedCourseCountLikePublicCourseCategoryList(keyword);
+
+            if (old == null) {
+                // 首次插入
+                FsPublicCourseSearchKeywordStat stat = new FsPublicCourseSearchKeywordStat();
+                stat.setKeyword(keyword);
+                stat.setSearchPv(incrementalPv);
+                stat.setSearchUv(newUv);
+                stat.setRelatedCourseCount(relatedCourseCount);
+                stat.setCreateTime(DateUtils.getNowDate());
+                stat.setUpdateTime(DateUtils.getNowDate());
+                fsPublicCourseSearchKeywordStatMapper.insertFsPublicCourseSearchKeywordStat(stat);
+            } else {
+                // 累加更新
+                old.setSearchPv(old.getSearchPv() + incrementalPv);
+                old.setSearchUv(old.getSearchUv() + newUv);
+                old.setRelatedCourseCount(relatedCourseCount);
+                old.setUpdateTime(DateUtils.getNowDate());
+                fsPublicCourseSearchKeywordStatMapper.updateFsPublicCourseSearchKeywordStat(old);
+            }
+
+            // 将新用户加入Redis已统计集合
+            if (!newUsers.isEmpty()) {
+                redisCache.addToSet(syncedUsersKey, newUsers.toArray(new String[0]));
+            }
+
+            // 清除Redis中的PV增量缓存(已经同步到DB了)
+            String pvIncrementKey = SearchRedisConstants.SEARCH_STAT_PV_INCREMENT + keyword;
+            redisCache.delete(pvIncrementKey);
+
+            log.info("关键词[{}]:增量PV={}, 新增UV={}, relatedCourseCount={}, 已同步用户数={}",
+                    keyword, incrementalPv, newUv, relatedCourseCount, alreadySyncedUsers.size() + newUsers.size());
+        }
+
+        // 5. 更新最后同步时间
+        redisCache.setString(SearchRedisConstants.SEARCH_STAT_LAST_SYNC_TIME,
+                String.valueOf(now.getTime()));
+        redisCache.delete(SearchRedisConstants.SEARCH_STAT_LAST_SYNC_STATUS);
+        log.info("搜索统计刷新完成,同步时间更新至:{}", now);
+    }
+}

+ 20 - 0
fs-service/src/main/java/com/fs/course/vo/CompanyUserCourseFavoriteToggleVO.java

@@ -0,0 +1,20 @@
+package com.fs.course.vo;
+
+import io.swagger.annotations.ApiModel;
+import io.swagger.annotations.ApiModelProperty;
+import lombok.AllArgsConstructor;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+/**
+ * 销售收藏课程切换结果
+ */
+@Data
+@NoArgsConstructor
+@AllArgsConstructor
+@ApiModel("销售课程收藏切换结果")
+public class CompanyUserCourseFavoriteToggleVO {
+
+    @ApiModelProperty("当前是否已收藏 true=已收藏 false=已取消")
+    private boolean favorited;
+}

+ 33 - 0
fs-service/src/main/java/com/fs/course/vo/FsCourseAnswerStatusVO.java

@@ -0,0 +1,33 @@
+package com.fs.course.vo;
+
+import lombok.Data;
+
+import java.io.Serializable;
+
+/**
+ * 用户端答题状态:与 {@link com.fs.course.service.impl.FsCourseQuestionBankServiceImpl#courseAnswerForUserAppLocked} 判定一致。
+ * <p>
+ * status:1 未答题(无错题记录且未完成);2 可以答题(仍有次数且未完成);
+ * 3 答题达到上限;4 已完成答题。负数为前置条件不满足。
+ */
+@Data
+public class FsCourseAnswerStatusVO implements Serializable {
+
+    private static final long serialVersionUID = 1L;
+
+    /**
+     * 1 未答题 2 可以答题 3 达到上限 4 已完成;-1 学习记录缺失;-2 无看课记录;-3 未完课
+     */
+    private Integer status;
+
+    private String statusText;
+
+    /** 当前已答错次数(与统计 SQL 一致) */
+    private Integer errorCount;
+
+    /** 剩余可答错次数(在达上限前) */
+    private Integer remainWrongAttempts;
+
+    /** 答题尝试上限(与实现中写死常量一致) */
+    private Integer maxAttempts;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserCoursePublicAppVO.java

@@ -27,6 +27,9 @@ public class FsUserCoursePublicAppVO implements Serializable {
     @ApiModelProperty("课程封面")
     private String imgUrl;
 
+    @ApiModelProperty("课程简介")
+    private String courseDesc;
+
     @ApiModelProperty("小封面")
     private String secondImg;
 

+ 3 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserCourseStudyListUVO.java

@@ -26,6 +26,9 @@ public class FsUserCourseStudyListUVO extends BaseEntity
     /** 课程名称 */
     private String courseName;
 
+    /** 课程描述 */
+    private String courseDesc;
+
     /** 课程封面 */
     private String imgUrl;
 

+ 5 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoListUVO.java

@@ -65,6 +65,11 @@ public class FsUserCourseVideoListUVO extends BaseEntity
 
     private String packageJson;
 
+    /** 观看时长(分钟),用户在页面需停留时间 */
+    private Integer watchDurationMinutes;
+
+    /** 积分奖励 */
+    private Integer integralReward;
 
 
 

+ 6 - 0
fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoQVO.java

@@ -59,6 +59,12 @@ public class FsUserCourseVideoQVO extends BaseEntity {
     @Excel(name = "课程排序")
     private Long courseSort;
 
+    /** 观看时长(分钟) */
+    private Integer watchDurationMinutes;
+
+    /** 积分奖励 */
+    private Integer integralReward;
+
     private String fileName;
 
     private Integer isDel;

+ 26 - 0
fs-service/src/main/java/com/fs/course/vo/PublicCourseSearchKeywordStatExportVO.java

@@ -0,0 +1,26 @@
+package com.fs.course.vo;
+
+import com.fs.common.annotation.Excel;
+import lombok.Data;
+
+/**
+ * 课程搜索管理 - 导出
+ */
+@Data
+public class PublicCourseSearchKeywordStatExportVO {
+
+    @Excel(name = "序号", sort = 1)
+    private Integer rowNum;
+
+    @Excel(name = "搜索词", sort = 2)
+    private String keyword;
+
+    @Excel(name = "搜索次数", sort = 3)
+    private Long searchPv;
+
+    @Excel(name = "搜索人数", sort = 4)
+    private Long searchUv;
+
+    @Excel(name = "关联课程数量", sort = 5)
+    private Integer relatedCourseCount;
+}

+ 3 - 0
fs-service/src/main/java/com/fs/course/vo/newfs/FsUserCourseListVO.java

@@ -21,4 +21,7 @@ public class FsUserCourseListVO {
     @ApiModelProperty(value = "营期名称")
     private String periodName;
 
+    @ApiModelProperty(value = "是否收藏该营期 1是 0否")
+    private Integer isFavorite;
+
 }

+ 3 - 0
fs-service/src/main/java/com/fs/course/vo/newfs/FsUserCourseVideoPageListVO.java

@@ -61,6 +61,9 @@ public class FsUserCourseVideoPageListVO extends BaseEntity {
     @ApiModelProperty(value = "营期名称")
     private String periodName;
 
+    @ApiModelProperty(value = "是否收藏该营期 1是 0否")
+    private Integer isFavorite;
+
     @ApiModelProperty(value = "营期课程ID")
     private Long id;
 

+ 7 - 0
fs-service/src/main/java/com/fs/his/config/CidPhoneConfig.java

@@ -53,4 +53,11 @@ public class CidPhoneConfig implements Serializable {
      * 配置回调地址
      */
     private String callbackUrl;
+
+    /**
+     * 呼入回调地址
+     */
+    private String inboundCallbackUrl;
+
+
 }

+ 10 - 0
fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralLogsMapper.java

@@ -105,6 +105,10 @@ public interface FsUserIntegralLogsMapper
     @Select("select * from fs_user_integral_logs where log_type=10 and user_id = #{userId} and business_id = #{videoId} limit 1")
     FsUserIntegralLogs selectFsUserIntegralByCourseVideo(@Param("videoId") Long videoId,@Param("userId")Long userId);
 
+    /** log_type=17 点播答题积分;business_id 与题库领取幂等键一致(videoId 或 videoId:p:periodId) */
+    @Select("select * from fs_user_integral_logs where log_type = 17 and user_id = #{userId} and business_id = #{businessId} limit 1")
+    FsUserIntegralLogs selectAnswerRewardIntegralLog(@Param("userId") Long userId, @Param("businessId") String businessId);
+
     @Select("select * from fs_user_integral_logs where log_type=11 and user_id = #{userId} limit 1")
     FsUserIntegralLogs selectFsUserIntegralLogsAddPatient(@Param("userId")Long userId);
 
@@ -138,6 +142,12 @@ public interface FsUserIntegralLogsMapper
     @Select("select count(1) from fs_user_integral_logs where log_type = 16 and user_id =#{userId} and business_id = #{videoId} ")
     Long selectH5VideoIntegralCount(@Param("userId") Long userId,@Param("videoId") Long videoId);
 
+    /**
+     * 用户端公域答题积分是否已领取({@link com.fs.his.enums.FsUserIntegralLogTypeEnum#TYPE_17},business_id 与领取接口约定一致)
+     */
+    @Select("select * from fs_user_integral_logs where log_type = 17 and user_id = #{userId} and business_id = #{businessId} limit 1")
+    FsUserIntegralLogs selectAnswerIntegralRewardLog(@Param("userId") Long userId, @Param("businessId") String businessId);
+
     List<FsUserIntegralLogs> selectFsUserIntegralLogsByUserIdAndLogType(@Param("userId") Long userId, @Param("logType") Integer logType, @Param("date") LocalDate date);
 
     /**

+ 7 - 0
fs-service/src/main/java/com/fs/his/utils/RedisCacheUtil.java

@@ -30,4 +30,11 @@ public class RedisCacheUtil {
         String key = cacheName + "::" + cacheKey;
         redisCache.deleteObject(key);
     }
+
+    /**
+     * 清除某个 Spring {@link org.springframework.cache.annotation.Cacheable} 名称下的全部 Redis 项(同前缀 "cacheName::")
+     */
+    public void delSpringCacheAllByName(String cacheName) {
+        delRedisKey(cacheName + "::");
+    }
 }

+ 15 - 0
fs-service/src/main/java/com/fs/hisStore/dto/FsStoreOrderPayPostageEditDTO.java

@@ -0,0 +1,15 @@
+package com.fs.hisStore.dto;
+
+import lombok.Data;
+
+import java.math.BigDecimal;
+
+/**
+ * 待支付订单仅修改运费
+ */
+@Data
+public class FsStoreOrderPayPostageEditDTO {
+    private Long id;
+    /** 运费(支付邮费) */
+    private BigDecimal payPostage;
+}

+ 26 - 0
fs-service/src/main/java/com/fs/hisStore/mapper/FsUserAddressScrmMapper.java

@@ -79,4 +79,30 @@ public interface FsUserAddressScrmMapper
     @Select("select * from fs_user_address where user_id=#{uid} and is_default=1 and is_del=0 limit 1")
     @DataSource(DataSourceType.SLAVE)
     FsUserAddress selectFsUserAddressByDefault(long userId);
+
+    @Select("SELECT a.* " +
+            "FROM fs_store_order_scrm o " +
+            "INNER JOIN fs_user_address a ON a.user_id = o.user_id " +
+            "  AND IFNULL(a.is_del,0)=0 " +
+            "  AND IFNULL(a.real_name,'') = IFNULL(o.real_name,'') " +
+            "  AND IFNULL(a.phone,'') = IFNULL(o.user_phone,'') " +
+            "  AND o.user_address like concat('%',a.detail,'%') " +
+            "WHERE  " +
+            "   o.user_id = #{userId} and pay_time is not null " +
+            "ORDER BY o.create_time DESC, o.id DESC LIMIT 1")
+    @DataSource(DataSourceType.SLAVE)
+    FsUserAddress selectFsUserAddressByLastPaidOrder(long userId);
+
+    @Select("SELECT a.* " +
+            "FROM fs_store_order_scrm o " +
+            "INNER JOIN fs_user_address a ON a.user_id = o.user_id " +
+            "  AND IFNULL(a.is_del,0)=0 " +
+            "  AND IFNULL(a.real_name,'') = IFNULL(o.real_name,'') " +
+            "  AND IFNULL(a.phone,'') = IFNULL(o.user_phone,'') " +
+            "  AND o.user_address like concat('%',a.detail,'%') " +
+            "WHERE  " +
+            "   o.user_id = #{userId} and pay_time is not null " +
+            "ORDER BY o.create_time DESC, o.id DESC LIMIT 1")
+    @DataSource(DataSourceType.SLAVE)
+    FsUserAddressScrm selectFsUserAddressScrmByLastPaidOrder(long userId);
 }

+ 6 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsStoreOrderScrmService.java

@@ -27,6 +27,7 @@ import com.fs.hisStore.domain.FsStorePaymentScrm;
 import com.fs.hisStore.dto.ExpressNotifyDTO;
 import com.fs.hisStore.dto.ExpressResultDTO;
 import com.fs.hisStore.dto.FsStoreOrderComputeDTO;
+import com.fs.hisStore.dto.FsStoreOrderPayPostageEditDTO;
 import com.fs.hisStore.dto.StoreOrderExpressExportDTO;
 import com.fs.hisStore.param.*;
 import com.fs.hisStore.vo.*;
@@ -78,6 +79,11 @@ public interface IFsStoreOrderScrmService
      */
     public int updateFsStoreOrder(FsStoreOrderScrm fsStoreOrder);
 
+    /**
+     * 待支付订单仅改运费:更新 pay_postage,并按差额同步 pay_price、total_postage;货到付款( pay_type=3 )时同步 pay_delivery
+     */
+    R updateUnpaidOrderPayPostage(FsStoreOrderPayPostageEditDTO param);
+
     /**
      * 批量删除订单
      *

+ 5 - 0
fs-service/src/main/java/com/fs/hisStore/service/IFsUserAddressScrmService.java

@@ -70,4 +70,9 @@ public interface IFsUserAddressScrmService
     String getKdnAddress(String address);
 
     FsUserAddress selectFsUserAddressByDefault(long l);
+
+    /**
+     * 默认地址为空时,按最近一次支付成功订单匹配历史地址
+     */
+    FsUserAddress selectFsUserAddressByLastPaidOrder(long userId);
 }

+ 60 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java

@@ -606,6 +606,46 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
         return fsStoreOrderMapper.updateFsStoreOrder(fsStoreOrder);
     }
 
+    @Override
+    public R updateUnpaidOrderPayPostage(FsStoreOrderPayPostageEditDTO param) {
+        if (param == null || param.getId() == null) {
+            return R.error("订单ID不能为空");
+        }
+        if (param.getPayPostage() == null) {
+            return R.error("运费不能为空");
+        }
+        BigDecimal newPostage = param.getPayPostage().setScale(2, RoundingMode.HALF_UP);
+        if (newPostage.compareTo(BigDecimal.ZERO) < 0) {
+            return R.error("运费不能为负数");
+        }
+        FsStoreOrderScrm order = fsStoreOrderMapper.selectFsStoreOrderById(param.getId());
+        if (order == null) {
+            return R.error("订单不存在");
+        }
+        if (order.getStatus() == null || order.getStatus() != 0) {
+            return R.error("仅待支付订单可修改运费");
+        }
+        BigDecimal oldPostage = order.getPayPostage() != null ? order.getPayPostage() : BigDecimal.ZERO;
+        BigDecimal payPrice = order.getPayPrice();
+        if (payPrice == null) {
+            return R.error("订单应付金额异常,无法调整运费");
+        }
+        BigDecimal newPayPrice = payPrice.subtract(oldPostage).add(newPostage);
+        if (newPayPrice.compareTo(BigDecimal.ZERO) < 0) {
+            return R.error("调整后应付金额不能小于0,请检查运费");
+        }
+        FsStoreOrderScrm up = new FsStoreOrderScrm();
+        up.setId(order.getId());
+        up.setPayPostage(newPostage);
+        up.setTotalPostage(newPostage);
+        up.setPayPrice(newPayPrice);
+        if ("3".equals(order.getPayType()) && order.getPayMoney() != null) {
+            up.setPayDelivery(newPayPrice.subtract(order.getPayMoney()));
+        }
+        int n = this.updateFsStoreOrder(up);
+        return n > 0 ? R.ok() : R.error("更新失败");
+    }
+
     /**
      * 推送修改订单的最新地址到 ERP
      *
@@ -789,6 +829,16 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     public R confirmOrder(long uid, FsStoreConfirmOrderParam cartParam) {
         //获取地址信息和购物车信息
         FsUserAddressScrm address = userAddressMapper.selectFsUserAddressByDefaultAddress(uid);
+        if (address == null) {
+            // 默认地址为空时,按最近一次支付成功订单匹配用户历史地址
+            address = userAddressMapper.selectFsUserAddressScrmByLastPaidOrder(uid);
+        }
+        if (address!=null&&address.getPhone()!=null&&address.getPhone().length()>11&&!address.getPhone().matches("\\d+")){
+            address.setPhone(ParseUtils.parsePhone(address.getPhone()));
+        }
+        if (address != null && address.getId() == null) {
+            address.setId(address.getAddressId());
+        }
         List<FsStoreCartQueryVO> carts = cartMapper.selectFsStoreCartListByIds(cartParam.getCartIds());
         for (FsStoreCartQueryVO cart : carts) {
             if (cart.getChangePrice() != null && BigDecimal.ZERO.compareTo(cart.getChangePrice()) < 0) {
@@ -2222,6 +2272,16 @@ public class FsStoreOrderScrmServiceImpl implements IFsStoreOrderScrmService {
     @Override
     public R confirmPackageOrder(long uid, FsStoreConfirmPackageIdOrderParam param) {
         FsUserAddressScrm address = userAddressMapper.selectFsUserAddressByDefaultAddress(uid);
+        if (address == null) {
+            // 默认地址为空时,按最近一次支付成功订单匹配用户历史地址
+            address = userAddressMapper.selectFsUserAddressScrmByLastPaidOrder(uid);
+        }
+        if (address!=null&&address.getPhone()!=null&&address.getPhone().length()>11&&!address.getPhone().matches("\\d+")){
+            address.setPhone(ParseUtils.parsePhone(address.getPhone()));
+        }
+        if (address != null && address.getId() == null) {
+            address.setId(address.getAddressId());
+        }
         FsStoreProductPackageScrm storeProductPackage = productPackageService.selectFsStoreProductPackageById(param.getPackageId());
         // 由于套餐制单前面有生成oderkey,并且要取修改的价格,所以这里判断,如果有传就用传的orderkey,如果没有就生成(代表走的是直接购买)
         String uuid;

+ 5 - 0
fs-service/src/main/java/com/fs/hisStore/service/impl/FsUserAddressScrmServiceImpl.java

@@ -154,6 +154,11 @@ public class FsUserAddressScrmServiceImpl implements IFsUserAddressScrmService
         return fsUserAddressMapper.selectFsUserAddressByDefault(userId);
     }
 
+    @Override
+    public FsUserAddress selectFsUserAddressByLastPaidOrder(long userId) {
+        return fsUserAddressMapper.selectFsUserAddressByLastPaidOrder(userId);
+    }
+
     private String encrypt(String content, String keyValue, String charset) {
         if (keyValue != null) {
             content = content + keyValue;

+ 20 - 16
fs-live-app/src/main/java/com/fs/live/config/ProductionWordFilter.java → fs-service/src/main/java/com/fs/sensitive/ProductionWordFilter.java

@@ -1,4 +1,4 @@
-package com.fs.live.config;
+package com.fs.sensitive;
 
 import com.fs.live.service.ILiveSensitiveWordsService;
 import lombok.Getter;
@@ -7,18 +7,25 @@ import org.springframework.beans.factory.InitializingBean;
 import org.springframework.beans.factory.annotation.Autowired;
 import org.springframework.stereotype.Component;
 
-import java.util.*;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.Executors;
 import java.util.concurrent.ScheduledExecutorService;
 import java.util.concurrent.TimeUnit;
 
+/**
+ * 敏感词过滤(与直播 WebSocket 同逻辑,词库来自 live_sensitive 表 selectAllWords)
+ */
 @Component
 public class ProductionWordFilter implements InitializingBean {
 
     @Autowired
     private ILiveSensitiveWordsService liveSensitiveWordsService;
 
-
     private static class TrieNode {
         @Getter
         private final Map<Character, TrieNode> children = new HashMap<>();
@@ -31,8 +38,8 @@ public class ProductionWordFilter implements InitializingBean {
         public void setEndOfWord(boolean endOfWord) {
             isEndOfWord = endOfWord;
         }
-
     }
+
     @Getter
     private static class MatchResult {
         private final String word;
@@ -59,15 +66,13 @@ public class ProductionWordFilter implements InitializingBean {
             this.matchedWords = matchedWords;
             this.replacedCount = replacedCount;
         }
-
     }
 
     private TrieNode root = new TrieNode();
-    private List<String> wordSources;
+    private List<String> wordSources = Collections.emptyList();
     private final ScheduledExecutorService executor;
 
-    public ProductionWordFilter(List<String> wordSources) {
-        this.wordSources = wordSources;
+    public ProductionWordFilter() {
         this.executor = Executors.newSingleThreadScheduledExecutor();
     }
 
@@ -87,11 +92,14 @@ public class ProductionWordFilter implements InitializingBean {
     }
 
     public FilterResult filter(String text) {
+        if (text == null) {
+            return new FilterResult("", new HashSet<>(), 0);
+        }
         StringBuilder result = new StringBuilder();
         Set<String> foundWords = new HashSet<>();
         int replacedCount = 0;
 
-        for (int i = 0; i < text.length(); ) {
+        for (int i = 0; i < text.length();) {
             MatchResult match = findNextMatch(text, i);
             if (match != null) {
                 foundWords.add(match.getWord());
@@ -107,7 +115,6 @@ public class ProductionWordFilter implements InitializingBean {
         return new FilterResult(result.toString(), foundWords, replacedCount);
     }
 
-
     private MatchResult findNextMatch(String text, int start) {
         TrieNode current = root;
         for (int i = start; i < text.length(); i++) {
@@ -123,8 +130,8 @@ public class ProductionWordFilter implements InitializingBean {
         return null;
     }
 
-    private void addWord(TrieNode root, String word) {
-        TrieNode node = root;
+    private void addWord(TrieNode r, String word) {
+        TrieNode node = r;
         for (char c : word.toCharArray()) {
             node = node.getChildren().computeIfAbsent(c, k -> new TrieNode());
         }
@@ -132,9 +139,6 @@ public class ProductionWordFilter implements InitializingBean {
     }
 
     private List<String> loadWords(String source) {
-        // 示例:实际应从 source(如 URL 或文件路径)读取
-        return Collections.singletonList(source); // 替换为真实逻辑
+        return Collections.singletonList(source);
     }
-
-
 }

+ 77 - 0
fs-service/src/main/resources/mapper/company/CompanyInboundBindMapper.xml

@@ -0,0 +1,77 @@
+<?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.company.mapper.CompanyInboundBindMapper">
+    
+    <resultMap type="CompanyInboundBind" id="CompanyInboundBindResult">
+        <result property="id"    column="id"    />
+        <result property="companyId"    column="company_id"    />
+        <result property="inboundLlmAccountId"    column="inbound_llm_account_id"    />
+        <result property="createTime"    column="create_time"    />
+    </resultMap>
+
+    <sql id="selectCompanyInboundBindVo">
+        select id, company_id, inbound_llm_account_id, create_time from company_inbound_bind
+    </sql>
+
+    <select id="selectCompanyInboundBindList" parameterType="CompanyInboundBind" resultMap="CompanyInboundBindResult">
+        <include refid="selectCompanyInboundBindVo"/>
+        <where>  
+            <if test="companyId != null "> and company_id = #{companyId}</if>
+            <if test="inboundLlmAccountId != null "> and inbound_llm_account_id = #{inboundLlmAccountId}</if>
+        </where>
+    </select>
+    
+    <select id="selectCompanyInboundBindById" parameterType="Long" resultMap="CompanyInboundBindResult">
+        <include refid="selectCompanyInboundBindVo"/>
+        where id = #{id}
+    </select>
+        
+    <insert id="insertCompanyInboundBind" parameterType="CompanyInboundBind" useGeneratedKeys="true" keyProperty="id">
+        insert into company_inbound_bind
+        <trim prefix="(" suffix=")" suffixOverrides=",">
+            <if test="companyId != null">company_id,</if>
+            <if test="inboundLlmAccountId != null">inbound_llm_account_id,</if>
+            <if test="createTime != null">create_time,</if>
+         </trim>
+        <trim prefix="values (" suffix=")" suffixOverrides=",">
+            <if test="companyId != null">#{companyId},</if>
+            <if test="inboundLlmAccountId != null">#{inboundLlmAccountId},</if>
+            <if test="createTime != null">#{createTime},</if>
+         </trim>
+    </insert>
+
+    <update id="updateCompanyInboundBind" parameterType="CompanyInboundBind">
+        update company_inbound_bind
+        <trim prefix="SET" suffixOverrides=",">
+            <if test="companyId != null">company_id = #{companyId},</if>
+            <if test="inboundLlmAccountId != null">inbound_llm_account_id = #{inboundLlmAccountId},</if>
+            <if test="createTime != null">create_time = #{createTime},</if>
+        </trim>
+        where id = #{id}
+    </update>
+
+    <delete id="deleteCompanyInboundBindById" parameterType="Long">
+        delete from company_inbound_bind where id = #{id}
+    </delete>
+
+    <delete id="deleteCompanyInboundBindByIds" parameterType="String">
+        delete from company_inbound_bind where id in 
+        <foreach item="id" collection="array" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <select id="selectIdsByCompanyId" resultType="CompanyInboundBind">
+        select * from company_inbound_bind where company_id = #{companyId}
+    </select>
+
+    <delete id="deleteByCompanyIdAndInboundLlmAccountIds">
+        delete from company_inbound_bind where company_id = #{companyId}
+              and  inbound_llm_account_id in
+        <foreach item="id" collection="inboundLlmAccountIds" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+</mapper>

+ 2 - 40
fs-service/src/main/resources/mapper/company/CompanyWithdrawDetailMapper.xml

@@ -9,17 +9,14 @@
             c.company_name AS companyName,
             CASE
                 WHEN IFNULL(l.type, 0) = 0 THEN cu.nick_name
-                WHEN IFNULL(l.type, 0) = 1 THEN cul.nick_name
                 ELSE NULL
             END AS salesName,
             CASE
                 WHEN IFNULL(l.type, 0) = 0 THEN o.order_code
-                WHEN IFNULL(l.type, 0) = 1 THEN lo.order_code
                 ELSE NULL
             END AS orderCode,
             CASE
                 WHEN IFNULL(l.type, 0) = 0 THEN p.bank_transaction_id
-                WHEN IFNULL(l.type, 0) = 1 THEN lop.bank_transaction_id
                 ELSE NULL
             END AS tradeNo,
             CASE
@@ -34,35 +31,10 @@
                         WHEN 3 THEN '交易完成'
                         ELSE CAST(o.status AS CHAR)
                     END
-                WHEN IFNULL(l.type, 0) = 1 AND lo.order_id IS NOT NULL THEN
-                    CASE lo.status
-                        WHEN -1 THEN '退款中'
-                        WHEN -2 THEN '已退款'
-                        WHEN 0 THEN '已取消'
-                        WHEN 1 THEN '待支付'
-                        WHEN 2 THEN '待发货'
-                        WHEN 3 THEN '待收货'
-                        WHEN 4 THEN '交易完成'
-                        WHEN 5 THEN '交易完成'
-                        WHEN 6 THEN '被拆分'
-                        ELSE CAST(lo.status AS CHAR)
-                    END
                 ELSE '-'
             END AS orderStatusText,
             CASE
                 WHEN IFNULL(l.type, 0) = 0 AND o.id IS NOT NULL THEN o.status
-                WHEN IFNULL(l.type, 0) = 1 AND lo.order_id IS NOT NULL THEN
-                    CASE lo.status
-                        WHEN -1 THEN -1
-                        WHEN -2 THEN -2
-                        WHEN 0 THEN -3
-                        WHEN 1 THEN 0
-                        WHEN 2 THEN 1
-                        WHEN 3 THEN 2
-                        WHEN 4 THEN 3
-                        WHEN 5 THEN 3
-                        ELSE lo.status
-                    END
                 ELSE NULL
             END AS orderStatus,
             CASE
@@ -109,19 +81,9 @@
             AND o.id = CAST(NULLIF(l.business_id, '') AS UNSIGNED)
             AND o.company_id = l.company_id
         )
-        LEFT JOIN live_order lo ON (
-            IFNULL(l.type, 0) = 1
-            AND l.logs_type IN (3, 4, 5, 6)
-            AND lo.order_id = CAST(NULLIF(l.business_id, '') AS UNSIGNED)
-            AND lo.company_id = l.company_id
-        )
         LEFT JOIN company_user cu ON cu.user_id = o.company_user_id
-        LEFT JOIN company_user cul ON cul.user_id = lo.company_user_id
         LEFT JOIN fs_store_payment_scrm p ON (
-            p.business_code = o.order_code  AND IFNULL(l.type, 0) = 0
-        )
-        LEFT JOIN live_order_payment lop ON (
-            lop.business_code = lo.order_code  AND IFNULL(l.type, 0) = 1
+            p.business_code = o.order_code  AND IFNULL(l.type, 0) = 0 and p.pay_time is not null
         )
         LEFT JOIN (
             SELECT a1.*
@@ -132,7 +94,7 @@
                 WHERE IFNULL(is_del, 0) = 0
                 GROUP BY order_code
             ) am ON a1.id = am.mid
-        ) a ON a.order_code = COALESCE(o.order_code, lo.order_code)
+        ) a ON a.order_code = o.order_code
     </sql>
 
     <sql id="withdrawDetailBaseWhere">

+ 111 - 2
fs-service/src/main/resources/mapper/company/EasyCallInboundLlmMapper.xml

@@ -56,7 +56,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     </resultMap>
 
     <sql id="selectInboundLlmVo">
-        select id, llm_account_id, callee, voice_code, voice_source, service_type, asr_provider, ai_transfer_type, ai_transfer_data, ivr_id, satisf_survey_ivr_id, inbound_alias from cc_inbound_llm_account
+        select id, llm_account_id, callee, voice_code, voice_source, service_type, asr_provider, ai_transfer_type, ai_transfer_data, ivr_id, satisf_survey_ivr_id, inbound_alias,call_back_url,fs_scene_type from cc_inbound_llm_account
     </sql>
 
     <select id="selectInboundLlmList" parameterType="com.fs.company.vo.easycall.EasyCallInboundLlmVO" resultMap="InboundLlmResult">
@@ -71,6 +71,14 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="asrProvider != null and asrProvider != ''">and asr_provider = #{asrProvider}</if>
             <if test="aiTransferType != null and aiTransferType != ''">and ai_transfer_type = #{aiTransferType}</if>
             <if test="aiTransferData != null and aiTransferData != ''">and ai_transfer_data = #{aiTransferData}</if>
+            <if test="visibleIds != null ">
+                <if test="visibleIds.size() > 0">
+                    and id in
+                    <foreach item="item" collection="visibleIds" index="index" separator="," open="(" close=")">
+                        #{item}
+                    </foreach>
+                </if>
+            </if>
         </where>
         order by id desc
     </select>
@@ -85,7 +93,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         where callee = #{callee}
     </select>
 
-    <insert id="insertInboundLlm" parameterType="com.fs.company.vo.easycall.EasyCallInboundLlmVO">
+    <insert id="insertInboundLlm" parameterType="com.fs.company.vo.easycall.EasyCallInboundLlmVO" useGeneratedKeys="true" keyProperty="id" keyColumn="id">
         insert into cc_inbound_llm_account
         <trim prefix="(" suffix=")" suffixOverrides=",">
             <if test="id != null">id,</if>
@@ -100,6 +108,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="aiTransferData != null and aiTransferData != ''">ai_transfer_data,</if>
             <if test="ivrId != null">ivr_id,</if>
             <if test="satisfSurveyIvrId != null">satisf_survey_ivr_id,</if>
+            <if test="companyId != null">fs_company_id,</if>
+            <if test="callBackUrl != null">call_back_url,</if>
+            <if test="fsSceneType != null">fs_scene_type,</if>
         </trim>
         <trim prefix="values (" suffix=")" suffixOverrides=",">
             <if test="id != null">#{id},</if>
@@ -114,6 +125,9 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="aiTransferData != null and aiTransferData != ''">#{aiTransferData},</if>
             <if test="ivrId != null">#{ivrId},</if>
             <if test="satisfSurveyIvrId != null">#{satisfSurveyIvrId},</if>
+            <if test="companyId != null">#{companyId},</if>
+            <if test="callBackUrl != null">#{callBackUrl},</if>
+            <if test="fsSceneType != null">#{fsSceneType},</if>
         </trim>
     </insert>
 
@@ -131,6 +145,8 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             <if test="aiTransferData != null and aiTransferData != ''">ai_transfer_data = #{aiTransferData},</if>
             <if test="ivrId != null">ivr_id = #{ivrId},</if>
             <if test="satisfSurveyIvrId != null">satisf_survey_ivr_id = #{satisfSurveyIvrId},</if>
+            <if test="callBackUrl != null">call_back_url = #{callBackUrl},</if>
+            <if test="fsSceneType != null">fs_scene_type = #{fsSceneType},</if>
         </set>
         where id = #{id}
     </update>
@@ -149,6 +165,13 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
     <select id="selectLlmAccountList" resultMap="LlmAccountResult">
         select id, name, account_json, account_entity, provider_class_name
         from cc_llm_agent_account
+        <where>
+            <if test="modelsId != null and modelsId.size() > 0"> and id in
+                <foreach item="modelId" collection="modelsId" open="(" separator="," close=")">
+                    #{modelId}
+                </foreach>
+            </if>
+        </where>
         order by id desc
     </select>
 
@@ -211,4 +234,90 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by id
     </select>
 
+    <resultMap type="com.fs.company.domain.EasyCallInboundCdrVO" id="EasyCallInboundCdrResult">
+        <result property="id"    column="id"    />
+        <result property="caller"    column="caller"    />
+        <result property="callee"    column="callee"    />
+        <result property="inboundTime"    column="inbound_time"    />
+        <result property="groupId"    column="group_id"    />
+        <result property="answeredTime"    column="answered_time"    />
+        <result property="extnum"    column="extnum"    />
+        <result property="opnum"    column="opnum"    />
+        <result property="hangupTime"    column="hangup_time"    />
+        <result property="answeredTimeLen"    column="answered_time_len"    />
+        <result property="timeLen"    column="time_len"    />
+        <result property="uuid"    column="uuid"    />
+        <result property="wavFile"    column="wav_file"    />
+        <result property="chatContent"    column="chat_content"    />
+        <result property="asrSeconds"    column="asr_seconds"    />
+        <result property="ttsTimes"    column="tts_times"    />
+        <result property="ttsFlowTokens"    column="tts_flow_tokens"    />
+        <result property="inputTokens"    column="input_tokens"    />
+        <result property="outputTokens"    column="output_tokens"    />
+        <result property="totalCost"    column="total_cost"    />
+        <result property="billingStatus"    column="billing_status"    />
+        <result property="ivrDtmfDigits"    column="ivr_dtmf_digits"    />
+        <result property="hangupCause"    column="hangup_cause"    />
+        <result property="manualAnsweredTime"    column="manual_answered_time"    />
+        <result property="manualAnsweredTimeLen"    column="manual_answered_time_len"    />
+        <result property="groupName"    column="group_name"    />
+    </resultMap>
+
+    <select id="selectInboundCdrList" parameterType="com.fs.company.domain.EasyCallInboundCdrVO" resultMap="EasyCallInboundCdrResult">
+        select c.id, c.caller, c.callee, c.inbound_time, c.group_id, c.answered_time, c.extnum, c.opnum,
+               c.hangup_time, c.answered_time_len, c.time_len, c.uuid, c.wav_file, c.chat_content,
+               c.asr_seconds, c.tts_times, c.tts_flow_tokens, c.input_tokens, c.output_tokens,
+               c.total_cost, c.billing_status, c.ivr_dtmf_digits, c.hangup_cause,
+               c.manual_answered_time, c.manual_answered_time_len,
+               g.biz_group_name as group_name
+        from cc_inbound_cdr c
+        LEFT JOIN cc_biz_group g ON c.group_id = g.group_id
+        <where>
+            c.hangup_time &gt; 0
+            <if test="uuid != null and uuid != ''"> and c.uuid = #{uuid}</if>
+            <if test="caller != null and caller != ''"> and c.caller = #{caller}</if>
+            <if test="callee != null and callee != ''"> and c.callee = #{callee}</if>
+            <if test="extnum != null and extnum != ''"> and c.extnum = #{extnum}</if>
+            <if test="opnum != null and opnum != ''"> and c.opnum = #{opnum}</if>
+            <if test="groupId != null and groupId != ''"> and c.group_id = #{groupId}</if>
+            <if test="billingStatus != null"> and c.billing_status = #{billingStatus}</if>
+            <!-- 呼入时间范围 -->
+            <if test="params != null and params.inboundTimeStart != null">
+                and c.inbound_time &gt;= #{params.inboundTimeStart}
+            </if>
+            <if test="params != null and params.inboundTimeEnd != null">
+                and c.inbound_time &lt;= #{params.inboundTimeEnd}
+            </if>
+            <!-- 接听时间范围 -->
+            <if test="params != null and params.answeredTimeStart != null">
+                and c.answered_time &gt;= #{params.answeredTimeStart}
+            </if>
+            <if test="params != null and params.answeredTimeEnd != null">
+                and c.answered_time &lt;= #{params.answeredTimeEnd}
+            </if>
+            <!-- 挂机时间范围 -->
+            <if test="params != null and params.hangupTimeStart != null">
+                and c.hangup_time &gt;= #{params.hangupTimeStart}
+            </if>
+            <if test="params != null and params.hangupTimeEnd != null">
+                and c.hangup_time &lt;= #{params.hangupTimeEnd}
+            </if>
+            <!-- 通话时长范围 -->
+            <if test="params != null and params.timeLenStart != null">
+                and c.time_len &gt;= #{params.timeLenStart}
+            </if>
+            <if test="params != null and params.timeLenEnd != null">
+                and c.time_len &lt;= #{params.timeLenEnd}
+            </if>
+            <!-- 多租户隔离:可见被叫号码过滤 -->
+            <if test="visibleCallees != null and visibleCallees.size() > 0">
+                and c.callee in
+                <foreach item="calleeItem" collection="visibleCallees" open="(" separator="," close=")">
+                    #{calleeItem}
+                </foreach>
+            </if>
+        </where>
+        order by c.hangup_time desc
+    </select>
+
 </mapper>

+ 13 - 0
fs-service/src/main/resources/mapper/company/EasyCallMapper.xml

@@ -11,5 +11,18 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         select * from cc_outbound_cdr where uuid = #{uuid}
     </select>
 
+    <select id="selectInboundCallbackInfoByUuid" resultType="com.fs.company.vo.InboundCallInfo">
+        select
+            t1.uuid,
+            t2.call_back_url,
+            t2.fs_company_id,
+            t2.fs_scene_type,
+            t1.chat_content,
+            t1.caller
+        from cc_inbound_cdr t1
+                 inner join cc_inbound_llm_account t2 on t1.callee = t2.callee
+        where  t1.uuid = #{uuid}
+        limit 1
+    </select>
 
 </mapper>

+ 32 - 0
fs-service/src/main/resources/mapper/course/FsCompanyUserCourseFavoriteMapper.xml

@@ -0,0 +1,32 @@
+<?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.course.mapper.FsCompanyUserCourseFavoriteMapper">
+
+    <resultMap type="com.fs.course.domain.FsCompanyUserCourseFavorite" id="BaseResultMap">
+        <id property="id" column="id"/>
+        <result property="companyId" column="company_id"/>
+        <result property="companyUserId" column="company_user_id"/>
+        <result property="periodId" column="period_id"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <select id="selectByCompanyUserAndPeriod" resultMap="BaseResultMap">
+        SELECT id, company_id, company_user_id, period_id, create_time
+        FROM fs_company_user_course_favorite
+        WHERE company_user_id = #{companyUserId}
+          AND period_id = #{periodId}
+        LIMIT 1
+    </select>
+
+    <insert id="insertFsCompanyUserCourseFavorite" parameterType="com.fs.course.domain.FsCompanyUserCourseFavorite"
+            useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO fs_company_user_course_favorite (company_id, company_user_id, period_id, create_time)
+        VALUES (#{companyId}, #{companyUserId}, #{periodId}, #{createTime})
+    </insert>
+
+    <delete id="deleteByCompanyUserAndPeriod">
+        DELETE FROM fs_company_user_course_favorite
+        WHERE company_user_id = #{companyUserId}
+          AND period_id = #{periodId}
+    </delete>
+</mapper>

+ 81 - 0
fs-service/src/main/resources/mapper/course/FsCourseKeywordSearchLogMapper.xml

@@ -0,0 +1,81 @@
+<?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.course.mapper.FsCourseKeywordSearchLogMapper">
+
+    <resultMap id="FsCourseKeywordSearchLogResult" type="com.fs.course.domain.FsCourseKeywordSearchLog">
+        <id property="id" column="id"/>
+        <result property="keyword" column="keyword"/>
+        <result property="userId" column="user_id"/>
+        <result property="createTime" column="create_time"/>
+    </resultMap>
+
+    <sql id="selectColumns">
+        id, keyword, user_id, create_time
+    </sql>
+
+    <select id="selectFsCourseKeywordSearchLogById" resultMap="FsCourseKeywordSearchLogResult">
+        SELECT
+        <include refid="selectColumns"/>
+        FROM course_keyword_search_log
+        WHERE id = #{id}
+    </select>
+
+    <select id="selectFsCourseKeywordSearchLogList" resultMap="FsCourseKeywordSearchLogResult">
+        SELECT
+        <include refid="selectColumns"/>
+        FROM course_keyword_search_log
+        <where>
+            <if test="keyword != null and keyword != ''">
+                AND keyword LIKE CONCAT('%', #{keyword}, '%')
+            </if>
+            <if test="userId != null">
+                AND user_id = #{userId}
+            </if>
+        </where>
+        ORDER BY id DESC
+    </select>
+
+    <insert id="insertFsCourseKeywordSearchLog" parameterType="com.fs.course.domain.FsCourseKeywordSearchLog" useGeneratedKeys="true" keyProperty="id">
+        INSERT INTO course_keyword_search_log (keyword, user_id, create_time)
+        VALUES (#{keyword}, #{userId}, #{createTime})
+    </insert>
+
+    <update id="updateFsCourseKeywordSearchLog" parameterType="com.fs.course.domain.FsCourseKeywordSearchLog">
+        UPDATE course_keyword_search_log
+        <set>
+            <if test="keyword != null">keyword = #{keyword},</if>
+            <if test="userId != null">user_id = #{userId},</if>
+        </set>
+        WHERE id = #{id}
+    </update>
+
+    <delete id="deleteFsCourseKeywordSearchLogById">
+        DELETE FROM course_keyword_search_log
+        WHERE id = #{id}
+    </delete>
+
+    <delete id="deleteFsCourseKeywordSearchLogByIds">
+        DELETE FROM course_keyword_search_log
+        WHERE id IN
+        <foreach collection="array" item="id" open="(" separator="," close=")">
+            #{id}
+        </foreach>
+    </delete>
+
+    <!-- 增量PV:按keyword分组统计搜索次数 -->
+    <select id="selectPvGroupByKeyword" resultType="java.util.Map">
+        SELECT keyword, COUNT(*) AS pv
+        FROM course_keyword_search_log
+        WHERE create_time &gt; #{startTime} AND create_time &lt;= #{endTime}
+        GROUP BY keyword
+    </select>
+
+    <!-- 增量UV:按keyword分组查询去重用户 -->
+    <select id="selectDistinctUserByKeyword" resultType="java.util.Map">
+        SELECT keyword, user_id
+        FROM course_keyword_search_log
+        WHERE create_time &gt; #{startTime} AND create_time &lt;= #{endTime}
+        GROUP BY keyword, user_id
+    </select>
+</mapper>

+ 1 - 0
fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml

@@ -984,6 +984,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         l.last_heartbeat_time,
         l.company_id,
         l.company_user_id,
+        l.reward_type,
         l.course_id,
         l.video_id,
         l.qw_user_id,

+ 57 - 0
fs-service/src/main/resources/mapper/course/FsPublicCourseSearchKeywordStatMapper.xml

@@ -0,0 +1,57 @@
+<?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.course.mapper.FsPublicCourseSearchKeywordStatMapper">
+
+    <resultMap id="FsPublicCourseSearchKeywordStatResult" type="com.fs.course.domain.FsPublicCourseSearchKeywordStat">
+        <id property="statId" column="stat_id"/>
+        <result property="keyword" column="keyword"/>
+        <result property="searchPv" column="search_pv"/>
+        <result property="searchUv" column="search_uv"/>
+        <result property="relatedCourseCount" column="related_course_count"/>
+        <result property="createTime" column="create_time"/>
+        <result property="updateTime" column="update_time"/>
+    </resultMap>
+
+    <sql id="selectColumns">
+        stat_id, keyword, search_pv, search_uv, related_course_count, create_time, update_time
+    </sql>
+
+    <select id="selectFsPublicCourseSearchKeywordStatList" resultMap="FsPublicCourseSearchKeywordStatResult">
+        SELECT
+        <include refid="selectColumns"/>
+        FROM fs_public_course_search_keyword_stat
+        <where>
+            <if test="keyword != null and keyword != ''">
+                AND keyword LIKE concat('%', #{keyword}, '%')
+            </if>
+        </where>
+        ORDER BY search_pv DESC, stat_id DESC
+    </select>
+
+    <select id="selectByKeyword" resultMap="FsPublicCourseSearchKeywordStatResult">
+        SELECT
+        <include refid="selectColumns"/>
+        FROM fs_public_course_search_keyword_stat
+        WHERE keyword = #{keyword}
+        LIMIT 1
+    </select>
+
+    <insert id="insertFsPublicCourseSearchKeywordStat" parameterType="com.fs.course.domain.FsPublicCourseSearchKeywordStat" useGeneratedKeys="true" keyProperty="statId">
+        INSERT INTO fs_public_course_search_keyword_stat
+        (keyword, search_pv, search_uv, related_course_count, create_time, update_time)
+        VALUES
+        (#{keyword}, #{searchPv}, #{searchUv}, #{relatedCourseCount}, #{createTime}, #{updateTime})
+    </insert>
+
+    <update id="updateFsPublicCourseSearchKeywordStat" parameterType="com.fs.course.domain.FsPublicCourseSearchKeywordStat">
+        UPDATE fs_public_course_search_keyword_stat
+        <set>
+            <if test="searchPv != null">search_pv = #{searchPv},</if>
+            <if test="searchUv != null">search_uv = #{searchUv},</if>
+            <if test="relatedCourseCount != null">related_course_count = #{relatedCourseCount},</if>
+            <if test="updateTime != null">update_time = #{updateTime},</if>
+        </set>
+        WHERE stat_id = #{statId}
+    </update>
+</mapper>

+ 73 - 0
fs-service/src/main/resources/mapper/course/FsUserCourseCategoryMapper.xml

@@ -34,6 +34,79 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         </where>
     </select>
 
+    <!-- 小程序:只查二级分类;父行须为一级(pid=0);cateName 模糊;isShow 筛选;cateType 与 Service 默认公域=1 一致。
+         pid 有值:只查该一级下的二级;pid 无值:仅保留在公域课 fs_user_course 中 sub_cate_id 有上架使用 的二级 -->
+    <select id="selectFsUserCourseCategoryAppList" parameterType="com.fs.course.param.FsUserCourseCategoryAppQueryParam" resultType="com.fs.course.domain.FsUserCourseCategory">
+        SELECT
+            c.cate_id, c.pid, c.cate_name, c.sort, c.is_show, c.create_time, c.update_time, c.is_del, c.cate_type
+        FROM fs_user_course_category c
+        INNER JOIN fs_user_course_category p ON p.cate_id = c.pid
+        <where>
+            c.is_del = 0
+            AND p.is_del = 0
+            AND p.pid = 0
+            <choose>
+                <when test="yxxTag != null and yxxTag == 1">
+                    AND c.pid = (select cate_id from fs_user_course_category WHERE cate_name like '%央广原乡行%' AND cate_type = 1 limit 1)
+                </when>
+                <otherwise>
+                    AND c.pid != (select cate_id from fs_user_course_category WHERE cate_name like '%央广原乡行%' AND cate_type = 1 limit 1)
+                </otherwise>
+            </choose>
+            AND c.pid <![CDATA[>]]> 0
+            <if test="cateType != null">
+                AND c.cate_type = #{cateType}
+                AND p.cate_type = #{cateType}
+            </if>
+            <if test="cateName != null and cateName != ''">
+                AND c.cate_name LIKE CONCAT('%', #{cateName}, '%')
+            </if>
+            <if test="isShow != null">
+                AND c.is_show = #{isShow}
+            </if>
+            <if test="pid != null">
+                AND c.pid = #{pid}
+                AND EXISTS (
+                    SELECT 1
+                    FROM fs_user_course uc
+                    WHERE uc.is_del = 0
+                      AND uc.is_show = 1
+                      AND uc.is_private = 0
+                      AND uc.sub_cate_id = c.cate_id
+                      AND EXISTS (
+                          SELECT 1
+                          FROM fs_user_course_category r
+                          WHERE r.cate_id = uc.cate_id
+                            AND r.is_del = 0
+                            <if test="cateType != null">
+                            AND r.cate_type = #{cateType}
+                            </if>
+                      )
+                )
+            </if>
+            <if test="pid == null">
+                AND EXISTS (
+                    SELECT 1
+                    FROM fs_user_course uc
+                    WHERE uc.is_del = 0
+                      AND uc.is_show = 1
+                      AND uc.is_private = 0
+                      AND uc.sub_cate_id = c.cate_id
+                      AND EXISTS (
+                          SELECT 1
+                          FROM fs_user_course_category r
+                          WHERE r.cate_id = uc.cate_id
+                            AND r.is_del = 0
+                            <if test="cateType != null">
+                            AND r.cate_type = #{cateType}
+                            </if>
+                      )
+                )
+            </if>
+        </where>
+        ORDER BY c.sort ASC, c.cate_id ASC
+    </select>
+
     <select id="selectFsUserCourseCategoryByCateId" parameterType="Long" resultMap="FsUserCourseCategoryResult">
         <include refid="selectFsUserCourseCategoryVo"/>
         where cate_id = #{cateId}

Some files were not shown because too many files changed in this diff