Pārlūkot izejas kodu

卓美公开课 第三次点播优化

yuhongqi 1 dienu atpakaļ
vecāks
revīzija
642c156299
82 mainītis faili ar 2273 papildinājumiem un 103 dzēšanām
  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. 34 0
      fs-common/src/main/java/com/fs/common/utils/RedisUtil.java
  10. 14 0
      fs-company-app/src/main/java/com/fs/app/controller/FsUserCourseVideoController.java
  11. 1 1
      fs-live-app/src/main/java/com/fs/live/websocket/service/WebSocketServer.java
  12. 17 0
      fs-service/src/main/java/com/fs/course/cache/PublicCourseAppCacheNames.java
  13. 12 0
      fs-service/src/main/java/com/fs/course/constant/SearchRedisConstants.java
  14. 24 0
      fs-service/src/main/java/com/fs/course/domain/FsCompanyUserCourseFavorite.java
  15. 19 0
      fs-service/src/main/java/com/fs/course/domain/FsCourseKeywordSearchLog.java
  16. 27 0
      fs-service/src/main/java/com/fs/course/domain/FsPublicCourseSearchKeywordStat.java
  17. 6 0
      fs-service/src/main/java/com/fs/course/domain/FsUserCourseVideo.java
  18. 17 0
      fs-service/src/main/java/com/fs/course/mapper/FsCompanyUserCourseFavoriteMapper.java
  19. 13 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseAnswerLogsMapper.java
  20. 35 0
      fs-service/src/main/java/com/fs/course/mapper/FsCourseKeywordSearchLogMapper.java
  21. 19 0
      fs-service/src/main/java/com/fs/course/mapper/FsPublicCourseSearchKeywordStatMapper.java
  22. 14 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseCategoryMapper.java
  23. 10 2
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseMapper.java
  24. 5 0
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseStudyLogMapper.java
  25. 1 1
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseStudyMapper.java
  26. 31 2
      fs-service/src/main/java/com/fs/course/mapper/FsUserCourseVideoMapper.java
  27. 11 0
      fs-service/src/main/java/com/fs/course/mapper/FsVideoResourceMapper.java
  28. 28 0
      fs-service/src/main/java/com/fs/course/param/FsCourseAnswerStatusQueryParam.java
  29. 22 0
      fs-service/src/main/java/com/fs/course/param/FsUserCourseCategoryAppQueryParam.java
  30. 15 0
      fs-service/src/main/java/com/fs/course/param/FsUserCoursePublicAppQueryParam.java
  31. 23 0
      fs-service/src/main/java/com/fs/course/param/FsUserCourseVideoBatchWatchIntegralParam.java
  32. 3 0
      fs-service/src/main/java/com/fs/course/param/newfs/FsUserCourseListParam.java
  33. 11 0
      fs-service/src/main/java/com/fs/course/service/IFsCompanyUserCourseFavoriteService.java
  34. 20 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseKeywordSearchLogService.java
  35. 13 0
      fs-service/src/main/java/com/fs/course/service/IFsCourseQuestionBankService.java
  36. 22 0
      fs-service/src/main/java/com/fs/course/service/IFsPublicCourseSearchKeywordStatService.java
  37. 12 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseCategoryService.java
  38. 6 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseService.java
  39. 5 0
      fs-service/src/main/java/com/fs/course/service/IFsUserCourseVideoService.java
  40. 2 0
      fs-service/src/main/java/com/fs/course/service/IFsVideoResourceService.java
  41. 12 0
      fs-service/src/main/java/com/fs/course/service/SearchStatSyncService.java
  42. 39 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCompanyUserCourseFavoriteServiceImpl.java
  43. 48 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseKeywordSearchLogServiceImpl.java
  44. 362 0
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseQuestionBankServiceImpl.java
  45. 1 1
      fs-service/src/main/java/com/fs/course/service/impl/FsCourseWatchLogServiceImpl.java
  46. 74 0
      fs-service/src/main/java/com/fs/course/service/impl/FsPublicCourseSearchKeywordStatServiceImpl.java
  47. 29 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseCategoryServiceImpl.java
  48. 19 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseServiceImpl.java
  49. 16 0
      fs-service/src/main/java/com/fs/course/service/impl/FsUserCourseVideoServiceImpl.java
  50. 5 0
      fs-service/src/main/java/com/fs/course/service/impl/FsVideoResourceServiceImpl.java
  51. 186 0
      fs-service/src/main/java/com/fs/course/service/impl/SearchStatSyncServiceImpl.java
  52. 20 0
      fs-service/src/main/java/com/fs/course/vo/CompanyUserCourseFavoriteToggleVO.java
  53. 33 0
      fs-service/src/main/java/com/fs/course/vo/FsCourseAnswerStatusVO.java
  54. 3 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCoursePublicAppVO.java
  55. 3 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseStudyListUVO.java
  56. 5 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoListUVO.java
  57. 6 0
      fs-service/src/main/java/com/fs/course/vo/FsUserCourseVideoQVO.java
  58. 26 0
      fs-service/src/main/java/com/fs/course/vo/PublicCourseSearchKeywordStatExportVO.java
  59. 3 0
      fs-service/src/main/java/com/fs/course/vo/newfs/FsUserCourseListVO.java
  60. 3 0
      fs-service/src/main/java/com/fs/course/vo/newfs/FsUserCourseVideoPageListVO.java
  61. 10 0
      fs-service/src/main/java/com/fs/his/mapper/FsUserIntegralLogsMapper.java
  62. 7 0
      fs-service/src/main/java/com/fs/his/utils/RedisCacheUtil.java
  63. 15 0
      fs-service/src/main/java/com/fs/hisStore/dto/FsStoreOrderPayPostageEditDTO.java
  64. 26 0
      fs-service/src/main/java/com/fs/hisStore/mapper/FsUserAddressScrmMapper.java
  65. 6 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsStoreOrderScrmService.java
  66. 5 0
      fs-service/src/main/java/com/fs/hisStore/service/IFsUserAddressScrmService.java
  67. 60 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsStoreOrderScrmServiceImpl.java
  68. 5 0
      fs-service/src/main/java/com/fs/hisStore/service/impl/FsUserAddressScrmServiceImpl.java
  69. 20 16
      fs-service/src/main/java/com/fs/sensitive/ProductionWordFilter.java
  70. 2 40
      fs-service/src/main/resources/mapper/company/CompanyWithdrawDetailMapper.xml
  71. 32 0
      fs-service/src/main/resources/mapper/course/FsCompanyUserCourseFavoriteMapper.xml
  72. 81 0
      fs-service/src/main/resources/mapper/course/FsCourseKeywordSearchLogMapper.xml
  73. 1 0
      fs-service/src/main/resources/mapper/course/FsCourseWatchLogMapper.xml
  74. 57 0
      fs-service/src/main/resources/mapper/course/FsPublicCourseSearchKeywordStatMapper.xml
  75. 73 0
      fs-service/src/main/resources/mapper/course/FsUserCourseCategoryMapper.xml
  76. 15 5
      fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml
  77. 30 2
      fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml
  78. 51 0
      fs-service/src/main/resources/mapper/course/FsVideoResourceMapper.xml
  79. 20 1
      fs-user-app/src/main/java/com/fs/app/controller/CourseCommentController.java
  80. 61 12
      fs-user-app/src/main/java/com/fs/app/controller/CourseController.java
  81. 7 2
      fs-user-app/src/main/java/com/fs/app/controller/store/AddressScrmController.java
  82. 21 12
      fs-user-app/src/main/java/com/fs/framework/config/RedisConfig.java

+ 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));
     }
 

+ 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("获取视频下拉列表")

+ 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;

+ 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;
 

+ 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);
     }
-
-
 }

+ 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">

+ 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}

+ 15 - 5
fs-service/src/main/resources/mapper/course/FsUserCourseMapper.xml

@@ -386,6 +386,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             c.course_id AS courseId,
             COALESCE(c.title, c.course_name) AS courseTitle,
             c.course_name AS courseName,
+            c.description AS courseDesc,
             c.img_url AS imgUrl,
             c.second_img AS secondImg,
             IFNULL(wl_stat.watch_uv, 0) AS watchUserCount,
@@ -405,18 +406,27 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
             WHERE send_type = 1
             GROUP BY course_id
         ) wl_stat ON wl_stat.course_id = c.course_id
-        WHERE IFNULL(c.is_del, 0) = 0
-          AND IFNULL(c.is_show, 0) = 1
-          AND IFNULL(c.is_private, 0) = 0
+        WHERE c.is_del = 0
+        AND c.is_show = 1
+        AND c.is_private = 0
+
+        <choose>
+            <when test="q.yxxTag != null and q.yxxTag == 1">
+                AND c.cate_id = (select cate_id from fs_user_course_category WHERE cate_name like '%央广原乡行%' AND cate_type = 1 limit 1)
+            </when>
+            <otherwise>
+                AND c.cate_id != (select cate_id from fs_user_course_category WHERE cate_name like '%央广原乡行%' AND cate_type = 1 limit 1)
+            </otherwise>
+        </choose>
           AND EXISTS (
                 SELECT 1 FROM fs_user_course_category pc2
-                WHERE pc2.cate_id = c.cate_id AND pc2.cate_type = 1 AND IFNULL(pc2.is_del, 0) = 0
+                WHERE pc2.cate_id = c.cate_id AND pc2.cate_type = 1 AND pc2.is_del = 0
             )
           AND (
                 c.sub_cate_id IS NULL
                 OR EXISTS (
                     SELECT 1 FROM fs_user_course_category sc2
-                    WHERE sc2.cate_id = c.sub_cate_id AND sc2.cate_type = 1 AND IFNULL(sc2.is_del, 0) = 0
+                    WHERE sc2.cate_id = c.sub_cate_id AND sc2.cate_type = 1 AND sc2.is_del = 0
                 )
             )
         <if test="q.cateId != null and q.subCateId != null">

+ 30 - 2
fs-service/src/main/resources/mapper/course/FsUserCourseVideoMapper.xml

@@ -18,6 +18,8 @@
         <result property="courseId"    column="course_id"    />
         <result property="status"    column="status"    />
         <result property="courseSort"    column="course_sort"    />
+        <result property="watchDurationMinutes"    column="watch_duration_minutes"    />
+        <result property="integralReward"    column="integral_reward"    />
         <result property="fileName"    column="file_name"    />
         <result property="isDel"    column="is_del"    />
         <result property="questionBankId"    column="question_bank_id"    />
@@ -84,6 +86,8 @@
             <if test="courseId != null">course_id,</if>
             <if test="status != null">status,</if>
             <if test="courseSort != null">course_sort,</if>
+            <if test="watchDurationMinutes != null">watch_duration_minutes,</if>
+            <if test="integralReward != null">integral_reward,</if>
             <if test="fileName != null">file_name,</if>
             <if test="isDel != null">is_del,</if>
             <if test="questionBankId != null">question_bank_id,</if>
@@ -126,6 +130,8 @@
             <if test="courseId != null">#{courseId},</if>
             <if test="status != null">#{status},</if>
             <if test="courseSort != null">#{courseSort},</if>
+            <if test="watchDurationMinutes != null">#{watchDurationMinutes},</if>
+            <if test="integralReward != null">#{integralReward},</if>
             <if test="fileName != null">#{fileName},</if>
             <if test="isDel != null">#{isDel},</if>
             <if test="questionBankId != null">#{questionBankId},</if>
@@ -218,6 +224,8 @@
             <if test="courseId != null">course_id = #{courseId},</if>
             <if test="status != null">status = #{status},</if>
             <if test="courseSort != null">course_sort = #{courseSort},</if>
+            <if test="watchDurationMinutes != null">watch_duration_minutes = #{watchDurationMinutes},</if>
+            <if test="integralReward != null">integral_reward = #{integralReward},</if>
             <if test="fileName != null">file_name = #{fileName},</if>
             <if test="isDel != null">is_del = #{isDel},</if>
             <if test="questionBankId != null">question_bank_id = #{questionBankId},</if>
@@ -260,6 +268,20 @@
             #{videoId}
         </foreach>
     </update>
+
+    <update id="batchUpdateWatchIntegralByVideoIds">
+        update fs_user_course_video
+        set watch_duration_minutes = #{watchDurationMinutes},
+            integral_reward = #{integralReward},
+            update_time = now()
+        where is_del = 0
+          and course_id = #{courseId}
+          and video_id in
+        <foreach collection="videoIds" item="vid" open="(" separator="," close=")">
+            #{vid}
+        </foreach>
+    </update>
+
     <update id="updates">
         update fs_user_course_video set view_start_time = #{viewStartTime},view_end_time = #{viewEndTime},last_join_time = #{lastJoinTime} where video_id in
         <foreach item="videoId" collection="ids" open="(" separator="," close=")">
@@ -305,7 +327,7 @@
         </if>
         <!-- 营销提前查看天数逻辑 -->
         AND DATE_SUB(fcpd.day_date, INTERVAL fcp.max_view_num DAY) &lt;= now()
-        order by fcpd.start_date_time, ccut.start_date_time, video.course_sort
+        order by fcpd.start_date_time desc , ccut.start_date_time, video.course_sort
     </select>
 
     <select id="selectVideoListByMap" resultType="com.fs.his.vo.OptionsVO">
@@ -343,6 +365,7 @@
         fcpd.last_join_time,
         fcpd.id,
         fcp.period_name,
+        if(fav.id is null, 0, 1) as isFavorite,
         if(ccut.start_date_time is null, fcpd.start_date_time, ccut.start_date_time) as startDateTime,
         if(ccut.end_date_time is null, fcpd.end_date_time, ccut.end_date_time) as endDateTime,
         course.project as projectId
@@ -350,6 +373,9 @@
         left join fs_user_course_period_days fcpd on fcpd.video_id = video.video_id
         left join fs_user_course_period fcp on fcp.period_id = fcpd.period_id
         left join fs_user_course course ON video.course_id = course.course_id
+        left join fs_company_user_course_favorite fav
+            on fav.period_id = fcpd.period_id
+            and fav.company_user_id = #{params.companyUserId}
         LEFT JOIN fs_user_course_company_user_time ccut ON ccut.period_id = fcpd.period_id
         AND ccut.course_id = fcpd.course_id
         AND ccut.video_id = fcpd.video_id
@@ -368,7 +394,9 @@
         (fcpd.start_date_time &lt;=  CONCAT( CURDATE(), ' 23:59:59' ) and fcpd.end_date_time >= CONCAT( CURDATE(), ' 00:00:00' ))
         or (ccut.start_date_time &lt;=  CONCAT( CURDATE(), ' 23:59:59' ) and ccut.end_date_time >= CONCAT( CURDATE(), ' 00:00:00' ))
         )
-        order by video.course_sort
+        order by if(fav.id is null, 0, 1) desc,
+        if(ccut.start_date_time is null, fcpd.start_date_time, ccut.start_date_time) desc,
+        video.course_sort
     </select>
     <select id="selectFsUserCourseVideoVoByVideoId" resultType="com.fs.course.vo.FsUserCourseVO">
         select

+ 51 - 0
fs-service/src/main/resources/mapper/course/FsVideoResourceMapper.xml

@@ -37,6 +37,57 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
         order by rr.sort,rr.id desc
     </select>
 
+    <!-- 公域素材库:一级分类须为 cate_type=1 且 pid=0;若填了二级,须为 cate_type=1 的二级(pid>0) -->
+    <select id="selectPublicVideoResourceListByMap" resultType="com.fs.course.vo.FsVideoResourceVO">
+        select rr.*
+        from fs_video_resource rr
+        where rr.is_del = 0
+        and rr.type_id is not null
+        and exists (
+            select 1 from fs_user_course_category c1
+            where c1.cate_id = rr.type_id
+            and ifnull(c1.is_del, 0) = 0
+            and c1.cate_type = 1
+            and c1.pid = 0
+        )
+        and (
+            rr.type_sub_id is null
+            or exists (
+                select 1 from fs_user_course_category c2
+                where c2.cate_id = rr.type_sub_id
+                and ifnull(c2.is_del, 0) = 0
+                and c2.cate_type = 1
+                and c2.pid <![CDATA[>]]> 0
+            )
+        )
+        <if test="params.resourceName != null and params.resourceName != ''">
+            and rr.resource_name like concat('%', #{params.resourceName}, '%')
+        </if>
+        <if test="params.fileName != null and params.fileName != ''">
+            and rr.file_name like concat('%', #{params.fileName}, '%')
+        </if>
+        <if test="params.typeId != null">
+            and rr.type_id = #{params.typeId}
+        </if>
+<!--        <if test="params.userId != null">-->
+<!--            and rr.user_id = #{params.userId}-->
+<!--        </if>-->
+        <if test="params.typeSubId != null">
+            and rr.type_sub_id = #{params.typeSubId}
+        </if>
+        <if test="params.videoType != null">
+            <choose>
+                <when test="params.videoType == 0">
+                    and (rr.video_type = 0 or rr.video_type is null)
+                </when>
+                <otherwise>
+                    and rr.video_type = #{params.videoType}
+                </otherwise>
+            </choose>
+        </if>
+        order by rr.sort, rr.id desc
+    </select>
+
     <select id="selectByIds" parameterType="String" resultType="com.fs.course.domain.FsVideoResource">
         SELECT *
         FROM fs_video_resource

+ 20 - 1
fs-user-app/src/main/java/com/fs/app/controller/CourseCommentController.java

@@ -22,6 +22,8 @@ import com.fs.course.service.IFsUserCourseCommentLikeService;
 import com.fs.course.service.IFsUserCourseCommentService;
 import com.fs.course.param.FsUserCourseCommentUParam;
 import com.fs.course.service.IFsUserCourseService;
+import com.fs.config.cloud.CloudHostProper;
+import com.fs.sensitive.ProductionWordFilter;
 import com.fs.course.vo.FsUserCourseCommentListUVO;
 import com.fs.course.vo.FsUserCourseCommentReplyListUVO;
 import com.fs.course.vo.FsUserCourseCommentVO;
@@ -64,6 +66,12 @@ public class CourseCommentController extends AppBaseController
 
     @Autowired
     private FsUserCourseMapper fsUserCourseMapper;
+
+    @Autowired
+    private CloudHostProper cloudHostProper;
+
+    @Autowired
+    private ProductionWordFilter productionWordFilter;
     /**
      * 查询课堂评论列表
      */
@@ -106,10 +114,21 @@ public class CourseCommentController extends AppBaseController
     @PostMapping("/addComment")
     public R add(@RequestBody FsUserCourseCommentAddParam param)
     {
+        String content = param.getContent();
+        if ("北京卓美".equals(cloudHostProper.getCompanyName())) {
+            if (content == null) {
+                content = "";
+            }
+            String filtered = productionWordFilter.filter(content).getFilteredText();
+            if (StringUtils.isEmpty(filtered)) {
+                return R.error("评论内容无效,请重新输入");
+            }
+            content = filtered;
+        }
         FsUserCourseComment fsUserCourseComment = new FsUserCourseComment();
         fsUserCourseComment.setUserId(Long.parseLong(getUserId()));
         fsUserCourseComment.setCourseId(param.getCourseId());
-        fsUserCourseComment.setContent(param.getContent());
+        fsUserCourseComment.setContent(content);
         fsUserCourseComment.setType(param.getType());
         if (param.getType()==1){
             fsUserCourseComment.setParentId(0L);

+ 61 - 12
fs-user-app/src/main/java/com/fs/app/controller/CourseController.java

@@ -4,6 +4,7 @@ package com.fs.app.controller;
 import com.fs.app.annotation.Login;
 import com.fs.common.annotation.RepeatSubmit;
 import com.fs.common.core.domain.R;
+import com.fs.common.core.domain.ResponseResult;
 import com.fs.common.utils.DateUtils;
 import com.fs.common.utils.ServletUtils;
 import com.fs.common.utils.StringUtils;
@@ -12,6 +13,7 @@ import com.fs.course.mapper.FsCourseWatchLogMapper;
 import com.fs.course.param.*;
 import com.fs.course.service.*;
 import com.fs.course.vo.*;
+import com.fs.course.vo.newfs.FsUserCourseVideoDetailsVO;
 import com.fs.his.vo.OptionsVO;
 import com.github.pagehelper.PageHelper;
 import com.github.pagehelper.PageInfo;
@@ -57,6 +59,12 @@ public class CourseController extends  AppBaseController{
     private IFsCourseLinkService linkService;
     @Autowired
     private FsCourseWatchLogMapper fsCourseWatchLogMapper;
+    @Autowired
+    private IFsPublicCourseSearchKeywordStatService publicCourseSearchKeywordStatService;
+    @Autowired
+    IFsUserCourseVideoService fsUserCourseVideoService;
+    @Autowired
+    private IFsCourseQuestionBankService fsCourseQuestionBankService;
 
 //    @Cacheable(value="getCourseCate" )
     @ApiOperation("获取分类")
@@ -82,20 +90,57 @@ public class CourseController extends  AppBaseController{
         }
     }
 
+    @ApiOperation("课程视频详情")
+    @GetMapping(value = "/videoDetails")
+    public ResponseResult<FsUserCourseVideoDetailsVO> getVideoDetails(Long videoId) {
+        return fsUserCourseVideoService.getVideoDetails(videoId);
+    }
+
+    @Login
+    @ApiOperation("答题")
+    @PostMapping("/publicCourseAnswer")
+    public R courseAnswer(@RequestBody FsCourseQuestionAnswerUParam param) {
+        param.setUserId(Long.parseLong(getUserId()));
+        log.info("【用户端答题】userId={} videoId={} questions={}", param.getUserId(), param.getVideoId(), param.getQuestions());
+        if (param.getDuration() == null) {
+            log.info("【用户端答题】未识别到时长 userId={}", param.getUserId());
+        }
+        return fsCourseQuestionBankService.courseAnswerForUserApp(param);
+    }
+
+    @Login
+    @ApiOperation("答题状态:1未答题 2可以答题 3达到上限 4已完成;-1学习缺失 -2无看课 -3未完课")
+    @GetMapping("/publicCourseAnswer/status")
+    public R courseAnswerStatus(FsCourseAnswerStatusQueryParam param) {
+        param.setUserId(Long.parseLong(getUserId()));
+        return fsCourseQuestionBankService.getCourseAnswerStatusForUserApp(param);
+    }
+
+    @Login
+    @ApiOperation("答题成功后领取本节答题积分(小节 integralReward);参数维度与答题/状态接口一致")
+    @PostMapping("/publicCourseAnswer/claimIntegral")
+    public R claimCourseAnswerIntegral(@RequestBody FsCourseAnswerStatusQueryParam param) {
+        param.setUserId(Long.parseLong(getUserId()));
+        return fsCourseQuestionBankService.claimPublicCourseAnswerIntegralReward(param);
+    }
+
     /**
      * 小程序端:公域课程分类分页(默认 cateType=1,与后台公域课分类一致)
      */
-    @ApiOperation(value = "小程序-课程分类分页", notes = "仅返回「二级分类」,且至少被一门公域课占用(sub_cate_id);一级父分类也需为公域。可选 pid=一级 cateId 收窄范围。排序 sort asc, cate_id asc。")
+    @ApiOperation(value = "小程序-课程分类分页", notes = "仅返回「二级分类」,且(按 yxxTag)至少被一门公域课占用;一级父分类也需为公域。可选 pid=一级 cateId 收窄范围。yxxTag:不传/0=仅统计/展示未打「原乡行」标签的课所挂分类,1=仅含「原乡行」标签的课。排序 sort asc, cate_id asc。")
     @GetMapping("/publicCourseCategory/list")
     public R listPublicCourseCategory(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);
-        List<FsUserCourseCategory> list = courseCategoryService.selectFsUserCourseCategoryAppList(param);
-        PageInfo<FsUserCourseCategory> pageInfo = new PageInfo<>(list);
+        PageInfo<FsUserCourseCategory> pageInfo = courseCategoryService.selectFsUserCourseCategoryAppPage(param);
+        if (StringUtils.isNotEmpty(param.getCateName())) {
+            int relatedCourseCount = (int) pageInfo.getTotal();
+            Long userId = StringUtils.isNotEmpty(getUserId()) ? Long.parseLong(getUserId()) : null;
+            if (userId != null) {
+                publicCourseSearchKeywordStatService.recordKeywordSearch(param.getKeyword(), relatedCourseCount, userId);
+            }
+        }
         return R.ok().put("data", pageInfo);
     }
 
@@ -104,17 +149,21 @@ public class CourseController extends  AppBaseController{
      */
     @ApiOperation(value = "小程序-公域课程分页", notes = "仅公域课;返回封面、标题、看课人数、推荐管理字段。看课人数=fs_course_watch_log send_type=1 去重 user_id。"
             + "分类:同时传 cateId+subCateId 时按父子双条件;仅 subCateId 按二级;仅 cateId 时按一级或二级 id 匹配(cate_id 或 sub_cate_id)。"
-            + "推荐位 recommendSlot:1首页顶部 2商城首页 3长视频瀑布流,仅返回对应推荐位已勾选课程并按位置序号排序。")
+            + "推荐位 recommendSlot:1首页顶部 2商城首页 3长视频瀑布流,仅返回对应推荐位已勾选课程并按位置序号排序。"
+            + "yxxTag:不传/0=只查课程 tags 中不含「原乡行」的;1=只查带「原乡行」标签的;依据 fs_user_course.tags(逗号分隔)。")
     @GetMapping("/publicCourse/list")
     public R listPublicCourse(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 = courseService.selectFsUserCoursePublicAppList(param);
-        PageInfo<FsUserCoursePublicAppVO> pageInfo = new PageInfo<>(list);
+        PageInfo<FsUserCoursePublicAppVO> pageInfo = courseService.selectFsUserCoursePublicAppPage(param);
+        if (StringUtils.isNotEmpty(param.getKeyword())) {
+            int relatedCourseCount = (int) pageInfo.getTotal();
+            Long userId = StringUtils.isNotEmpty(getUserId()) ? Long.parseLong(getUserId()) : null;
+            if (userId != null) {
+                publicCourseSearchKeywordStatService.recordKeywordSearch(param.getKeyword(), relatedCourseCount, userId);
+            }
+        }
         return R.ok().put("data", pageInfo);
     }
 

+ 7 - 2
fs-user-app/src/main/java/com/fs/app/controller/store/AddressScrmController.java

@@ -158,12 +158,17 @@ public class AddressScrmController extends AppBaseController {
     @ApiOperation("获取默认地址信息")
     @GetMapping("/getAddressByDefault")
     public R getAddressByDefault( HttpServletRequest request){
-        FsUserAddress address=addressService.selectFsUserAddressByDefault(Long.parseLong(getUserId()));
+        long userId = Long.parseLong(getUserId());
+        FsUserAddress address=addressService.selectFsUserAddressByDefault(userId);
+        if (address == null) {
+            // 默认地址为空时,按最近一次支付成功订单匹配用户历史地址
+            address = addressService.selectFsUserAddressByLastPaidOrder(userId);
+        }
         if (address!=null&&address.getPhone()!=null&&address.getPhone().length()>11&&!address.getPhone().matches("\\d+")){
             address.setPhone(ParseUtils.parsePhone(address.getPhone()));
         }
         if (null!=address){
-            if(!address.getUserId().equals(Long.parseLong(getUserId()))){
+            if(!address.getUserId().equals(userId)){
                 return R.error("非法操作");
             }
         }

+ 21 - 12
fs-user-app/src/main/java/com/fs/framework/config/RedisConfig.java

@@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
 import com.fasterxml.jackson.annotation.PropertyAccessor;
 import com.fasterxml.jackson.databind.ObjectMapper;
 import com.fasterxml.jackson.databind.jsontype.impl.LaissezFaireSubTypeValidator;
+import org.springframework.cache.CacheManager;
 import org.springframework.cache.annotation.CachingConfigurerSupport;
 import org.springframework.cache.annotation.EnableCaching;
 import org.springframework.context.annotation.Bean;
@@ -32,18 +33,26 @@ import java.time.Duration;
 public class RedisConfig extends CachingConfigurerSupport
 {
 
-//    @Bean
-//    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {
-//        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
-//                .entryTtl(Duration.ofSeconds(30))
-//                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
-//                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()))
-//                .disableCachingNullValues();
-//
-//        return RedisCacheManager.builder(connectionFactory)
-//                .cacheDefaults(config)
-//                .build();
-//    }
+    @Bean
+    public CacheManager cacheManager(RedisConnectionFactory connectionFactory) {
+        ObjectMapper objectMapper = new ObjectMapper();
+        objectMapper.setVisibility(PropertyAccessor.ALL, JsonAutoDetect.Visibility.ANY);
+        objectMapper.activateDefaultTyping(
+                LaissezFaireSubTypeValidator.instance,
+                ObjectMapper.DefaultTyping.NON_FINAL,
+                JsonTypeInfo.As.PROPERTY);
+        GenericJackson2JsonRedisSerializer valueSerializer = new GenericJackson2JsonRedisSerializer(objectMapper);
+        RedisCacheConfiguration config = RedisCacheConfiguration.defaultCacheConfig()
+                .entryTtl(Duration.ofHours(1))
+                .serializeKeysWith(
+                        RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer()))
+                .serializeValuesWith(
+                        RedisSerializationContext.SerializationPair.fromSerializer(valueSerializer))
+                .disableCachingNullValues();
+        return RedisCacheManager.builder(connectionFactory)
+                .cacheDefaults(config)
+                .build();
+    }
 
     @Bean
     @SuppressWarnings(value = { "unchecked", "rawtypes" })